Vellum/docs/decisions/ADR-002-direct-play-only.md
mithral fad0d7bc96 ADR-008: transcoding is optional, hardware-gated, off by default
ADR-002 decided never to transcode because the server is a headless,
GPU-less Xeon, and its measurements still hold. The problem is scope:
"this server cannot transcode" became "Vellum does not transcode", and
only the first is forced by the hardware.

Intel Quick Sync has been in most desktop CPUs since 2015, NVENC is in
every recent Nvidia card, and VAAPI covers AMD and Intel on Linux. Capping
the product at the hardware in one closet is the wrong constraint, and the
gap is visible: a client that cannot decode HEVC gets nothing, and in
Chrome that is not even a clean refusal - the video never decodes and
audio plays over a blank frame.

So the engine is added and its registration is gated:

- registered only when usable transcode hardware AND ffmpeg are both present
- off by default; direct play stays the default and the preference
- consulted only when no other engine can direct-play the file, which is
  exactly the case that returns "no engine can play this" today
- hardware-accelerated only. No CPU fallback: that is the stall ADR-002
  measured, and it fails slowly, which is worse than refusing.

This deployment is unchanged. On a GPU-less Xeon the engine is never
registered and the path is never reached, so ADR-002 holds as written.
ADR-002's status now points here rather than being deleted, per the rule
that a changed decision gets a new record instead of an edited one.
2026-10-12 01:14:37 -04:00

4.3 KiB

ADR-002: Direct play only — no transcoding, ever

Status

Accepted, amended by ADR-008. 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

Context

A media server normally transcodes: it re-encodes a file on the fly when the client cannot play it. That is how Jellyfin and Plex make an arbitrary library playable on an arbitrary device.

Vellum runs on a headless, GPU-less Xeon. It has no display and no spare CPU for real-time encoding. A 1080p HEVC transcode on a CPU-only machine runs at a few times realtime at best; a two-hour film would take tens of minutes, per viewer, forever.

The question was what to do instead.

Decision

Never transcode. A file either direct-plays in the client or it does not. The server reports what is in the file and the client decides whether it can play it.

Alternatives Considered

Transcode on demand, CPU only

  • Pros: every file plays everywhere.
  • Cons: on this hardware a single viewer would saturate the CPU for minutes per film. Two viewers would make it unusable.
  • Rejected: the hardware cannot support it.

Remux on demand (-c copy), no re-encode

  • Pros: cheap — no video re-encode, just a container change. Would fix "mkv will not play".
  • Cons: the premise turned out to be false. Measurement showed Chrome plays mkv + h264 + aac perfectly. The container was never the problem.
  • Rejected: it solves a problem that does not exist.

Pre-transcode everything to a normalised format

  • Pros: paid once, everyone direct-plays forever after.
  • Cons: doubles disk usage for a whole library, and takes days of CPU for a large collection. Also irreversible if the settings were wrong.
  • Rejected: too much cost and risk for the benefit.

The measurement that decided it

Files with known codecs, played through Vellum's own stream endpoint in Chrome 155 on Linux:

container video audio result
mp4 h264 aac plays
mkv h264 aac plays
mov h264 aac plays
webm vp9 / vp8 opus / vorbis plays
mp4 av1 opus plays
mp4 hevc aac audio only, videoWidth stays 0, no error
mkv hevc flac audio only
avi mpeg4 mp3 fails outright

All seven audio formats play.

Two conclusions:

  1. Codec matters, container barely does. mkv+h264 plays; mp4+HEVC does not. Judging a file by its extension is wrong.
  2. canPlayType is unreliable in both directions. It says "no" for mov+h264 (which plays) and "maybe" for mkv+hevc (whose video never decodes). The only trustworthy test is loading the file and checking videoWidth.

And the conclusion that shapes the architecture: playability is a property of the client, not the file. Chrome cannot decode HEVC; Safari can; Jellyfin's native apps handle far more than either. So a server-side playable: true/false would be wrong for most clients.

Consequences

  • internal/probe records codec facts at scan time (optional dependency — the server works without ffprobe, just with less information).
  • internal/engine holds a registry of playback engines. html5 ships built in; mpv, vlc and mdk are registered for native clients. Adding one is a declaration, not a re-architecture.
  • POST /api/v1/playback/resolve matches a file against the engines the client declares. The same HEVC file is unplayable with ["html5"] and playable with ["html5","mpv"].
  • The player treats the server as an advisory and the media element as the final authority, because canPlayType lies.
  • Jellyfin's PlaybackInfo reports SupportsTranscoding: false, and a client demanding a transcode gets NoCompatibleStream rather than a URL that would never resolve.
  • The honest UX consequence: files that cannot direct-play say so clearly, the same way an unreadable .mobi does — rather than a player that loads and shows nothing. For HEVC, Chrome plays audio over a blank frame with no error, which is the worst possible outcome, so the player detects exactly that.