// Direct-play decisions. // // Vellum does not transcode — the server has no spare CPU for it — so a file // either plays in the client or it does not. Which one it is depends on the // CLIENT, not the file: Chrome on Linux cannot decode HEVC, Safari can, and the // native Jellyfin apps handle far more than any browser. So nothing here is a // server-side verdict; it is a per-client guess, and the player always confirms // it by actually loading the file. // // The mappings below were measured, not guessed. Files generated with known // codecs and played through the server's own stream endpoint in Chrome 155 on // Linux gave this: // // mp4 h264 + aac plays mkv h264 + aac PLAYS (container is fine!) // webm vp9 + opus plays mov h264 + aac plays // mp4 av1 + opus plays webm vp8 + vorbis plays // mp4 hevc + aac AUDIO ONLY — videoWidth stays 0 // mkv hevc + flac AUDIO ONLY // avi mpeg4 + mp3 fails outright // // Two things that follow, and that the UI must respect: // // 1. Container matters far less than codec. mkv plays fine when the video // track is h264; mp4 fails when the track is HEVC. Judging a file by its // extension is wrong. // 2. `canPlayType` is a hint, not an answer. It said "no" for mov+h264 (which // plays) and "maybe" for mkv+hevc (whose video never decodes). The only // reliable test is to load the file and check `videoWidth`. import type { MediaFile } from './api' /** Container extension → MIME type. Mirrors internal/stream's table. */ const CONTAINER_MIME: Record = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska', webm: 'video/webm', avi: 'video/x-msvideo', mp3: 'audio/mpeg', m4a: 'audio/mp4', m4b: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', opus: 'audio/ogg', ogg: 'audio/ogg', wav: 'audio/wav', } /** * ffprobe codec name → an RFC 6381 codec parameter good enough for * `canPlayType`. The full parameter string (profile, level, tier) is not * recoverable from a bare codec name, so these are the shortest forms browsers * accept. */ const VCODEC: Record = { h264: 'avc1.42E01E', avc1: 'avc1.42E01E', hevc: 'hvc1.1.6.L93.B0', h265: 'hvc1.1.6.L93.B0', vp8: 'vp8', vp9: 'vp09.00.10.08', av1: 'av01.0.04M.08', mpeg4: 'mp4v.20.9', mpeg2video: 'mp2v', vc1: 'vc-1', theora: 'theora', } const ACODEC: Record = { aac: 'mp4a.40.2', mp3: 'mp3', opus: 'opus', vorbis: 'vorbis', flac: 'flac', ac3: 'ac-3', eac3: 'ec-3', dts: 'dts', pcm_s16le: 'pcm', } export type Verdict = 'probably' | 'maybe' | 'no' /** The MIME type for a file's container, or '' if it is unknown. */ export function mimeFor(file: MediaFile): string { return CONTAINER_MIME[(file.container || '').toLowerCase()] ?? '' } /** * Build the `type` string for a media element, e.g. * `video/mp4; codecs="avc1.42E01E, mp4a.40.2"`. Returns '' when the container * is unknown, in which case the caller should let the element sniff the file. */ export function sourceType(file: MediaFile): string { const mime = mimeFor(file) if (!mime) return '' const codecs: string[] = [] const v = VCODEC[(file.vcodec || '').toLowerCase()] const a = ACODEC[(file.acodec || '').toLowerCase()] if (v) codecs.push(v) if (a) codecs.push(a) return codecs.length ? `${mime}; codecs="${codecs.join(', ')}"` : mime } /** * Ask this browser whether it can play the file. A hint only — see the note at * the top of this file about how wrong `canPlayType` is in both directions. */ export function canDirectPlay(file: MediaFile): Verdict { if (typeof document === 'undefined') return 'maybe' const type = sourceType(file) if (!type) return 'maybe' const el = document.createElement('video') const verdict = el.canPlayType(type) if (verdict === 'probably') return 'probably' if (verdict === 'maybe') return 'maybe' return 'no' } /** * A short human explanation for a file that is not expected to play, or '' when * there is nothing worth saying. The wording avoids blaming the user's machine * and points at the actual cause. */ export function playbackNote(file: MediaFile): string { if (canDirectPlay(file) !== 'no') return '' const codec = (file.vcodec || '').toLowerCase() if (codec === 'hevc' || codec === 'h265') { return 'This file uses HEVC video, which this browser cannot decode. Safari and the native apps can.' } if (codec === 'mpeg4' || codec === 'mpeg2video' || codec === 'vc1') { return `This file uses ${codec} video, which browsers no longer support.` } return 'This browser cannot play this file directly, and Vellum does not transcode.' } /** * Whether a loaded media element actually decoded a video track. * * This is the reliable test, and the reason the player never trusts * `canPlayType`: an element whose metadata loaded but whose video track is * undecodable reports a duration and plays AUDIO while `videoWidth` stays 0. * That is exactly what HEVC does in Chrome. */ export function hasDecodedVideo(el: HTMLVideoElement): boolean { return el.videoWidth > 0 && el.videoHeight > 0 }