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
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -80,11 +80,11 @@ type transcodeSource struct {
|
||||||
name string
|
name string
|
||||||
estimate int64
|
estimate int64
|
||||||
|
|
||||||
mu sync.Mutex
|
ctx context.Context
|
||||||
cond *sync.Cond
|
notify chan struct{} // size grew or final flipped; cap=1, non-blocking send
|
||||||
size atomic.Int64
|
size atomic.Int64
|
||||||
final atomic.Bool
|
final atomic.Bool
|
||||||
failure error
|
failure atomic.Pointer[error]
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,8 +119,7 @@ func newTranscodeSource(
|
||||||
|
|
||||||
// Spawn ffmpeg directly (not via NewTranscoder pipe) so it writes to
|
// Spawn ffmpeg directly (not via NewTranscoder pipe) so it writes to
|
||||||
// disk in real time. We re-use the rest of TranscodeOpts wiring.
|
// disk in real time. We re-use the rest of TranscodeOpts wiring.
|
||||||
cmd := &Transcoder{}
|
cmd, err := startTranscoderToFile(ctx, opts.FFmpegPath, args, nil)
|
||||||
cmd, err = startTranscoderToFile(ctx, opts.FFmpegPath, args, cmd)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -133,9 +132,10 @@ func newTranscodeSource(
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
estimate: estimate,
|
estimate: estimate,
|
||||||
|
ctx: ctx,
|
||||||
|
notify: make(chan struct{}, 1),
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
t.cond = sync.NewCond(&t.mu)
|
|
||||||
|
|
||||||
// Re-open the tmp file for reading; ffmpeg keeps writing to it.
|
// Re-open the tmp file for reading; ffmpeg keeps writing to it.
|
||||||
rf, err := os.Open(tmpPath)
|
rf, err := os.Open(tmpPath)
|
||||||
|
|
@ -151,6 +151,17 @@ func newTranscodeSource(
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
|
||||||
|
// notification is already pending the new event is folded into it (callers
|
||||||
|
// always re-check size + final after waking, so a coalesced signal still
|
||||||
|
// produces correct behaviour).
|
||||||
|
func (t *transcodeSource) signalNotify() {
|
||||||
|
select {
|
||||||
|
case t.notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// watchSize polls the temp file size every 200 ms and wakes any blocked
|
// watchSize polls the temp file size every 200 ms and wakes any blocked
|
||||||
// ReadAt callers once new bytes arrive.
|
// ReadAt callers once new bytes arrive.
|
||||||
func (t *transcodeSource) watchSize(ctx context.Context) {
|
func (t *transcodeSource) watchSize(ctx context.Context) {
|
||||||
|
|
@ -159,16 +170,12 @@ func (t *transcodeSource) watchSize(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
t.mu.Lock()
|
t.signalNotify()
|
||||||
t.cond.Broadcast()
|
|
||||||
t.mu.Unlock()
|
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
}
|
}
|
||||||
if t.final.Load() {
|
if t.final.Load() {
|
||||||
t.mu.Lock()
|
t.signalNotify()
|
||||||
t.cond.Broadcast()
|
|
||||||
t.mu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stat, err := os.Stat(t.tmpPath)
|
stat, err := os.Stat(t.tmpPath)
|
||||||
|
|
@ -178,91 +185,83 @@ func (t *transcodeSource) watchSize(ctx context.Context) {
|
||||||
current := stat.Size()
|
current := stat.Size()
|
||||||
if current > t.size.Load() {
|
if current > t.size.Load() {
|
||||||
t.size.Store(current)
|
t.size.Store(current)
|
||||||
t.mu.Lock()
|
t.signalNotify()
|
||||||
t.cond.Broadcast()
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchExit waits for ffmpeg to exit and locks in the final size.
|
// watchExit waits for ffmpeg to exit (via Transcoder's single-Wait goroutine)
|
||||||
|
// and locks in the final size. A kill triggered by Close() is NOT a failure.
|
||||||
func (t *transcodeSource) watchExit() {
|
func (t *transcodeSource) watchExit() {
|
||||||
err := t.cmd.cmd.Wait()
|
<-t.cmd.Done()
|
||||||
if err != nil && !isExpectedExit(err) {
|
err := t.cmd.WaitErr()
|
||||||
t.mu.Lock()
|
if err != nil && !t.cmd.IsClosing() {
|
||||||
t.failure = fmt.Errorf("ffmpeg exited: %w (%s)", err, t.cmd.Stderr())
|
failure := fmt.Errorf("ffmpeg exited: %w (%s)", err, t.cmd.Stderr())
|
||||||
t.mu.Unlock()
|
t.failure.Store(&failure)
|
||||||
}
|
}
|
||||||
if stat, err := os.Stat(t.tmpPath); err == nil {
|
if stat, err := os.Stat(t.tmpPath); err == nil {
|
||||||
t.size.Store(stat.Size())
|
t.size.Store(stat.Size())
|
||||||
}
|
}
|
||||||
t.final.Store(true)
|
t.final.Store(true)
|
||||||
t.mu.Lock()
|
t.signalNotify()
|
||||||
t.cond.Broadcast()
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExpectedExit(err error) bool {
|
// loadFailure returns the current failure (or nil) without taking a lock.
|
||||||
// Killed by Close() — pion DC closed, that's fine.
|
func (t *transcodeSource) loadFailure() error {
|
||||||
if err == nil {
|
if p := t.failure.Load(); p != nil {
|
||||||
return true
|
return *p
|
||||||
}
|
}
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *transcodeSource) ReadAt(p []byte, off int64) (int, error) {
|
func (t *transcodeSource) ReadAt(p []byte, off int64) (int, error) {
|
||||||
if t.failure != nil {
|
if err := t.loadFailure(); err != nil {
|
||||||
return 0, t.failure
|
return 0, err
|
||||||
}
|
}
|
||||||
if int64(len(p)) == 0 {
|
if len(p) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(readBlockTimeout)
|
if off < 0 {
|
||||||
|
return 0, fmt.Errorf("transcode source: negative offset %d", off)
|
||||||
|
}
|
||||||
|
want := int64(len(p))
|
||||||
|
|
||||||
|
deadline := time.Now().Add(readBlockTimeout)
|
||||||
for {
|
for {
|
||||||
|
if t.final.Load() {
|
||||||
|
break
|
||||||
|
}
|
||||||
size := t.size.Load()
|
size := t.size.Load()
|
||||||
if off+int64(len(p)) <= size || t.final.Load() {
|
// Overflow-safe form of "off + want <= size":
|
||||||
|
if size >= off && size-off >= want {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Need to wait for ffmpeg to write more.
|
remaining := time.Until(deadline)
|
||||||
t.mu.Lock()
|
if remaining <= 0 {
|
||||||
// Check again under lock to avoid lost wakeup.
|
|
||||||
size = t.size.Load()
|
|
||||||
if off+int64(len(p)) <= size || t.final.Load() {
|
|
||||||
t.mu.Unlock()
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Wait with timeout via a small sleep loop — sync.Cond doesn't
|
wait := 500 * time.Millisecond
|
||||||
// support timed wait, and a goroutine-per-sleep pattern works fine
|
if remaining < wait {
|
||||||
// for our scale.
|
wait = remaining
|
||||||
waited := time.NewTimer(500 * time.Millisecond)
|
}
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
t.cond.Wait()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
t.mu.Unlock()
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-t.ctx.Done():
|
||||||
case <-waited.C:
|
return 0, t.ctx.Err()
|
||||||
t.mu.Lock()
|
case <-t.notify:
|
||||||
t.cond.Broadcast() // wake the goroutine so it can return
|
case <-time.After(wait):
|
||||||
t.mu.Unlock()
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.failure != nil {
|
if err := t.loadFailure(); err != nil {
|
||||||
return 0, t.failure
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := t.tmpFile.ReadAt(p, off)
|
n, err := t.tmpFile.ReadAt(p, off)
|
||||||
// On growing file ReadAt returns io.EOF when reading past current size.
|
// On a growing file ReadAt returns io.EOF when reading past current size.
|
||||||
// Convert to io.ErrUnexpectedEOF only when we actually exceeded the
|
// Translate that into "send what we have, RangeEnd will follow" by
|
||||||
// final size; otherwise return n, nil so the pump sends what we have.
|
// returning (n, nil) so the pump treats the data as a partial chunk and
|
||||||
|
// caller re-requests once more bytes appear. Only true EOF (final=true)
|
||||||
|
// propagates as io.EOF.
|
||||||
if err == io.EOF && !t.final.Load() {
|
if err == io.EOF && !t.final.Load() {
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
return n, nil
|
return n, nil
|
||||||
|
|
@ -281,23 +280,26 @@ func (t *transcodeSource) EstimatedSize() int64 {
|
||||||
return t.estimate
|
return t.estimate
|
||||||
}
|
}
|
||||||
func (t *transcodeSource) FileName() string {
|
func (t *transcodeSource) FileName() string {
|
||||||
// Keep the original extension stripped — output is always fragmented MP4.
|
// Output is always fragmented MP4 regardless of source extension.
|
||||||
base := t.name
|
return strings.TrimSuffix(t.name, filepath.Ext(t.name)) + ".mp4"
|
||||||
if i := lastIndexByte(base, '.'); i >= 0 {
|
|
||||||
base = base[:i]
|
|
||||||
}
|
|
||||||
return base + ".mp4"
|
|
||||||
}
|
}
|
||||||
func (t *transcodeSource) Transcoded() bool { return true }
|
func (t *transcodeSource) Transcoded() bool { return true }
|
||||||
func (t *transcodeSource) Close() error {
|
func (t *transcodeSource) Close() error {
|
||||||
_ = t.cmd.Close()
|
var errs []error
|
||||||
|
if err := t.cmd.Close(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
if t.tmpFile != nil {
|
if t.tmpFile != nil {
|
||||||
_ = t.tmpFile.Close()
|
if err := t.tmpFile.Close(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if t.tmpPath != "" {
|
if t.tmpPath != "" {
|
||||||
_ = os.Remove(t.tmpPath)
|
if err := os.Remove(t.tmpPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// estimateOutputSize converts probed bitrate × duration into a byte estimate
|
// estimateOutputSize converts probed bitrate × duration into a byte estimate
|
||||||
|
|
@ -343,12 +345,3 @@ func parseBitrateKbps(s string, fallback int) int {
|
||||||
}
|
}
|
||||||
return v * mult
|
return v * mult
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastIndexByte(s string, c byte) int {
|
|
||||||
for i := len(s) - 1; i >= 0; i-- {
|
|
||||||
if s[i] == c {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ type TranscodeOpts struct {
|
||||||
// One Transcoder == one playback position. A seek beyond the buffered window
|
// One Transcoder == one playback position. A seek beyond the buffered window
|
||||||
// requires Close()ing this transcoder and starting a new one with a higher
|
// requires Close()ing this transcoder and starting a new one with a higher
|
||||||
// StartSeconds (handled in webrtc_stream.go).
|
// 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 {
|
type Transcoder struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
out io.ReadCloser
|
out io.ReadCloser
|
||||||
|
|
@ -42,6 +46,9 @@ type Transcoder struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closed bool
|
closed bool
|
||||||
stderr strings.Builder
|
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
|
// 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 {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
|
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
|
||||||
}
|
}
|
||||||
|
t.startWaitGoroutine()
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,13 +89,43 @@ func startTranscoderToFile(ctx context.Context, ffmpegPath string, args []string
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
|
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
|
||||||
}
|
}
|
||||||
|
t.startWaitGoroutine()
|
||||||
return t, nil
|
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.
|
// Read implements io.Reader.
|
||||||
func (t *Transcoder) Read(p []byte) (int, error) { return t.out.Read(p) }
|
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.
|
// 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 {
|
func (t *Transcoder) Close() error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if t.closed {
|
if t.closed {
|
||||||
|
|
@ -106,19 +144,26 @@ func (t *Transcoder) Close() error {
|
||||||
if t.cmd != nil && t.cmd.Process != nil {
|
if t.cmd != nil && t.cmd.Process != nil {
|
||||||
_ = t.cmd.Process.Kill()
|
_ = t.cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
if t.cmd == nil {
|
if t.done == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- t.cmd.Wait() }()
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-t.done:
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
// Process refused to die — leak it; the OS will clean up on exit.
|
// Process refused to die — leak it; the OS will clean up on exit.
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// Stderr returns the accumulated ffmpeg stderr so far. Useful for surfacing
|
||||||
// failure reasons in logs after Close().
|
// failure reasons in logs after Close().
|
||||||
func (t *Transcoder) Stderr() string {
|
func (t *Transcoder) Stderr() string {
|
||||||
|
|
@ -185,27 +230,47 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
|
||||||
if videoCodec == "libx264" {
|
if videoCodec == "libx264" {
|
||||||
args = append(args, "-preset", coalesce(opts.Preset, "veryfast"))
|
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"))
|
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 {
|
if opts.MaxHeight > 0 {
|
||||||
// `-2:H` scales to height H, derives width preserving aspect ratio,
|
filterChain = fmt.Sprintf(
|
||||||
// and rounds to a multiple of 2 (libx264 refuses odd dimensions).
|
"scale=-2:%d:force_original_aspect_ratio=decrease,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
|
||||||
// `force_original_aspect_ratio=decrease` keeps shorter sources
|
opts.MaxHeight,
|
||||||
// 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",
|
|
||||||
)
|
)
|
||||||
} else {
|
} 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"))
|
args = append(args, "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common output flags — fragmented MP4 to a single pipe.
|
// 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,
|
args = append(args,
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof+faststart",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-f", "mp4",
|
"-f", "mp4",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ func buildStreamSource(
|
||||||
log StreamLogger,
|
log StreamLogger,
|
||||||
) (streamSource, error) {
|
) (streamSource, error) {
|
||||||
tc := cfg.Transcode
|
tc := cfg.Transcode
|
||||||
cap := resolveQualityCap(cfg.Quality)
|
qcap := resolveQualityCap(cfg.Quality)
|
||||||
|
|
||||||
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
|
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
|
||||||
return newDiskFileSource(abs)
|
return newDiskFileSource(abs)
|
||||||
|
|
@ -160,14 +160,10 @@ func buildStreamSource(
|
||||||
|
|
||||||
// Quality cap can promote a passthrough/remux decision into a full video
|
// Quality cap can promote a passthrough/remux decision into a full video
|
||||||
// transcode when the source resolution exceeds the requested cap.
|
// transcode when the source resolution exceeds the requested cap.
|
||||||
forcedByQuality := false
|
if qcap.MaxHeight > 0 && probe.Height > 0 && probe.Height > qcap.MaxHeight && action != ActionTranscodeVideo {
|
||||||
if cap.MaxHeight > 0 && probe.Height > 0 && probe.Height > cap.MaxHeight {
|
log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode",
|
||||||
if action != ActionTranscodeVideo {
|
agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, qcap.MaxHeight)
|
||||||
log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode",
|
action = ActionTranscodeVideo
|
||||||
agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, cap.MaxHeight)
|
|
||||||
action = ActionTranscodeVideo
|
|
||||||
forcedByQuality = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if action == ActionPassthrough {
|
if action == ActionPassthrough {
|
||||||
|
|
@ -178,15 +174,14 @@ func buildStreamSource(
|
||||||
|
|
||||||
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
|
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
|
||||||
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec,
|
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec,
|
||||||
action, coalesceLabel(cfg.Quality))
|
action, coalesce(cfg.Quality, "default"))
|
||||||
|
|
||||||
maxHeight := tc.MaxHeight
|
maxHeight := tc.MaxHeight
|
||||||
videoBitrate := tc.VideoBitrate
|
videoBitrate := tc.VideoBitrate
|
||||||
if cap.MaxHeight > 0 {
|
if qcap.MaxHeight > 0 {
|
||||||
maxHeight = cap.MaxHeight
|
maxHeight = qcap.MaxHeight
|
||||||
videoBitrate = cap.VideoBitrate
|
videoBitrate = qcap.VideoBitrate
|
||||||
}
|
}
|
||||||
_ = forcedByQuality // reserved for future telemetry
|
|
||||||
|
|
||||||
opts := TranscodeOpts{
|
opts := TranscodeOpts{
|
||||||
Action: action,
|
Action: action,
|
||||||
|
|
@ -200,13 +195,6 @@ func buildStreamSource(
|
||||||
return newTranscodeSource(ctx, abs, probe, action, opts, displayName)
|
return newTranscodeSource(ctx, abs, probe, action, opts, displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func coalesceLabel(s string) string {
|
|
||||||
if s == "" {
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunWebRTCStream blocks until the session ends — either the DataChannel
|
// RunWebRTCStream blocks until the session ends — either the DataChannel
|
||||||
// closes, the peer connection drops, or ctx is cancelled. Always returns a
|
// closes, the peer connection drops, or ctx is cancelled. Always returns a
|
||||||
// non-nil error explaining the termination reason.
|
// non-nil error explaining the termination reason.
|
||||||
|
|
@ -520,6 +508,13 @@ func (p *dataChannelPump) onOpen() {
|
||||||
// ffmpeg writes more bytes; the estimate just bootstraps the UI.
|
// ffmpeg writes more bytes; the estimate just bootstraps the UI.
|
||||||
announceSize := p.source.EstimatedSize()
|
announceSize := p.source.EstimatedSize()
|
||||||
transcoding := p.source.Transcoded()
|
transcoding := p.source.Transcoded()
|
||||||
|
// Browsers refuse to start playback when Content-Length is 0. If we don't
|
||||||
|
// have a duration estimate (e.g. ffprobe couldn't tag the source), declare
|
||||||
|
// a large sentinel so the browser issues range requests; the Transcoding
|
||||||
|
// flag tells it the value is provisional.
|
||||||
|
if transcoding && announceSize <= 0 {
|
||||||
|
announceSize = math.MaxInt64
|
||||||
|
}
|
||||||
// Seekable=true even for transcoded sources because we read from a tmp
|
// Seekable=true even for transcoded sources because we read from a tmp
|
||||||
// file (random access). Seek backwards just works; seek forward beyond
|
// file (random access). Seek backwards just works; seek forward beyond
|
||||||
// what ffmpeg has produced will block briefly inside ReadAt.
|
// what ffmpeg has produced will block briefly inside ReadAt.
|
||||||
|
|
@ -633,22 +628,35 @@ func (p *dataChannelPump) serveRange(streamID uint32, req wire.RangeReqPayload)
|
||||||
if req.Length > math.MaxInt64 {
|
if req.Length > math.MaxInt64 {
|
||||||
want = 0 // treat absurd length as "remainder of file"
|
want = 0 // treat absurd length as "remainder of file"
|
||||||
}
|
}
|
||||||
// "Remainder" target: prefer current known size, fall back to estimate
|
// Cap by *final* size, not currentSize. For a still-transcoding stream
|
||||||
// for transcoded streams so the browser can keep scrolling forward as
|
// currentSize grows over time and ReadAt below already blocks until
|
||||||
// ffmpeg produces output.
|
// ffmpeg produces the requested bytes (with a deadline). If we cap
|
||||||
knownEnd := currentSize
|
// `want` by currentSize here we'll send an empty RangeEnd whenever the
|
||||||
if p.source.Final() {
|
// browser asks for bytes faster than ffmpeg writes them — which is
|
||||||
knownEnd = finalSize
|
// always true on the first few seconds — and the browser then aborts
|
||||||
|
// playback with "Format error".
|
||||||
|
cap := finalSize
|
||||||
|
if !p.source.Final() && cap < int64(req.Offset)+1 {
|
||||||
|
// Estimate too small: serve as much as the browser asked for and
|
||||||
|
// let ReadAt block.
|
||||||
|
cap = int64(req.Offset) + want
|
||||||
}
|
}
|
||||||
if knownEnd < int64(req.Offset) {
|
if int64(req.Offset) >= cap && p.source.Final() {
|
||||||
knownEnd = int64(req.Offset)
|
// Past true end of a finished file.
|
||||||
|
p.sendRangeEnd(streamID, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining := cap - int64(req.Offset)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
}
|
}
|
||||||
remaining := knownEnd - int64(req.Offset)
|
|
||||||
if want <= 0 || want > remaining {
|
if want <= 0 || want > remaining {
|
||||||
want = remaining
|
want = remaining
|
||||||
}
|
}
|
||||||
|
p.log.Infof("dc: range_req sid=%d offset=%d wantReq=%d wantServe=%d currentSize=%d final=%v",
|
||||||
|
streamID, req.Offset, req.Length, want, currentSize, p.source.Final())
|
||||||
if want <= 0 {
|
if want <= 0 {
|
||||||
// Nothing to serve right now (transcoder hasn't reached this offset).
|
// Only happens for a finished file when offset is at/past EOF.
|
||||||
p.sendRangeEnd(streamID, 0)
|
p.sendRangeEnd(streamID, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue