@torrent-tv/proxy 2.9.29 → 2.9.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.9.31
2
+
3
+ - **New**: Realtime transcode budget — startup resolution + preset selection (OpenSpec change `transcode-quality`, part 2.1). For the software encoder the proxy now picks the output RESOLUTION as well as the libx264 preset from the startup benchmark: the client-requested box (capped to the source, never upscaled) is the ceiling, and the proxy chooses the highest resolution rung at or below it that the benchmark predicts encodes faster than realtime (with the existing margin), then the best preset at that resolution. On a weak host this downscales (e.g. a 720p60→30 stream that ran at ~0.9× on a Home Assistant box now encodes at ~480p in realtime) instead of dropping into sub-realtime playback with constant stalls. Capable hosts keep full resolution and spend the headroom on a higher-quality preset; hardware encoders and the no-benchmark case are unchanged. Also fixed the realtime-need calculation to use the session's actual output frame rate instead of the fixed 24 fps constant (it under-counted for 25/30 fps content). The chosen encode resolution is logged (`enc=WxH@fps budget=on`). This scales down from the orientation-independent ceiling the browser now sends (server 0.8.43).
4
+ - **New**: Realtime transcode budget — runtime downswitch (OpenSpec change `transcode-quality`, part 2.2). If a software transcode runs below realtime for a sustained window (ffmpeg `speed` < ~0.95× for ~15 s), the proxy steps the resolution one rung down the ladder and restarts the encode at the segment the viewer is on, so a stream that starts fine but bogs down on a heavy passage recovers instead of stalling. It first checks the bottleneck: if the torrent download can't sustain the source's byte rate (and the file isn't fully downloaded), the limit is the download, not the encoder — the proxy logs that and does NOT degrade quality. Conservative guards prevent thrash: a 30 s post-action cooldown, at most 3 downshifts, a resolution floor, the slow window reset on every (re)start, and no automatic upswitch yet. The switch point uses a hard encoder restart (a brief blip is possible there; a seamless discontinuity/parallel tier is a later refinement). Logged as `[budget] … CPU-bound speed=… → downscale to WxH` or `… download-limited; not downscaling`.
5
+
6
+ ## 2.9.30
7
+
8
+ - **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
9
+
1
10
  ## 2.9.29
2
11
 
3
12
  - **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-07-07
@@ -0,0 +1,50 @@
1
+ # Proposal: Proxy owns subtitle conversion + content-based language detection
2
+
3
+ ## Why
4
+
5
+ Subtitle language was guessed only from the filename, so a file without a
6
+ language code (e.g. the Enola release's `.srt`) showed "Unknown", and the
7
+ owner specifically does not want Ukrainian and Russian confused. Reliable
8
+ detection needs an n-gram model on the actual text — which belongs on the
9
+ proxy (Node, node_modules, no per-browser payload, and the subtitle bytes are
10
+ right there) rather than in the browser.
11
+
12
+ ## What Changes
13
+
14
+ - The proxy becomes the single owner of subtitle content: `GET /api/subtitles`
15
+ now also serves EXTERNAL subtitle files (no `trackIndex`) — it reads the
16
+ file, decodes its encoding (UTF-8 or Windows-1251, common for Russian
17
+ `.srt`), and converts `.srt`/`.ass`/`.ssa` → WebVTT here. The browser no
18
+ longer converts.
19
+ - The proxy detects the language from the full text with `franc` (n-gram /
20
+ trigram, MIT), restricted to a curated set of plausible subtitle languages
21
+ (distinguishes ru/uk/bg/sr and Latin languages; avoids short-text false
22
+ positives like English→Scots), and reports it in `X-Subtitle-Language`
23
+ (+ `X-Subtitle-Language-Name`). Embedded tracks detect from the first chunk
24
+ of extracted VTT.
25
+ - The browser sets each track's language by priority: explicit code in the
26
+ filename / container metadata (author intent) → proxy content detection →
27
+ the film's audio-track language (forced-signs subs usually match the dub)
28
+ → Unknown.
29
+
30
+ ## Capabilities
31
+
32
+ ### New Capabilities
33
+
34
+ - `subtitle-language`: proxy-side subtitle conversion, encoding handling and
35
+ content-based language detection.
36
+
37
+ ### Modified Capabilities
38
+
39
+ <!-- track-selection covered embedded extraction; this extends /api/subtitles
40
+ to external files and adds detection. Its change is unarchived, so this
41
+ lands as a new capability rather than a delta. -->
42
+
43
+ ## Impact
44
+
45
+ - proxy: new `services/subtitle-convert.js`, `services/language-detect.js`
46
+ (franc dep), extended `routes/api/subtitles/get.js`.
47
+ - server: `components/loading/loading.js` fetches VTT from the proxy for
48
+ external subs (drops client-side conversion) and applies the language
49
+ priority; browser franc/alphabet detection removed.
50
+ - Pairs with a server release; requires the ha-addon bump.
@@ -0,0 +1,34 @@
1
+ # subtitle-language — delta spec
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Proxy converts subtitles and reports the language
6
+ `GET /api/subtitles` SHALL serve every subtitle as WebVTT and report the
7
+ detected language in `X-Subtitle-Language` (ISO 639-1) and
8
+ `X-Subtitle-Language-Name`. It SHALL handle two modes: an embedded track
9
+ (`trackIndex` given — extracted via ffmpeg) and an external subtitle file (no
10
+ `trackIndex` — the file is read, its encoding decoded, and `.srt`/`.ass`/
11
+ `.ssa` converted to WebVTT on the proxy). A leading BOM SHALL be stripped and
12
+ Windows-1251 bytes decoded when the file is not valid UTF-8.
13
+
14
+ #### Scenario: External Russian .srt without a filename code
15
+ - **WHEN** an external `.srt` whose name has no language code is requested
16
+ - **THEN** the response is WebVTT and `X-Subtitle-Language` is `ru`
17
+
18
+ #### Scenario: Ukrainian is not reported as Russian
19
+ - **WHEN** the subtitle text is Ukrainian
20
+ - **THEN** the detected language is `uk`, not `ru`
21
+
22
+ #### Scenario: Unsupported format
23
+ - **WHEN** an image-based or unconvertible subtitle is requested
24
+ - **THEN** the proxy responds 422
25
+
26
+ ### Requirement: Detection is confidence-gated
27
+ Language detection SHALL be restricted to a curated set of plausible subtitle
28
+ languages and SHALL return no language (omit the header) when the text is too
29
+ short or undetermined, rather than emitting a wrong guess.
30
+
31
+ #### Scenario: Too little text
32
+ - **WHEN** the subtitle has only a few characters
33
+ - **THEN** no language header is set (the browser falls back to filename or
34
+ audio language)
@@ -0,0 +1,26 @@
1
+ # Tasks: Proxy subtitle conversion + language detection
2
+
3
+ ## 1. Proxy
4
+
5
+ - [x] 1.1 `services/subtitle-convert.js`: encoding-aware decode (UTF-8/BOM/
6
+ Windows-1251) + srt/ass/ssa → WebVTT (ported, BOM-stripped)
7
+ - [x] 1.2 `services/language-detect.js`: franc restricted to a curated
8
+ ISO 639-3→639-1 allowlist; null when undetermined/too short
9
+ - [x] 1.3 `routes/api/subtitles/get.js`: external-file mode (read, decode,
10
+ convert, detect) + `X-Subtitle-Language(-Name)` on both modes
11
+ - [x] 1.4 franc dependency added to proxy
12
+ - [x] 1.5 Verified: unit-tested convert+detect (ru/uk/en/de, short→null) and
13
+ the route end-to-end on the real Enola .srt (→ ru, 19 cues)
14
+
15
+ ## 2. Server (browser)
16
+
17
+ - [x] 2.1 External subs fetch VTT from `/api/subtitles` (no client convert)
18
+ - [x] 2.2 Language priority: filename code → X-Subtitle-Language → audio-track
19
+ language → und; embedded track uses metadata → header → audio
20
+ - [x] 2.3 Removed the client-side convertSubtitleToVtt call + dead import
21
+
22
+ ## 3. Release
23
+
24
+ - [ ] 3.1 Proxy publish (OTP) + ha-addon bump + server patch
25
+ - [ ] 3.2 Field-check: Enola external .srt labels "Russian"; a Ukrainian sub
26
+ labels "Ukrainian"; embedded tracks keep their metadata language
@@ -35,3 +35,62 @@ chosen encoder places keyframes:
35
35
  - **WHEN** the source frame rate cannot be probed on the software path
36
36
  - **THEN** the output falls back to the default rate and playback still
37
37
  segments correctly
38
+
39
+ ### Requirement: Software encode fits a realtime budget at startup
40
+
41
+ For the software encoder, the proxy SHALL choose the output resolution and
42
+ libx264 preset a startup benchmark predicts this host can encode faster than
43
+ realtime (with a margin), rather than always encoding at the client-requested
44
+ resolution. The client-requested box, capped to the source resolution (never
45
+ upscaled), is the ceiling; the proxy SHALL pick the highest resolution rung at
46
+ or below that ceiling that clears the realtime margin, then the highest-quality
47
+ preset that still clears it at that resolution. When even the lowest rung
48
+ cannot clear the margin, the proxy SHALL use the lowest rung (best effort). The
49
+ realtime need SHALL be computed from the session's actual output frame rate.
50
+ Hardware encoders and the no-benchmark case SHALL keep the ceiling resolution
51
+ and the default preset.
52
+
53
+ #### Scenario: Weak host, source above realtime capacity
54
+ - **WHEN** the software benchmark shows the host cannot encode the source-capped
55
+ resolution faster than realtime (e.g. 720p60→30 that runs below 1×)
56
+ - **THEN** the proxy downscales to the highest ladder rung that clears the
57
+ realtime margin (e.g. 480p) instead of encoding sub-realtime at full size
58
+
59
+ #### Scenario: Capable host
60
+ - **WHEN** the benchmark shows ample headroom at the ceiling resolution
61
+ - **THEN** the proxy keeps the ceiling resolution and spends the headroom on a
62
+ higher-quality (slower) preset
63
+
64
+ #### Scenario: Hardware encoder
65
+ - **WHEN** a hardware encoder is selected
66
+ - **THEN** no benchmark-based downscale is applied and the ceiling resolution
67
+ is used
68
+
69
+ ### Requirement: Software encode downswitches at runtime when CPU-bound
70
+
71
+ The proxy SHALL, for the software encoder, step the output resolution one rung
72
+ down the ladder and restart the encode at the segment currently being watched
73
+ when a transcode runs below realtime for a sustained window. Before downscaling
74
+ it SHALL determine whether the limit is the encoder or a download-starved
75
+ input — comparing the torrent download rate with the source's average byte rate
76
+ (a fully-downloaded file is never download-bound) — and SHALL NOT downscale when
77
+ the limit is the download (it SHALL log that instead). The downswitch SHALL be
78
+ bounded by a sustained-slow window, a post-action cooldown, a maximum number of
79
+ steps, and a resolution floor, and SHALL reset its slow window on every encode
80
+ (re)start. There SHALL be no automatic upswitch in this version.
81
+
82
+ #### Scenario: Sustained CPU-bound transcode
83
+ - **WHEN** a software transcode's encoder speed stays below realtime for the
84
+ sustained window while the input download keeps up
85
+ - **THEN** the proxy downscales one rung and restarts at the current segment,
86
+ up to the step cap / resolution floor
87
+
88
+ #### Scenario: Download-limited, not CPU-limited
89
+ - **WHEN** the encoder speed is below realtime but the torrent cannot download
90
+ the source's byte rate and the file is not fully downloaded
91
+ - **THEN** the proxy does not downscale and logs that the download is the limit
92
+
93
+ #### Scenario: No thrash after a switch
94
+ - **WHEN** a downswitch (or a viewer seek) has just restarted the encode
95
+ - **THEN** the slow window is reset and no further downswitch occurs until a new
96
+ sustained-slow window elapses after the cooldown
@@ -11,11 +11,27 @@
11
11
  - [x] 1.3 Unit-verify fps choice and fps↔GOP consistency (25→100, 24→96,
12
12
  default→96); syntax checks
13
13
 
14
- ## 2. Realtime budget (planned)
15
-
16
- - [ ] 2.1 Benchmark picks encoder/preset/resolution/fps within a realtime
17
- margin; downscale instead of refuse
18
- - [ ] 2.2 Runtime `speed<1` watch → restart with a lighter profile
14
+ ## 2. Realtime budget
15
+
16
+ - [x] 2.1 Startup: benchmark picks resolution + preset within a realtime
17
+ margin; downscale below the client-target ceiling instead of refusing
18
+ (`chooseSoftwareEncodeSettings`/`buildResolutionLadder` in hwaccel;
19
+ `#chooseEncodeBudget` + `encodeWidth`/`encodeHeight` in the session
20
+ manager; fixed the needed-pixels calc to use the session's `outputFps`
21
+ not the fixed `TRANSCODE_FPS`). Verified on the FIFA host profile
22
+ (720p60→480p) + strong/weak/no-benchmark profiles.
23
+ - [x] 2.2 Runtime `speed<1` watch → step down the resolution ladder + restart
24
+ at the current segment (hard-restart tier). Gate on CPU-bound only:
25
+ compare `getFileStats().downloadSpeed` with the source byte-rate so a
26
+ download-starved input is NOT misread as an encoder limit (don't degrade
27
+ quality for a download bottleneck — log it instead). Hysteresis +
28
+ cooldown + floor + max steps; slow window reset on every (re)start; no
29
+ upswitch in v1 (oscillation risk). `#enforceRealtimeBudget` +
30
+ `#classifyTranscodeBound` + `#applyBudgetDownshift` in the session
31
+ manager; `getSourceStats` injected from server.js.
32
+ NOTE (follow-up 2.2b): seamless switch via `EXT-X-DISCONTINUITY` /
33
+ parallel encoder tier keyed by host resources — the hard restart can blip
34
+ at the switch point.
19
35
  - [ ] 2.3 `-maxrate`/`-bufsize`
20
36
 
21
37
  ## 3. HDR tone mapping (planned)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.29",
3
+ "version": "2.9.31",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -26,6 +26,7 @@
26
26
  "commander": "^12.1.0",
27
27
  "fastify": "^5.8.5",
28
28
  "ffmpeg-static": "^5.3.0",
29
+ "franc": "^6.2.0",
29
30
  "get-port": "^7.1.0",
30
31
  "node-datachannel": "^0.32.0",
31
32
  "webtorrent": "^2.8.4",
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Extract an embedded text subtitle track from a torrent file as WebVTT.
2
+ * Serve a subtitle as WebVTT, with the detected language reported in the
3
+ * `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
4
+ * modes:
3
5
  *
4
- * GET /api/subtitles?sourceKey=...&fileIndex=N&trackIndex=M
6
+ * - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
7
+ * ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
8
+ * streamed as it is produced.
9
+ * - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
10
+ * the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
11
+ * (.srt/.ass/.ssa → WebVTT) here on the proxy.
5
12
  *
6
- * `trackIndex` is the TYPE-RELATIVE subtitle stream index (what ffmpeg's
7
- * `-map 0:s:M` selects), as reported by the playback plan's
8
- * `subtitleTracks[].index`.
9
- *
10
- * The response streams while ffmpeg produces it. Extraction has to read the
11
- * file up to the last cue, so on a cold torrent this drives (and waits for)
12
- * the sequential download — callers must use a generous timeout.
13
+ * The proxy owns subtitle conversion + language detection so no model or
14
+ * converter ships to the browser and detection sees the full text.
13
15
  *
14
16
  * @param {import("fastify").FastifyRequest} req
15
17
  * @param {import("fastify").FastifyReply} reply
@@ -23,19 +25,35 @@
23
25
  */
24
26
 
25
27
  import { spawn } from "node:child_process";
28
+ import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
29
+ import { detectLanguage } from "../../../services/language-detect.js";
26
30
 
27
- // Safety cap: no extraction may outlive this (a dead swarm would otherwise
28
- // hold the ffmpeg process forever).
31
+ // Safety cap: no embedded extraction may outlive this.
29
32
  const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
33
+ // External subtitle files are small; cap the read to guard against a bad index.
34
+ const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
35
+
36
+ /** Set the detected-language response headers (no-op when detection failed). */
37
+ function setLanguageHeaders(reply, lang) {
38
+ if (lang && typeof lang.code === "string") {
39
+ reply.raw.setHeader("X-Subtitle-Language", lang.code);
40
+ if (typeof lang.name === "string") {
41
+ reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
42
+ }
43
+ // These are custom headers on a cross-origin fetch — expose them.
44
+ reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
45
+ }
46
+ }
30
47
 
31
48
  export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
32
49
  const query = req.query ?? {};
33
50
  const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
34
51
  const fileIndex = Number(query.fileIndex);
52
+ const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
35
53
  const trackIndex = Number(query.trackIndex);
36
54
 
37
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0 || !Number.isInteger(trackIndex) || trackIndex < 0) {
38
- return reply.code(400).send({ error: "sourceKey, fileIndex and trackIndex are required." });
55
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
56
+ return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
39
57
  }
40
58
 
41
59
  const sourceRecord = sourceRegistry.get(sourceKey);
@@ -43,28 +61,47 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
43
61
  return reply.code(404).send({ error: "Source key was not found." });
44
62
  }
45
63
  const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
46
- if (!torrent.files[fileIndex]) {
64
+ const file = torrent.files[fileIndex];
65
+ if (!file) {
47
66
  return reply.code(404).send({ error: "File index was not found in torrent." });
48
67
  }
49
68
 
69
+ // ---- External subtitle FILE (no trackIndex) -----------------------------
70
+ if (!hasTrackIndex) {
71
+ const name = typeof file.name === "string" ? file.name : "";
72
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
73
+ const release = torrentPool.acquireFile(torrent, fileIndex);
74
+ try {
75
+ const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
76
+ const text = decodeSubtitleBytes(bytes);
77
+ const vtt = convertSubtitleToVtt(text, ext);
78
+ if (!vtt) {
79
+ return reply.code(422).send({ error: `Unsupported subtitle format: ${ext}` });
80
+ }
81
+ setLanguageHeaders(reply, detectLanguage(text));
82
+ reply.header("content-type", "text/vtt; charset=utf-8");
83
+ reply.header("cache-control", "no-store");
84
+ return reply.send(vtt);
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ return reply.code(502).send({ error: `Could not read subtitle file: ${message}` });
88
+ } finally {
89
+ release();
90
+ }
91
+ }
92
+
93
+ // ---- Embedded track (ffmpeg extraction, streamed) -----------------------
94
+ if (!Number.isInteger(trackIndex) || trackIndex < 0) {
95
+ return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
96
+ }
97
+
50
98
  const inputUrl = new URL("/stream", `${localBaseUrl}/`);
51
99
  inputUrl.searchParams.set("sourceKey", sourceKey);
52
100
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
53
101
 
54
102
  const ffmpeg = spawn(
55
103
  ffmpegBin,
56
- [
57
- "-hide_banner",
58
- "-loglevel",
59
- "error",
60
- "-i",
61
- inputUrl.toString(),
62
- "-map",
63
- `0:s:${trackIndex}`,
64
- "-f",
65
- "webvtt",
66
- "pipe:1"
67
- ],
104
+ ["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
68
105
  { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
69
106
  );
70
107
 
@@ -81,8 +118,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
81
118
  }
82
119
  }, EXTRACTION_TIMEOUT_MS);
83
120
  killTimer.unref?.();
84
-
85
- // Stop extracting when the client goes away.
86
121
  req.raw.on("close", () => {
87
122
  clearTimeout(killTimer);
88
123
  if (!ffmpeg.killed) {
@@ -90,8 +125,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
90
125
  }
91
126
  });
92
127
 
93
- // Distinguish "bad track / not text-based" (ffmpeg dies before any output)
94
- // from a mid-stream failure (headers already sent; the stream just ends).
95
128
  const firstChunk = await new Promise((resolve) => {
96
129
  let settled = false;
97
130
  const settle = (value) => {
@@ -112,6 +145,9 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
112
145
  .send({ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}` });
113
146
  }
114
147
 
148
+ // Detect language from the first chunk of the produced VTT (embedded tracks
149
+ // frequently lack a language tag in their container metadata).
150
+ setLanguageHeaders(reply, detectLanguage(String(firstChunk)));
115
151
  reply.raw.writeHead(200, {
116
152
  "content-type": "text/vtt; charset=utf-8",
117
153
  "cache-control": "no-store",
@@ -127,3 +163,29 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
127
163
  reply.raw.end();
128
164
  return reply;
129
165
  }
166
+
167
+ /**
168
+ * Read a torrent file fully into a Buffer, bounded by `maxBytes`.
169
+ *
170
+ * @param {{ createReadStream: () => import("node:stream").Readable, length?: number }} file
171
+ * @param {number} maxBytes
172
+ * @returns {Promise<Buffer>}
173
+ */
174
+ function readFileFully(file, maxBytes) {
175
+ return new Promise((resolve, reject) => {
176
+ const stream = file.createReadStream();
177
+ const chunks = [];
178
+ let total = 0;
179
+ stream.on("data", (chunk) => {
180
+ total += chunk.length;
181
+ if (total > maxBytes) {
182
+ stream.destroy();
183
+ reject(new Error("subtitle file exceeds the size cap"));
184
+ return;
185
+ }
186
+ chunks.push(chunk);
187
+ });
188
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
189
+ stream.on("error", reject);
190
+ });
191
+ }
package/server.js CHANGED
@@ -117,7 +117,21 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
117
117
  localBindHost: host,
118
118
  localPort: selectedPort,
119
119
  videoEncoder,
120
- softwarePresetBenchmark
120
+ softwarePresetBenchmark,
121
+ // Live download stats accessor for the realtime budget: lets it tell a
122
+ // CPU-bound transcode from a download-starved input before downscaling.
123
+ getSourceStats: async (sourceKey, fileIndex) => {
124
+ const record = sourceRegistry.get(sourceKey);
125
+ if (!record) {
126
+ return null;
127
+ }
128
+ try {
129
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
130
+ return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
121
135
  });
122
136
  const playbackPlanner = createPlaybackPlanner({
123
137
  ffmpegBin,
@@ -15,7 +15,13 @@ import path from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { logger } from "../utils/logger.js";
18
- import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS, chooseOutputFps } from "./hwaccel.js";
18
+ import {
19
+ softwareDescriptor,
20
+ chooseSoftwareEncodeSettings,
21
+ pickSoftwarePreset,
22
+ TRANSCODE_FPS,
23
+ chooseOutputFps
24
+ } from "./hwaccel.js";
19
25
 
20
26
  const PLAYLIST_FILE_NAME = "index.m3u8";
21
27
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -36,6 +42,30 @@ const RESTART_COOLDOWN_MS = 4_000;
36
42
  // segment fetch, so it never expires mid-watch.
37
43
  const DEFAULT_SESSION_TTL_MS = 120 * 1000;
38
44
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
45
+ // Realtime budget — runtime downswitch (software encoder only). Periodically
46
+ // check each active software-transcode session's ffmpeg `speed`; when it stays
47
+ // below realtime for a sustained window AND the input is not download-starved
48
+ // (so the limit is the encoder, not the torrent), step down one resolution rung
49
+ // and restart at the current segment. Conservative so it never thrashes: a long
50
+ // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
51
+ const BUDGET_CHECK_INTERVAL_MS = 5_000;
52
+ // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
53
+ // realtime resets the slow window (hysteresis).
54
+ const BUDGET_SPEED_SLOW = 0.95;
55
+ const BUDGET_SPEED_OK = 1.0;
56
+ // Slow must persist this long before a downshift (absorbs warm-up + brief
57
+ // complex scenes; the cumulative average won't dip this long unless the host
58
+ // genuinely can't keep up).
59
+ const BUDGET_SUSTAINED_MS = 15_000;
60
+ // After a downshift, wait this long before another (lets the new profile settle
61
+ // and a fresh cumulative average build).
62
+ const BUDGET_ACTION_COOLDOWN_MS = 30_000;
63
+ // Never step down more than this many rungs below the startup choice.
64
+ const BUDGET_MAX_DOWNSHIFTS = 3;
65
+ // The input counts as "keeping up" when the torrent downloads at least this
66
+ // multiple of the source's average byte rate. Below it (and not yet fully
67
+ // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
68
+ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
39
69
  const MICROSECONDS_PER_SECOND = 1_000_000;
40
70
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
41
71
  // Read segment files in large blocks so the body is delivered to the data
@@ -625,10 +655,15 @@ export class HlsSessionManager {
625
655
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
626
656
  startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
627
657
  videoEncoder = null,
628
- softwarePresetBenchmark = null
658
+ softwarePresetBenchmark = null,
659
+ getSourceStats = null
629
660
  }) {
630
661
  this.enabled = Boolean(enabled);
631
662
  this.ffmpegBin = ffmpegBin;
663
+ // Optional async accessor for a source's live download stats, used by the
664
+ // realtime budget to tell a CPU limit from a download-starved input:
665
+ // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
666
+ this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
632
667
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
633
668
  // software libx264 when no detection result is supplied. May be downgraded
634
669
  // to software at runtime if a hardware encode fails.
@@ -647,6 +682,13 @@ export class HlsSessionManager {
647
682
  void this.cleanupExpired();
648
683
  }, CLEANUP_INTERVAL_MS);
649
684
  this.cleanupTimer.unref();
685
+ // Realtime-budget monitor: only meaningful for the software encoder with a
686
+ // benchmark (the only path that can pick/step resolution). Cheap no-op scan
687
+ // otherwise.
688
+ this.budgetTimer = setInterval(() => {
689
+ void this.#enforceRealtimeBudget();
690
+ }, BUDGET_CHECK_INTERVAL_MS);
691
+ this.budgetTimer.unref();
650
692
  }
651
693
 
652
694
  /**
@@ -787,17 +829,25 @@ export class HlsSessionManager {
787
829
  const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
788
830
  const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
789
831
 
790
- // Pick the highest-quality software preset that still encodes the actual
791
- // (source-capped) output resolution faster than realtime. Null for hardware
792
- // encoders or when the source size / benchmark is unavailable — buildVideoArgs
793
- // then uses its static default preset.
794
- const softwarePreset = this.#chooseSoftwarePreset({
832
+ // Realtime budget (software encoder): pick the output resolution + libx264
833
+ // preset this host can encode faster than realtime. On a weak host this
834
+ // downscales below the client target (the orientation-independent ceiling)
835
+ // instead of dropping into sub-realtime playback. Null for hardware
836
+ // encoders or when the source size / benchmark is unavailable — the encode
837
+ // then keeps the client target box and buildVideoArgs's default preset.
838
+ const encodeBudget = this.#chooseEncodeBudget({
795
839
  transcodeVideo,
796
840
  targetWidth: normalizedTargetWidth,
797
841
  targetHeight: normalizedTargetHeight,
798
842
  sourceWidth,
799
- sourceHeight
843
+ sourceHeight,
844
+ outputFps
800
845
  });
846
+ const softwarePreset = encodeBudget?.preset ?? null;
847
+ // Effective encode box: the budget's downscaled resolution when applied,
848
+ // otherwise the client target (0 = keep source, handled by buildVideoArgs).
849
+ const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
850
+ const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
801
851
 
802
852
  const session = {
803
853
  id: sessionId,
@@ -818,8 +868,24 @@ export class HlsSessionManager {
818
868
  transcodeAudio,
819
869
  audioTrackIndex: normalizedAudioTrack,
820
870
  outputFps,
871
+ // Client-requested target box (the orientation-independent ceiling). Kept
872
+ // for the session key and reference; the actual encode uses encodeWidth/
873
+ // encodeHeight, which the realtime budget may have downscaled below this.
821
874
  targetWidth: normalizedTargetWidth,
822
875
  targetHeight: normalizedTargetHeight,
876
+ // Effective encode resolution handed to ffmpeg (budget-selected on weak
877
+ // software hosts, else the client target). 0 = keep source.
878
+ encodeWidth,
879
+ encodeHeight,
880
+ // Realtime-budget runtime state (software encoder only). The ladder is the
881
+ // resolution rungs from the ceiling down; rungIndex is the current rung.
882
+ // The monitor steps rungIndex down when the encoder is sustainedly
883
+ // CPU-bound and restarts ffmpeg at the current segment.
884
+ budgetLadder: encodeBudget?.ladder ?? null,
885
+ budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
886
+ budgetDownshifts: 0,
887
+ budgetSlowSince: 0,
888
+ budgetLastActionAt: 0,
823
889
  sourceWidth,
824
890
  sourceHeight,
825
891
  // Container start time (seconds); subtracted on the copy path so the
@@ -866,6 +932,9 @@ export class HlsSessionManager {
866
932
  `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
867
933
  `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
868
934
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
935
+ // Effective encode resolution and whether the realtime budget downscaled
936
+ // it below the client target (the orientation-independent ceiling).
937
+ `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
869
938
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
870
939
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
871
940
  );
@@ -971,25 +1040,190 @@ export class HlsSessionManager {
971
1040
  }
972
1041
 
973
1042
  /**
974
- * Choose the libx264 preset for a software video transcode: the highest
975
- * quality the startup benchmark says this host can encode at the actual
976
- * (source-capped) output resolution faster than realtime. Returns null when
977
- * not applicable (no video transcode, hardware encoder, or missing
978
- * benchmark/source size) buildVideoArgs then uses its default preset.
1043
+ * Realtime budget (software encoder only): choose the output resolution AND
1044
+ * libx264 preset this host can encode faster than realtime, from the startup
1045
+ * benchmark. The ceiling is the client-requested box capped to the source
1046
+ * (never upscaled); the budget picks the highest resolution rung at or below
1047
+ * that ceiling that clears realtime × margin, then the best preset at that
1048
+ * resolution. On a weak host this downscales below the client target instead
1049
+ * of dropping into sub-realtime playback. Returns null when not applicable
1050
+ * (no video transcode, hardware encoder, or missing benchmark/source size) —
1051
+ * the encode then keeps the ceiling resolution and the default preset.
979
1052
  *
980
- * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null }} params
981
- * @returns {string | null}
1053
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
1054
+ * @returns {{ width: number, height: number, preset: string } | null}
982
1055
  */
983
- #chooseSoftwarePreset({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight }) {
1056
+ #chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
984
1057
  if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
985
1058
  return null;
986
1059
  }
987
- const out = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
988
- if (!out) {
1060
+ const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1061
+ if (!ceiling) {
1062
+ return null;
1063
+ }
1064
+ return chooseSoftwareEncodeSettings(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
1065
+ }
1066
+
1067
+ /**
1068
+ * Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
1069
+ * number. Returns null when it cannot be parsed (no data yet).
1070
+ *
1071
+ * @param {string} value
1072
+ * @returns {number | null}
1073
+ */
1074
+ #parseSpeed(value) {
1075
+ if (typeof value !== "string" || value.length === 0) {
989
1076
  return null;
990
1077
  }
991
- const pixelsPerSecNeeded = out.w * out.h * TRANSCODE_FPS;
992
- return pickSoftwarePreset(this.softwarePresetBenchmark, pixelsPerSecNeeded);
1078
+ const numeric = Number.parseFloat(value);
1079
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
1080
+ }
1081
+
1082
+ /**
1083
+ * Realtime budget monitor (software encoder only). For each active
1084
+ * software-transcode session, watch the encoder's cumulative `speed`: when it
1085
+ * stays below realtime for a sustained window AND the input is not
1086
+ * download-starved (so the limit is the encoder, not the torrent), step the
1087
+ * resolution one rung down the ladder and restart the encode at the current
1088
+ * segment. Conservative: sustained window, post-action cooldown, a step cap,
1089
+ * and a resolution floor (the last ladder rung). No upswitch in v1.
1090
+ *
1091
+ * @returns {Promise<void>}
1092
+ */
1093
+ async #enforceRealtimeBudget() {
1094
+ if (this.videoEncoder?.kind !== "software") {
1095
+ return;
1096
+ }
1097
+ const now = Date.now();
1098
+ for (const session of this.sessionsById.values()) {
1099
+ if (
1100
+ !session ||
1101
+ session.state === "disposed" ||
1102
+ session.state === "failed" ||
1103
+ !session.transcodeVideo ||
1104
+ !Array.isArray(session.budgetLadder) ||
1105
+ session.budgetLadder.length < 2
1106
+ ) {
1107
+ continue;
1108
+ }
1109
+ // Already at the floor or out of steps — nothing more to give.
1110
+ if (
1111
+ session.budgetRungIndex >= session.budgetLadder.length - 1 ||
1112
+ session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
1113
+ ) {
1114
+ continue;
1115
+ }
1116
+ const speed = this.#parseSpeed(session.progress?.speed);
1117
+ if (speed === null) {
1118
+ continue; // no measurement yet
1119
+ }
1120
+ if (speed >= BUDGET_SPEED_OK) {
1121
+ session.budgetSlowSince = 0; // recovered — reset the slow window
1122
+ continue;
1123
+ }
1124
+ if (speed >= BUDGET_SPEED_SLOW) {
1125
+ continue; // in the hysteresis band; neither slow nor ok
1126
+ }
1127
+ // speed < BUDGET_SPEED_SLOW — track how long it has been slow.
1128
+ if (session.budgetSlowSince === 0) {
1129
+ session.budgetSlowSince = now;
1130
+ continue;
1131
+ }
1132
+ if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
1133
+ continue; // not sustained yet
1134
+ }
1135
+ if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1136
+ continue; // let the previous action settle
1137
+ }
1138
+ // Sustained sub-realtime. Only downscale if the encoder — not a
1139
+ // download-starved input — is the limit.
1140
+ const bound = await this.#classifyTranscodeBound(session);
1141
+ if (bound === "download") {
1142
+ logger.info(
1143
+ `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
1144
+ `"${session.fileName}"; not downscaling (torrent is the bottleneck)`
1145
+ );
1146
+ session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1147
+ continue;
1148
+ }
1149
+ this.#applyBudgetDownshift(session, speed, bound);
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Decide whether a sustained sub-realtime transcode is limited by the encoder
1155
+ * (CPU) or by a download-starved input. Compares the torrent's download rate
1156
+ * with the source's average byte rate; a fully-downloaded file can never be
1157
+ * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
1158
+ * as CPU by the caller — the common case, logged as such).
1159
+ *
1160
+ * @param {HlsSession} session
1161
+ * @returns {Promise<"cpu" | "download" | "unknown">}
1162
+ */
1163
+ async #classifyTranscodeBound(session) {
1164
+ if (!this.getSourceStats) {
1165
+ return "unknown";
1166
+ }
1167
+ let stats;
1168
+ try {
1169
+ stats = await this.getSourceStats(session.sourceKey, session.fileIndex);
1170
+ } catch {
1171
+ return "unknown";
1172
+ }
1173
+ if (!stats) {
1174
+ return "unknown";
1175
+ }
1176
+ // A fully (or almost fully) downloaded file cannot be download-bound.
1177
+ if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
1178
+ return "cpu";
1179
+ }
1180
+ const duration = Number.isFinite(session.totalDurationSeconds) ? session.totalDurationSeconds : 0;
1181
+ const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
1182
+ const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
1183
+ if (duration <= 0 || length <= 0) {
1184
+ return "unknown"; // cannot compute the source byte rate
1185
+ }
1186
+ const sourceByteRate = length / duration;
1187
+ return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
1188
+ }
1189
+
1190
+ /**
1191
+ * Step a session one resolution rung down the budget ladder and restart the
1192
+ * encode at the current segment with the lighter profile.
1193
+ *
1194
+ * @param {HlsSession} session
1195
+ * @param {number} speed - The measured (sub-realtime) speed, for logging.
1196
+ * @param {"cpu" | "unknown"} bound
1197
+ * @returns {void}
1198
+ */
1199
+ #applyBudgetDownshift(session, speed, bound) {
1200
+ const nextIndex = session.budgetRungIndex + 1;
1201
+ const rung = session.budgetLadder[nextIndex];
1202
+ if (!rung) {
1203
+ return;
1204
+ }
1205
+ const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
1206
+ session.budgetRungIndex = nextIndex;
1207
+ session.budgetDownshifts += 1;
1208
+ session.budgetLastActionAt = Date.now();
1209
+ session.budgetSlowSince = 0;
1210
+ session.encodeWidth = rung.width;
1211
+ session.encodeHeight = rung.height;
1212
+ session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
1213
+ // Restart at the current live-edge segment so the lighter profile takes over
1214
+ // from where the viewer is watching (hard-restart tier).
1215
+ const head = session.encodeStartIndex;
1216
+ const processed = Number.isFinite(session.progress?.processedSeconds)
1217
+ ? session.progress.processedSeconds
1218
+ : this.#segmentStartTime(session, head);
1219
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1220
+ logger.info(
1221
+ `[budget] transcode ${session.id} ${bound === "unknown" ? "assuming CPU-bound" : "CPU-bound"} ` +
1222
+ `speed=${speed.toFixed(2)}x → downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
1223
+ `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1224
+ `restart at segment #${currentSeg} "${session.fileName}"`
1225
+ );
1226
+ this.#startEncodeRun(session, currentSeg);
993
1227
  }
994
1228
 
995
1229
  /**
@@ -1027,8 +1261,10 @@ export class HlsSessionManager {
1027
1261
  // codec args (including keyframe alignment on segment boundaries).
1028
1262
  const videoCodecArgs = session.transcodeVideo
1029
1263
  ? this.videoEncoder.buildVideoArgs({
1030
- targetWidth: session.targetWidth,
1031
- targetHeight: session.targetHeight,
1264
+ // Budget-selected encode box (may be below the client target on weak
1265
+ // software hosts); falls back to the client target for hardware.
1266
+ targetWidth: session.encodeWidth,
1267
+ targetHeight: session.encodeHeight,
1032
1268
  segmentDurationSec: this.segmentDurationSec,
1033
1269
  // Source-inherited output rate (integer, capped); descriptors that
1034
1270
  // use time-based keyframes just apply it as the frame rate.
@@ -1115,6 +1351,11 @@ export class HlsSessionManager {
1115
1351
  session.progress.processedSeconds = startSeconds;
1116
1352
  session.progress.startPositionSeconds = startSeconds;
1117
1353
  session.progress.updatedAt = Date.now();
1354
+ // Any (re)start resets the cumulative `speed` ffmpeg reports, so reset the
1355
+ // realtime-budget slow window too — otherwise warm-up right after a user
1356
+ // seek could be mis-counted as sustained sub-realtime and trigger a
1357
+ // premature downscale.
1358
+ session.budgetSlowSince = 0;
1118
1359
 
1119
1360
  logger.info(
1120
1361
  `transcode ${session.id} encode-run from segment #${safeIndex} ` +
@@ -1533,6 +1774,7 @@ export class HlsSessionManager {
1533
1774
  */
1534
1775
  async disposeAll() {
1535
1776
  clearInterval(this.cleanupTimer);
1777
+ clearInterval(this.budgetTimer);
1536
1778
  const activeIds = Array.from(this.sessionsById.keys());
1537
1779
  for (const sessionId of activeIds) {
1538
1780
  await this.disposeSession(sessionId);
@@ -558,3 +558,87 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
558
558
  }
559
559
  return benchmark[benchmark.length - 1].preset;
560
560
  }
561
+
562
+ // Resolution-ladder heights (output height rungs), high→low. The ladder is
563
+ // derived per-stream from the ceiling (the client-requested, source-capped
564
+ // output box): only rungs at or below the ceiling height are used, so the
565
+ // budget never upscales past what the client asked for. Standard heights keep
566
+ // the downscaled output at familiar resolutions.
567
+ const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
568
+
569
+ /**
570
+ * Build the resolution ladder for a ceiling box. Returns candidate output
571
+ * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
572
+ * each even-sized. The ceiling itself is always the top rung; ladder heights
573
+ * at or above it are skipped (never upscale). Deduped by height.
574
+ *
575
+ * @param {number} ceilingWidth
576
+ * @param {number} ceilingHeight
577
+ * @returns {Array<{ width: number, height: number }>} high→low
578
+ */
579
+ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
580
+ const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
581
+ const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
582
+ if (!cw || !ch) {
583
+ return [];
584
+ }
585
+ const even = (v) => {
586
+ const r = Math.round(v);
587
+ return Math.max(2, r - (r % 2));
588
+ };
589
+ /** @type {Array<{ width: number, height: number }>} */
590
+ const rungs = [{ width: cw, height: ch }];
591
+ for (const h of RESOLUTION_LADDER_HEIGHTS) {
592
+ if (h >= ch) {
593
+ continue; // at/above the ceiling — the ceiling rung already covers it
594
+ }
595
+ rungs.push({ width: even(cw * (h / ch)), height: h });
596
+ }
597
+ const seen = new Set();
598
+ return rungs.filter((rung) => {
599
+ if (seen.has(rung.height)) {
600
+ return false;
601
+ }
602
+ seen.add(rung.height);
603
+ return true;
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Choose the software encode settings (resolution + preset) that fit the
609
+ * realtime budget on this host. From the resolution ladder (ceiling downward),
610
+ * pick the HIGHEST rung whose encode throughput — predicted from the startup
611
+ * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
612
+ * that resolution, pick the highest-quality preset that still clears the
613
+ * margin. When even the lowest rung cannot clear it, use the lowest rung with
614
+ * the fastest preset (best effort — a smaller picture beats sub-realtime
615
+ * playback at full size). Returns null when no benchmark or ceiling is
616
+ * available (the caller keeps the ceiling resolution and the default preset).
617
+ *
618
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
619
+ * @param {{ width: number, height: number }} ceiling
620
+ * @param {number} outputFps
621
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
622
+ */
623
+ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps) {
624
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
625
+ return null;
626
+ }
627
+ const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
628
+ const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
629
+ if (ladder.length === 0) {
630
+ return null;
631
+ }
632
+ const fastest = benchmark[benchmark.length - 1].pixelsPerSec; // ultrafast throughput
633
+ let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
634
+ for (let i = 0; i < ladder.length; i += 1) {
635
+ const needed = ladder[i].width * ladder[i].height * fps;
636
+ if (fastest >= needed * PRESET_SPEED_MARGIN) {
637
+ chosenIndex = i;
638
+ break;
639
+ }
640
+ }
641
+ const chosen = ladder[chosenIndex];
642
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps);
643
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
644
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file Content-based subtitle language detection (proxy side).
3
+ *
4
+ * Uses `franc` (n-gram / trigram frequency against per-language reference
5
+ * profiles — MIT). Runs on the proxy where the full subtitle text and
6
+ * node_modules live, so no detection model ships to the browser. Detection is
7
+ * restricted to a curated set of plausible subtitle languages via franc's
8
+ * `only` option: this both maps ISO 639-3 → ISO 639-1 + English name and
9
+ * avoids exotic false positives on short text (e.g. English mis-detected as
10
+ * Scots). Returns null when franc is not confident (too little text, or
11
+ * undetermined).
12
+ */
13
+
14
+ import { franc } from "franc";
15
+
16
+ /** ISO 639-3 (franc output) → { code: ISO 639-1 / BCP-47, name }. Curated allowlist. */
17
+ const LANG_3_TO_1 = {
18
+ eng: { code: "en", name: "English" },
19
+ rus: { code: "ru", name: "Russian" },
20
+ ukr: { code: "uk", name: "Ukrainian" },
21
+ bel: { code: "be", name: "Belarusian" },
22
+ jpn: { code: "ja", name: "Japanese" },
23
+ kor: { code: "ko", name: "Korean" },
24
+ cmn: { code: "zh", name: "Chinese" },
25
+ spa: { code: "es", name: "Spanish" },
26
+ fra: { code: "fr", name: "French" },
27
+ deu: { code: "de", name: "German" },
28
+ ita: { code: "it", name: "Italian" },
29
+ por: { code: "pt", name: "Portuguese" },
30
+ pol: { code: "pl", name: "Polish" },
31
+ nld: { code: "nl", name: "Dutch" },
32
+ arb: { code: "ar", name: "Arabic" },
33
+ tur: { code: "tr", name: "Turkish" },
34
+ vie: { code: "vi", name: "Vietnamese" },
35
+ tha: { code: "th", name: "Thai" },
36
+ hin: { code: "hi", name: "Hindi" },
37
+ ind: { code: "id", name: "Indonesian" },
38
+ zlm: { code: "ms", name: "Malay" },
39
+ ces: { code: "cs", name: "Czech" },
40
+ slk: { code: "sk", name: "Slovak" },
41
+ ron: { code: "ro", name: "Romanian" },
42
+ hun: { code: "hu", name: "Hungarian" },
43
+ srp: { code: "sr", name: "Serbian" },
44
+ hrv: { code: "hr", name: "Croatian" },
45
+ bul: { code: "bg", name: "Bulgarian" },
46
+ ell: { code: "el", name: "Greek" },
47
+ heb: { code: "he", name: "Hebrew" },
48
+ dan: { code: "da", name: "Danish" },
49
+ fin: { code: "fi", name: "Finnish" },
50
+ nob: { code: "no", name: "Norwegian" },
51
+ swe: { code: "sv", name: "Swedish" },
52
+ fas: { code: "fa", name: "Persian" }
53
+ };
54
+
55
+ const ONLY = Object.keys(LANG_3_TO_1);
56
+
57
+ /**
58
+ * Best-effort detect the language of subtitle text.
59
+ *
60
+ * @param {string} text - Decoded subtitle text (VTT/SRT/ASS — franc ignores markup well enough).
61
+ * @returns {{ code: string, name: string } | null} Detected language, or null when uncertain.
62
+ */
63
+ export function detectLanguage(text) {
64
+ if (typeof text !== "string" || text.trim().length < 15) {
65
+ return null;
66
+ }
67
+ // Restrict to plausible subtitle languages; require a little text.
68
+ const iso3 = franc(text, { only: ONLY, minLength: 15 });
69
+ if (iso3 === "und") {
70
+ return null;
71
+ }
72
+ return LANG_3_TO_1[iso3] ?? null;
73
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file Subtitle conversion (proxy side).
3
+ *
4
+ * Decodes subtitle file bytes (encoding-aware) and converts SubRip (.srt) and
5
+ * ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
6
+ * without any client-side conversion. The proxy owns subtitle conversion so it
7
+ * can also run language detection where the full text is available.
8
+ */
9
+
10
+ /**
11
+ * Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
12
+ * decode yields many replacement characters the bytes are re-decoded as
13
+ * Windows-1251 (very common for Russian .srt files) — otherwise both display
14
+ * and language detection would see mojibake.
15
+ *
16
+ * @param {Buffer | Uint8Array} bytes
17
+ * @returns {string}
18
+ */
19
+ export function decodeSubtitleBytes(bytes) {
20
+ const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
21
+ // UTF-8 BOM → definitely UTF-8.
22
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
23
+ return new TextDecoder("utf-8").decode(buf);
24
+ }
25
+ const utf8 = new TextDecoder("utf-8").decode(buf);
26
+ const replacements = (utf8.match(/�/g) || []).length;
27
+ // >0.5% replacement chars ⇒ not valid UTF-8; try the common legacy Cyrillic
28
+ // codepage. TextDecoder supports windows-1251 with a full-ICU Node build.
29
+ if (replacements > Math.max(2, utf8.length * 0.005)) {
30
+ try {
31
+ return new TextDecoder("windows-1251").decode(buf);
32
+ } catch {
33
+ // Decoder unavailable — fall back to the UTF-8 attempt.
34
+ }
35
+ }
36
+ return utf8;
37
+ }
38
+
39
+ /** Strip a leading UTF-8 BOM so it never leaks into the WEBVTT signature or first cue. */
40
+ function stripBom(text) {
41
+ return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
42
+ }
43
+
44
+ function srtTsToVtt(ts) {
45
+ return ts.replace(",", ".");
46
+ }
47
+
48
+ /**
49
+ * Convert SubRip (.srt) text to WebVTT.
50
+ *
51
+ * @param {string} text
52
+ * @returns {string}
53
+ */
54
+ function srtToVtt(text) {
55
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
56
+ const out = ["WEBVTT", ""];
57
+ for (const line of lines) {
58
+ const m = line.match(/^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})(.*)?$/);
59
+ out.push(m ? `${srtTsToVtt(m[1])} --> ${srtTsToVtt(m[2])}${m[3] ?? ""}` : line);
60
+ }
61
+ return out.join("\n");
62
+ }
63
+
64
+ function assTsToVtt(ts) {
65
+ const m = ts.match(/^(\d+):(\d{2}):(\d{2})\.(\d{2})$/);
66
+ if (!m) {
67
+ return "00:00:00.000";
68
+ }
69
+ const ms = (parseInt(m[4], 10) * 10).toString().padStart(3, "0");
70
+ return `${m[1].padStart(2, "0")}:${m[2]}:${m[3]}.${ms}`;
71
+ }
72
+
73
+ function stripAssTags(text) {
74
+ return text
75
+ .replace(/\{[^}]*\}/g, "")
76
+ .replace(/\\N/g, "\n")
77
+ .replace(/\\n/g, "\n")
78
+ .replace(/\\h/g, " ")
79
+ .trim();
80
+ }
81
+
82
+ /**
83
+ * Convert ASS/SSA text to WebVTT (only the [Events] section; styling dropped).
84
+ *
85
+ * @param {string} text
86
+ * @returns {string}
87
+ */
88
+ function assToVtt(text) {
89
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
90
+ let inEvents = false;
91
+ let formatCols = null;
92
+ const cues = [];
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ if (trimmed === "[Events]") {
96
+ inEvents = true;
97
+ continue;
98
+ }
99
+ if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
100
+ inEvents = false;
101
+ continue;
102
+ }
103
+ if (!inEvents) {
104
+ continue;
105
+ }
106
+ if (trimmed.startsWith("Format:")) {
107
+ formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
108
+ continue;
109
+ }
110
+ if (trimmed.startsWith("Dialogue:") && formatCols) {
111
+ const parts = trimmed.slice("Dialogue:".length).split(",");
112
+ const startIdx = formatCols.indexOf("start");
113
+ const endIdx = formatCols.indexOf("end");
114
+ const textIdx = formatCols.indexOf("text");
115
+ if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
116
+ continue;
117
+ }
118
+ const cueText = stripAssTags(parts.slice(textIdx).join(","));
119
+ if (!cueText) {
120
+ continue;
121
+ }
122
+ cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
123
+ }
124
+ }
125
+ return cues.length === 0 ? "WEBVTT\n" : `WEBVTT\n\n${cues.join("\n\n")}`;
126
+ }
127
+
128
+ /**
129
+ * Convert subtitle text to WebVTT by file extension. Returns null for formats
130
+ * that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
131
+ *
132
+ * @param {string} text
133
+ * @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
134
+ * @returns {string | null}
135
+ */
136
+ export function convertSubtitleToVtt(text, ext) {
137
+ const clean = stripBom(text);
138
+ switch (ext) {
139
+ case ".vtt":
140
+ case ".webvtt":
141
+ return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
142
+ case ".srt":
143
+ return srtToVtt(clean);
144
+ case ".ass":
145
+ case ".ssa":
146
+ return assToVtt(clean);
147
+ default:
148
+ return null;
149
+ }
150
+ }