Detect transcode hardware, and prove it can actually encode

First step of ADR-008. internal/transcode answers one question: can this host
hardware-transcode, and with which encoder.

The rule the package exists to enforce is that a device node is not proof. A
node can exist while the driver cannot encode, and that produces the worst
outcome ADR-008 describes: a transcode that starts, looks like it works, and
then cannot keep up. So every candidate runs a real two-frame probe encode and
is dropped unless that encode exits cleanly.

This host is a perfect illustration and the reason the rule is not theoretical:
it has /dev/dri/renderD128 AND Nvidia nodes. The render node exists, but VAAPI
fails to initialise against it ("Failed to initialise VAAPI connection"). A
detection that trusted the node would have picked VAAPI and been wrong. The
probe rejected it and selected NVENC, which was verified independently with
ffmpeg outside this code.

The probe is injected, so every branch is testable with no hardware present,
including the important one: device present, probe fails. Verified red-green -
ignoring the probe result fails TestDetectProbeFails with "got vaapi, want no
capability".

One real bug found while proving it: the probe used testsrc=size=64x48, below
NVENC's minimum frame size, so a host with working NVENC would have reported no
capability at all. Raised to 320x240, still two frames.

Also added internal/transcode to the arch layer map as a layer-1 primitive
beside probe and engine, which the arch rule caught immediately.

Not wired into the engine registry or playback yet - that is the next step.
This commit is contained in:
mithral 2026-10-12 11:49:05 -04:00
parent 08b894c411
commit 549b503fdf
3 changed files with 348 additions and 7 deletions

View file

@ -33,13 +33,14 @@ var layers = map[string]int{
"migrations": 0,
// 1 — primitives the application is built from.
"internal/auth": 1,
"internal/jobs": 1,
"internal/stream": 1,
"internal/progress": 1,
"internal/probe": 1,
"internal/engine": 1,
"internal/apitoken": 1,
"internal/auth": 1,
"internal/jobs": 1,
"internal/stream": 1,
"internal/progress": 1,
"internal/probe": 1,
"internal/transcode": 1,
"internal/engine": 1,
"internal/apitoken": 1,
// 2 — leaves that the application is built from: the data seam, and the
// external catalogues. providers sits here so it stays a leaf — a

View file

@ -0,0 +1,166 @@
// 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.
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
}

View file

@ -0,0 +1,174 @@
package transcode
import (
"reflect"
"testing"
)
// TestDetectNoFFmpeg is the first gate: without ffmpeg there is no capability,
// full stop, and the candidates/probe are never consulted.
func TestDetectNoFFmpeg(t *testing.T) {
got := detect(checks{
ffmpeg: func() bool { return false },
candidates: func() []Encoder {
t.Fatal("candidates consulted without ffmpeg")
return nil
},
probe: func(Encoder) bool {
t.Fatal("probe run without ffmpeg")
return false
},
})
if got.Available() {
t.Errorf("no ffmpeg: got %+v, want no capability", got)
}
}
// TestDetectNoCandidates covers a host with ffmpeg but no device nodes at all.
func TestDetectNoCandidates(t *testing.T) {
got := detect(checks{
ffmpeg: func() bool { return true },
candidates: func() []Encoder { return nil },
probe: func(Encoder) bool {
t.Fatal("probe run with no candidates")
return false
},
})
if got.Available() {
t.Errorf("no candidates: got %+v, want no capability", got)
}
}
// TestDetectProbeFails is the branch ADR-008 exists for: a device node exists
// but the encode does not actually work. Reporting it would produce a transcode
// that starts and then cannot keep up.
func TestDetectProbeFails(t *testing.T) {
got := detect(checks{
ffmpeg: func() bool { return true },
candidates: func() []Encoder {
return []Encoder{{Name: EncoderVAAPI, Args: []string{"-c:v", "h264_vaapi"}}}
},
probe: func(Encoder) bool { return false },
})
if got.Available() {
t.Errorf("probe failed: got %+v, want no capability", got)
}
}
// TestDetectFirstWorkingWins checks the candidate order and that a later
// candidate is used when an earlier one fails.
func TestDetectFirstWorkingWins(t *testing.T) {
vaapi := Encoder{
Name: EncoderVAAPI,
Args: []string{"-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi"},
}
nvenc := Encoder{Name: EncoderNVENC, Args: []string{"-c:v", "h264_nvenc"}}
qsv := Encoder{Name: EncoderQSV, Args: []string{"-c:v", "h264_qsv"}}
tests := []struct {
name string
ok map[string]bool
want Encoder
tried []string
}{
{
name: "vaapi works",
ok: map[string]bool{EncoderVAAPI: true},
want: vaapi,
tried: []string{EncoderVAAPI},
},
{
name: "vaapi fails, nvenc works",
ok: map[string]bool{EncoderNVENC: true},
want: nvenc,
tried: []string{EncoderVAAPI, EncoderNVENC},
},
{
name: "vaapi and nvenc fail, qsv works",
ok: map[string]bool{EncoderQSV: true},
want: qsv,
tried: []string{EncoderVAAPI, EncoderNVENC, EncoderQSV},
},
{
name: "none work",
ok: map[string]bool{},
want: Encoder{},
tried: []string{EncoderVAAPI, EncoderNVENC, EncoderQSV},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var tried []string
got := detect(checks{
ffmpeg: func() bool { return true },
candidates: func() []Encoder { return []Encoder{vaapi, nvenc, qsv} },
probe: func(e Encoder) bool {
tried = append(tried, e.Name)
return tc.ok[e.Name]
},
})
if tc.want.Name == "" {
if got.Available() {
t.Fatalf("got %+v, want no capability", got)
}
} else {
if got.Encoder != tc.want.Name {
t.Errorf("Encoder = %q, want %q", got.Encoder, tc.want.Name)
}
if !reflect.DeepEqual(got.Args, tc.want.Args) {
t.Errorf("Args = %q, want %q", got.Args, tc.want.Args)
}
}
if !reflect.DeepEqual(tried, tc.tried) {
t.Errorf("probed %q, want %q", tried, tc.tried)
}
})
}
}
// TestHostCandidatesOrder pins the candidate order the task requires:
// VAAPI, then NVENC, then QSV. It reads this host, so it only asserts on
// ordering, not on which nodes happen to exist.
func TestHostCandidatesOrder(t *testing.T) {
order := map[string]int{EncoderVAAPI: 0, EncoderNVENC: 1, EncoderQSV: 2}
prev := -1
for _, enc := range hostCandidates() {
i, ok := order[enc.Name]
if !ok {
t.Fatalf("hostCandidates returned unknown encoder %q", enc.Name)
}
if i <= prev {
t.Errorf("candidate %q out of order", enc.Name)
}
prev = i
if len(enc.Args) == 0 {
t.Errorf("candidate %q has no args", enc.Name)
}
}
}
// TestDetectReal runs the real detection once. It must not panic and must be
// consistent; on a host with no usable hardware it reports none. Cached, so
// calling it twice must return the same thing.
func TestDetectReal(t *testing.T) {
first := Detect()
t.Logf("real detection on this host: %+v", first)
if second := Detect(); !reflect.DeepEqual(first, second) {
t.Errorf("Detect not cached: %+v then %+v", first, second)
}
if first.Available() && first.Args == nil {
t.Error("Detect reported an encoder with nil args")
}
}
// TestProbeEncodeRejectsBogusEncoder proves the probe checks a real ffmpeg
// result rather than trusting the candidate's name. Skips without ffmpeg.
func TestProbeEncodeRejectsBogusEncoder(t *testing.T) {
if !ffmpegOnPath() {
t.Skip("ffmpeg not on PATH")
}
if probeEncode(Encoder{Name: "nope", Args: []string{"-c:v", "definitely_not_a_codec"}}) {
t.Error("probeEncode accepted a nonexistent encoder")
}
}