fix(stream): derive H.264 level from frame macroblocks, not height
Anamorphic 2.39:1 scaled to 1080 height = ~2586x1080 = 11016 MBs, busting level 4.1's 8192-MB MaxFS -> nvenc "InitializeEncoder failed: Invalid Level" (libx264: "frame MB size > level limit") -> 0 segments, session stalls. Most 4K rips are 2.39:1, so HLS playback was silently broken for them. H264LevelForFrame(w,h) derives the level from the real macroblock count (max of MB-tier and height-tier). hls.go computes output width and uses it. 16:9 unchanged; anamorphic bumps to 5.0 when needed. Discovered + verified during the trickplay smoke.
This commit is contained in:
parent
9c995fc4dd
commit
8207d1d2a9
4 changed files with 123 additions and 14 deletions
|
|
@ -72,7 +72,8 @@ desde la web. Diseño + set de opciones en el estado abajo.
|
||||||
- Sin seeding/ratio lifecycle (flags existen, nadie los aplica).
|
- Sin seeding/ratio lifecycle (flags existen, nadie los aplica).
|
||||||
- ~~Reproducir-mientras-baja: readahead estático 5MB~~ ✅ **Readahead dinámico (2026-05-31)** — `dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 8–96 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo.
|
- ~~Reproducir-mientras-baja: readahead estático 5MB~~ ✅ **Readahead dinámico (2026-05-31)** — `dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 8–96 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo.
|
||||||
- ~~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.
|
- ~~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.
|
- ~~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). Ver estado abajo.
|
||||||
|
- ~~Sin trickplay (preview en la barra)~~ ✅ **Trickplay bajo demanda (2026-06-01)** — pista WebVTT `thumbnails` con 1 cue/10s; cada cue es una URL `/thumbnail?pos=…#xywh=0,0,W,H` (frame completo), así media-chrome solo descarga el frame que se sobrevuela. Toggle on/off por navegador (`localStorage`, default ON) + doc (web `docs/architecture/trickplay.md`). Alcance "on-demand" decidido con el usuario. Sin pregenerar sprite/BIF (sigue siendo opción futura con cacheo en disco). Ver estado abajo.
|
||||||
- Subtítulos bitmap (PGS/DVB) sin burn-in.
|
- Subtítulos bitmap (PGS/DVB) sin burn-in.
|
||||||
- Audio siempre downmix estéreo AAC (sin passthrough 5.1).
|
- Audio siempre downmix estéreo AAC (sin passthrough 5.1).
|
||||||
- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia.
|
- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia.
|
||||||
|
|
@ -98,7 +99,8 @@ WireGuard endpoint sin pin · sesión única (1 viewer).
|
||||||
- **Data race preexistente manager↔reporter (baja)**: bajo `-race`, `Task.ToStatusUpdate()` (leído por `ProgressReporter.flushBatch`) corre sin lock contra la escritura de campos del task en `processTask` (`manager.go:371`). No introducido por el resume; expuesto al correr la suite con `-race` (la suite normal corre sin `-race`). Fix: proteger los campos de estado/progreso del `Task` con su `mu` en ToStatusUpdate + processTask. Chore aparte. Múltiples `task.ID[:8]` en `progress.go`/`torrent.go` paniquean con ids <8 chars (irreal: el web manda UUIDs) — limpiar a `ShortID` de paso.
|
- **Data race preexistente manager↔reporter (baja)**: bajo `-race`, `Task.ToStatusUpdate()` (leído por `ProgressReporter.flushBatch`) corre sin lock contra la escritura de campos del task en `processTask` (`manager.go:371`). No introducido por el resume; expuesto al correr la suite con `-race` (la suite normal corre sin `-race`). Fix: proteger los campos de estado/progreso del `Task` con su `mu` en ToStatusUpdate + processTask. Chore aparte. Múltiples `task.ID[:8]` en `progress.go`/`torrent.go` paniquean con ids <8 chars (irreal: el web manda UUIDs) — limpiar a `ShortID` de paso.
|
||||||
- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente.
|
- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente.
|
||||||
- ~~**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.
|
- ~~**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).
|
- ~~**Thumbnails — sprites/trickplay (media)**~~ ✅ **Trickplay CERRADO bajo demanda (2026-06-01)**: la preview de barra usa cues `/thumbnail` en vivo (un frame por cue al sobrevolar), no un sprite pregenerado. El sprite/BIF de toda la timeline con cacheo en disco del agente sigue siendo una optimización futura (no necesaria para la UX actual). Ver estado abajo.
|
||||||
|
- **nvenc "Invalid Level" en fuentes anamórficas (alta — destapado en el smoke de trickplay)** ✅ **ARREGLADO (2026-06-01)**: el nivel H.264 del transcode HLS se derivaba solo de la altura → una fuente 2.39:1 escalada a 1080 (~2586×1080 = 11016 MBs) revienta el `MaxFS` de L4.1 (8192); ffmpeg fallaba (`InitializeEncoder failed: invalid param (8): Invalid Level` en nvenc, `frame MB size > level limit` en libx264) y la sesión no producía ningún segmento. Casi todos los rips 4K son anamórficos → reproducción HLS rota en silencio. Fix (`hwaccel.go`): `H264LevelForFrame(width,height)` deriva el nivel del recuento de macrobloques real (máx. entre el nivel por-altura y el por-MB); `hls.go` calcula el ancho de salida y lo usa. Ver estado abajo.
|
||||||
|
|
||||||
### Hueco medio — Readahead dinámico (ver-mientras-baja) ✅ CERRADO (2026-05-31)
|
### Hueco medio — Readahead dinámico (ver-mientras-baja) ✅ CERRADO (2026-05-31)
|
||||||
El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stream 4K de 20 Mbps) → al reproducir un torrent a medio bajar, la reproducción adelantaba a la descarga y se atascaba.
|
El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stream 4K de 20 Mbps) → al reproducir un torrent a medio bajar, la reproducción adelantaba a la descarga y se atascaba.
|
||||||
|
|
@ -106,6 +108,18 @@ El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stre
|
||||||
- `torrentFileProvider` lleva `bitrateBps atomic.Int64`, sondeado **async** (`probeMediaInfo` en goroutine vía DataDir+DisplayPath) — sin coste de TTFF; hasta resolverse usa el default, y los readers posteriores (cada range/seek crea uno) cogen el valor preciso. StreamEngine (CLI) → default 24 MiB.
|
- `torrentFileProvider` lleva `bitrateBps atomic.Int64`, sondeado **async** (`probeMediaInfo` en goroutine vía DataDir+DisplayPath) — sin coste de TTFF; hasta resolverse usa el default, y los readers posteriores (cada range/seek crea uno) cogen el valor preciso. StreamEngine (CLI) → default 24 MiB.
|
||||||
- **Smoke**: ffprobe en 4K real (20.7 Mbps) → readahead **73 MiB** (~28s) vs 5 MiB. Tests del func puro + -race limpio en el probe async. /critico: código sólido, fix aplicado (probe síncrono→async para eliminar 3s de TTFF si falta la cabecera).
|
- **Smoke**: ffprobe en 4K real (20.7 Mbps) → readahead **73 MiB** (~28s) vs 5 MiB. Tests del func puro + -race limpio en el probe async. /critico: código sólido, fix aplicado (probe síncrono→async para eliminar 3s de TTFF si falta la cabecera).
|
||||||
|
|
||||||
|
### Hueco medio — Trickplay (preview en la barra) ✅ CERRADO (2026-06-01)
|
||||||
|
Preview de fotograma al pasar el ratón por la barra de búsqueda, **bajo demanda** (sin pregenerar sprite). Alcance decidido con el usuario: on-demand + UX no invasiva + activable/desactivable + documentado.
|
||||||
|
- **Web** (rama `feat/unarr-brand`): `buildTrickplayVtt()` (`src/lib/stream/trickplay.ts`) emite una pista WebVTT `thumbnails` con 1 cue/10s; cada cue apunta a `GET /thumbnail?pos=<seg>&w=320#xywh=0,0,W,H` (frame completo, alto par derivado del aspecto). media-chrome solo descarga el frame sobrevolado y lo cachea. Wiring en `HlsStreamPlayer` (fetch a `file-details` → blob VTT → `<track>`), botón on/off + var CSS de fondo en `MediaChromePlayer`, toggle por navegador en `localStorage` (`useTrickplay`, default ON). Doc: `docs/architecture/trickplay.md`. Tests: `trickplay.test.ts` (6, formato cue + alto par + token vacío + inputs insuficientes).
|
||||||
|
- **Smoke real** (iPhone-equiv en Chrome, F1 4K DV+HDR10): vídeo reproduce → hover en la barra renderiza un frame real en la posición (1:17:36) ≠ el frame en curso; etiqueta de tiempo inmediata; toggle off → `<track>` desaparece (sin preview) y persiste `localStorage="0"`; toggle on → vuelven los 932 cues. CORS del `<img crossorigin>` OK (allowlist del agente).
|
||||||
|
- **No invasivo**: nada carga hasta el hover; 1er frame ~0.8–2.1s en 4K-desde-NAS, re-hover instantáneo (caché navegador); la etiqueta de tiempo aparece ya aunque el frame se esté generando.
|
||||||
|
|
||||||
|
### Bug agente — nvenc "Invalid Level" en fuentes anamórficas ✅ ARREGLADO (2026-06-01)
|
||||||
|
Destapado durante el smoke de trickplay: el HLS de un 4K anamórfico (3840×1604, 2.39:1) no producía **ningún** segmento.
|
||||||
|
- **Causa**: el nivel H.264 se derivaba solo de la altura de salida (`H264LevelForHeight`). Escalado a 1080 de alto, un 2.39:1 queda ~2586×1080 = 11016 macrobloques, que supera el `MaxFS` del nivel 4.1 (8192). ffmpeg fallaba al abrir el encoder (`InitializeEncoder failed: invalid param (8): Invalid Level` en h264_nvenc; el equivalente `frame MB size > level limit` en libx264) → 0 paquetes → la sesión se quedaba en "preparando sesión" hasta el timeout de mark-ready. Casi todo rip 4K es 2.39:1, así que la reproducción HLS estaba rota para la mayoría de pelis 4K (en silencio).
|
||||||
|
- **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1).
|
||||||
|
- **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `<video>` carga (`readyState 4`, `duration 9313s`). Tests `H264LevelForFrame` (16:9 sin regresión + anamórfico → 5.0).
|
||||||
|
|
||||||
### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (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`.
|
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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -1283,11 +1284,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
|
||||||
// per session start, polluting logs even though encode succeeds.
|
// per session start, polluting logs even though encode succeeds.
|
||||||
args = append(args, "-vaapi_device", "/dev/dri/renderD128")
|
args = append(args, "-vaapi_device", "/dev/dri/renderD128")
|
||||||
}
|
}
|
||||||
// Derive H.264 level from the actual output height. A fixed "4.0" caps the
|
// Derive H.264 level from the actual output FRAME (width × height), not just
|
||||||
// encoder at 1080p — anything taller (1440p, 4K source on quality=original)
|
// height. A fixed "4.0" caps the encoder at 1080p; deriving by height alone
|
||||||
// fails libx264 with "frame MB size > level limit" and emits unplayable
|
// still under-levels anamorphic content — a 2.39:1 source scaled to 1080
|
||||||
// segments. The output height matches qcap.MaxHeight when the source is
|
// height is ~2586×1080 = 11016 MBs, busting level 4.1's 8192-MB cap, which
|
||||||
// downscaled, otherwise probe.Height (already populated by ffprobe).
|
// fails the encode ("Invalid Level" on nvenc, "frame MB size > level limit"
|
||||||
|
// on libx264) and stalls the session. The output height matches qcap.MaxHeight
|
||||||
|
// when the source is downscaled, otherwise probe.Height; the output width is
|
||||||
|
// the source width scaled by the same factor (the filter chain preserves AR).
|
||||||
qcap := resolveQualityCap(cfg.Quality)
|
qcap := resolveQualityCap(cfg.Quality)
|
||||||
outputHeight := qcap.MaxHeight
|
outputHeight := qcap.MaxHeight
|
||||||
if outputHeight == 0 {
|
if outputHeight == 0 {
|
||||||
|
|
@ -1296,7 +1300,11 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
|
||||||
if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) {
|
if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) {
|
||||||
outputHeight = probe.Height
|
outputHeight = probe.Height
|
||||||
}
|
}
|
||||||
args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight))
|
outputWidth := probe.Width
|
||||||
|
if probe.Height > 0 && outputHeight != probe.Height {
|
||||||
|
outputWidth = int(math.Round(float64(probe.Width) * float64(outputHeight) / float64(probe.Height)))
|
||||||
|
}
|
||||||
|
args = append(args, "-profile:v", "main", "-level:v", H264LevelForFrame(outputWidth, outputHeight))
|
||||||
|
|
||||||
// Bitrate must match the level libx264 actually picks for outputHeight,
|
// Bitrate must match the level libx264 actually picks for outputHeight,
|
||||||
// not the qcap target for the user's requested label. If a user asks for
|
// not the qcap target for the user's requested label. If a user asks for
|
||||||
|
|
|
||||||
|
|
@ -271,3 +271,60 @@ func H264LevelForHeight(height int) string {
|
||||||
return "6.0"
|
return "6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// h264LevelRank orders level strings so callers can pick the higher of two.
|
||||||
|
var h264LevelRank = map[string]int{
|
||||||
|
"3.0": 30, "3.1": 31, "3.2": 32,
|
||||||
|
"4.0": 40, "4.1": 41, "4.2": 42,
|
||||||
|
"5.0": 50, "5.1": 51, "6.0": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelForMacroblocks returns the lowest H.264 level whose MaxFS (frame size in
|
||||||
|
// macroblocks) covers `mbs`. The height-based H264LevelForHeight tier is correct
|
||||||
|
// for 16:9, but anamorphic content (2.39:1 cinemascope) scaled to a given height
|
||||||
|
// has a much wider frame: a 2.39:1 source downscaled to 1080 height becomes
|
||||||
|
// ~2586×1080 = 11016 MBs, which busts level 4.1's 8192-MB MaxFS. ffmpeg then
|
||||||
|
// fails the encode — libx264 with "frame MB size > level limit", h264_nvenc with
|
||||||
|
// "InitializeEncoder failed: invalid param (8): Invalid Level" — and emits zero
|
||||||
|
// packets (the whole HLS session stalls at "preparando sesión"). MaxFS values
|
||||||
|
// from the H.264 spec, Table A-1.
|
||||||
|
func levelForMacroblocks(mbs int) string {
|
||||||
|
switch {
|
||||||
|
case mbs <= 1620:
|
||||||
|
return "3.0"
|
||||||
|
case mbs <= 3600:
|
||||||
|
return "3.1"
|
||||||
|
case mbs <= 5120:
|
||||||
|
return "3.2"
|
||||||
|
case mbs <= 8192: // levels 4.0 and 4.1 share MaxFS 8192; pick 4.1 for headroom
|
||||||
|
return "4.1"
|
||||||
|
case mbs <= 8704:
|
||||||
|
return "4.2"
|
||||||
|
case mbs <= 22080:
|
||||||
|
return "5.0"
|
||||||
|
case mbs <= 36864:
|
||||||
|
return "5.1"
|
||||||
|
default:
|
||||||
|
return "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H264LevelForFrame returns the lowest H.264 level that satisfies BOTH the
|
||||||
|
// height-derived tier (which carries macroblock-rate / fps headroom) and the
|
||||||
|
// actual frame's macroblock count (which catches anamorphic frames that are far
|
||||||
|
// wider than 16:9 at a given height). Use this instead of H264LevelForHeight
|
||||||
|
// wherever the output width is known — it never under-levels an ultra-wide
|
||||||
|
// frame, and for 16:9 content it returns exactly what H264LevelForHeight does.
|
||||||
|
func H264LevelForFrame(width, height int) string {
|
||||||
|
byHeight := H264LevelForHeight(height)
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return byHeight
|
||||||
|
}
|
||||||
|
// Macroblocks are 16×16; partial blocks at the edge still count (ceil).
|
||||||
|
mbs := ((width + 15) / 16) * ((height + 15) / 16)
|
||||||
|
byMB := levelForMacroblocks(mbs)
|
||||||
|
if h264LevelRank[byMB] > h264LevelRank[byHeight] {
|
||||||
|
return byMB
|
||||||
|
}
|
||||||
|
return byHeight
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,33 @@ func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestH264LevelForFrame(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
width, height int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// 16:9 must match the height-only helper exactly (no regression).
|
||||||
|
{"720p 16:9", 1280, 720, "4.0"},
|
||||||
|
{"1080p 16:9", 1920, 1080, "4.1"},
|
||||||
|
{"1440p 16:9", 2560, 1440, "5.0"},
|
||||||
|
{"2160p 16:9", 3840, 2160, "5.1"},
|
||||||
|
// Anamorphic 2.39:1 at 1080 height — the regression: ~2586×1080 = 11016
|
||||||
|
// MBs busts level 4.1 (8192 MaxFS); must bump to 5.0.
|
||||||
|
{"1080h anamorphic 2.39:1", 2586, 1080, "5.0"},
|
||||||
|
// Anamorphic 720 height (1728×720 = 4860 MBs) still fits the 4.0 the
|
||||||
|
// height floor already picks for fps headroom.
|
||||||
|
{"720h anamorphic 2.4:1", 1728, 720, "4.0"},
|
||||||
|
// Source 4K anamorphic (3840×1604) encoded at source: 24240 MBs → 5.1.
|
||||||
|
{"4K anamorphic source", 3840, 1604, "5.1"},
|
||||||
|
// Width unknown → fall back to the height-only tier.
|
||||||
|
{"width unknown", 0, 1080, "4.1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := H264LevelForFrame(c.width, c.height); got != c.want {
|
||||||
|
t.Errorf("H264LevelForFrame(%d,%d) = %q, want %q", c.width, c.height, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue