fix(transcoder): force main profile + setparams Rec.709 + serveRange wait

1. -profile:v main + -level:v 4.0 to avoid Chrome's HW decoder path that
   failed with "VaapiWrapper: failed initializing for h264 high" on Linux.
2. setparams to rewrite HDR HEVC color metadata to SDR Rec.709 so browsers
   don't reject wide-gamut output.
3. serveRange caps `want` by estimated final size (not current). ReadAt
   blocks until ffmpeg catches up — that's the right behaviour. Returning
   RangeEnd inmediato was making the browser abort with "Format error".
4. Debug log on every range_req.
This commit is contained in:
Deivid Soto 2026-05-07 13:48:45 +02:00
parent 457d6e1f7c
commit 27fe84f2a0
3 changed files with 196 additions and 130 deletions

View file

@ -35,6 +35,10 @@ type TranscodeOpts struct {
// One Transcoder == one playback position. A seek beyond the buffered window
// requires Close()ing this transcoder and starting a new one with a higher
// StartSeconds (handled in webrtc_stream.go).
//
// A single internal goroutine owns cmd.Wait() — never call cmd.Wait()
// directly from outside (os/exec forbids concurrent Wait callers). Use
// Done() / WaitErr() instead.
type Transcoder struct {
cmd *exec.Cmd
out io.ReadCloser
@ -42,6 +46,9 @@ type Transcoder struct {
mu sync.Mutex
closed bool
stderr strings.Builder
done chan struct{} // closed once cmd.Wait returns; nil if cmd never started
waitErr error // populated before done is closed; read-only after
}
// NewTranscoder spawns ffmpeg and returns a Transcoder whose Read() yields
@ -61,6 +68,7 @@ func NewTranscoder(ctx context.Context, filePath string, opts TranscodeOpts) (*T
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
}
t.startWaitGoroutine()
return t, nil
}
@ -81,13 +89,43 @@ func startTranscoderToFile(ctx context.Context, ffmpegPath string, args []string
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
}
t.startWaitGoroutine()
return t, nil
}
// startWaitGoroutine launches the single goroutine that owns cmd.Wait().
// Idempotent — protected by sync.Once-via-nil-check on done.
func (t *Transcoder) startWaitGoroutine() {
if t.done != nil {
return
}
t.done = make(chan struct{})
go func() {
t.waitErr = t.cmd.Wait()
close(t.done)
}()
}
// Done returns a channel that closes when ffmpeg exits. Returns nil for a
// Transcoder whose cmd never started.
func (t *Transcoder) Done() <-chan struct{} { return t.done }
// WaitErr blocks until ffmpeg exits and returns the wait error. Safe to
// call concurrently from multiple goroutines.
func (t *Transcoder) WaitErr() error {
if t.done == nil {
return nil
}
<-t.done
return t.waitErr
}
// Read implements io.Reader.
func (t *Transcoder) Read(p []byte) (int, error) { return t.out.Read(p) }
// Close kills the child process if still running and waits up to 2s for exit.
// IsClosing reports true after Close has been invoked — used by streamSource
// to distinguish a kill-by-Close from a genuine ffmpeg crash.
func (t *Transcoder) Close() error {
t.mu.Lock()
if t.closed {
@ -106,19 +144,26 @@ func (t *Transcoder) Close() error {
if t.cmd != nil && t.cmd.Process != nil {
_ = t.cmd.Process.Kill()
}
if t.cmd == nil {
if t.done == nil {
return nil
}
done := make(chan error, 1)
go func() { done <- t.cmd.Wait() }()
select {
case <-done:
case <-t.done:
case <-time.After(2 * time.Second):
// Process refused to die — leak it; the OS will clean up on exit.
}
return nil
}
// IsClosing reports whether Close has been invoked. Cheap atomic-ish check
// for callers that want to distinguish a kill-by-Close exit from a real
// ffmpeg failure when reading WaitErr.
func (t *Transcoder) IsClosing() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.closed
}
// Stderr returns the accumulated ffmpeg stderr so far. Useful for surfacing
// failure reasons in logs after Close().
func (t *Transcoder) Stderr() string {
@ -185,27 +230,47 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
if videoCodec == "libx264" {
args = append(args, "-preset", coalesce(opts.Preset, "veryfast"))
}
// Force the broadest browser-compatible h264 profile. `high` (libx264
// default) makes Chrome try its hardware decoder path first, which
// can fail with "VaapiWrapper: failed initializing" on Linux boxes
// where VA-API isn't fully wired up. `main` keeps a clean software
// decode fallback on every desktop + mobile platform.
args = append(args, "-profile:v", "main", "-level:v", "4.0")
args = append(args, "-b:v", coalesce(opts.VideoBitrate, "5M"))
// Filter chain:
// 1. scale (optional) — cap height + force even width.
// 2. format=yuv420p — drop 10-bit + reset pix_fmt to 8-bit before
// libx264 (which refuses 10-bit unless built with --bit-depth=10).
// 3. setparams — REWRITE the color metadata in the output stream's
// VUI/SEI without touching pixels. This is what makes HDR HEVC
// sources (color_primaries=bt2020, color_transfer=arib-std-b67)
// decodeable in browsers that reject anything but Rec.709. We
// can't actually tonemap without libzimg/zscale (most ffmpeg
// builds — including ours — ship without it), so colours look
// desaturated on HDR sources, but the file plays. SDR sources
// already match these params and are unaffected.
var filterChain string
if opts.MaxHeight > 0 {
// `-2:H` scales to height H, derives width preserving aspect ratio,
// and rounds to a multiple of 2 (libx264 refuses odd dimensions).
// `force_original_aspect_ratio=decrease` keeps shorter sources
// untouched instead of upscaling. `pix_fmt yuv420p` keeps 10-bit
// HEVC sources playable in browsers (8-bit only).
args = append(args,
"-vf",
fmt.Sprintf("scale=-2:%d:force_original_aspect_ratio=decrease", opts.MaxHeight),
"-pix_fmt", "yuv420p",
filterChain = fmt.Sprintf(
"scale=-2:%d:force_original_aspect_ratio=decrease,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
opts.MaxHeight,
)
} else {
args = append(args, "-pix_fmt", "yuv420p")
filterChain = "format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
args = append(args, "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
}
// Common output flags — fragmented MP4 to a single pipe.
// NO faststart: that flag rewrites the moov atom to the front of the
// file as a SECOND pass after encoding finishes, which means the
// browser never sees a moov until ffmpeg exits. For live transcoding
// we need empty_moov (write a placeholder up front) so MSE can start
// decoding the very first fragment. faststart is only safe for
// already-finished files.
args = append(args,
"-movflags", "frag_keyframe+empty_moov+default_base_moof+faststart",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4",
"pipe:1",
)