The browser-side WebRTC reproductor needs MP4 / H.264 / AAC / yuv420p to
keep MSE happy. This package decides per request whether to:
• direct-play — input already MSE-compatible, just remux to fMP4
• transcode — re-encode video (libx264 / NVENC / QSV / VAAPI /
VideoToolbox) + audio (AAC), fragment to fMP4
Pieces:
- internal/streaming/transcoder.go — AnalyzeCompatibility decides the
recipe from a parsed mediainfo. CompatibilityReport carries the reasons
so the player UI can show "transcoding video: HEVC → H.264".
- internal/streaming/ffmpeg_args.go — BuildFFmpegArgs assembles the argv
for ffmpeg. Direct play uses `-c copy`; transcode uses libx264 or the
selected HW encoder. Output is always fragmented MP4 piped to stdout
(-movflags frag_keyframe+empty_moov+default_base_moof) so the HTTP
handler can stream straight to the browser without disk I/O.
Quality ladder: 480p (1.5Mb), 720p (3.5Mb), 1080p (6Mb), 2160p (25Mb).
Default 1080p when unset / unknown. -ss seek for resume / scrubbing.
- internal/streaming/hwaccel.go — DetectHWAccel runs `ffmpeg -encoders`
once per process and caches the best available. Order: NVENC → QSV →
VAAPI → VideoToolbox → libx264. VAAPI is the only family that wires up
HW decode too (`-hwaccel vaapi`); the others software-decode and HW-
encode (works fine and avoids /dev/dri permission rabbit holes).
- internal/streaming/stream.go — Transcoder facade wires Analyze + Stream
together for the API handler in Fase 4. Captures the last 8 KiB of
ffmpeg stderr for diagnosable errors without unbounded memory.
Tests (20 unit, all green):
- AnalyzeCompatibility: h264+aac direct, video-only direct, HEVC →
transcode, 10-bit HDR → transcode, EAC3 audio → transcode, nil guards
- ResolveQuality: empty + unknown fallback to 1080p, 4-step ladder
- BuildFFmpegArgs: direct play -c copy, transcode libx264 + bitrate +
scale, NVENC swaps encoder & drops preset, VAAPI injects -hwaccel +
scale_vaapi, -ss timestamp formatting
- HWAccel: encoder-name table, VAAPI is the only one with HW decode
- formatDuration: zero, sub-second, HH:MM:SS, negative-clamped
- cappedBuffer: tail retention through multi-write and large-write paths
- NewTranscoder: rejects empty paths
144 lines
4.1 KiB
Go
144 lines
4.1 KiB
Go
package streaming
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// HWAccel identifies which hardware encoder family the host can use.
|
|
type HWAccel string
|
|
|
|
const (
|
|
HWAccelUnset HWAccel = ""
|
|
HWAccelNone HWAccel = "none" // explicit software libx264
|
|
HWAccelNVENC HWAccel = "nvenc" // NVIDIA GPUs
|
|
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync (Linux/Win)
|
|
HWAccelVAAPI HWAccel = "vaapi" // Intel/AMD GPUs on Linux
|
|
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS native
|
|
)
|
|
|
|
// VideoEncoder returns the ffmpeg `-c:v` argument for this accelerator.
|
|
func (h HWAccel) VideoEncoder() string {
|
|
switch h {
|
|
case HWAccelNVENC:
|
|
return "h264_nvenc"
|
|
case HWAccelQSV:
|
|
return "h264_qsv"
|
|
case HWAccelVAAPI:
|
|
return "h264_vaapi"
|
|
case HWAccelVideoToolbox:
|
|
return "h264_videotoolbox"
|
|
default:
|
|
return "libx264"
|
|
}
|
|
}
|
|
|
|
// HasDecoder reports whether the accelerator also supports HW decode.
|
|
// We always feed encoders software-decoded frames except for VAAPI where
|
|
// the GPU pipeline expects HW-decoded surfaces end-to-end.
|
|
func (h HWAccel) HasDecoder() bool {
|
|
return h == HWAccelVAAPI
|
|
}
|
|
|
|
// DecoderArgs returns the ffmpeg flags that enable HW decode for this
|
|
// accelerator. Only meaningful when HasDecoder() == true.
|
|
func (h HWAccel) DecoderArgs() []string {
|
|
if h == HWAccelVAAPI {
|
|
return []string{
|
|
"-hwaccel", "vaapi",
|
|
"-hwaccel_device", "/dev/dri/renderD128",
|
|
"-hwaccel_output_format", "vaapi",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// detectedHWAccel caches the result of DetectHWAccel so we don't fork
|
|
// ffmpeg on every transcode request.
|
|
var (
|
|
detectedHWAccelOnce sync.Once
|
|
detectedHWAccel HWAccel
|
|
)
|
|
|
|
// DetectHWAccel asks ffmpeg what encoders it supports and returns the
|
|
// best available. Result is cached for the process lifetime — callers
|
|
// should construct the Transcoder once and reuse it.
|
|
//
|
|
// Detection order (best perf → fallback):
|
|
// 1. NVENC (NVIDIA GPU + CUDA driver)
|
|
// 2. QSV (Intel iGPU/dGPU + libmfx/intel-media-driver)
|
|
// 3. VAAPI (Linux Intel/AMD via /dev/dri)
|
|
// 4. VideoToolbox (macOS only)
|
|
// 5. None (fallback to libx264 software)
|
|
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
|
|
detectedHWAccelOnce.Do(func() {
|
|
detectedHWAccel = doDetectHWAccel(ctx, ffmpegPath)
|
|
})
|
|
return detectedHWAccel
|
|
}
|
|
|
|
// ResetHWAccelCache forces the next DetectHWAccel call to re-probe.
|
|
// Intended for tests.
|
|
func ResetHWAccelCache() {
|
|
detectedHWAccelOnce = sync.Once{}
|
|
detectedHWAccel = HWAccelUnset
|
|
}
|
|
|
|
func doDetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
|
|
if ctx == nil {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
}
|
|
|
|
// macOS videotoolbox is reliable enough that we don't bother probing
|
|
// — every Apple Silicon Mac has it; Intel Macs since 10.13 do too.
|
|
if runtime.GOOS == "darwin" {
|
|
if encoderAvailable(ctx, ffmpegPath, "h264_videotoolbox") {
|
|
return HWAccelVideoToolbox
|
|
}
|
|
}
|
|
|
|
for _, candidate := range []struct {
|
|
Name HWAccel
|
|
Encoder string
|
|
}{
|
|
{HWAccelNVENC, "h264_nvenc"},
|
|
{HWAccelQSV, "h264_qsv"},
|
|
{HWAccelVAAPI, "h264_vaapi"},
|
|
} {
|
|
if encoderAvailable(ctx, ffmpegPath, candidate.Encoder) {
|
|
return candidate.Name
|
|
}
|
|
}
|
|
|
|
return HWAccelNone
|
|
}
|
|
|
|
// encoderAvailable returns true when `ffmpeg -hide_banner -encoders`
|
|
// lists the named encoder.
|
|
//
|
|
// Note: this only verifies ffmpeg was COMPILED with the encoder. It does
|
|
// NOT guarantee the host hardware works at runtime — some users will see
|
|
// libx264 fall back at the first failed encode. That's OK; the worst
|
|
// case is a one-time slow request.
|
|
func encoderAvailable(ctx context.Context, ffmpegPath, encoder string) bool {
|
|
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
// `-encoders` output looks like:
|
|
// V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 2 && fields[1] == encoder {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|