@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 +9 -0
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -0
- package/openspec/changes/subtitle-language/proposal.md +50 -0
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -0
- package/openspec/changes/subtitle-language/tasks.md +26 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +59 -0
- package/openspec/changes/transcode-quality/tasks.md +21 -5
- package/package.json +2 -1
- package/routes/api/subtitles/get.js +92 -30
- package/server.js +15 -1
- package/services/hls-session-manager.js +264 -22
- package/services/hwaccel.js +84 -0
- package/services/language-detect.js +73 -0
- package/services/subtitle-convert.js +150 -0
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,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
|
|
15
|
-
|
|
16
|
-
- [
|
|
17
|
-
margin; downscale instead of
|
|
18
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
38
|
-
return reply.code(400).send({ error: "sourceKey
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
|
|
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
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
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
|
-
#
|
|
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
|
|
988
|
-
if (!
|
|
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
|
|
992
|
-
return
|
|
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
|
-
|
|
1031
|
-
|
|
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);
|
package/services/hwaccel.js
CHANGED
|
@@ -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
|
+
}
|