feat(hls): resume-aware first spawn + capped-CRF/CQ rate control

- HLSSessionConfig.StartSec (sync StreamSession.startSec): el primer
  ffmpeg arranca ya seekeado en el punto de resume (-ss +
  -output_ts_offset + -start_number, misma maquinaria que el
  seek-restart) en vez de encodear desde seg-0 para morir en el
  seek-restart inmediato del player (doble spawn, resume lento).
  readyMax se pre-siembra al índice de arranque; el ready-watcher
  compara ReadyCount() > WriterStartIdx() para no marcar "ready" antes
  del primer segmento real. startSec >= duración → arranque desde 0
  (resume obsoleto de un fichero reemplazado).
- Rate control: capped constant-quality donde el encoder lo hace bien —
  libx264 -crf 23, NVENC -cq 23 -b:v 0 — con el mismo -maxrate de
  siempre y -bufsize 2x (antes 1x estrangulaba picos). Escenas fáciles
  emiten muchos menos bits (menos stalls vía funnel/LTE); el peor caso
  no cambia. QSV/VideoToolbox/VAAPI conservan el triple de bitrate fijo
  probado (sus knobs de calidad tienen gotchas de vendor).
- Limpieza: wrapper buildHLSFFmpegArgs y guard startIdx<0 muertos.
This commit is contained in:
Deivid Soto 2026-06-10 00:21:15 +02:00
parent f7ca282ca0
commit 9b97aedfe4
5 changed files with 259 additions and 16 deletions

View file

@ -0,0 +1,111 @@
package engine
import (
"strings"
"testing"
)
func TestDoubleBitrate(t *testing.T) {
cases := map[string]string{
"6000k": "12000k",
"25000k": "50000k",
"1500k": "3000k",
"5M": "10M",
"1.5M": "3M",
"2.5m": "5m",
"800000": "1600000",
"": "",
"garbage": "garbage", // unparseable → unchanged (1× bufsize fallback)
"-5M": "-5M", // non-positive → unchanged
}
for in, want := range cases {
if got := doubleBitrate(in); got != want {
t.Errorf("doubleBitrate(%q) = %q, want %q", in, got, want)
}
}
}
// segmentIdxForTime must be the exact inverse of segmentStartSec so the
// resume-aware first spawn (HLSSessionConfig.StartSec) lands on the same
// segment the player's hls.js startPosition will request.
func TestSegmentIdxForTime(t *testing.T) {
cases := map[float64]int{
0: 0,
-3: 0,
0.5: 0,
1.99: 0,
2: 1,
3.9: 1,
60: 30,
3599.9: 1799,
}
for sec, want := range cases {
if got := segmentIdxForTime(sec); got != want {
t.Errorf("segmentIdxForTime(%v) = %d, want %d", sec, got, want)
}
}
// Round-trip: the start time of the segment we resolve must never be
// AFTER the requested position (the player would miss its first frames).
for _, sec := range []float64{0, 1, 2, 7.3, 119.9, 4321} {
idx := segmentIdxForTime(sec)
if start := segmentStartSec(idx); start > sec {
t.Errorf("segmentStartSec(segmentIdxForTime(%v)) = %v > %v", sec, start, sec)
}
}
}
// Capped constant-quality rate control: libx264 gets -crf (no -b:v), NVENC
// gets -cq with -b:v 0, both keep -maxrate at the level-coherent cap and a
// 2× -bufsize. VAAPI (and the other vendor encoders) keep the proven
// fixed-bitrate triple untouched.
func TestBuildHLSFFmpegArgsRateControl(t *testing.T) {
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
base := HLSSessionConfig{
SessionID: "test",
SourcePath: "/media/Movie.mkv",
Quality: "1080p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
},
}
t.Run("libx264 capped CRF", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNone
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-crf 23", "-maxrate 6000k", "-bufsize 12000k"} {
if !strings.Contains(got, want) {
t.Errorf("libx264 argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-b:v 6000k") {
t.Errorf("libx264 argv must not carry -b:v alongside -crf\n%s", got)
}
})
t.Run("nvenc constant-quality VBR", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNVENC
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-rc vbr", "-cq 23", "-b:v 0", "-maxrate 6000k", "-bufsize 12000k"} {
if !strings.Contains(got, want) {
t.Errorf("nvenc argv missing %q\n%s", want, got)
}
}
})
t.Run("vaapi keeps fixed-bitrate triple", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelVAAPI
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-b:v 6000k", "-maxrate 6000k", "-bufsize 6000k"} {
if !strings.Contains(got, want) {
t.Errorf("vaapi argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-crf") || strings.Contains(got, "-cq") {
t.Errorf("vaapi argv must not carry constant-quality flags\n%s", got)
}
})
}