Vellum/internal/transcode/transcode.go
mithral 365aac0cc6 Add the transcode session manager
Second step of ADR-008. A session spawns ffmpeg with the detected encoder's
args, packages the file as HLS, and is reused while it is alive so two range
requests do not start two ffmpegs.

Three things it has to get right, and all three are about not burning the GPU:
a session nobody is watching is reaped after a few minutes idle; concurrent
sessions are capped rather than queued, because a GPU encodes one or two
streams and a queue would just make everyone wait longer; and Shutdown stops
everything so a restart does not leave ffmpeg running.

Found a real bug in review, and it is the kind that only a real host catches.
The hardware args were placed BEFORE -i. ffmpeg reads options before the input
as INPUT options, so "-c:v h264_nvenc" was looked up as a decoder and failed
with "Unknown decoder 'h264_nvenc'". Transcoding would have produced nothing on
any NVENC host while looking correct in the code.

Verified against the real hardware rather than reasoned about: the same ffmpeg
command produces zero segments with the args before -i and a valid playlist and
segments with them after. The test now asserts the ordering directly and names
the reason, so a future reordering fails with an explanation instead of two arg
lists. Red-green: moving them back fails with "the hardware args must follow
the input".

Verified end-to-end on this host: the Manager started a session against a real
20-second file and produced a 132-byte EVENT playlist and segments.

Known limit: a stopped session leaves its segment directory on disk, so the
cache directory needs pruning out of band.
2026-10-12 11:54:20 -04:00

170 lines
5.6 KiB
Go

// 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
}