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.
211 lines
7.8 KiB
Go
211 lines
7.8 KiB
Go
// Package engine describes the playback engines a client can offer and decides
|
|
// whether a particular file can be direct-played by that client.
|
|
//
|
|
// Vellum never transcodes: the server is a headless, GPU-less Xeon, and measured
|
|
// against real files, playability turned out to be a property of the CLIENT, not
|
|
// the file. Chrome on Linux plays an MKV carrying H.264 perfectly well, yet
|
|
// cannot decode an MP4 carrying HEVC at all. So the server's job is only to
|
|
// report what is inside each file (see internal/probe) and to match it against
|
|
// what the client says it has.
|
|
//
|
|
// This package is the registry of those engines and the matcher. The web app's
|
|
// only engine is "html5"; native clients (mpv, libvlc, MDK) declare their own.
|
|
// Adding a native engine later is a registration, not a re-architecture — the
|
|
// native libraries themselves can only live inside a native client, never in a
|
|
// browser.
|
|
package engine
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Engine kinds. A "browser" engine is an HTML5 media element and is bound by
|
|
// what the browser's decoders support; a "native" engine links a real media
|
|
// library (mpv/libmpv, libvlc, MDK) and can therefore live only inside a native
|
|
// client.
|
|
const (
|
|
KindBrowser = "browser"
|
|
KindNative = "native"
|
|
)
|
|
|
|
// 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
|
|
// must not change without changing that file too.
|
|
type Engine struct {
|
|
// ID is the stable identifier clients pass in their declared engine list.
|
|
ID string `json:"id"`
|
|
// Name is the human label shown in the UI.
|
|
Name string `json:"name"`
|
|
// Kind is KindBrowser or KindNative.
|
|
Kind string `json:"kind"`
|
|
// Containers are lowercase extensions, without a leading dot ("mkv").
|
|
Containers []string `json:"containers"`
|
|
// VCodecs are ffprobe video codec names ("h264", "hevc", ...).
|
|
VCodecs []string `json:"vcodecs"`
|
|
// ACodecs are ffprobe audio codec names ("aac", "opus", ...).
|
|
ACodecs []string `json:"acodecs"`
|
|
}
|
|
|
|
// builtins are the engines Vellum knows out of the box. html5 is the browser
|
|
// baseline; the native engines are registered so a native client can name them,
|
|
// and are never selectable from a browser.
|
|
var builtins = []Engine{
|
|
{
|
|
ID: HTML5ID,
|
|
Name: "HTML5",
|
|
Kind: KindBrowser,
|
|
// A deliberately CONSERVATIVE browser baseline. The real answer comes
|
|
// from the client: MediaCapabilities (see web/src/lib/capabilities.ts)
|
|
// can distinguish "cannot decode at all" from "decodes but will
|
|
// stutter", and only the client's GPU knows that. This list is what the
|
|
// server can honestly assume of a browser with no other information.
|
|
//
|
|
// Note what is absent: hevc/h265 and mpeg4 are measured as undecodable
|
|
// in Chrome, so they must not be claimed here. Container support is
|
|
// broad because mkv/mov play fine when the video track is h264.
|
|
Containers: []string{
|
|
"mp4", "m4v", "mov", "mkv", "webm",
|
|
"mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav",
|
|
},
|
|
VCodecs: []string{"h264", "vp8", "vp9", "av1"},
|
|
// pcm appears under several ffprobe names (pcm_s16le, ...); the aliases
|
|
// are listed so a WAV is not rejected over a naming detail.
|
|
ACodecs: []string{"aac", "mp3", "opus", "vorbis", "flac", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8"},
|
|
},
|
|
{
|
|
// mpv (libmpv) handles essentially every ffmpeg demuxer and decoder.
|
|
// Registered for NATIVE clients only: a browser cannot link libmpv.
|
|
ID: "mpv",
|
|
Name: "mpv",
|
|
Kind: KindNative,
|
|
Containers: []string{
|
|
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
|
|
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
|
|
},
|
|
VCodecs: []string{
|
|
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
|
|
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
|
|
},
|
|
ACodecs: []string{
|
|
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
|
|
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
|
|
},
|
|
},
|
|
{
|
|
// libvlc is similarly broad. Registered for NATIVE clients only.
|
|
ID: "vlc",
|
|
Name: "VLC",
|
|
Kind: KindNative,
|
|
Containers: []string{
|
|
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
|
|
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob", "asf", "rm",
|
|
},
|
|
VCodecs: []string{
|
|
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
|
|
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo", "rv40",
|
|
},
|
|
ACodecs: []string{
|
|
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
|
|
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8", "cook",
|
|
},
|
|
},
|
|
{
|
|
// MDK is a cross-platform multimedia SDK (ffmpeg-based). Registered for
|
|
// NATIVE clients only.
|
|
ID: "mdk",
|
|
Name: "MDK",
|
|
Kind: KindNative,
|
|
Containers: []string{
|
|
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
|
|
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
|
|
},
|
|
VCodecs: []string{
|
|
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
|
|
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
|
|
},
|
|
ACodecs: []string{
|
|
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
|
|
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
|
|
},
|
|
},
|
|
}
|
|
|
|
// registry is built once from builtins. Read-only after init, so All/Get need no
|
|
// lock; if runtime registration is ever added it must grow a mutex.
|
|
var registry = func() map[string]Engine {
|
|
m := make(map[string]Engine, len(builtins))
|
|
for _, e := range builtins {
|
|
m[e.ID] = e
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// All returns every registered engine, sorted by ID so the response is stable.
|
|
func All() []Engine {
|
|
out := make([]Engine, 0, len(registry))
|
|
for _, e := range registry {
|
|
out = append(out, e)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// Get looks up one engine by ID. The second result is false for an unknown ID,
|
|
// which callers treat as "this client has no such engine" rather than an error.
|
|
func Get(id string) (Engine, bool) {
|
|
e, ok := registry[strings.ToLower(strings.TrimSpace(id))]
|
|
return e, ok
|
|
}
|
|
|
|
// Facts packs a file's codec facts into the positional slice Resolve expects:
|
|
// container, video codec, audio codec. An empty video codec means there is no
|
|
// video track (audio, or a comic).
|
|
func Facts(container, vcodec, acodec string) []string {
|
|
return []string{container, vcodec, acodec}
|
|
}
|
|
|
|
// canPlay reports whether an engine can direct-play a file with these facts.
|
|
//
|
|
// A missing codec ("" for an audio-only file's video track) is not a blocker:
|
|
// there is simply no such track to decode. Container is always required.
|
|
func (e Engine) canPlay(container, vcodec, acodec string) bool {
|
|
if !contains(e.Containers, container) {
|
|
return false
|
|
}
|
|
if vcodec != "" && !contains(e.VCodecs, vcodec) {
|
|
return false
|
|
}
|
|
if acodec != "" && !contains(e.ACodecs, acodec) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// contains reports membership, case-insensitively, so "H264" and "h264" match
|
|
// and a stray leading dot on a container extension does not.
|
|
func contains(list []string, want string) bool {
|
|
want = strings.ToLower(strings.TrimSpace(want))
|
|
want = strings.TrimPrefix(want, ".")
|
|
if want == "" {
|
|
return false
|
|
}
|
|
for _, v := range list {
|
|
if strings.ToLower(strings.TrimSpace(v)) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|