feat(stream): burn bitmap (PGS/DVB) subtitles into the video via overlay
Bitmap subs can't be served as WebVTT, so the user picks one and the daemon
re-encodes with it overlaid. HLSSessionConfig.BurnSubtitleIndex (*int, nil=no
burn) flows into the cache key + a -filter_complex graph:
[0✌️0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]
Overlay after the tonemap (SDR subs keep brightness); scale2ref fits the PGS
canvas to the output. Invalid/text/out-of-range index -> clean-encode fallback.
IsTextSubtitle now includes "text" (parity with the web classifier).
This commit is contained in:
parent
8207d1d2a9
commit
665ec0a34f
9 changed files with 196 additions and 49 deletions
|
|
@ -74,7 +74,7 @@ desde la web. Diseño + set de opciones en el 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). 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.
|
- ~~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~~ ✅ **Burn-in PGS/DVB bajo demanda (2026-06-01)** — el usuario elige una pista bitmap en el reproductor → la sesión fuerza HLS y el agente re-codifica con `[0:v:0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]` (overlay tras el tonemap = brillo SDR correcto; scale2ref = encaje a cualquier resolución del PGS). En la cache key. Selector web alimentado de file-details (funciona también en direct-play). Caveat: PGS + seek pierde el subtítulo. Verificado en Sonic BDremux (ES quemado). Ver estado abajo.
|
||||||
- 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.
|
||||||
- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS).
|
- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS).
|
||||||
|
|
@ -114,6 +114,14 @@ Preview de fotograma al pasar el ratón por la barra de búsqueda, **bajo demand
|
||||||
- **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).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
### Hueco medio — Burn-in de subtítulos bitmap (PGS/DVB) ✅ CERRADO (2026-06-01)
|
||||||
|
Los subs de imagen (PGS/DVB/VOBSUB) no se pueden servir como WebVTT; se incrustan en el vídeo durante el transcode. Alcance (decidido con el usuario): bajo demanda + nudge cuando el fichero SOLO tiene bitmap (sin auto-activar).
|
||||||
|
- **Agente** (rama `unarr-burnin` ex `feat/unarr-agent`): `HLSSessionConfig.BurnSubtitleIndex *int` (nil=sin burn; puntero para que el 0 no se confunda con "quema pista 0"); en la cache key (`KeyFor`/`KeyForID`). `buildHLSFFmpegArgsAt`: si el índice apunta a una pista bitmap válida, `-map [vout]` + `-filter_complex [0:v:0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]`. Overlay TRAS el tonemap (subs SDR no se aplastan); scale2ref encaja el lienzo PGS al frame. Índice inválido/texto/fuera de rango → fallback a encode limpio (log). `IsTextSubtitle` ahora incluye `"text"` (paridad con el clasificador web). Tests `TestBuildHLSFFmpegArgsBurnSubtitle` (filter_complex/overlay/[vout] vs -vf según bitmap/texto/rango) + cache-key.
|
||||||
|
- **Web** (rama `unarr-burnin` ex `feat/unarr-brand`): columna `streaming_session.burn_subtitle_index` (migración 0139, NOT NULL default -1) en identidad de sesión + dedup; `session/route` fuerza `playMethod=hls` cuando hay burn; `agent.ts` lo pasa al daemon. Selector en `MediaChromePlayer` alimentado de **file-details** (`subtitleTracks`, mediainfo estática) → aparece también en direct-play; posición del array = `-map 0:s:N`. `isBitmapSubtitleCodec` (`src/lib/stream/subtitles.ts`) espeja `IsTextSubtitle`. Notice: "incrustando" al quemar / nudge si solo-bitmap. Doc: `docs/architecture/subtitle-burn-in.md`.
|
||||||
|
- **Smoke real** (Sonic 2020 BDremux 1080p, 7 PGS + 1 subrip): selector lista los 7 PGS (EN/ES/NL · imagen), excluye el subrip; elegir ES (`0:s:2`) fuerza HLS, el agente transcodifica con overlay sin error y el frame muestra **"Sé lo que estáis pensando."** quemado (posición + brillo correctos). /critico 2 revisores: arreglado `"text"` (paridad), reset de burn al cambiar de ítem, `bitmapSubtitles` a flatMap.
|
||||||
|
- **Caveat**: PGS + seek pierde el subtítulo (el `-ss` antes de `-i` tira el estado del decoder PGS). Reproducción lineal desde el inicio = OK. Mitigación futura: decodificar PGS desde el epoch cercano.
|
||||||
|
- **Aislamiento**: este trabajo se hizo en worktrees dedicados (`/tmp/tc-unarr-{web,cli}`, rama `unarr-burnin`) tras una colisión de ramas en los checkouts primarios compartidos. Merge a `feat/unarr-{brand,agent}` pendiente de decisión del operador.
|
||||||
|
|
||||||
### Bug agente — nvenc "Invalid Level" en fuentes anamórficas ✅ ARREGLADO (2026-06-01)
|
### 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.
|
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).
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,14 @@ type StreamSession struct {
|
||||||
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
|
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
|
||||||
// "use the default/first track".
|
// "use the default/first track".
|
||||||
AudioIndex int `json:"audioIndex,omitempty"`
|
AudioIndex int `json:"audioIndex,omitempty"`
|
||||||
|
// BurnSubtitleIndex, when set, is the 0-based subtitle stream index
|
||||||
|
// (-map 0:s:N) of a BITMAP subtitle (PGS/DVB) to burn into the video. Text
|
||||||
|
// subtitles are served as separate WebVTT tracks and never burned. A pointer
|
||||||
|
// (not int) so absent/null = "no burn": the zero value 0 is a valid track
|
||||||
|
// index, so an int sentinel would silently burn track 0 when the field is
|
||||||
|
// omitted. Forces a full video re-encode (the overlay can't ride a copy
|
||||||
|
// path), so the web only sends it when the user picks a bitmap sub.
|
||||||
|
BurnSubtitleIndex *int `json:"burnSubtitleIndex,omitempty"`
|
||||||
// PlayMethod is how the daemon should serve this session:
|
// PlayMethod is how the daemon should serve this session:
|
||||||
// "" — default (HLS transcode); also what legacy servers send.
|
// "" — default (HLS transcode); also what legacy servers send.
|
||||||
// "direct" — the source is already browser-native (the web decided this
|
// "direct" — the source is already browser-native (the web decided this
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,7 @@ func runDaemonStart() error {
|
||||||
FileName: sess.FileName,
|
FileName: sess.FileName,
|
||||||
Quality: sess.Quality,
|
Quality: sess.Quality,
|
||||||
AudioIndex: sess.AudioIndex,
|
AudioIndex: sess.AudioIndex,
|
||||||
|
BurnSubtitleIndex: sess.BurnSubtitleIndex,
|
||||||
Transcode: tcRuntime,
|
Transcode: tcRuntime,
|
||||||
Cache: hlsCache,
|
Cache: hlsCache,
|
||||||
// 2c: refresh the debrid link if it expires mid-transcode; the
|
// 2c: refresh the debrid link if it expires mid-transcode; the
|
||||||
|
|
@ -793,6 +794,7 @@ func runDaemonStart() error {
|
||||||
FileName: sess.FileName,
|
FileName: sess.FileName,
|
||||||
Quality: sess.Quality,
|
Quality: sess.Quality,
|
||||||
AudioIndex: sess.AudioIndex,
|
AudioIndex: sess.AudioIndex,
|
||||||
|
BurnSubtitleIndex: sess.BurnSubtitleIndex,
|
||||||
Transcode: tcRuntime,
|
Transcode: tcRuntime,
|
||||||
Cache: hlsCache,
|
Cache: hlsCache,
|
||||||
}, hlsCtx, hlsCancel)
|
}, hlsCtx, hlsCancel)
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,14 @@ type HLSSessionConfig struct {
|
||||||
FileName string
|
FileName string
|
||||||
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
|
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
|
||||||
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
|
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
|
||||||
|
// BurnSubtitleIndex burns a BITMAP subtitle (PGS/DVB) at this 0-based
|
||||||
|
// subtitle stream index into the video. nil = no burn (text subs are served
|
||||||
|
// as separate WebVTT). A pointer (not int) so the zero value 0 — a valid
|
||||||
|
// stream index — can't be mistaken for a burn request when a caller leaves
|
||||||
|
// the field unset. Part of the cache key so a burned encode never collides
|
||||||
|
// with the clean one. Forces the video re-encode the HLS path already does
|
||||||
|
// to also composite the subtitle overlay.
|
||||||
|
BurnSubtitleIndex *int
|
||||||
Transcode TranscodeRuntime
|
Transcode TranscodeRuntime
|
||||||
// Cache is an optional persistent segment cache keyed by (source, quality,
|
// Cache is an optional persistent segment cache keyed by (source, quality,
|
||||||
// audio). When set, completed encodes are kept across sessions so re-plays
|
// audio). When set, completed encodes are kept across sessions so re-plays
|
||||||
|
|
@ -169,6 +177,15 @@ func (cfg HLSSessionConfig) sourceRef() string {
|
||||||
return cfg.SourcePath
|
return cfg.SourcePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// burnSubtitleIndexOrNone resolves the optional burn-in subtitle pointer to the
|
||||||
|
// int sentinel the cache key and filtergraph use: nil → -1 ("no burn").
|
||||||
|
func (cfg HLSSessionConfig) burnSubtitleIndexOrNone() int {
|
||||||
|
if cfg.BurnSubtitleIndex == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return *cfg.BurnSubtitleIndex
|
||||||
|
}
|
||||||
|
|
||||||
// logName is a short, log-friendly source label. For local files it's the base
|
// logName is a short, log-friendly source label. For local files it's the base
|
||||||
// name; for a URL source (no SourcePath) it prefers FileName over the raw URL
|
// name; for a URL source (no SourcePath) it prefers FileName over the raw URL
|
||||||
// (which would leak a query-string token into the logs).
|
// (which would leak a query-string token into the logs).
|
||||||
|
|
@ -383,9 +400,9 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
// Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
|
// Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
|
||||||
// despite the URL changing each resolution; local files key by path.
|
// despite the URL changing each resolution; local files key by path.
|
||||||
if cfg.CacheID != "" {
|
if cfg.CacheID != "" {
|
||||||
cacheKey = cfg.Cache.KeyForID(cfg.CacheID, cfg.Quality, cfg.AudioIndex)
|
cacheKey = cfg.Cache.KeyForID(cfg.CacheID, cfg.Quality, cfg.AudioIndex, cfg.burnSubtitleIndexOrNone())
|
||||||
} else {
|
} else {
|
||||||
cacheKey = cfg.Cache.KeyFor(cfg.SourcePath, cfg.Quality, cfg.AudioIndex)
|
cacheKey = cfg.Cache.KeyFor(cfg.SourcePath, cfg.Quality, cfg.AudioIndex, cfg.burnSubtitleIndexOrNone())
|
||||||
}
|
}
|
||||||
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
|
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
|
||||||
// the last segment vanished (external rm, partial-disk failure), we
|
// the last segment vanished (external rm, partial-disk failure), we
|
||||||
|
|
@ -1217,8 +1234,31 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
|
||||||
args = append(args, "-output_ts_offset", strconv.FormatFloat(startSec, 'f', 3, 64))
|
args = append(args, "-output_ts_offset", strconv.FormatFloat(startSec, 'f', 3, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map video + selected audio. Always use first video stream.
|
// Burn a bitmap subtitle (PGS/DVB) into the video when requested. Validate
|
||||||
|
// the index points at a real bitmap track in range — text subs are served as
|
||||||
|
// separate WebVTT and never burned, and a stale/out-of-range index falls
|
||||||
|
// back to a clean encode rather than failing the session.
|
||||||
|
burnIdx := -1
|
||||||
|
if reqBurn := cfg.burnSubtitleIndexOrNone(); reqBurn >= 0 {
|
||||||
|
if reqBurn < len(probe.SubtitleTracks) &&
|
||||||
|
!probe.SubtitleTracks[reqBurn].IsTextSubtitle() {
|
||||||
|
burnIdx = reqBurn
|
||||||
|
} else {
|
||||||
|
log.Printf("[hls %s] burn subtitle %d ignored — not a bitmap track in range",
|
||||||
|
shortHLSID(cfg.SessionID), reqBurn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map video + selected audio. With burn-in the video comes from the
|
||||||
|
// filter_complex graph ([vout], built below); otherwise map the source video
|
||||||
|
// stream directly. ffmpeg resolves the [vout] label from -filter_complex
|
||||||
|
// regardless of argv order, so mapping it here (before audio) keeps video as
|
||||||
|
// output stream 0.
|
||||||
|
if burnIdx >= 0 {
|
||||||
|
args = append(args, "-map", "[vout]")
|
||||||
|
} else {
|
||||||
args = append(args, "-map", "0:v:0")
|
args = append(args, "-map", "0:v:0")
|
||||||
|
}
|
||||||
audioIdx := cfg.AudioIndex
|
audioIdx := cfg.AudioIndex
|
||||||
if audioIdx < 0 {
|
if audioIdx < 0 {
|
||||||
audioIdx = 0
|
audioIdx = 0
|
||||||
|
|
@ -1362,19 +1402,37 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
|
||||||
if probe.HDR != "" && cfg.Transcode.TonemapHDR {
|
if probe.HDR != "" && cfg.Transcode.TonemapHDR {
|
||||||
tonemap = hdrTonemapChain
|
tonemap = hdrTonemapChain
|
||||||
}
|
}
|
||||||
var filterChain string
|
// Core video chain (scale + optional tonemap + pixel format + color metadata),
|
||||||
|
// WITHOUT the optional hwUploadTail — that has to run last, after any subtitle
|
||||||
|
// overlay, so it's appended separately below.
|
||||||
|
var vchain string
|
||||||
if maxH > 0 && probe.Height > maxH {
|
if maxH > 0 && probe.Height > maxH {
|
||||||
filterChain = fmt.Sprintf(
|
vchain = fmt.Sprintf(
|
||||||
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s",
|
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s",
|
||||||
maxH, tonemap, pixFormat, colorTail, hwUploadTail,
|
maxH, tonemap, pixFormat, colorTail,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
filterChain = fmt.Sprintf(
|
vchain = fmt.Sprintf(
|
||||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s",
|
"scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s",
|
||||||
tonemap, pixFormat, colorTail, hwUploadTail,
|
tonemap, pixFormat, colorTail,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
args = append(args, "-vf", filterChain)
|
if burnIdx >= 0 {
|
||||||
|
// Burn-in: process the video to its final size + SDR colorspace FIRST,
|
||||||
|
// then composite the subtitle. Overlaying SDR PGS/DVB graphics onto a
|
||||||
|
// still-HDR (PQ) frame and tonemapping afterwards would crush the
|
||||||
|
// subtitle brightness, so the overlay must come after the tonemap. The
|
||||||
|
// subtitle canvas is scaled to the processed frame via scale2ref so a
|
||||||
|
// PGS/DVB stream authored at any resolution lines up. hwUploadTail
|
||||||
|
// (VAAPI) runs last, on the composited frame.
|
||||||
|
filterComplex := fmt.Sprintf(
|
||||||
|
"[0:v:0]%s[base];[0:s:%d][base]scale2ref[sub][base2];[base2][sub]overlay%s[vout]",
|
||||||
|
vchain, burnIdx, hwUploadTail,
|
||||||
|
)
|
||||||
|
args = append(args, "-filter_complex", filterComplex)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-vf", vchain+hwUploadTail)
|
||||||
|
}
|
||||||
|
|
||||||
// Audio: AAC stereo 48 kHz — broadest browser compatibility.
|
// Audio: AAC stereo 48 kHz — broadest browser compatibility.
|
||||||
audioBitrate := cfg.Transcode.AudioBitrate
|
audioBitrate := cfg.Transcode.AudioBitrate
|
||||||
|
|
|
||||||
|
|
@ -153,12 +153,12 @@ func (c *HLSCache) ReleaseWriter(key string) {
|
||||||
// KeyFor derives a stable cache key for (source, quality, audioIndex). Using
|
// KeyFor derives a stable cache key for (source, quality, audioIndex). Using
|
||||||
// the absolute source path means renaming a file invalidates the cache, which
|
// the absolute source path means renaming a file invalidates the cache, which
|
||||||
// is correct — segment content is tied to the encoded source.
|
// is correct — segment content is tied to the encoded source.
|
||||||
func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex int) string {
|
func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex, burnSubtitleIndex int) string {
|
||||||
abs, err := filepath.Abs(sourcePath)
|
abs, err := filepath.Abs(sourcePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
abs = sourcePath
|
abs = sourcePath
|
||||||
}
|
}
|
||||||
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", abs, quality, audioIndex)))
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", abs, quality, audioIndex, burnSubtitleIndex)))
|
||||||
return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
|
return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,8 +167,8 @@ func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex int) string {
|
||||||
// the debrid direct URL is re-resolved per play and would never cache-hit, so
|
// the debrid direct URL is re-resolved per play and would never cache-hit, so
|
||||||
// we key by the torrent info_hash — the same content always maps to the same
|
// we key by the torrent info_hash — the same content always maps to the same
|
||||||
// key across plays. NOT run through filepath.Abs (an id/URL is not a path).
|
// key across plays. NOT run through filepath.Abs (an id/URL is not a path).
|
||||||
func (c *HLSCache) KeyForID(id, quality string, audioIndex int) string {
|
func (c *HLSCache) KeyForID(id, quality string, audioIndex, burnSubtitleIndex int) string {
|
||||||
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", id, quality, audioIndex)))
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", id, quality, audioIndex, burnSubtitleIndex)))
|
||||||
return hex.EncodeToString(h[:8])
|
return hex.EncodeToString(h[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ func TestHLSCacheSmoke(t *testing.T) {
|
||||||
encodeDur := time.Since(t0)
|
encodeDur := time.Since(t0)
|
||||||
t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
|
t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
|
||||||
|
|
||||||
key := cache.KeyFor(source, "720p", 0)
|
key := cache.KeyFor(source, "720p", 0, -1)
|
||||||
if !cache.HasComplete(key) {
|
if !cache.HasComplete(key) {
|
||||||
t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
|
t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,21 @@ func newTestCache(t *testing.T, sizeGB int) *HLSCache {
|
||||||
|
|
||||||
func TestKeyForStable(t *testing.T) {
|
func TestKeyForStable(t *testing.T) {
|
||||||
c := newTestCache(t, 1)
|
c := newTestCache(t, 1)
|
||||||
k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
|
k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0, -1)
|
||||||
k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
|
k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0, -1)
|
||||||
if k1 != k2 {
|
if k1 != k2 {
|
||||||
t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
|
t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
|
||||||
}
|
}
|
||||||
if c.KeyFor("/a/b/movie.mkv", "720p", 0) == k1 {
|
if c.KeyFor("/a/b/movie.mkv", "720p", 0, -1) == k1 {
|
||||||
t.Fatal("quality should change key")
|
t.Fatal("quality should change key")
|
||||||
}
|
}
|
||||||
if c.KeyFor("/a/b/movie.mkv", "1080p", 1) == k1 {
|
if c.KeyFor("/a/b/movie.mkv", "1080p", 1, -1) == k1 {
|
||||||
t.Fatal("audio index should change key")
|
t.Fatal("audio index should change key")
|
||||||
}
|
}
|
||||||
if c.KeyFor("/x/y/other.mkv", "1080p", 0) == k1 {
|
if c.KeyFor("/a/b/movie.mkv", "1080p", 0, 2) == k1 {
|
||||||
|
t.Fatal("burn subtitle index should change key")
|
||||||
|
}
|
||||||
|
if c.KeyFor("/x/y/other.mkv", "1080p", 0, -1) == k1 {
|
||||||
t.Fatal("path should change key")
|
t.Fatal("path should change key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,12 +111,80 @@ func TestHLSSourceRefAndCacheID(t *testing.T) {
|
||||||
|
|
||||||
c := &HLSCache{root: "/tmp/cache"}
|
c := &HLSCache{root: "/tmp/cache"}
|
||||||
// Same CacheID + quality + audio → same key regardless of the (volatile) URL.
|
// Same CacheID + quality + audio → same key regardless of the (volatile) URL.
|
||||||
k1 := c.KeyForID("hash1", "720p", -1)
|
k1 := c.KeyForID("hash1", "720p", -1, -1)
|
||||||
k2 := c.KeyForID("hash1", "720p", -1)
|
k2 := c.KeyForID("hash1", "720p", -1, -1)
|
||||||
if k1 != k2 {
|
if k1 != k2 {
|
||||||
t.Errorf("KeyForID not stable: %q != %q", k1, k2)
|
t.Errorf("KeyForID not stable: %q != %q", k1, k2)
|
||||||
}
|
}
|
||||||
if c.KeyForID("hash2", "720p", -1) == k1 {
|
if c.KeyForID("hash2", "720p", -1, -1) == k1 {
|
||||||
t.Error("KeyForID collision across distinct ids")
|
t.Error("KeyForID collision across distinct ids")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Burn-in: a bitmap subtitle index routes the video through -filter_complex with
|
||||||
|
// scale2ref + overlay and maps [vout]; a nil / text / out-of-range index keeps
|
||||||
|
// the plain -vf path (text subs are served as WebVTT, never burned).
|
||||||
|
func TestBuildHLSFFmpegArgsBurnSubtitle(t *testing.T) {
|
||||||
|
idx := func(n int) *int { return &n }
|
||||||
|
base := func() HLSSessionConfig {
|
||||||
|
return HLSSessionConfig{
|
||||||
|
SessionID: "burn",
|
||||||
|
SourcePath: "/tmp/movie.mkv",
|
||||||
|
Quality: "1080p",
|
||||||
|
Transcode: TranscodeRuntime{
|
||||||
|
FFmpegPath: "/usr/bin/ffmpeg",
|
||||||
|
FFprobePath: "/usr/bin/ffprobe",
|
||||||
|
HWAccel: HWAccelNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe := &StreamProbe{
|
||||||
|
Width: 1920, Height: 1080, DurationSec: 100,
|
||||||
|
SubtitleTracks: []ProbeSubtitleTrack{
|
||||||
|
{Index: 0, Codec: "subrip"}, // text → not burnable
|
||||||
|
{Index: 1, Codec: "hdmv_pgs_subtitle"}, // bitmap → burnable
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("nil = clean -vf path", func(t *testing.T) {
|
||||||
|
got := strings.Join(buildHLSFFmpegArgsAt(base(), probe, "/tmp/d", 0, 0), " ")
|
||||||
|
if strings.Contains(got, "-filter_complex") || strings.Contains(got, "overlay") {
|
||||||
|
t.Errorf("no-burn argv must not overlay: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "-map 0:v:0") || !strings.Contains(got, "-vf") {
|
||||||
|
t.Errorf("no-burn argv must -map 0:v:0 with -vf: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bitmap index burns via filter_complex", func(t *testing.T) {
|
||||||
|
cfg := base()
|
||||||
|
cfg.BurnSubtitleIndex = idx(1)
|
||||||
|
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
|
||||||
|
for _, want := range []string{"-filter_complex", "[0:s:1]", "scale2ref", "overlay", "-map [vout]"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("burn argv missing %q: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "-map 0:v:0") {
|
||||||
|
t.Errorf("burn argv must map [vout], not 0:v:0: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("text index is ignored (served as WebVTT)", func(t *testing.T) {
|
||||||
|
cfg := base()
|
||||||
|
cfg.BurnSubtitleIndex = idx(0) // subrip → not a bitmap track
|
||||||
|
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
|
||||||
|
if strings.Contains(got, "overlay") || strings.Contains(got, "-filter_complex") {
|
||||||
|
t.Errorf("text-sub burn must fall back to clean encode: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("out-of-range index is ignored", func(t *testing.T) {
|
||||||
|
cfg := base()
|
||||||
|
cfg.BurnSubtitleIndex = idx(9)
|
||||||
|
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
|
||||||
|
if strings.Contains(got, "overlay") {
|
||||||
|
t.Errorf("out-of-range burn must fall back to clean encode: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ type ProbeSubtitleTrack struct {
|
||||||
// without re-rendering. Bitmap subs (PGS, DVB) need burn-in.
|
// without re-rendering. Bitmap subs (PGS, DVB) need burn-in.
|
||||||
func (s ProbeSubtitleTrack) IsTextSubtitle() bool {
|
func (s ProbeSubtitleTrack) IsTextSubtitle() bool {
|
||||||
switch s.Codec {
|
switch s.Codec {
|
||||||
case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text":
|
case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue