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:
parent
457d6e1f7c
commit
27fe84f2a0
3 changed files with 196 additions and 130 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue