diff --git a/internal/arch/arch_test.go b/internal/arch/arch_test.go index df5f252..fc26289 100644 --- a/internal/arch/arch_test.go +++ b/internal/arch/arch_test.go @@ -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 diff --git a/internal/transcode/transcode.go b/internal/transcode/transcode.go new file mode 100644 index 0000000..ced6cdc --- /dev/null +++ b/internal/transcode/transcode.go @@ -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 +} diff --git a/internal/transcode/transcode_test.go b/internal/transcode/transcode_test.go new file mode 100644 index 0000000..c063c39 --- /dev/null +++ b/internal/transcode/transcode_test.go @@ -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") + } +}