From 957d4996583798ec07ebac79b7bfb9cdac086cbd Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 31 May 2026 12:44:12 +0200 Subject: [PATCH] feat(stream): device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hueco #3 / 3c (CLI). NewRemuxSource now copies the video for any browser-decodable codec: h264, or HEVC/AV1 when the web says the device decodes them (caps). HEVC is muxed with -tag:v hvc1 (Apple requirement), and non-aac audio (ac3/eac3/dts) is transcoded to aac while the video is still copied (ActionRemuxAudio) — this covers the very common h264+ac3 mkv. Startup instrumentation for time-to-first-frame diagnosis: - remux branch logs [probe=.. spawn=..] - transcodeSource logs 'first fMP4 bytes after ..' (ffmpeg → first output) - serveGrowing logs reads that block >250ms (client seeking ahead of the live edge) + the first read's offset vs produced/estimated size. Verified: caps gate (hls without caps, remux with), hvc1 retag (ffprobe of the /stream output = hevc/hvc1), HEVC playback confirmed on a real iPhone Safari over Tailscale. LAN timeline: probe 16ms, spawn 1ms, first byte 201ms, no serveGrowing blocks. --- internal/cmd/daemon.go | 10 +++++++++- internal/engine/stream_server.go | 13 +++++++++++++ internal/engine/stream_source.go | 25 +++++++++++++++++++++++-- internal/engine/transcoder.go | 15 ++++++++++++++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index ef999a3..0c24d1c 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -624,6 +624,7 @@ func runDaemonStart() error { // over /stream — no video re-encode, no HLS. The web decided this from // scan metadata + version gate; we still need ffmpeg (copy uses it). if sess.PlayMethod == "remux" { + tStart := time.Now() probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second) probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath) cancelProbe() @@ -631,6 +632,7 @@ func runDaemonStart() error { log.Printf("[stream %s] remux probe failed: %v", agent.ShortID(sess.SessionID), perr) return } + tProbe := time.Now() remuxCtx, remuxCancel := context.WithCancel(ctx) src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName) if serr != nil { @@ -642,7 +644,13 @@ func runDaemonStart() error { // cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close() // the source, so the temp file is always cleaned up. playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() }) - log.Printf("[stream %s] remux (copy) → fMP4: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath)) + // Startup timing (TTFF diagnosis): probe = ffprobe on the source; + // 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() { rctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index fb3e76c..83d4360 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -863,6 +863,7 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src buf := make([]byte, 256*1024) off := start + firstRead := true for { if explicitEnd >= 0 && off > explicitEnd { return @@ -870,7 +871,19 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src if r.Context().Err() != nil { return // client disconnected / request cancelled } + readStart := time.Now() 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 { toWrite := n if explicitEnd >= 0 { diff --git a/internal/engine/stream_source.go b/internal/engine/stream_source.go index 301a899..58f66ba 100644 --- a/internal/engine/stream_source.go +++ b/internal/engine/stream_source.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "os" "path/filepath" "strings" @@ -172,8 +173,21 @@ func newTranscodeSource( // before sending PlayMethod="remux". The browser plays the result progressively // 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) { - opts := TranscodeOpts{Action: ActionRemux, FFmpegPath: ffmpegPath} - return newTranscodeSource(ctx, srcPath, probe, ActionRemux, opts, displayName) + // Audio: copy when already AAC; otherwise transcode to AAC (ActionRemuxAudio). + // Either way the VIDEO is copied — the expensive part is never re-encoded. + // 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 @@ -209,6 +223,13 @@ func (t *transcodeSource) watchSize(ctx context.Context) { } current := stat.Size() 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.signalNotify() } diff --git a/internal/engine/transcoder.go b/internal/engine/transcoder.go index 030c28c..6bd613e 100644 --- a/internal/engine/transcoder.go +++ b/internal/engine/transcoder.go @@ -27,6 +27,11 @@ type TranscodeOpts struct { SourceHeight int // probed source height — used to derive a sane H.264 level StartSeconds float64 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 @@ -222,8 +227,16 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string { switch opts.Action { case ActionPassthrough, ActionRemux: 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: - args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k")) + args = append(args, "-c:v", "copy") + 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: videoCodec := opts.HWAccel.FFmpegVideoCodec("h264") args = append(args, "-c:v", videoCodec)