feat(stream): device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers

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.
This commit is contained in:
Deivid Soto 2026-05-31 12:44:12 +02:00
parent c18876471c
commit 957d499658
4 changed files with 59 additions and 4 deletions

View file

@ -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 {

View file

@ -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()
}

View file

@ -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)