# ADR-008: Optional, hardware-gated transcoding ## Status Accepted. **Amends [ADR-002](ADR-002-direct-play-only.md) — it does not reverse it.** Direct play remains the default, the preference and the only path this deployment uses. ## Date 2026-10-12 ## Context ADR-002 decided that Vellum never transcodes, because the server is a headless, GPU-less Xeon and a CPU transcode of 1080p HEVC runs far below real time. That reasoning is sound and its measurements still hold. The problem is what the decision was scoped to. "This server cannot transcode" became "Vellum does not transcode", and those are different claims. Only the first is forced by the hardware. The constraint is one deployment, not the product: - Intel Quick Sync has shipped in most desktop CPUs since 2015. - NVENC is in every recent Nvidia card; VAAPI covers AMD and Intel on Linux. - A user running Vellum on a gaming PC, a mini-PC with an iGPU, or a NAS with a modern SoC has transcode hardware sitting idle. Meanwhile the gap is real and visible. Direct play only means a client that cannot decode HEVC gets **nothing** — the file is reported unplayable and there is no fallback. Measured behaviour in Chrome is worse than a clean refusal: the video never decodes, `videoWidth` stays 0, and audio plays over a blank frame. A general audience reads that as "broken", not as "your phone lacks an HEVC decoder". A competing product's headline claim is "your graphics card converts anything else on the fly". That is not a feature Vellum lacks the architecture for — the engine registry was designed for exactly this. It is a feature the deployment cannot use. ## Decision **Transcoding is optional, off by default, and available only when the server has usable hardware for it.** 1. A `transcode` engine is added to the `internal/engine` registry, registered **only** when both are true: usable transcode hardware is detected (VAAPI, NVENC or QSV) *and* an `ffmpeg` binary is on PATH. 2. **Off by default.** Direct play remains the default and the preference. A client that can play the original always receives the original. Transcoding is never chosen to normalise a file that already works. 3. **Only as a last resort.** The engine is consulted only when no other declared engine can direct-play the file — precisely the case that returns "no engine can play this" today. 4. **Hardware-accelerated only.** There is no CPU fallback path. A CPU transcode is the stall ADR-002 warns about, and offering it would produce a feature that appears to work and then does not keep up. 5. The capability is advertised: a flag in `GET /api/v1/capabilities` and in the mDNS TXT records, so a client knows before it asks. 6. This deployment keeps `transcode` unregistered. On a GPU-less Xeon the engine is never built, the code path is never reached, and ADR-002's reasoning holds exactly as written. ## Alternatives Considered ### Keep "never transcode" as a product rule - **Pros:** no new dependency, no new failure mode, the story stays simple. - **Cons:** caps the product at the hardware in one closet, and leaves the worst-case client experience — audio over a blank frame — unaddressed. - **Rejected:** the constraint is a deployment detail, not a product decision. ### CPU transcode as a fallback - **Pros:** works everywhere, no hardware detection needed. - **Cons:** on a CPU-only machine a 1080p HEVC transcode does not keep up. The user gets a stream that starts, stutters and never finishes — worse than the honest refusal they get today, because it fails slowly. - **Rejected:** this is exactly the failure ADR-002 measured. ### Transcode by default, as other servers do - **Pros:** simplest mental model, every file plays. - **Cons:** re-encodes files that would have direct-played, burning power and losing quality for no benefit. It also makes the hardware a hard requirement rather than an optional upgrade. - **Rejected:** direct play is strictly better whenever it is possible. ### Pre-transcode the library at scan time - **Pros:** paid once, everyone direct-plays forever. - **Cons:** doubles disk usage for a whole library and takes days of CPU. Still requires the same hardware for a good result. - **Rejected:** already rejected in ADR-002, and for the same reasons. ### Client-side transcoding - **Pros:** no server load at all. - **Cons:** not possible. Real-time decode-and-re-encode in a browser or a TV app is beyond what those runtimes offer. - **Rejected:** technically unavailable. ## Consequences **What this buys.** A user with transcode hardware gets the "anything plays" experience without a second server. A user without it loses nothing — direct play is unchanged and remains the default. The same file is still unplayable with `["html5"]` on a GPU-less host, exactly as ADR-002 describes. **What it costs, honestly:** - `ffmpeg` becomes a real runtime dependency for the transcode path. It is already an *optional* dependency for `probe`, so the packaging change is a promotion, not a new requirement — but it is a change. - A transcode session needs CPU/GPU and memory accounting. The job queue's four workers are for scans and enrichment; a transcode must not be scheduled through them or it will starve the queue. - HLS or DASH packaging appears where none exists today. The current stream path is `http.ServeContent` over a whole file, which gives Range and seeking for free. A transcoded stream is a playlist of segments and needs its own serving path. - Session cleanup becomes a real concern: a transcode that nobody is watching must be torn down, or it burns the GPU forever. - "One binary, one database file" gains a caveat — still true, but the binary now shells out to `ffmpeg`. - Hardware detection is per-platform and can be wrong. A detection that reports VAAPI available when the driver cannot actually encode would produce the slow failure this ADR exists to avoid, so detection must be verified by a probe encode at startup rather than by the presence of a device node. **What does not change.** The engine registry, the resolve path, the client capability handshake and `internal/probe` all stay as they are. The registry was built to hold engines like this; this ADR adds one and a condition for registering it. `SupportsTranscoding` in the Jellyfin surface becomes `true` only when the engine is registered, which is the same "report what is true" rule the rest of the surface follows.