Compare commits
No commits in common. "89236f13b5d8cb742df57bef1dfb1c7c926fbfb9" and "c18876471c5d1ac1824c728f01f83f0f033373b9" have entirely different histories.
89236f13b5
...
c18876471c
5 changed files with 5 additions and 93 deletions
|
|
@ -54,8 +54,7 @@ El path HLS **siempre re-encoda** (incluso mp4 h264/aac ya compatible). `DecideA
|
||||||
(passthrough/remux) existe pero muerto en el path browser. Sin negociación por
|
(passthrough/remux) existe pero muerto en el path browser. Sin negociación por
|
||||||
capacidades del dispositivo. Sin ABR multi-bitrate.
|
capacidades del dispositivo. Sin ABR multi-bitrate.
|
||||||
Diseño por fases (3a direct-play / 3b remux fMP4 / 3c capability-negotiation / 3d ABR)
|
Diseño por fases (3a direct-play / 3b remux fMP4 / 3c capability-negotiation / 3d ABR)
|
||||||
en el estado abajo. **Fases 3a + 3b + 3c CERRADAS** (smoke e2e, incl. HEVC en iPhone
|
en el estado abajo. **Fases 3a + 3b CERRADAS** (smoke e2e en browser); 3c/3d pendientes.
|
||||||
Safari real); 3d (ABR) pendiente, baja prioridad.
|
|
||||||
|
|
||||||
### Hueco #4 — Pre-transcode (transcode-on-download) 🔵 DISEÑADO (ver estado abajo)
|
### Hueco #4 — Pre-transcode (transcode-on-download) 🔵 DISEÑADO (ver estado abajo)
|
||||||
Al completar una descarga/import, transcodificar/remuxar en background para que el
|
Al completar una descarga/import, transcodificar/remuxar en background para que el
|
||||||
|
|
@ -399,38 +398,6 @@ binario local para el smoke, **sin publicar nada**. `DIRECT_PLAY_MIN_VERSION = 0
|
||||||
- Listener `loadedmetadata {once:true}` del attach nativo no se limpia
|
- Listener `loadedmetadata {once:true}` del attach nativo no se limpia
|
||||||
explícitamente en cleanup (idempotente, impacto nulo).
|
explícitamente en cleanup (idempotente, impacto nulo).
|
||||||
|
|
||||||
**Fase 3c CERRADA 2026-05-31** (capability-negotiation, alcance ampliado):
|
|
||||||
- CLI (`feat/unarr-agent` 957d499): `NewRemuxSource` copia el vídeo para cualquier
|
|
||||||
codec decodificable: h264, o HEVC/AV1 si el dispositivo lo declara. HEVC se muxea
|
|
||||||
con `-tag:v hvc1` (Apple lo exige). Audio no-aac (ac3/eac3/dts) se transcodifica a
|
|
||||||
aac copiando el vídeo (`ActionRemuxAudio`) → cubre el muy común **h264+ac3 mkv**.
|
|
||||||
- WEB (`feat/unarr-brand` b0681d99): player sondea `canPlayType` (`detectDeviceCaps`)
|
|
||||||
y envía `{hevc,av1}` en el POST; `decidePlayMethod(p, caps)` device-aware:
|
|
||||||
HEVC/AV1 → `remux` solo si el dispositivo decodifica; audio no-aac ya no fuerza
|
|
||||||
`hls`. Tests caps actualizados (10).
|
|
||||||
- **Smoke e2e:** caps gate (sin caps→`hls`, con caps→`remux`); h264+ac3 remux
|
|
||||||
reproduce en Chrome (audio transcodeado, vídeo copiado); retag verificado por
|
|
||||||
ffprobe (`codec_name=hevc`, `codec_tag_string=hvc1`); **HEVC reproduce en iPhone
|
|
||||||
Safari real (Tailscale) — confirmado por el usuario.** ✓
|
|
||||||
- **Caveat:** playback HEVC en Apple no se puede smokear en este host (Chrome-Linux
|
|
||||||
no decodifica HEVC; Mac-mini Safari por SSH bloqueado por TCC: Automation +
|
|
||||||
Screen Recording necesitan click GUI). Verificado vía iPhone del usuario.
|
|
||||||
|
|
||||||
**Diagnóstico time-to-first-frame (2026-05-31)** (instrumentación en 957d499:
|
|
||||||
timers `probe`/`spawn`, `first fMP4 bytes after`, `serveGrowing blocked`):
|
|
||||||
- Agente NO es el cuello: probe 16–98ms, spawn 1–194ms, primer byte fMP4 ~201ms,
|
|
||||||
**0 bloqueos** en `serveGrowing` (LAN ni remoto). Remux `-c copy` completo de un
|
|
||||||
fichero de ~780MB en ~16s (limitado por lectura NAS).
|
|
||||||
- `moov` al frente (empty_moov OK) → el player no busca metadata al final.
|
|
||||||
- Cliente (Chrome/LAN): POST→primer request ~480ms (sobre todo carga de página).
|
|
||||||
- **1ª reproducción lenta = warm-up de red (Tailscale); 2ª/3ª rápidas** (confirmado
|
|
||||||
por el usuario). No es un problema de código.
|
|
||||||
- Player YA da feedback (fases `loading-meta`/`probing-transport`/`playing` +
|
|
||||||
overlay "Preparando…" + spinner de buffering + mensaje stuck >10s). El "sin
|
|
||||||
feedback" del test fue por usar URL cruda (sin UI), no el flujo real.
|
|
||||||
- **Conclusión:** sin optimización de código necesaria. Arranque garantizado-instante
|
|
||||||
= **hueco #4 (pre-transcode)**: dejar el remux/encode hecho antes del play.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Hueco #4 — Pre-transcode (transcode-on-download)
|
### Hueco #4 — Pre-transcode (transcode-on-download)
|
||||||
|
|
|
||||||
|
|
@ -624,7 +624,6 @@ func runDaemonStart() error {
|
||||||
// over /stream — no video re-encode, no HLS. The web decided this from
|
// over /stream — no video re-encode, no HLS. The web decided this from
|
||||||
// scan metadata + version gate; we still need ffmpeg (copy uses it).
|
// scan metadata + version gate; we still need ffmpeg (copy uses it).
|
||||||
if sess.PlayMethod == "remux" {
|
if sess.PlayMethod == "remux" {
|
||||||
tStart := time.Now()
|
|
||||||
probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second)
|
probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second)
|
||||||
probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath)
|
probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath)
|
||||||
cancelProbe()
|
cancelProbe()
|
||||||
|
|
@ -632,7 +631,6 @@ func runDaemonStart() error {
|
||||||
log.Printf("[stream %s] remux probe failed: %v", agent.ShortID(sess.SessionID), perr)
|
log.Printf("[stream %s] remux probe failed: %v", agent.ShortID(sess.SessionID), perr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tProbe := time.Now()
|
|
||||||
remuxCtx, remuxCancel := context.WithCancel(ctx)
|
remuxCtx, remuxCancel := context.WithCancel(ctx)
|
||||||
src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName)
|
src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName)
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
|
|
@ -644,13 +642,7 @@ func runDaemonStart() error {
|
||||||
// cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close()
|
// cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close()
|
||||||
// the source, so the temp file is always cleaned up.
|
// the source, so the temp file is always cleaned up.
|
||||||
playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() })
|
playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() })
|
||||||
// Startup timing (TTFF diagnosis): probe = ffprobe on the source;
|
log.Printf("[stream %s] remux (copy) → fMP4: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
|
||||||
// spawn = ffmpeg launch + tmp setup. First-fMP4-byte is logged by the
|
|
||||||
// source itself; serveGrowing logs any client read that blocks waiting
|
|
||||||
// for ffmpeg to catch up.
|
|
||||||
log.Printf("[stream %s] remux (copy) → fMP4: %s [probe=%v spawn=%v]",
|
|
||||||
agent.ShortID(sess.SessionID), filepath.Base(filePath),
|
|
||||||
tProbe.Sub(tStart).Round(time.Millisecond), time.Since(tProbe).Round(time.Millisecond))
|
|
||||||
go func() {
|
go func() {
|
||||||
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
|
|
@ -863,7 +863,6 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
|
||||||
|
|
||||||
buf := make([]byte, 256*1024)
|
buf := make([]byte, 256*1024)
|
||||||
off := start
|
off := start
|
||||||
firstRead := true
|
|
||||||
for {
|
for {
|
||||||
if explicitEnd >= 0 && off > explicitEnd {
|
if explicitEnd >= 0 && off > explicitEnd {
|
||||||
return
|
return
|
||||||
|
|
@ -871,19 +870,7 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
|
||||||
if r.Context().Err() != nil {
|
if r.Context().Err() != nil {
|
||||||
return // client disconnected / request cancelled
|
return // client disconnected / request cancelled
|
||||||
}
|
}
|
||||||
readStart := time.Now()
|
|
||||||
n, err := src.ReadAt(buf, off)
|
n, err := src.ReadAt(buf, off)
|
||||||
// TTFF diagnosis: a read that blocks means the client asked for bytes the
|
|
||||||
// remux hasn't produced yet (a seek ahead of the live edge, or the very
|
|
||||||
// first read before ffmpeg's init lands). Log it so a slow start is
|
|
||||||
// attributable to "waiting on ffmpeg" vs network/decoder.
|
|
||||||
if waited := time.Since(readStart); waited > 250*time.Millisecond {
|
|
||||||
log.Printf("[stream] serveGrowing read off=%d blocked %v (produced=%d est=%d)",
|
|
||||||
off, waited.Round(time.Millisecond), src.Size(), src.EstimatedSize())
|
|
||||||
} else if firstRead {
|
|
||||||
log.Printf("[stream] serveGrowing start off=%d (produced=%d est=%d)", start, src.Size(), src.EstimatedSize())
|
|
||||||
}
|
|
||||||
firstRead = false
|
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
toWrite := n
|
toWrite := n
|
||||||
if explicitEnd >= 0 {
|
if explicitEnd >= 0 {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -173,21 +172,8 @@ func newTranscodeSource(
|
||||||
// before sending PlayMethod="remux". The browser plays the result progressively
|
// before sending PlayMethod="remux". The browser plays the result progressively
|
||||||
// via byte-range. Caller MUST Close() it (kills ffmpeg + removes the temp file).
|
// via byte-range. Caller MUST Close() it (kills ffmpeg + removes the temp file).
|
||||||
func NewRemuxSource(ctx context.Context, srcPath string, probe *StreamProbe, ffmpegPath, displayName string) (GrowingSource, error) {
|
func NewRemuxSource(ctx context.Context, srcPath string, probe *StreamProbe, ffmpegPath, displayName string) (GrowingSource, error) {
|
||||||
// Audio: copy when already AAC; otherwise transcode to AAC (ActionRemuxAudio).
|
opts := TranscodeOpts{Action: ActionRemux, FFmpegPath: ffmpegPath}
|
||||||
// Either way the VIDEO is copied — the expensive part is never re-encoded.
|
return newTranscodeSource(ctx, srcPath, probe, ActionRemux, opts, displayName)
|
||||||
// This lets remux cover the very common h264+AC3/DTS mkv case (hueco #3 / 3c),
|
|
||||||
// not just h264+AAC.
|
|
||||||
action := ActionRemux
|
|
||||||
if probe != nil && probe.AudioCodec != "" && probe.AudioCodec != "aac" {
|
|
||||||
action = ActionRemuxAudio
|
|
||||||
}
|
|
||||||
opts := TranscodeOpts{Action: action, FFmpegPath: ffmpegPath}
|
|
||||||
// HEVC muxed into MP4 must carry the hvc1 tag or Apple/Safari won't decode
|
|
||||||
// it (hueco #3 / 3c). h264 (avc1) needs no override.
|
|
||||||
if probe != nil && probe.VideoCodec == "hevc" {
|
|
||||||
opts.VideoTag = "hvc1"
|
|
||||||
}
|
|
||||||
return newTranscodeSource(ctx, srcPath, probe, action, opts, displayName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
|
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
|
||||||
|
|
@ -223,13 +209,6 @@ func (t *transcodeSource) watchSize(ctx context.Context) {
|
||||||
}
|
}
|
||||||
current := stat.Size()
|
current := stat.Size()
|
||||||
if current > t.size.Load() {
|
if current > t.size.Load() {
|
||||||
if t.size.Load() == 0 && current > 0 {
|
|
||||||
// TTFF diagnosis: how long from ffmpeg spawn to the first
|
|
||||||
// fMP4 bytes (init + first fragment) landing — the floor on
|
|
||||||
// when /stream can serve anything playable.
|
|
||||||
log.Printf("[stream] %s first fMP4 bytes after %v (%d KB)",
|
|
||||||
t.name, time.Since(t.startedAt).Round(time.Millisecond), current/1024)
|
|
||||||
}
|
|
||||||
t.size.Store(current)
|
t.size.Store(current)
|
||||||
t.signalNotify()
|
t.signalNotify()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,6 @@ type TranscodeOpts struct {
|
||||||
SourceHeight int // probed source height — used to derive a sane H.264 level
|
SourceHeight int // probed source height — used to derive a sane H.264 level
|
||||||
StartSeconds float64
|
StartSeconds float64
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
// VideoTag forces the output stream's codec tag on a copy remux. HEVC muxed
|
|
||||||
// into MP4 must carry the `hvc1` tag (not the default `hev1`) or Safari /
|
|
||||||
// Apple devices refuse to decode it. Empty = leave ffmpeg's default. Only
|
|
||||||
// applied on copy actions (passthrough/remux); a real re-encode sets its own.
|
|
||||||
VideoTag string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
|
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
|
||||||
|
|
@ -227,16 +222,8 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
|
||||||
switch opts.Action {
|
switch opts.Action {
|
||||||
case ActionPassthrough, ActionRemux:
|
case ActionPassthrough, ActionRemux:
|
||||||
args = append(args, "-c:v", "copy", "-c:a", "copy")
|
args = append(args, "-c:v", "copy", "-c:a", "copy")
|
||||||
// HEVC → MP4 needs the hvc1 tag for Apple/Safari (hueco #3 / 3c).
|
|
||||||
if opts.VideoTag != "" {
|
|
||||||
args = append(args, "-tag:v", opts.VideoTag)
|
|
||||||
}
|
|
||||||
case ActionRemuxAudio:
|
case ActionRemuxAudio:
|
||||||
args = append(args, "-c:v", "copy")
|
args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
|
||||||
if opts.VideoTag != "" {
|
|
||||||
args = append(args, "-tag:v", opts.VideoTag) // HEVC → hvc1 for Apple
|
|
||||||
}
|
|
||||||
args = append(args, "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
|
|
||||||
case ActionTranscodeVideo:
|
case ActionTranscodeVideo:
|
||||||
videoCodec := opts.HWAccel.FFmpegVideoCodec("h264")
|
videoCodec := opts.HWAccel.FFmpegVideoCodec("h264")
|
||||||
args = append(args, "-c:v", videoCodec)
|
args = append(args, "-c:v", videoCodec)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue