// Package transcode reports whether this host can hardware-transcode, and with // which encoder. // // ADR-008 makes transcoding optional and hardware-gated: an engine is // registered only when usable transcode hardware AND an ffmpeg binary are both // present. The hazard the ADR calls out is a detection that trusts a device // node: a node can exist while the driver cannot encode, and the resulting // transcode starts fine and then fails to keep up. So every candidate is // proved by a real probe encode before it is reported. // // Detection is cached for the process lifetime. It spawns ffmpeg, so it must // not run per request; callers ask Detect and the spawn happens at most once. // // Manager, in session.go, turns that capability into running HLS sessions: // it spawns ffmpeg with the detected encoder's args, reuses a session for the // same item, reaps idle ones and caps how many run at once. package transcode import ( "context" "os" "os/exec" "path/filepath" "sync" "time" ) // Encoder names, in the order they are tried. const ( EncoderVAAPI = "vaapi" EncoderNVENC = "nvenc" EncoderQSV = "qsv" ) // probeTimeout bounds a single probe encode. Hardware that cannot encode // usually fails fast; this bounds the cases that hang instead. const probeTimeout = 10 * time.Second // Hardware is the answer: which encoder this host can use, and the ffmpeg // arguments it needs. The zero value means no usable hardware. type Hardware struct { // Encoder is one of the Encoder constants, or "" when there is none. Encoder string // Args are the ffmpeg arguments the encoder requires, inserted after the // input and before the output, e.g. ["-vaapi_device", "...", "-vf", // "format=nv12,hwupload", "-c:v", "h264_vaapi"]. Read-only: callers must // not mutate the slice. Args []string } // Available reports whether hardware transcoding is usable on this host. func (h Hardware) Available() bool { return h.Encoder != "" } // checks are the impure edges of detection. They are injected so a test can // drive every branch — no ffmpeg, no candidate, a candidate whose probe fails, // a later candidate that succeeds — with no hardware present. type checks struct { // ffmpeg reports whether an ffmpeg binary is on PATH. No ffmpeg means no // capability, full stop. ffmpeg func() bool // candidates returns the encoders whose device nodes exist, in the order // they should be tried. A device node is a hint, not proof. candidates func() []Encoder // probe runs a tiny real encode through the encoder and reports whether it // succeeded. This is the proof. probe func(Encoder) bool } // Encoder is a candidate: the encoder name and the ffmpeg arguments it needs. type Encoder struct { Name string Args []string } var ( once sync.Once detected Hardware ) // Detect returns the host's transcode capability, running the probe encode at // most once per process. It never blocks a request on a repeated spawn. func Detect() Hardware { once.Do(func() { detected = detect(hostChecks()) }) return detected } // detect is Detect's logic with the impure checks supplied, so it is testable. func detect(c checks) Hardware { if !c.ffmpeg() { return Hardware{} } for _, enc := range c.candidates() { if c.probe(enc) { return Hardware{Encoder: enc.Name, Args: enc.Args} } } return Hardware{} } // hostChecks returns the real, impure checks. func hostChecks() checks { return checks{ffmpeg: ffmpegOnPath, candidates: hostCandidates, probe: probeEncode} } func ffmpegOnPath() bool { _, err := exec.LookPath("ffmpeg") return err == nil } // hostCandidates enumerates encoders whose device nodes exist, VAAPI first, // then NVENC, then QSV as the task requires. Each probe decides whether the // node is real enough to encode. func hostCandidates() []Encoder { var out []Encoder render := renderNodes() if len(render) > 0 { // Intel and AMD both expose a render node; VAAPI covers the two. out = append(out, Encoder{ Name: EncoderVAAPI, Args: []string{"-vaapi_device", render[0], "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi"}, }) } if nvidiaPresent() { out = append(out, Encoder{Name: EncoderNVENC, Args: []string{"-c:v", "h264_nvenc"}}) } if len(render) > 0 { // QSV uses the same render node on Intel; on non-Intel hardware the // probe fails and the candidate is dropped. out = append(out, Encoder{Name: EncoderQSV, Args: []string{"-c:v", "h264_qsv"}}) } return out } // renderNodes returns the DRM render device nodes, or none if the directory is // absent (a host with no /dev/dri). func renderNodes() []string { devs, _ := filepath.Glob("/dev/dri/renderD*") return devs } // nvidiaPresent reports whether the Nvidia kernel driver exposed its devices. func nvidiaPresent() bool { for _, p := range []string{"/dev/nvidiactl", "/dev/nvidia0"} { if _, err := os.Stat(p); err == nil { return true } } return false } // probeEncode runs a two-frame testsrc through the encoder to /dev/null and // reports whether ffmpeg exited cleanly. That is the only trustworthy signal // that the hardware can actually encode. func probeEncode(enc Encoder) bool { ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) defer cancel() // 320x240, not the smallest possible frame: NVENC rejects anything below // its minimum dimensions (~145x49), so a tiny testsrc would falsely report // working hardware as unusable. Two frames keep the probe cheap. args := []string{ "-v", "error", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=30", "-frames:v", "2", } args = append(args, enc.Args...) args = append(args, "-f", "null", "-") return exec.CommandContext(ctx, "ffmpeg", args...).Run() == nil }