diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md index 42a43cf..9f8dc5f 100644 --- a/Docs/plans/unarr-agent-roadmap.md +++ b/Docs/plans/unarr-agent-roadmap.md @@ -71,7 +71,7 @@ desde la web. Diseño + set de opciones en el estado abajo. - ~~Resume de torrent NO persiste reinicio del daemon~~ ✅ **Auto-resume tras reinicio (2026-05-31)** — `agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo. - Sin seeding/ratio lifecycle (flags existen, nadie los aplica). - Reproducir-mientras-baja: readahead estático 5MB, sin playhead→prioridad dinámica. -- HDR→SDR sin tonemap (zscale/zimg) → HDR desaturado. +- ~~HDR→SDR sin tonemap~~ ✅ **Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo. - ~~Sin thumbnails~~ ✅ **Fotogramas bajo demanda (2026-05-31)** — `GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Sprites/trickplay (scrubber pregenerado) siguen pendientes. Ver estado abajo. - Subtítulos bitmap (PGS/DVB) sin burn-in. - Audio siempre downmix estéreo AAC (sin passthrough 5.1). @@ -100,6 +100,12 @@ WireGuard endpoint sin pin · sesión única (1 viewer). - ~~**Rutas localizadas unarr 404 (media)**~~ ✅ **ARREGLADO (2026-05-31)**: bajo `NEXT_PUBLIC_BRAND=unarr` el allowlist `UNARR_PAGE_PREFIXES` (paths EN) no reconocía los localizados de next-intl (`/es/biblioteca`, `/es/descargas`, `/es/perfil`) → 404. Fix (web): `enFirstSegmentByLocalized` (mapa localizado→EN derivado de `routing.pathnames`) + `toCanonicalPath()` en `branding/routes.ts` traduce el 1er segmento antes del match. Assertion anti-colisión en el build del mapa (fail-fast si una ruta futura reusa un segmento → no puede colar una ruta denegada). Verificado: 175 entradas, cero crossover; denegadas siguen denegadas. - **Thumbnails — sprites/trickplay (media)**: cerrado solo el camino bajo demanda (N frames en vivo). El scrubber pregenerado (sprite/BIF de toda la timeline, preview al pasar el ratón por la barra) queda como hueco propio: reaprovecharía `/thumbnail` + cacheo en disco del agente. Decidido alcance "solo bajo demanda" con el usuario (2026-05-31). +### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (2026-05-31) +HDR (HDR10/HLG/DV) transcodificado a SDR salía lavado/desaturado (sin tonemap). Ahora `buildHLSFFmpegArgsAt` inserta `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv` tras el scale y antes de `format=`, cuando `probe.HDR != "" && Transcode.TonemapHDR`. +- **Gate por capacidad**: `FFmpegSupportsZscale(ffmpegPath)` (cacheado, `ffmpeg -filters`) → solo activa si el build trae zscale/zimg. Sin zscale → no se inserta (la fuente sigue reproduciéndose, desaturada — no rompe). `transcoder.go:270` ya advertía que builds sin zimg no pueden tonemapear; el static ffbinaries puede faltarle, pero `/usr/bin/ffmpeg` (distro) y el docker sí lo traen. +- **Filtro CPU válido para todos los encoders**: el decode hwaccel deja los frames en memoria de sistema (no se setea `-hwaccel_output_format`), así que el zscale CPU corre antes del `format=`/`hwupload` (VAAPI) igual que el scale existente. +- **Smoke real**: extraído un frame de un 4K HDR10 (Frankenstein DV+HDR10) con y sin la cadena → ambas válidas (sin error), la tonemapeada con rojo vívido + negros profundos vs la lavada. /critico 1 revisor: cadena correcta, sin bugs bloqueantes; fix aplicado (soltar mutex antes del exec en la detección), HLG/DV-only documentados como aproximación (mejor que el baseline). + ### Hueco medio — Auto-resume de descargas tras reinicio ✅ CERRADO (2026-05-31) Antes: tras reiniciar el daemon, una descarga en vuelo quedaba abandonada (cola in-memory perdida, el web no re-despacha una tarea "downloading" atascada) hasta reintento manual. Los BYTES ya persistían (mmap + completion DB BoltDB de anacrolix, keyed por info_hash; debrid Range; usenet tracker) — faltaba que el daemon se re-sometiera solo. - **`agent.ActiveTaskStore`** (`active-tasks.json`, atómico tmp+rename): persiste el payload `agent.Task` re-submittable de descargas en vuelo. Add al arrancar la descarga, Remove en terminal genuino. diff --git a/internal/cmd/player_session_registry.go b/internal/cmd/player_session_registry.go index bb3743b..0ae2906 100644 --- a/internal/cmd/player_session_registry.go +++ b/internal/cmd/player_session_registry.go @@ -92,5 +92,8 @@ func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.Transc VideoBitrate: cfg.Download.Transcode.VideoBitrate, AudioBitrate: cfg.Download.Transcode.AudioBitrate, MaxHeight: cfg.Download.Transcode.MaxHeight, + // Tonemap HDR→SDR only when this ffmpeg build has zscale; otherwise the + // filter would error and break playback, so HDR plays untonemapped. + TonemapHDR: engine.FFmpegSupportsZscale(ffmpegPath), } } diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 12bad0e..ad27a01 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1347,16 +1347,23 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin hwUploadTail = ",hwupload" colorTail = "" } + // HDR→SDR tonemap, inserted after the scale (downscale-first = fewer pixels + // to tonemap) and before format=. Only for an HDR source on a zscale-capable + // ffmpeg; the trailing comma in hdrTonemapChain slots it in front of format=. + tonemap := "" + if probe.HDR != "" && cfg.Transcode.TonemapHDR { + tonemap = hdrTonemapChain + } var filterChain string if maxH > 0 && probe.Height > maxH { filterChain = fmt.Sprintf( - "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s", - maxH, pixFormat, colorTail, hwUploadTail, + "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s", + maxH, tonemap, pixFormat, colorTail, hwUploadTail, ) } else { filterChain = fmt.Sprintf( - "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s", - pixFormat, colorTail, hwUploadTail, + "scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s", + tonemap, pixFormat, colorTail, hwUploadTail, ) } args = append(args, "-vf", filterChain) diff --git a/internal/engine/tonemap.go b/internal/engine/tonemap.go new file mode 100644 index 0000000..2c80bae --- /dev/null +++ b/internal/engine/tonemap.go @@ -0,0 +1,68 @@ +package engine + +import ( + "bytes" + "context" + "log" + "os/exec" + "sync" + "time" +) + +// hdrTonemapChain is the ffmpeg filter segment that maps an HDR source +// (HDR10/HLG, or a Dolby Vision base layer) down to SDR BT.709: linearise the +// PQ/HLG signal, tonemap the highlights (hable), then re-encode to BT.709 +// transfer/matrix/primaries in limited range. Without it an HDR source +// transcoded to an SDR encode keeps wide-gamut/PQ data the SDR player can't +// interpret, so colour looks washed-out / desaturated. +// +// Requires the zscale filter (libzimg) in the ffmpeg build — gate on +// FFmpegSupportsZscale. Trailing comma so it slots in front of the chain's +// `format=` stage. CPU filter: valid for every encoder here because the decode +// hwaccel intentionally leaves frames in system memory (see buildHLSFFmpegArgsAt). +// +// Tuned for HDR10/PQ (npl=100) and the common DV+HDR10 case. HLG and bare-DV +// (Profile 5, no PQ signalling) get an approximate mapping — zscale linearises +// from whatever transfer the stream declares — but the result is still clearly +// better than the untonemapped washed-out baseline. A per-transfer chain is a +// possible follow-up if HLG/DV-only sources become common. +const hdrTonemapChain = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv," + +var ( + zscaleCacheMu sync.Mutex + zscaleCache = map[string]bool{} +) + +// FFmpegSupportsZscale reports whether the ffmpeg binary at path was built with +// the zscale filter (libzimg), required for HDR→SDR tonemapping. Cached per +// path. A detection failure (binary missing, exec error) is treated as "no" so +// tonemapping is simply skipped — the source still plays, just without it. +func FFmpegSupportsZscale(ffmpegPath string) bool { + if ffmpegPath == "" { + return false + } + zscaleCacheMu.Lock() + if v, ok := zscaleCache[ffmpegPath]; ok { + zscaleCacheMu.Unlock() + return v + } + zscaleCacheMu.Unlock() + + // Probe OUTSIDE the lock: `ffmpeg -filters` can take a beat, and holding the + // mutex across it would stall a concurrent session start. Worst case two + // cold callers probe the same binary at once — both write the same bool. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").Output() + supported := err == nil && bytes.Contains(out, []byte("zscale")) + + zscaleCacheMu.Lock() + zscaleCache[ffmpegPath] = supported + zscaleCacheMu.Unlock() + if supported { + log.Printf("[tonemap] ffmpeg has zscale — HDR sources will be tonemapped to SDR") + } else { + log.Printf("[tonemap] ffmpeg %q lacks zscale — HDR sources play without tonemapping (desaturated)", ffmpegPath) + } + return supported +} diff --git a/internal/engine/tonemap_test.go b/internal/engine/tonemap_test.go new file mode 100644 index 0000000..33efed9 --- /dev/null +++ b/internal/engine/tonemap_test.go @@ -0,0 +1,118 @@ +package engine + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func hlsArgsFor(hdr string, tonemap bool, hw HWAccel) string { + cfg := HLSSessionConfig{ + SessionID: "test", + SourcePath: "/movies/x.mkv", + Quality: "720p", + Transcode: TranscodeRuntime{ + FFmpegPath: "/usr/bin/ffmpeg", + FFprobePath: "/usr/bin/ffprobe", + HWAccel: hw, + TonemapHDR: tonemap, + }, + } + probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: hdr, DurationSec: 100} + return strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " ") +} + +func vfChain(joined string) string { + parts := strings.Split(joined, " ") + for i, p := range parts { + if p == "-vf" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +func TestTonemap_AppliedForHDRWhenSupported(t *testing.T) { + vf := vfChain(hlsArgsFor("HDR10", true, HWAccelNone)) + if !strings.Contains(vf, "zscale=t=linear") || !strings.Contains(vf, "tonemap=tonemap=hable") { + t.Fatalf("HDR + zscale-capable: expected tonemap in -vf, got %q", vf) + } + // Order: a scale filter, then tonemap (zscale), then format=. + scaleIdx := strings.Index(vf, "scale=") + zIdx := strings.Index(vf, "zscale=t=linear") + fmtIdx := strings.Index(vf, "format=") + if !(scaleIdx >= 0 && scaleIdx < zIdx && zIdx < fmtIdx) { + t.Errorf("filter order wrong (scale < tonemap < format): %q", vf) + } +} + +func TestTonemap_AppliedInNoDownscaleBranch(t *testing.T) { + // Source already within the quality cap → no downscale; tonemap must still + // be inserted before format=. + cfg := HLSSessionConfig{ + SessionID: "test", + SourcePath: "/movies/x.mkv", + Quality: "2160p", + Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true}, + } + probe := &StreamProbe{Width: 3840, Height: 2160, HDR: "HDR10", DurationSec: 100} + vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " ")) + if !strings.Contains(vf, "tonemap=tonemap=hable") { + t.Errorf("no-downscale branch: expected tonemap, got %q", vf) + } + if z, f := strings.Index(vf, "zscale=t=linear"), strings.Index(vf, "format="); !(z >= 0 && z < f) { + t.Errorf("tonemap must precede format=: %q", vf) + } +} + +func TestTonemap_SkippedWhenFFmpegLacksZscale(t *testing.T) { + vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone)) + if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") { + t.Errorf("ffmpeg without zscale: tonemap must be skipped, got %q", vf) + } +} + +func TestTonemap_SkippedForSDR(t *testing.T) { + vf := vfChain(hlsArgsFor("", true, HWAccelNone)) + if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") { + t.Errorf("SDR source: no tonemap expected, got %q", vf) + } +} + +func TestTonemap_VAAPIInsertsBeforeHwupload(t *testing.T) { + vf := vfChain(hlsArgsFor("HDR10", true, HWAccelVAAPI)) + if !strings.Contains(vf, "tonemap=tonemap=hable") { + t.Fatalf("VAAPI HDR: expected tonemap, got %q", vf) + } + // Tonemap is a CPU filter — must run before the GPU upload. + if up := strings.Index(vf, "hwupload"); up >= 0 { + if strings.Index(vf, "zscale=t=linear") > up { + t.Errorf("tonemap must precede hwupload: %q", vf) + } + } +} + +func TestFFmpegSupportsZscale_Stub(t *testing.T) { + dir := t.TempDir() + + withZ := filepath.Join(dir, "ffmpeg-with.sh") + if err := os.WriteFile(withZ, []byte("#!/bin/sh\necho ' .SC zscale V->V'\n"), 0o755); err != nil { + t.Fatal(err) + } + if !FFmpegSupportsZscale(withZ) { + t.Error("expected true for an ffmpeg whose -filters lists zscale") + } + + noZ := filepath.Join(dir, "ffmpeg-without.sh") + if err := os.WriteFile(noZ, []byte("#!/bin/sh\necho ' ... scale V->V'\n"), 0o755); err != nil { + t.Fatal(err) + } + if FFmpegSupportsZscale(noZ) { + t.Error("expected false for an ffmpeg whose -filters omits zscale") + } + + if FFmpegSupportsZscale("") { + t.Error("empty path must be false") + } +} diff --git a/internal/engine/transcode_quality.go b/internal/engine/transcode_quality.go index 4efda59..1fac2b1 100644 --- a/internal/engine/transcode_quality.go +++ b/internal/engine/transcode_quality.go @@ -14,6 +14,10 @@ type TranscodeRuntime struct { // browser-friendly. Useful when the user explicitly turns transcoding // off in config. Disabled bool + // TonemapHDR enables HDR→SDR tonemapping of HDR sources during transcode. + // Set only when the ffmpeg build has zscale (FFmpegSupportsZscale); without + // it the tonemap filter would error and break playback, so it stays off. + TonemapHDR bool } // qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)