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 "" }