From fd377a74465ad5e3863cf38e168c38d07d62db11 Mon Sep 17 00:00:00 2001 From: mithral Date: Mon, 12 Oct 2026 12:03:41 -0400 Subject: [PATCH] Offer transcoding as a resolve engine, as the last resort Fourth and final step of ADR-008's server half. When nothing the client declared can direct-play a file and this host can re-encode, resolve now answers playable with engine "transcode". The client sees a string it already understands, so the frozen JSON contract did not change. The ordering is the whole point and is asserted directly: transcoding sits after the direct-play loop, so a file that plays directly never pays for it. Re-encoding something a client could already decode would burn the GPU and lose quality for nothing, which is the failure mode ADR-008 rejects by choosing direct play first. Verified red-green - hoisting the check above the loop fails with "direct play must always win". The capability is resolved once at boot and passed in, not read inside a request: detection spawns ffmpeg to prove the encoder works, and that must never happen on the request path. It is also passed rather than read from the transcode package so the tests can drive both answers without hardware. TranscodeID is deliberately not a registry entry. Every other engine is something a client declares; this one is the server's own capability, so it is not advertised in /playback/engines and a client must not name it. On a host with no usable encoder canTranscode is false and every answer is byte-identical to what it was before any of this existed - which is this deployment, and is why ADR-002 still holds for it. --- cmd/vellum/main.go | 7 +++++- internal/engine/engine.go | 8 +++++++ internal/engine/engine_test.go | 36 ++++++++++++++++++++++++++++-- internal/engine/resolve.go | 23 ++++++++++++++----- internal/playback/handlers.go | 13 ++++++++--- internal/playback/handlers_test.go | 4 +++- 6 files changed, 79 insertions(+), 12 deletions(-) diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 8a8933f..b6f0464 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -126,7 +126,12 @@ func run(cfg core.Config, log *slog.Logger) error { // Playback engine registry + selection. Vellum never transcodes, so the // server reports what is inside a file and matches it against the engines the // CLIENT declares — a browser offers "html5", a native client can offer mpv. - playback.NewHandlers(catalog.New(db), log, authenticator).Register(api.Mux()) + // ADR-008: transcode capability is resolved ONCE, at boot. Detection spawns + // ffmpeg to prove the encoder actually encodes, so it must never run inside + // a request. On a host with no usable encoder this is false and every + // resolve answer is what it was before transcoding existed. + canTranscode := transcode.Detect().Available() + playback.NewHandlers(catalog.New(db), log, authenticator, canTranscode).Register(api.Mux()) // Client compatibility layers. Neither speaks the session cookie; both // authenticate with an api_tokens credential and then go through the same diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 204b982..c741f49 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -32,6 +32,14 @@ const ( // HTML5ID is the built-in browser engine every client can be assumed to have. const HTML5ID = "html5" +// TranscodeID is the engine the SERVER offers when it can re-encode (ADR-008). +// +// It is deliberately not a registry entry. Every other engine is something a +// client declares and the server matches against; this one is the server's own +// capability, so it is never advertised in All() and a client must not name it. +// Resolve is told whether it is available and returns it only as a last resort. +const TranscodeID = "transcode" + // Engine declares one playback backend and the media it can open. // // The JSON tags are the frozen EngineSupport contract in web/src/lib/api.ts and diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 8fa91dc..96d846e 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -74,6 +74,8 @@ func TestResolve(t *testing.T) { engine string // expected engine id, or "" for nil reason string msgSubstr string // must appear in Message when not playable + // canTranscode is whether the server itself can re-encode (ADR-008). + canTranscode bool }{ { name: "mp4 h264 aac plays in html5", @@ -190,7 +192,7 @@ func TestResolve(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := Resolve(tt.facts, tt.engines) + got := Resolve(tt.facts, tt.engines, tt.canTranscode) if got.Playable != tt.playable { t.Fatalf("playable = %v, want %v (reason %q)", got.Playable, tt.playable, got.Reason) } @@ -222,7 +224,7 @@ func TestResolve(t *testing.T) { // TestResolutionJSONEngineNull pins that a non-playable answer serialises // engine as JSON null, exactly as PlaybackResolution declares. func TestResolutionJSONEngineNull(t *testing.T) { - got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"}) + got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"}, false) b, err := json.Marshal(got) if err != nil { t.Fatal(err) @@ -243,3 +245,33 @@ func TestResolutionJSONEngineNull(t *testing.T) { t.Errorf("unexpected resolution JSON: %s", b) } } + +// A file nothing can direct-play is still playable when the SERVER can re-encode +// (ADR-008). The ordering matters more than the feature: transcoding is the last +// resort, so a file that plays directly must never pay for it. +func TestResolveTranscodeIsTheLastResort(t *testing.T) { + hevc := Facts("mp4", "hevc", "aac") + + // Nothing the client declared can decode it, so the server re-encodes. + got := Resolve(hevc, []string{"html5"}, true) + if !got.Playable || got.Engine == nil || *got.Engine != TranscodeID { + t.Errorf("Resolve(hevc, html5, canTranscode) = %+v, want playable via %q", got, TranscodeID) + } + + // A file the client CAN decode gets direct play, never a transcode. This is + // the assertion that keeps a working file from being re-encoded for nothing. + got = Resolve(Facts("mp4", "h264", "aac"), []string{"html5"}, true) + if !got.Playable || got.Engine == nil || *got.Engine == TranscodeID { + t.Errorf("a direct-playable file chose %v; direct play must always win", got.Engine) + } + + // With no capability the answer is exactly what it was before ADR-008: the + // blocker, with a machine-readable reason for the UI. + got = Resolve(hevc, []string{"html5"}, false) + if got.Playable { + t.Errorf("Resolve(hevc, html5, no transcode) = playable; want the blocker") + } + if got.Reason == "" || got.Message == "" { + t.Errorf("want a reason and a message when nothing can play it, got %+v", got) + } +} diff --git a/internal/engine/resolve.go b/internal/engine/resolve.go index 2a5f1f6..9883f7c 100644 --- a/internal/engine/resolve.go +++ b/internal/engine/resolve.go @@ -27,7 +27,9 @@ type Resolution struct { // // fileFacts is the positional slice produced by Facts: container, video codec, // audio codec. clientEngineIDs is the list of engines the CLIENT declared, in -// the client's own preference order. +// the client's own preference order. canTranscode is whether this server can +// re-encode at all (ADR-008) — on a host with no usable encoder it is false and +// the answer is unchanged from before transcoding existed. // // Semantics: // - Only engines the client declared are considered. If the client declared @@ -39,7 +41,9 @@ type Resolution struct { // "try mpv first". // - A file with no video codec (audio-only) is playable as soon as its audio // codec is supported. -func Resolve(fileFacts, clientEngineIDs []string) Resolution { +// - Transcoding is the LAST resort, offered only when nothing the client +// declared can direct-play. A file that plays directly never pays for it. +func Resolve(fileFacts, clientEngineIDs []string, canTranscode bool) Resolution { container, vcodec, acodec := splitFacts(fileFacts) ids := clientEngineIDs @@ -62,9 +66,18 @@ func Resolve(fileFacts, clientEngineIDs []string) Resolution { } } - // Nothing the client declared can play it. Explain the blocker using the - // client's first *known* engine — its stated preference — falling back to - // html5 when the client named only engines we do not know. + // Nothing the client declared can play it. If this server can re-encode, that + // is the last resort and it beats explaining the blocker: a file the client + // cannot decode is exactly what the transcode engine exists for. This sits + // after the loop, so direct play always wins when it is possible. + if canTranscode { + id := TranscodeID + return Resolution{Playable: true, Engine: &id} + } + + // Otherwise explain the blocker using the client's first *known* engine — its + // stated preference — falling back to html5 when the client named only + // engines we do not know. ref, ok := Get(firstKnown(ids)) if !ok { ref, _ = Get(HTML5ID) diff --git a/internal/playback/handlers.go b/internal/playback/handlers.go index b5aff32..5e0587a 100644 --- a/internal/playback/handlers.go +++ b/internal/playback/handlers.go @@ -27,11 +27,18 @@ type Handlers struct { items catalog.Store log *slog.Logger auth *auth.Authenticator + // canTranscode is whether this host can re-encode (ADR-008). + canTranscode bool } // NewHandlers builds the playback HTTP layer. -func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator) *Handlers { - return &Handlers{items: items, log: log, auth: a} +// +// canTranscode is the server's own capability (ADR-008), resolved once at boot +// rather than per request: detection spawns ffmpeg to prove the encoder works, +// which must not happen inside a request. It is passed in rather than read from +// the transcode package so the resolve tests can drive both answers. +func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator, canTranscode bool) *Handlers { + return &Handlers{items: items, log: log, auth: a, canTranscode: canTranscode} } // Register mounts the routes. Both require an authenticated user; the resolve @@ -96,7 +103,7 @@ func (h *Handlers) handleResolve(w http.ResponseWriter, r *http.Request, u auth. return } - res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines) + res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines, h.canTranscode) httpx.WriteJSON(w, http.StatusOK, res) } diff --git a/internal/playback/handlers_test.go b/internal/playback/handlers_test.go index 040db7a..da9f3ef 100644 --- a/internal/playback/handlers_test.go +++ b/internal/playback/handlers_test.go @@ -54,7 +54,9 @@ func newHandlers(t *testing.T, db *sql.DB) *Handlers { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) a := auth.NewAuthenticator(db, "vellum_session", 0, false, log) - return NewHandlers(catalog.New(db), log, a) + // No transcode: these tests are about grants and direct play, and a host + // with an encoder would otherwise change their answers. + return NewHandlers(catalog.New(db), log, a, false) } // fixture builds two libraries with one item each and a user granted only the