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.
169 lines
6.3 KiB
Go
169 lines
6.3 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Resolution is the answer to "can this client play this file, and with what?".
|
|
//
|
|
// The JSON shape is frozen by PlaybackResolution in web/src/lib/api.ts:
|
|
// playable, engine (null when nothing can), reason, message. Reason is
|
|
// machine-readable ("vcodec:hevc"); message is the human text the UI shows.
|
|
type Resolution struct {
|
|
Playable bool `json:"playable"`
|
|
// Engine is the ID of the engine that should play the file, or nil when
|
|
// nothing the client declared can. It is a pointer so it serialises to JSON
|
|
// null, matching the frozen contract.
|
|
Engine *string `json:"engine"`
|
|
// Reason is empty when playable; otherwise a machine-readable cause such as
|
|
// "vcodec:hevc", "container:avi" or "acodec:dts".
|
|
Reason string `json:"reason"`
|
|
// Message is human text for the UI. Empty when playable.
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Resolve decides whether a client can direct-play a file.
|
|
//
|
|
// 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. 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
|
|
// none (an older client, or one that has not implemented capability
|
|
// detection), we fall back to the html5 baseline rather than refusing to
|
|
// answer.
|
|
// - An engine that can play the file always beats one that cannot; among
|
|
// capable engines the client's ordering wins, so a native client can say
|
|
// "try mpv first".
|
|
// - A file with no video codec (audio-only) is playable as soon as its audio
|
|
// codec is supported.
|
|
// - 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
|
|
if len(ids) == 0 {
|
|
// No declared engines: fall back to the conservative html5 baseline so
|
|
// the caller still gets a useful verdict. The client is always free to
|
|
// refine it (see web/src/lib/capabilities.ts).
|
|
ids = []string{HTML5ID}
|
|
}
|
|
|
|
// First declared capable engine wins.
|
|
for _, id := range ids {
|
|
e, ok := Get(id)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if e.canPlay(container, vcodec, acodec) {
|
|
engineID := e.ID
|
|
return Resolution{Playable: true, Engine: &engineID}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
reason := blocker(ref, container, vcodec, acodec)
|
|
return Resolution{
|
|
Playable: false,
|
|
Reason: reason,
|
|
Message: message(reason, container, vcodec, acodec),
|
|
}
|
|
}
|
|
|
|
// splitFacts unpacks the positional fileFacts slice, tolerating a short slice.
|
|
func splitFacts(fileFacts []string) (container, vcodec, acodec string) {
|
|
get := func(i int) string {
|
|
if i < len(fileFacts) {
|
|
return fileFacts[i]
|
|
}
|
|
return ""
|
|
}
|
|
return get(0), get(1), get(2)
|
|
}
|
|
|
|
// firstKnown returns the first ID the registry knows, or "" if none.
|
|
func firstKnown(ids []string) string {
|
|
for _, id := range ids {
|
|
if _, ok := Get(id); ok {
|
|
return id
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// blocker names the first fact the reference engine cannot handle, in the order
|
|
// container, video codec, audio codec. The reason is machine-readable; the
|
|
// caller turns it into a human message.
|
|
func blocker(e Engine, container, vcodec, acodec string) string {
|
|
switch {
|
|
case container != "" && !contains(e.Containers, container):
|
|
return "container:" + strings.ToLower(strings.TrimPrefix(container, "."))
|
|
case vcodec != "" && !contains(e.VCodecs, vcodec):
|
|
return "vcodec:" + strings.ToLower(vcodec)
|
|
case acodec != "" && !contains(e.ACodecs, acodec):
|
|
return "acodec:" + strings.ToLower(acodec)
|
|
default:
|
|
// canPlay was false, so one of the checks above should have matched.
|
|
return "unsupported"
|
|
}
|
|
}
|
|
|
|
// message renders the human explanation for a non-playable file. It names the
|
|
// offending codec, never blames the user, and — when a registered native engine
|
|
// could play the file — points at it, because the browser is not the only way
|
|
// in and a native client may well be on hand.
|
|
func message(reason, container, vcodec, acodec string) string {
|
|
var lead string
|
|
switch {
|
|
case strings.HasPrefix(reason, "container:"):
|
|
lead = fmt.Sprintf("This file is a %s, which this browser cannot open directly.",
|
|
strings.ToUpper(strings.TrimPrefix(container, ".")))
|
|
case strings.HasPrefix(reason, "vcodec:"):
|
|
lead = fmt.Sprintf("This file uses %s video, which this browser cannot decode.",
|
|
strings.ToUpper(vcodec))
|
|
case strings.HasPrefix(reason, "acodec:"):
|
|
lead = fmt.Sprintf("This file uses %s audio, which this browser cannot decode.",
|
|
strings.ToUpper(acodec))
|
|
default:
|
|
lead = "This browser cannot play this file directly."
|
|
}
|
|
|
|
if native := nativeEngineFor(container, vcodec, acodec); native != "" {
|
|
lead += fmt.Sprintf(" A native player such as %s can play this.", native)
|
|
}
|
|
lead += " Vellum does not transcode."
|
|
return lead
|
|
}
|
|
|
|
// nativeEngineFor returns the ID of a registered native engine that can play
|
|
// the file, or "" if none can. It is used only to make the message honest about
|
|
// the ways the file could still be played. mpv is named first because it is the
|
|
// engine Vellum's native client reaches for; the others follow.
|
|
func nativeEngineFor(container, vcodec, acodec string) string {
|
|
for _, id := range []string{"mpv", "vlc", "mdk"} {
|
|
if e, ok := Get(id); ok && e.Kind == KindNative && e.canPlay(container, vcodec, acodec) {
|
|
return e.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|