diff --git a/docs/decisions/ADR-002-direct-play-only.md b/docs/decisions/ADR-002-direct-play-only.md index d0d09e0..c52b3fa 100644 --- a/docs/decisions/ADR-002-direct-play-only.md +++ b/docs/decisions/ADR-002-direct-play-only.md @@ -1,7 +1,12 @@ # ADR-002: Direct play only — no transcoding, ever ## Status -Accepted + +Accepted, **amended by [ADR-008](ADR-008-optional-hardware-transcoding.md)**. +The reasoning below still holds for this deployment: a GPU-less Xeon never +registers a transcode engine, so nothing here is reached. ADR-008 scopes the +rule to the hardware rather than to the product, and adds an optional +hardware-gated engine for servers that have the capability. ## Date 2026-10-12 diff --git a/docs/decisions/ADR-008-optional-hardware-transcoding.md b/docs/decisions/ADR-008-optional-hardware-transcoding.md new file mode 100644 index 0000000..6dd0064 --- /dev/null +++ b/docs/decisions/ADR-008-optional-hardware-transcoding.md @@ -0,0 +1,137 @@ +# 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c4818e3..4248a26 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -16,6 +16,7 @@ Read the ones relevant to what you are changing. | [005](ADR-005-one-item-access-chokepoint.md) | One item-access chokepoint, `internal/catalog` | Leave the duplication; a shared helper; a generic repository | | [006](ADR-006-table-backed-job-queue.md) | The job queue is a table | Redis or a broker; in-memory goroutines | | [007](ADR-007-fixed-width-timestamps.md) | Fixed-width timestamps via `core.Stamp` | Integer nanoseconds; six/nine digits; sorting in Go | +| [008](ADR-008-optional-hardware-transcoding.md) | Transcoding is optional, hardware-gated, off by default | Keeping never-transcode as a product rule; CPU fallback; transcode by default | ## Writing a new one