@torrent-tv/proxy 2.9.31 → 2.9.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +23 -0
- package/openspec/changes/transcode-quality/tasks.md +11 -4
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +5 -0
- package/services/hls-session-manager.js +23 -11
- package/services/playback-planner.js +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.9.32
|
|
2
|
+
|
|
3
|
+
- **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
|
|
4
|
+
|
|
1
5
|
## 2.9.31
|
|
2
6
|
|
|
3
7
|
- **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).
|
|
@@ -94,3 +94,26 @@ steps, and a resolution floor, and SHALL reset its slow window on every encode
|
|
|
94
94
|
- **WHEN** a downswitch (or a viewer seek) has just restarted the encode
|
|
95
95
|
- **THEN** the slow window is reset and no further downswitch occurs until a new
|
|
96
96
|
sustained-slow window elapses after the cooldown
|
|
97
|
+
|
|
98
|
+
### Requirement: Manual quality forces a constant resolution
|
|
99
|
+
|
|
100
|
+
The viewer SHALL be able to force a specific output resolution instead of Auto.
|
|
101
|
+
When a resolution is forced the proxy SHALL encode exactly that box (capped to
|
|
102
|
+
the source, never upscaled) with the realtime budget disabled — no startup
|
|
103
|
+
auto-downscale and no runtime downswitch — so the resolution stays constant for
|
|
104
|
+
the whole session. The browser SHALL offer Auto plus resolutions at or below the
|
|
105
|
+
source height, built from the source resolution reported in the playback plan.
|
|
106
|
+
Selecting a quality SHALL re-open the stream at the new resolution with the
|
|
107
|
+
playback position preserved. Auto SHALL keep the current realtime-budget
|
|
108
|
+
behaviour.
|
|
109
|
+
|
|
110
|
+
#### Scenario: Forced resolution is constant
|
|
111
|
+
- **WHEN** the viewer forces a resolution (e.g. 480p)
|
|
112
|
+
- **THEN** the proxy encodes at that resolution for the whole session, with no
|
|
113
|
+
budget downscale or runtime downswitch, and playback resumes at the same
|
|
114
|
+
position
|
|
115
|
+
|
|
116
|
+
#### Scenario: Auto
|
|
117
|
+
- **WHEN** the viewer selects Auto
|
|
118
|
+
- **THEN** the proxy applies the realtime budget (startup selection + runtime
|
|
119
|
+
downswitch) as before
|
|
@@ -39,10 +39,17 @@
|
|
|
39
39
|
- [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
|
|
40
40
|
- [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
|
|
41
41
|
|
|
42
|
-
## 4. Manual quality
|
|
43
|
-
|
|
44
|
-
- [
|
|
45
|
-
-
|
|
42
|
+
## 4. Manual quality
|
|
43
|
+
|
|
44
|
+
- [x] 4.1 Proxy honours a forced resolution: `manualQuality` flag on the
|
|
45
|
+
transcode-session request encodes the requested box exactly (capped to
|
|
46
|
+
source, budget + runtime downswitch disabled); `manualQuality` in the
|
|
47
|
+
session key; playback plan reports source `videoWidth`/`videoHeight`.
|
|
48
|
+
- [x] 4.2 Server Quality menu (Auto + forced resolutions <= source). Custom
|
|
49
|
+
media-chrome submenu mirroring the audio menu; selection re-opens the
|
|
50
|
+
stream at the forced resolution with the position preserved (reuses the
|
|
51
|
+
audio-switch machinery); shared settings button shows for audio OR
|
|
52
|
+
quality.
|
|
46
53
|
|
|
47
54
|
## 5. Release
|
|
48
55
|
|
package/package.json
CHANGED
|
@@ -33,6 +33,10 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
33
33
|
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
|
34
34
|
const targetWidth = Number(payload.targetWidth);
|
|
35
35
|
const targetHeight = Number(payload.targetHeight);
|
|
36
|
+
// Manual quality: the target box is a user-forced resolution, encoded exactly
|
|
37
|
+
// (capped to source), with the realtime budget's auto-downscale + runtime
|
|
38
|
+
// downswitch disabled for the session.
|
|
39
|
+
const manualQuality = payload.manualQuality === true;
|
|
36
40
|
const startPositionSeconds = Number(payload.startPositionSeconds);
|
|
37
41
|
const audioTrackIndex = Number(payload.audioTrackIndex);
|
|
38
42
|
|
|
@@ -50,6 +54,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
50
54
|
fileName,
|
|
51
55
|
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
52
56
|
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
57
|
+
manualQuality,
|
|
53
58
|
startPositionSeconds:
|
|
54
59
|
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
55
60
|
? startPositionSeconds
|
|
@@ -709,6 +709,7 @@ export class HlsSessionManager {
|
|
|
709
709
|
* @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
|
|
710
710
|
* @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
|
|
711
711
|
* @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
|
|
712
|
+
* @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
|
|
712
713
|
* @returns {Promise<HlsSession>}
|
|
713
714
|
*/
|
|
714
715
|
async createOrGetSession({
|
|
@@ -721,7 +722,8 @@ export class HlsSessionManager {
|
|
|
721
722
|
targetWidth = 0,
|
|
722
723
|
targetHeight = 0,
|
|
723
724
|
startPositionSeconds = 0,
|
|
724
|
-
audioTrackIndex = 0
|
|
725
|
+
audioTrackIndex = 0,
|
|
726
|
+
manualQuality = false
|
|
725
727
|
}) {
|
|
726
728
|
if (!this.enabled) {
|
|
727
729
|
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
@@ -739,6 +741,7 @@ export class HlsSessionManager {
|
|
|
739
741
|
: 0;
|
|
740
742
|
const normalizedAudioTrack =
|
|
741
743
|
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
|
|
744
|
+
const forceManualQuality = manualQuality === true && transcodeVideo;
|
|
742
745
|
const sourceMapKey = [
|
|
743
746
|
sourceKey,
|
|
744
747
|
String(fileIndex),
|
|
@@ -747,6 +750,7 @@ export class HlsSessionManager {
|
|
|
747
750
|
`t${normalizedAudioTrack}`,
|
|
748
751
|
String(normalizedTargetWidth),
|
|
749
752
|
String(normalizedTargetHeight),
|
|
753
|
+
forceManualQuality ? "q-manual" : "q-auto",
|
|
750
754
|
String(normalizedStartPosition)
|
|
751
755
|
].join(":");
|
|
752
756
|
const existingId = this.sessionIdBySource.get(sourceMapKey);
|
|
@@ -835,14 +839,21 @@ export class HlsSessionManager {
|
|
|
835
839
|
// instead of dropping into sub-realtime playback. Null for hardware
|
|
836
840
|
// encoders or when the source size / benchmark is unavailable — the encode
|
|
837
841
|
// then keeps the client target box and buildVideoArgs's default preset.
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
842
|
+
//
|
|
843
|
+
// Manual quality bypasses the budget entirely: the user forced a specific
|
|
844
|
+
// resolution, so encode exactly that box (capped to source by the scale
|
|
845
|
+
// filter) with the default preset, and the runtime downswitch is skipped
|
|
846
|
+
// for the session (budgetLadder stays null).
|
|
847
|
+
const encodeBudget = forceManualQuality
|
|
848
|
+
? null
|
|
849
|
+
: this.#chooseEncodeBudget({
|
|
850
|
+
transcodeVideo,
|
|
851
|
+
targetWidth: normalizedTargetWidth,
|
|
852
|
+
targetHeight: normalizedTargetHeight,
|
|
853
|
+
sourceWidth,
|
|
854
|
+
sourceHeight,
|
|
855
|
+
outputFps
|
|
856
|
+
});
|
|
846
857
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
847
858
|
// Effective encode box: the budget's downscaled resolution when applied,
|
|
848
859
|
// otherwise the client target (0 = keep source, handled by buildVideoArgs).
|
|
@@ -932,9 +943,10 @@ export class HlsSessionManager {
|
|
|
932
943
|
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
933
944
|
`seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
|
|
934
945
|
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
935
|
-
// Effective encode resolution
|
|
936
|
-
//
|
|
946
|
+
// Effective encode resolution: budget-on (auto downscale from the
|
|
947
|
+
// ceiling), manual (user-forced, budget off), or unset (keep source).
|
|
937
948
|
`${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
|
|
949
|
+
`${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
|
|
938
950
|
`${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
|
|
939
951
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
940
952
|
);
|
|
@@ -68,6 +68,18 @@ function parseStreams(ffmpegOutput) {
|
|
|
68
68
|
function parseStreamCodecs(ffmpegOutput) {
|
|
69
69
|
const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
|
|
70
70
|
const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
|
|
71
|
+
// Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
|
|
72
|
+
// The first WxH is the coded size (any trailing "[SAR …]" is ignored).
|
|
73
|
+
const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
|
|
74
|
+
let videoWidth = 0;
|
|
75
|
+
let videoHeight = 0;
|
|
76
|
+
if (videoLineMatch) {
|
|
77
|
+
const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
|
|
78
|
+
if (dim) {
|
|
79
|
+
videoWidth = Number(dim[1]);
|
|
80
|
+
videoHeight = Number(dim[2]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
71
83
|
const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
|
|
72
84
|
const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
|
|
73
85
|
let durationSeconds = 0;
|
|
@@ -106,6 +118,8 @@ function parseStreamCodecs(ffmpegOutput) {
|
|
|
106
118
|
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
|
|
107
119
|
container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
|
|
108
120
|
durationSeconds,
|
|
121
|
+
videoWidth,
|
|
122
|
+
videoHeight,
|
|
109
123
|
audioTracks,
|
|
110
124
|
subtitleTracks
|
|
111
125
|
};
|
|
@@ -208,6 +222,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
208
222
|
* @property {string} videoCodec
|
|
209
223
|
* @property {string} container - Demuxer/container name(s) reported by ffmpeg.
|
|
210
224
|
* @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
|
|
225
|
+
* @property {number} videoWidth - Source coded width (0 if unknown).
|
|
226
|
+
* @property {number} videoHeight - Source coded height (0 if unknown).
|
|
211
227
|
*/
|
|
212
228
|
|
|
213
229
|
/**
|
|
@@ -289,6 +305,8 @@ export function createPlaybackPlanner({
|
|
|
289
305
|
videoCodec: "",
|
|
290
306
|
container: "",
|
|
291
307
|
durationSeconds: 0,
|
|
308
|
+
videoWidth: 0,
|
|
309
|
+
videoHeight: 0,
|
|
292
310
|
audioTracks: [],
|
|
293
311
|
subtitleTracks: []
|
|
294
312
|
};
|
|
@@ -316,7 +334,7 @@ export function createPlaybackPlanner({
|
|
|
316
334
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
317
335
|
probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
318
336
|
}
|
|
319
|
-
const { audioCodec, videoCodec, container, durationSeconds, audioTracks, subtitleTracks } = probe;
|
|
337
|
+
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
320
338
|
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
321
339
|
|
|
322
340
|
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
@@ -331,6 +349,10 @@ export function createPlaybackPlanner({
|
|
|
331
349
|
videoCodec,
|
|
332
350
|
container,
|
|
333
351
|
durationSeconds,
|
|
352
|
+
// Source coded resolution — drives the browser's manual quality menu
|
|
353
|
+
// (list of forced resolutions <= source). 0 when unknown.
|
|
354
|
+
videoWidth,
|
|
355
|
+
videoHeight,
|
|
334
356
|
// Full track inventory for the browser's audio/subtitle menus.
|
|
335
357
|
audioTracks: audioTracks ?? [],
|
|
336
358
|
subtitleTracks: subtitleTracks ?? []
|