@torrent-tv/proxy 2.9.33 → 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 +4 -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/package.json +1 -1
- package/server.js +5 -1
- package/services/ffmpeg-banner.js +125 -0
- package/services/hls-session-manager.js +54 -121
- package/services/playback-planner.js +57 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
## 2.9.33
|
|
2
6
|
|
|
3
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.
|
|
@@ -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).
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -138,7 +138,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
138
138
|
} catch {
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
|
-
}
|
|
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)
|
|
142
146
|
});
|
|
143
147
|
const playbackPlanner = createPlaybackPlanner({
|
|
144
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,76 +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
|
-
/**
|
|
352
|
-
* Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
|
|
353
|
-
* metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
|
|
354
|
-
* HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
|
|
355
|
-
* without tone mapping produces a washed-out, desaturated picture, so this
|
|
356
|
-
* gates the tonemap filter chain.
|
|
357
|
-
*
|
|
358
|
-
* @param {string} stderrText
|
|
359
|
-
* @returns {boolean}
|
|
360
|
-
*/
|
|
361
|
-
function parseFfmpegHdr(stderrText) {
|
|
362
|
-
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
363
|
-
return false;
|
|
364
|
-
}
|
|
365
|
-
const videoLine = stderrText.match(/Video:[^\n]*/i);
|
|
366
|
-
if (!videoLine) {
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
// ffmpeg prints the colour info in parentheses, e.g.
|
|
370
|
-
// "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
|
|
371
|
-
// the reliable HDR signal.
|
|
372
|
-
return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
267
|
/**
|
|
376
268
|
* Run a short ffmpeg probe to extract the total duration AND video resolution
|
|
377
269
|
* of a stream from the container header. Both are printed almost immediately
|
|
@@ -682,10 +574,14 @@ export class HlsSessionManager {
|
|
|
682
574
|
videoEncoder = null,
|
|
683
575
|
softwarePresetBenchmark = null,
|
|
684
576
|
getSourceStats = null,
|
|
685
|
-
tonemapSupported = false
|
|
577
|
+
tonemapSupported = false,
|
|
578
|
+
getCachedMediaInfo = null
|
|
686
579
|
}) {
|
|
687
580
|
this.enabled = Boolean(enabled);
|
|
688
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;
|
|
689
585
|
// Optional async accessor for a source's live download stats, used by the
|
|
690
586
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
691
587
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
@@ -804,17 +700,35 @@ export class HlsSessionManager {
|
|
|
804
700
|
}
|
|
805
701
|
|
|
806
702
|
const sessionId = randomUUID();
|
|
703
|
+
const createEntryMs = Date.now();
|
|
807
704
|
const sessionDir = createSessionDirPath(sessionId);
|
|
808
705
|
await mkdir(sessionDir, { recursive: true });
|
|
809
706
|
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
810
707
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
811
708
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
812
709
|
|
|
813
|
-
//
|
|
814
|
-
// playlist (
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
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";
|
|
818
732
|
const durationSeconds = mediaInfo.durationSeconds;
|
|
819
733
|
const sourceWidth = mediaInfo.width;
|
|
820
734
|
const sourceHeight = mediaInfo.height;
|
|
@@ -847,11 +761,14 @@ export class HlsSessionManager {
|
|
|
847
761
|
// uniform grid (current behaviour). Re-encoded video uses a uniform grid
|
|
848
762
|
// (its fixed GOP makes the cuts land there).
|
|
849
763
|
let keyframeTimes = null;
|
|
764
|
+
let keyframeMs = -1; // -1 = not run (skipped)
|
|
850
765
|
if (hasDuration && !transcodeVideo) {
|
|
851
766
|
// Short timeout: mp4 keyframes come from the moov index (fast); containers
|
|
852
767
|
// that force a full packet scan time out and fall back to a uniform grid,
|
|
853
768
|
// so this never adds more than ~6 s to session start.
|
|
769
|
+
const keyframeStartMs = Date.now();
|
|
854
770
|
keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
|
|
771
|
+
keyframeMs = Date.now() - keyframeStartMs;
|
|
855
772
|
if (!keyframeTimes) {
|
|
856
773
|
logger.warn(
|
|
857
774
|
`transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
|
|
@@ -859,6 +776,10 @@ export class HlsSessionManager {
|
|
|
859
776
|
);
|
|
860
777
|
}
|
|
861
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
|
+
);
|
|
862
783
|
const segmentBoundaries = hasDuration
|
|
863
784
|
? computeSegmentBoundaries({
|
|
864
785
|
transcodeVideo,
|
|
@@ -908,6 +829,10 @@ export class HlsSessionManager {
|
|
|
908
829
|
lastAccessedAt: Date.now(),
|
|
909
830
|
ffmpeg: null,
|
|
910
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,
|
|
911
836
|
consumers: new Set(consumerId ? [consumerId] : []),
|
|
912
837
|
// Transcode parameters retained so the encode run can be restarted at an
|
|
913
838
|
// arbitrary segment when the player seeks (server-side seeking).
|
|
@@ -1673,6 +1598,14 @@ export class HlsSessionManager {
|
|
|
1673
1598
|
try {
|
|
1674
1599
|
await access(filePath);
|
|
1675
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
|
+
}
|
|
1676
1609
|
return {
|
|
1677
1610
|
kind: "file",
|
|
1678
1611
|
stream: isPlaylist
|
|
@@ -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 };
|