From 556c5cb05f3c25bc32ecc240f1a54cede7680332 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 10 Jun 2026 10:44:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(hls):=20forced-idr=20en=20NVENC/QSV=20?= =?UTF-8?q?=E2=80=94=20los=20segmentos=20ignoraban=20force=5Fkey=5Fframes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NVENC (ffmpeg 6.1 + drivers actuales) emite los keyframes forzados por -force_key_frames como I-frames NO-IDR; el muxer HLS solo corta en IDR, así que cada segmento se estiraba en silencio al GOP por defecto (250 frames ≈ 10.4 s @24fps) mientras la playlist server-side seguía prometiendo 2 s por segmento. Con los PTS reales ~5× fuera del mapa de la playlist, los seeks aterrizaban donde podían y los subtítulos se desincronizaban en cuanto se mezclaban segmentos de runs distintos (seek-restart) en el mismo dir. Medido: 3 segmentos por 30 s de encode en vez de 15; con -forced-idr 1 exactamente 15, y post-fix seg-150/151/158 arrancan en 300.0/302.0/316.0 clavados. Afecta a TODO el HLS por NVENC histórico (no era del rate control nuevo: la config de bitrate fijo producía lo mismo). QSV recibe su grafía -forced_idr. Las entradas de caché viejas nunca llegaron a sellarse (el conteo de segmentos no cuadraba), así que no hay migración: solo sesiones vivas estaban afectadas. --- internal/engine/hls.go | 13 +++++++++++-- internal/engine/hls_ratecontrol_test.go | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index f617b75..7062b5d 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1483,12 +1483,21 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin // scene-cut). No B-frame reorder → monotonic DTS → uniform segments, no // "Packet duration is out of range" flood. Safe with -force_key_frames // (unlike -tune ll, which broke per-segment cuts — see note above). - args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-bf", "0", "-no-scenecut", "1") + // -forced-idr 1 is LOAD-BEARING: NVENC emits -force_key_frames frames + // as plain (non-IDR) I-frames on current ffmpeg/driver combos, the HLS + // muxer only cuts on IDR, and every segment silently stretches to the + // default GOP (250 frames ≈ 10.4 s @24fps) while the server-rendered + // playlist still promises hlsSegmentDuration. The PTS↔playlist mismatch + // breaks seeks and desyncs subtitles (measured 2026-06-10: 3 segments + // per 30 s instead of 15; with -forced-idr exactly 15). + args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-bf", "0", "-no-scenecut", "1", "-forced-idr", "1") case "h264_qsv": // veryfast is the fastest realistic QSV preset; medium was too // conservative for first-start. look_ahead=0 keeps the encoder // truly low-latency (no rate-control look-ahead window). - args = append(args, "-preset", profile.Preset, "-look_ahead", "0") + // -forced_idr: same non-IDR forced-keyframe failure mode as NVENC (see + // above) — QSV's AVOption spells it with an underscore. + args = append(args, "-preset", profile.Preset, "-look_ahead", "0", "-forced_idr", "1") case "h264_videotoolbox": // VideoToolbox has no "preset" knob; `-realtime` flips into the // low-latency path used by FaceTime. We let the `-b:v / -maxrate diff --git a/internal/engine/hls_ratecontrol_test.go b/internal/engine/hls_ratecontrol_test.go index 612f391..a971277 100644 --- a/internal/engine/hls_ratecontrol_test.go +++ b/internal/engine/hls_ratecontrol_test.go @@ -88,7 +88,10 @@ func TestBuildHLSFFmpegArgsRateControl(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"} { + // -forced-idr 1 is load-bearing: without it NVENC emits the forced + // keyframes as non-IDR and every HLS segment stretches to the full + // GOP, desyncing the playlist timeline (subs/seeks). + for _, want := range []string{"-rc vbr", "-cq 23", "-b:v 0", "-maxrate 6000k", "-bufsize 12000k", "-forced-idr 1"} { if !strings.Contains(got, want) { t.Errorf("nvenc argv missing %q\n%s", want, got) }