diff --git a/README.md b/README.md index 1a12114..2fe4e95 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,19 @@ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected a Whatever your ffmpeg was built with: mp3, flac, ogg, opus, m4a, aac, wav, wma, aiff, alac, and the audio track of mp4 and webm. +Video too, including raw transport streams — a `.ts`, `.m2ts` or `.mts` off a +capture card, a receiver or an IPTV recorder, at 1080p or 4K. H.264 is copied +into the fragmented MP4 a browser is sent, at whatever size it already is, so +a 4K recording costs no encoding to watch or to put on the air. H.265 is +copied too when the browser asking for it says it can decode one, and +otherwise re-encoded down to 1080p, because a 4K encode does not keep up with +playing it. A channel, which has one encode and a whole audience, re-encodes +H.265 by default; `NIXAMP_HEVC_CHANNELS=1` copies it through instead, for an +audience of phones and televisions. + +A `.ts` is opened rather than taken on its name: it is as often a TypeScript +file as a transport stream, and a checkout is not a playlist. + ## Status Early. It plays a directory, shows tags and timings, and draws what it hears — diff --git a/src/audio.ts b/src/audio.ts index bdc3004..8d5984a 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -10,6 +10,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { readdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { isTransportStream } from "./sources.ts"; export const RATE = 44100; export const CHANNELS = 2; @@ -404,6 +405,19 @@ export interface Codecs { * restart; a live channel is wherever it is now. */ duration?: number; + /** + * The size of the picture, when there is one. + * + * It decides the one thing that costs real money: whether a re-encode is + * asked to do 4K. Measured on this machine, 3840x2160 through libx264 + * -preset veryfast runs at about half of real time, so a 4K film re-encoded + * at its own size arrives slower than it plays -- a stream that falls + * further behind every second. The same source scaled to 1080p runs at + * about 1.6x real time and keeps up. Copying, of course, costs nothing at + * any size, which is why what is inside matters more than how big it is. + */ + width?: number; + height?: number; } /** @@ -425,7 +439,14 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = []) ...rest, "-v", "quiet", "-print_format", "json", - "-show_entries", "format=format_name,duration:stream=codec_type,codec_name", + "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height", + // A transport stream needs looking further into than a file with an + // index does: there is no header listing the tracks, only packets, and + // a 4K recording can carry a second of null padding and a long gap to + // its first keyframe. ffprobe's default gives up before the picture on + // exactly the recordings this is for, and "no video stream" is how a + // film comes back as its own soundtrack. + ...transportProbeArgs(path), // Headers the source's site expects, for a link resolved by yt-dlp. ...input, path, @@ -440,18 +461,21 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = []) child.on("close", () => { try { const parsed = JSON.parse(out) as { - streams?: { codec_type?: string; codec_name?: string }[]; + streams?: { codec_type?: string; codec_name?: string; width?: number; height?: number }[]; format?: { format_name?: string; duration?: string }; }; const streams = parsed.streams ?? []; // ffprobe prints seconds as a string, and "N/A" for a stream with no // end; both of those read as 0. const seconds = Number(parsed.format?.duration ?? 0); + const picture = streams.find((s) => s.codec_type === "video"); return done({ - video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "", + video: picture?.codec_name ?? "", audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "", container: parsed.format?.format_name ?? "", duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0, + ...(typeof picture?.width === "number" && picture.width > 0 ? { width: picture.width } : {}), + ...(typeof picture?.height === "number" && picture.height > 0 ? { height: picture.height } : {}), }); } catch { return done(empty); @@ -460,6 +484,78 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = []) }); } +/** How much of a transport stream is read before deciding what is in it. */ +export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024; +export const TRANSPORT_ANALYSE_US = 10_000_000; + +/** + * What to tell ffmpeg or ffprobe before it opens a transport stream. + * + * A `.ts` has no index and no header: it is packets, and the tracks are + * whatever turns up in them. The defaults are tuned for a file that describes + * itself, so a 4K recording -- padded with null packets, seconds between + * keyframes, sometimes several programmes -- gets read as having no picture, + * or no sound, or neither. Reading twenty megabytes before deciding costs a + * fraction of a second on a local disk and is the difference between a + * television recording and "nothing to play here". + * + * `+genpts` is for the other half of it: a recording that starts mid-stream + * has no timestamp on its first frames, and a fragmented MP4 built out of + * those has a duration of nothing and a seek bar that does not move. + * `+discardcorrupt` drops the half-packet at a cut rather than passing + * rubbish to the decoder. + */ +export function transportProbeArgs(path: string, container = ""): string[] { + if (!isTransportSource(path, container)) return []; + return ["-probesize", String(TRANSPORT_PROBE_BYTES), "-analyzeduration", String(TRANSPORT_ANALYSE_US)]; +} + +/** The same, for a decode rather than a probe: the timestamps matter too. */ +export function transportInputArgs(path: string, container = ""): string[] { + if (!isTransportSource(path, container)) return []; + return [...transportProbeArgs(path, container), "-fflags", "+genpts+discardcorrupt"]; +} + +/** + * Whether this source is a transport stream, by its name or by what a probe + * already found in it. The container is the better answer where there is one: + * an IPTV URL ending in `/301` is an mpegts and says so nowhere in its name. + */ +function isTransportSource(path: string, container = ""): boolean { + if (container.includes("mpegts")) return true; + return isTransportStream(path); +} + +/** How the two sides of `-c:v copy` are told apart in a name a person reads. */ +export interface VideoOptions { + /** + * Whether the thing at the other end can decode H.265. + * + * Safari and most televisions can; Chrome on a desktop cannot, and hands + * back nothing at all rather than an error anybody sees. So HEVC is only + * ever copied when the client said it could take it -- which is worth + * asking, because the alternative for a 4K HEVC film is an encode that does + * not keep up with playback. + */ + allowHevc?: boolean; + /** + * The tallest picture a re-encode may produce. A copy is never resized: a + * 4K stream a browser can already decode is handed over as it is. + */ + maxHeight?: number; +} + +/** + * The tallest re-encode that keeps up with playback. + * + * Measured on this box (8 cores, libx264 -preset veryfast, a 4K HEVC source): + * 4K out ran at 0.52x real time, 1080p out at 1.65x. An encode slower than + * real time is a live channel that falls behind for ever and a film that + * stalls every few seconds, so a re-encode of anything taller comes down to + * this. Copying is exempt, and copying is the ordinary case. + */ +export const MAX_TRANSCODE_HEIGHT = 1080; + /** * How to get this file into a browser, given what is inside it. * @@ -468,14 +564,21 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = []) * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it * would cost a core per viewer and look worse. So the streams decide, one part * at a time -- a film can have its video copied and only its DTS re-encoded. + * + * Resolution is deliberately not one of the deciders for a copy. 1080p and 4K + * H.264 out of a transport stream are copied exactly as 720p is, because the + * work of copying does not grow with the picture and a browser that can decode + * 4K should be given 4K. */ -export function videoArgs(codecs: Codecs, capKbps = 0): string[] { +export function videoArgs(codecs: Codecs, capKbps = 0, options: VideoOptions = {}): string[] { // A ceiling means re-encoding whatever is there, because you cannot cap the // bitrate of a stream you are copying: copying is what "unchanged" means. if (capKbps > 0) return cappedArgs(capKbps); - // What a browser can play inside MP4 without help. - const keepVideo = codecs.video === "h264"; + // What a browser can play inside MP4 without help -- and H.265, when the + // other end has said it can decode it, which saves re-encoding 4K. + const keepHevc = codecs.video === "hevc" && options.allowHevc === true; + const keepVideo = codecs.video === "h264" || keepHevc; // A transport stream's audio is never copied. Its AAC is ADTS-framed, which // MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and // says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off @@ -483,13 +586,23 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] { // no browser plays. Re-encoding audio is cheap; this failing is total. const transportStream = codecs.container.includes("mpegts"); const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3"); + // A re-encode of something taller than this comes down to it, because an + // encode slower than real time is not a stream. A copy keeps its size. + const ceiling = options.maxHeight ?? MAX_TRANSCODE_HEIGHT; + const tooTall = !keepVideo && (codecs.height ?? 0) > ceiling; return [ "-c:v", keepVideo ? "copy" : "libx264", + // H.265 in MP4 is `hvc1` to Safari and to every television; ffmpeg writes + // `hev1` by default, which Safari opens and then plays as a black panel. + ...(keepHevc ? ["-tag:v", "hvc1"] : []), // A keyframe every two seconds when encoding. A fragment starts on a // keyframe, so this is how soon a joiner sees a picture -- and an HLS // segment, which is cut on keyframes too, was ten seconds long on // x264's default and made a phone wait thirty before it played. ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]), + // -2 keeps the aspect ratio and an even height, which H.264 requires; the + // min() never enlarges, so a 720p source asked for 1080p stays 720p. + ...(tooTall ? ["-vf", `scale=-2:'min(${ceiling},ih)'`] : []), "-c:a", keepAudio ? "copy" : "aac", ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]), "-f", "mp4", diff --git a/src/channels.ts b/src/channels.ts index 373ea5a..9f20bc3 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -58,7 +58,16 @@ export interface ChannelInfo { position?: number; live?: boolean; /** What the source turned out to hold, so a restart need not ask again. */ - codecs?: { video: string; audio: string; container: string; duration?: number }; + codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number }; + /** + * What the channel itself is producing, which is not always what its source + * holds: an H.265 source is usually re-encoded to H.264 on the way out, + * because a channel has one encode and an audience that does not all + * decode the same things. Anything downstream -- the HLS packager above + * all, which has to choose between transport and fMP4 segments -- has to + * ask this rather than the source's codecs. + */ + emits?: string; /** * The nixamp.com account that put it on the air, when a member did rather * than the owner. Theirs to take off again, and counted against how many @@ -107,6 +116,31 @@ const TAIL = 2000; export const BACKLOG_VIDEO = 4 * 1024 * 1024; export const BACKLOG_AUDIO = 64 * 1024; +/** + * The backlog is really a number of seconds, and four megabytes was that + * number for the stream we happened to have. + * + * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream + * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed + * 4 MB hands a 4K joiner under a second of video, which is the live edge with + * no cushion, which is the play-wait-play loop the backlog exists to prevent. + * So the cap follows the stream: seconds times the rate it is actually + * running at, between the old floor and a ceiling that keeps a channel's + * memory bounded whatever it is carrying. + */ +export const BACKLOG_SECONDS = 6; +export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024; +/** + * How long a rate is measured over before it is believed. + * + * The first seconds of a pull are not a bitrate: ffmpeg opens the source, + * reads ahead, and empties what it has as fast as the pipe takes it. Sizing a + * buffer off that burst would reserve tens of megabytes for a stream that + * turns out to be a podcast. A window is measured, and until one has closed + * the floor stands. + */ +export const RATE_WINDOW_MS = 5000; + /** The four-letter name in a box header, or "" for something too short. */ function boxType(box: Buffer): string { return box.length >= 8 ? box.toString("latin1", 4, 8) : ""; @@ -151,6 +185,8 @@ export interface ChannelOptions { onEnd?: (info: ChannelInfo) => void; /** How long an on-demand channel outlives its last viewer. Tests shorten it. */ idleMs?: number; + /** How long a rate is measured over before the backlog is sized off it. Tests shorten it. */ + rateWindowMs?: number; } /** @@ -186,6 +222,10 @@ export class Channel { */ private recent: Buffer[] = []; private recentBytes = 0; + /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */ + private rateStart = 0; + private rateBytes = 0; + private rate = 0; /** * Started for whoever asked and stopped when nobody is left. A catalog * channel is one of thousands; keeping every one that was ever clicked @@ -401,6 +441,11 @@ export class Channel { if (this.info.kind === "video") this.fragments = new Fragments(); this.recent = []; this.recentBytes = 0; + // A new source may be a different size of stream, and the rate measured + // off the old one is not evidence about this one. + this.rateStart = 0; + this.rateBytes = 0; + this.rate = 0; this.hangUp(); } @@ -468,17 +513,43 @@ export class Channel { * make sense. */ private emit(chunk: Buffer): void { + this.measure(chunk.byteLength); if (!this.fragments) { this.remember(chunk, BACKLOG_AUDIO, false); this.send(chunk); return; } + const cap = this.backlogCap(); for (const box of this.fragments.push(chunk)) { - if (!isOpening(boxType(box))) this.remember(box, BACKLOG_VIDEO, true); + if (!isOpening(boxType(box))) this.remember(box, cap, true); this.send(box); } } + /** Watch how fast this channel is actually running, a window at a time. */ + private measure(bytes: number): void { + const now = Date.now(); + if (this.rateStart === 0) this.rateStart = now; + this.rateBytes += bytes; + const elapsed = now - this.rateStart; + if (elapsed < (this.options.rateWindowMs ?? RATE_WINDOW_MS)) return; + this.rate = (this.rateBytes * 1000) / elapsed; + this.rateStart = now; + this.rateBytes = 0; + } + + /** + * Six seconds of whatever this channel turned out to be, within bounds. + * + * Unmeasured -- the first window of a pull, or a channel that has only just + * started -- means the floor, which is what every channel had before. + */ + private backlogCap(): number { + if (this.rate <= 0) return BACKLOG_VIDEO; + const wanted = this.rate * BACKLOG_SECONDS; + return Math.min(BACKLOG_VIDEO_MAX, Math.max(BACKLOG_VIDEO, Math.round(wanted))); + } + /** Keep this for the next arrival, and let the oldest go once it is too much. */ private remember(piece: Buffer, cap: number, aligned: boolean): void { this.recent.push(piece); @@ -841,7 +912,7 @@ export interface RememberedChannel { * air as sound alone. */ kind?: "audio" | "video"; - codecs?: { video: string; audio: string; container: string; duration?: number }; + codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number }; /** Where a film had got to, in seconds, so it picks up there. */ position?: number; /** A live source has nowhere to pick up from. */ @@ -874,6 +945,11 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel if (typeof c["video"] === "string" && typeof c["audio"] === "string" && typeof c["container"] === "string") { kept.codecs = { video: c["video"], audio: c["audio"], container: c["container"] }; if (typeof c["duration"] === "number" && Number.isFinite(c["duration"])) kept.codecs.duration = c["duration"]; + // The size of the picture decides whether a re-encode has to come + // down to 1080p; forgetting it across a restart is how a 4K + // channel comes back at a size that cannot keep up. + if (typeof c["width"] === "number" && Number.isFinite(c["width"])) kept.codecs.width = c["width"]; + if (typeof c["height"] === "number" && Number.isFinite(c["height"])) kept.codecs.height = c["height"]; } } if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"]; diff --git a/src/hls.ts b/src/hls.ts index 40f5d1b..00dbffd 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -27,13 +27,27 @@ export const IDLE_MS = 60_000; /** How long the first playlist may take to appear before it is a failure. */ export const FIRST_PLAYLIST_MS = 20_000; -const SEGMENT = /^seg\d{5}\.ts$/; +/** + * The names a packager writes: MPEG-TS segments, or -- for a channel carrying + * H.265 -- an fMP4 init file and its parts. + * + * HLS in transport-stream segments is defined for H.264 and nothing else. + * Apple's own rule for H.265 is fMP4, and Safari, which is the whole reason + * this path exists, plays an HEVC channel packaged as TS as a black screen + * with sound. + */ +const SEGMENT = /^(?:seg\d{5}\.(?:ts|m4s)|init\.mp4)$/; /** A segment file name, or "" for anything that is not one. Never a path. */ export function segmentName(requested: string): string { return SEGMENT.test(requested) ? requested : ""; } +/** What to call a segment on the way out: a transport stream, or a piece of MP4. */ +export function segmentType(name: string): string { + return name.endsWith(".ts") ? "video/mp2t" : "video/mp4"; +} + /** * The playlist with the key on every segment. * @@ -50,7 +64,7 @@ export function withKey(playlist: string, key: string): string { } /** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */ -export function packagerArgs(dir: string): string[] { +export function packagerArgs(dir: string, fmp4 = false): string[] { return [ "-hide_banner", "-loglevel", "error", @@ -63,8 +77,9 @@ export function packagerArgs(dir: string): string[] { // keyframe so a joiner can begin anywhere; written whole then renamed so // a request never reads half a file. "-hls_flags", "delete_segments+omit_endlist+independent_segments+temp_file", - "-hls_segment_type", "mpegts", - "-hls_segment_filename", join(dir, "seg%05d.ts"), + ...(fmp4 + ? ["-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", "-hls_segment_filename", join(dir, "seg%05d.m4s")] + : ["-hls_segment_type", "mpegts", "-hls_segment_filename", join(dir, "seg%05d.ts")]), join(dir, "index.m3u8"), ]; } @@ -89,6 +104,8 @@ class Packager implements Packaged { private readonly ffmpeg: string[], private readonly onStop: (id: string) => void, private readonly onEvent: (message: string) => void, + /** Whether this channel has to be cut into fMP4 rather than TS: H.265. */ + private readonly fmp4 = false, ) { this.dir = mkdtempSync(join(tmpdir(), `nixamp-hls-${id}-`)); } @@ -96,7 +113,7 @@ class Packager implements Packaged { start(listen: (listener: Packaged) => (() => void) | null): boolean { const [command, ...prefix] = this.ffmpeg as [string, ...string[]]; try { - this.child = spawn(command, [...prefix, ...packagerArgs(this.dir)], { stdio: ["pipe", "ignore", "pipe"] }); + this.child = spawn(command, [...prefix, ...packagerArgs(this.dir, this.fmp4)], { stdio: ["pipe", "ignore", "pipe"] }); } catch (error) { this.onEvent(` HLS for "${this.id}" could not start: ${(error as Error).message}`); this.stop(); @@ -225,10 +242,10 @@ export class HlsPackagers { * and waiting for the first segments to exist. Null when the channel is not * there or nothing could be packaged. */ - async playlist(id: string): Promise { + async playlist(id: string, fmp4 = false): Promise { let packager = this.running.get(id); if (!packager) { - packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined)); + packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined), fmp4); this.running.set(id, packager); if (!packager.start((listener) => this.options.listen(id, listener))) return null; } diff --git a/src/playlist.ts b/src/playlist.ts index e14a26f..1938d06 100644 --- a/src/playlist.ts +++ b/src/playlist.ts @@ -4,9 +4,11 @@ import { join } from "node:path"; import { probe, probeAsync, type Tools, type Track } from "./audio.ts"; import { type Entry, + isAmbiguousTransportName, isHls, isPlaylistFile, isRemote, + looksLikeTransportStream, nameOf, parseM3u, parsePls, @@ -20,6 +22,11 @@ export const AUDIO_EXTENSIONS = new Set([ // long track with a picture nobody asked for -- and a library of them was // invisible to nixamp for want of the extension being on this list. ".mkv", ".avi", ".mov", ".m4v", ".mpg", ".mpeg", ".wmv", ".flv", + // Transport streams, which is what a recorder, a capture card or a receiver + // writes: 1080p and 4K television as it came off the wire. These names mean + // nothing else, so they are taken on the name. `.ts` is deliberately absent + // -- see `playable` below, which opens it instead of guessing. + ".m2ts", ".mts", ".m2t", ".trp", ".tp", ]); export function isAudio(path: string): boolean { @@ -27,6 +34,21 @@ export function isAudio(path: string): boolean { return dot > 0 && AUDIO_EXTENSIONS.has(path.slice(dot).toLowerCase()); } +/** + * Whether a file in a library is something to play. + * + * The name answers for everything except `.ts`, which is both a raw transport + * stream -- a 4K recording, an IPTV dump -- and every TypeScript file ever + * written, this program's own included. A library of recordings was invisible + * for want of the extension being listed, and listing it would have turned a + * checkout into a playlist. So a `.ts` is opened and asked: three sync bytes + * at one packet's spacing, which no source file has. + */ +export function playable(path: string): boolean { + if (isAudio(path)) return true; + return isAmbiguousTransportName(path) && looksLikeTransportStream(path); +} + /** Every audio file under `root`, depth first. A single file is a playlist of one. */ export function findAudio(root: string): string[] { const out: string[] = []; @@ -36,7 +58,7 @@ export function findAudio(root: string): string[] { } catch { return out; } - if (stats.isFile()) return isAudio(root) ? [root] : out; + if (stats.isFile()) return playable(root) ? [root] : out; const walk = (dir: string): void => { let entries: string[]; @@ -55,7 +77,7 @@ export function findAudio(root: string): string[] { continue; } if (s.isDirectory()) walk(full); - else if (isAudio(full)) out.push(full); + else if (playable(full)) out.push(full); } }; walk(root); @@ -174,7 +196,8 @@ export async function loadSource(tools: Tools, source: string, probeTags = true) // A URL that names no file is probably a folder, and a folder served over // http is a page of links. Asked only when it could be one: a stream URL // must not pay for a fetch that will tell us nothing. - if (!isAudio(new URL(source).pathname)) { + // A `.ts` address is a transport stream over http, never a folder listing. + if (!isAudio(new URL(source).pathname) && !isAmbiguousTransportName(source)) { const listed = await readRemoteIndex(source); if (listed.length > 0) return listed.map(bare); } @@ -269,7 +292,7 @@ export async function findAudioAsync(root: string, every = 200): Promise => { @@ -296,7 +319,7 @@ export async function findAudioAsync(root: string, every = 200): Promise 0 && PICTURE.has(path.slice(dot).toLowerCase()); + if (dot > 0 && PICTURE.has(path.slice(dot).toLowerCase())) return true; + // A `.ts` is a transport stream or a TypeScript file, and only its first + // bytes know which. Asked of the file rather than of the name, and the + // answer is remembered, because this is asked once per track per listing. + return isTransportStream(path); } /** @@ -1042,6 +1052,22 @@ export interface KnownSource { export const PROBE_TRIES = 3; export const PROBE_RETRY_MS = 1500; +/** + * Whether a channel may carry H.265 as it is. + * + * A channel has one encode and many viewers, so it has to be something they + * can all play, and H.265 is not that: Safari and televisions decode it, + * Chrome on a desktop mostly does not and shows nothing rather than saying + * so. So an HEVC source is re-encoded by default -- to 1080p H.264, because a + * 4K re-encode does not keep up with playback -- and an operator whose + * audience is phones and televisions can say `NIXAMP_HEVC_CHANNELS=1` and + * have the 4K copied through untouched. A single viewer asking for a file + * over /api/media is a different matter: there the browser says for itself. + */ +export function hevcChannelsAllowed(env: NodeJS.ProcessEnv = process.env): boolean { + return env["NIXAMP_HEVC_CHANNELS"] === "1"; +} + export async function pullChannel( channels: Channels, ffprobe: string[], @@ -1092,16 +1118,27 @@ export async function pullChannel( // from the first, the sound from the second. With one input ffmpeg // picks for itself, as it always did. ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []), - ...videoArgs(codecs), + ...videoArgs(codecs, 0, { allowHevc: hevcChannelsAllowed() }), ] // No picture in it, so none is invented: MP3 is the thing every browser // plays and the thing a listener can join halfway through. : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"]; + // A transport stream is read further into before it is decoded, and given + // the timestamps a recording cut mid-stream does not carry. Ahead of the + // caller's own input arguments, which are headers for the address itself. + const opening = [...transportInputArgs(source, codecs.container), ...input]; const channel = channels.pull( - id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "", + id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 }, ); if (channel && !assumed) channel.info.codecs = codecs; + // What comes out, as opposed to what went in. An H.265 source copied + // through stays H.265; one re-encoded arrives as H.264, and a packager + // told otherwise would cut fMP4 segments for a stream that did not need + // them. + if (channel && kind === "video") { + channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264"; + } return channel; } @@ -3011,7 +3048,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) { watch(request, response, "stream", entry.title); const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source); if (codecs.video !== "") { - pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4"); + pipeFfmpeg( + request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], + videoArgs(codecs, 0, { allowHevc: url.searchParams.get("hevc") === "1" }), "video/mp4", + transportInputArgs(entry.source, codecs.container), + ); } else { transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]); } @@ -3298,7 +3339,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) { return; } if (file === "index.m3u8") { - const playlist = await options.hls.playlist(id); + // A channel carrying H.265 is cut into fMP4 rather than transport + // segments: HLS in TS is defined for H.264 only, and Safari plays + // an HEVC channel packaged as TS as sound over a black screen. + const playlist = await options.hls.playlist(id, channels.info(id)?.emits === "hevc"); if (playlist === null) { json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" }); return; @@ -3321,7 +3365,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) { watch(request, response, "stream", id); response.writeHead(200, { ...CORS, - "content-type": "video/mp2t", + "content-type": segmentType(file ?? ""), "cache-control": "no-store", "content-length": statSync(segment).size, }); @@ -3904,6 +3948,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) { // and above 20 megabits the original was always the better answer. const asked = Number(url.searchParams.get("kbps") ?? ""); const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0; + // Whether this browser decodes H.265, which only it can know: Safari and + // televisions do, Chrome on a desktop does not and says nothing when + // handed it. Copying a 4K HEVC film is free; re-encoding one does not + // keep up with playing it, so the answer is worth carrying in the URL. + const allowHevc = url.searchParams.get("hevc") === "1"; if (playsInBrowser(file) && capKbps === 0) { sendFile(request, response, file); @@ -3922,7 +3971,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) { // added by an older nixamp goes to an audio element for ever, and // the only cure is noticing and adding it again. engine.sawPicture(index); - pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4"); + pipeFfmpeg( + request, response, file, options.ffmpeg ?? ["ffmpeg"], + videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", + transportInputArgs(file, codecs.container), + ); return; } } @@ -4240,6 +4293,12 @@ function pipeFfmpeg( ffmpeg: string[], outputArgs: string[], contentType: string, + /** + * What to say before the input is opened. A transport stream needs telling + * how far to read before it decides what is in it, and to make up the + * timestamps a recording cut mid-stream does not have. + */ + inputArgs: string[] = [], ): void { const [command, ...prefix] = ffmpeg as [string, ...string[]]; const child = spawn( @@ -4252,6 +4311,7 @@ function pipeFfmpeg( // belong to the http protocol, and ffmpeg rejects the whole command // when they are handed to it for a file on disk. ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []), + ...inputArgs, "-i", source, ...outputArgs, "-", diff --git a/src/sources.ts b/src/sources.ts index ca0eaf8..497f206 100644 --- a/src/sources.ts +++ b/src/sources.ts @@ -6,6 +6,7 @@ * list; what needs care is telling the four apart, and telling an .m3u that * lists tracks from an HLS playlist that *is* one track. */ +import { closeSync, openSync, readSync } from "node:fs"; /** http and https only. ffmpeg speaks more, but these are what a link is. */ export function isRemote(source: string): boolean { @@ -155,3 +156,102 @@ export function playsInBrowser(source: string): boolean { const dot = path.lastIndexOf("."); return dot > 0 && WEB_READY.has(path.slice(dot)); } + +/** + * Transport streams, which is what a raw `.ts` file off a capture card, a + * satellite receiver or an IPTV recorder is. + * + * `.m2ts`, `.mts` and the rest name nothing else, so the extension is answer + * enough. `.ts` is the awkward one: it is also every TypeScript file in every + * repository on the machine, and this program is written in them. So a `.ts` + * is never taken on its name -- it is opened and asked, which costs one read + * of two kilobytes and is the only honest way to tell 4K television from a + * module. + */ +const TRANSPORT_NAMES = new Set([".m2ts", ".mts", ".m2t", ".trp", ".tp", ".mpegts"]); + +/** A `.ts`, which may be a transport stream and may be a TypeScript file. */ +export function isAmbiguousTransportName(path: string): boolean { + return /\.ts$/i.test(isRemote(path) ? new URL(path).pathname : path); +} + +/** An extension that means a transport stream and nothing else. */ +export function isTransportName(path: string): boolean { + const file = isRemote(path) ? new URL(path).pathname : path; + const dot = file.lastIndexOf("."); + return dot > 0 && TRANSPORT_NAMES.has(file.slice(dot).toLowerCase()); +} + +/** How many bytes are read to decide. Three packets at the widest spacing, and room to find the first. */ +const SNIFF = 2048; +/** + * Packet sizes in the wild: 188 is MPEG-TS, 192 is what Blu-ray and a lot of + * recorders write (a four-byte arrival timestamp in front of each packet), 204 + * is 188 with Reed-Solomon parity from a DVB card. + */ +const STRIDES = [188, 192, 204]; + +/** + * Whether these bytes are a transport stream: a 0x47 sync byte at the start of + * every packet. + * + * Three in a row at the same spacing, because one 0x47 in a file is the letter + * G. The first packet may not be at byte zero -- a recording cut mid-stream + * starts mid-packet -- so every offset within one packet is tried. + */ +export function sniffTransportStream(head: Buffer): boolean { + for (const stride of STRIDES) { + for (let start = 0; start < stride; start++) { + if (start + stride * 2 >= head.length) break; + if (head[start] !== 0x47) continue; + if (head[start + stride] === 0x47 && head[start + stride * 2] === 0x47) return true; + } + } + return false; +} + +/** + * Whether the file at this path is a transport stream. + * + * Remembered, because the answer is asked once per snapshot per track and a + * library listing must not turn into a read per file per frame. A file that + * changes under us is a file being written, and a stale answer about it is a + * track that plays rather than a listing that stalls. + */ +const sniffed = new Map(); +const SNIFF_REMEMBERED = 5000; + +export function looksLikeTransportStream(path: string): boolean { + if (isRemote(path)) return false; + const remembered = sniffed.get(path); + if (remembered !== undefined) return remembered; + let answer = false; + let fd: number | null = null; + try { + fd = openSync(path, "r"); + const head = Buffer.alloc(SNIFF); + const read = readSync(fd, head, 0, SNIFF, 0); + answer = sniffTransportStream(head.subarray(0, read)); + } catch { + answer = false; + } finally { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // Closing a file that could not be opened is not a failure. + } + } + } + // A cap rather than a cache with eviction: a library of a million files + // must not be a million remembered answers, and forgetting costs one read. + if (sniffed.size >= SNIFF_REMEMBERED) sniffed.clear(); + sniffed.set(path, answer); + return answer; +} + +/** Whether this path is a transport stream, by name where the name is certain and by its bytes where it is not. */ +export function isTransportStream(path: string): boolean { + if (isTransportName(path)) return true; + return isAmbiguousTransportName(path) && looksLikeTransportStream(path); +} diff --git a/test/channels.test.ts b/test/channels.test.ts index 3a994df..87fbb8b 100644 --- a/test/channels.test.ts +++ b/test/channels.test.ts @@ -4,7 +4,8 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - BACKLOG_VIDEO, Channels, REDIAL, cleanId, generatedId, rememberChannels, rememberedChannels, + BACKLOG_SECONDS, BACKLOG_VIDEO, BACKLOG_VIDEO_MAX, Channels, REDIAL, cleanId, generatedId, + rememberChannels, rememberedChannels, } from "../src/channels.ts"; import { needsAdmin } from "../src/owner.ts"; @@ -445,6 +446,38 @@ test("the backlog is bounded, and never begins with an mdat", async () => { set.stopAll(); }); +test("a high-bitrate channel keeps seconds of backlog, not megabytes", async () => { + // 4K television copied straight through runs at tens of megabits, so the old + // fixed four megabytes was well under a second of it: a joiner landed on the + // live edge with no cushion and stalled on every hiccup, which is the exact + // thing a backlog is for. The cap follows the measured rate now. + const piece = 256 * 1024; + // Too big for a command line, so the pieces go through files, as the fixed + // backlog test above does. + const where = mkdtempSync(join(tmpdir(), "nixamp-rate-")); + const head = join(where, "head.mp4"); + const fragment = join(where, "fragment.mp4"); + writeFileSync(head, Buffer.concat([box("ftyp", "isom"), box("moov", "tracks")])); + writeFileSync(fragment, Buffer.concat([box("moof", "f"), box("mdat", "x".repeat(piece))])); + // A fragment every 20ms: about 12 MB/s, which is a 100 Mbit stream. + const fake = ["sh", "-c", `cat "${head}"; while :; do cat "${fragment}"; sleep 0.02; done`, "--"]; + const set = new Channels({ ffmpeg: fake, rateWindowMs: 300 }); + const channel = set.pull("uhd", "4K", "http://x.test/uhd", [], "video"); + try { + await wait(1500); + const late = collector(); + channel?.listen(late); + const total = Buffer.concat(late.chunks).byteLength; + assert.ok(total > BACKLOG_VIDEO, `only ${total} bytes of backlog for a fast channel`); + assert.ok(total <= BACKLOG_VIDEO_MAX, `${total} bytes is more than a channel may hold`); + // Seconds, not bytes: about six of them at the rate it is running. + const rate = piece / 0.02; + assert.ok(total <= rate * BACKLOG_SECONDS * 1.5, `${total} bytes is more than ${BACKLOG_SECONDS}s of this stream`); + } finally { + set.stopAll(); + } +}); + test("an on-demand channel stops itself a minute after its last viewer leaves", async () => { const set = new Channels({ ffmpeg: fakeVideoFfmpeg(), idleMs: 300 }); const channel = set.pull("cat-abc", "CNN", "http://x.test/301", [], "video"); diff --git a/test/transport.test.ts b/test/transport.test.ts new file mode 100644 index 0000000..f075e79 --- /dev/null +++ b/test/transport.test.ts @@ -0,0 +1,357 @@ +/** + * Raw transport streams: a `.ts` file with 1080p or 4K television in it. + * + * This is what a capture card, a satellite receiver, an IPTV recorder and + * `ffmpeg -f mpegts` all write, and it was the one shape of media nixamp + * could not take: the library walk skipped the extension, the router read it + * as a song, and what did get through was re-encoded when it did not need to + * be. The fixtures here are made with ffmpeg at test time rather than + * committed -- a few seconds of 4K is tens of megabytes -- and every test that + * needs one skips cleanly on a machine without ffmpeg. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + codecsOf, + detectTools, + MAX_TRANSCODE_HEIGHT, + transportInputArgs, + transportProbeArgs, + videoArgs, +} from "../src/audio.ts"; +import { + isAmbiguousTransportName, + isTransportName, + isTransportStream, + looksLikeTransportStream, + sniffTransportStream, +} from "../src/sources.ts"; +import { findAudio, isAudio, playable } from "../src/playlist.ts"; +import { HlsPackagers, packagerArgs, segmentName, segmentType } from "../src/hls.ts"; +import { hasPicture, pullChannel } from "../src/server.ts"; +import { Channels } from "../src/channels.ts"; + +const TOOLS = detectTools(); +const works = (argv: string[]): boolean => { + const [cmd, ...rest] = argv; + if (!cmd) return false; + const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 }); + return !r.error && r.status === 0; +}; +const ffmpegHere = works(TOOLS.ffmpeg); +const ffprobeHere = works(TOOLS.ffprobe); +const hevcHere = ffmpegHere && ((): boolean => { + const [cmd, ...rest] = TOOLS.ffmpeg as [string, ...string[]]; + const r = spawnSync(cmd, [...rest, "-hide_banner", "-encoders"], { encoding: "utf8", timeout: 20_000 }); + return (r.stdout ?? "").includes("libx265"); +})(); + +/** Somewhere to put fixtures, cleaned up when the process ends. */ +const dir = mkdtempSync(join(tmpdir(), "nixamp-ts-")); +process.on("exit", () => rmSync(dir, { recursive: true, force: true })); + +/** + * A short synthetic transport stream at a real broadcast size. + * + * Small on purpose -- a couple of seconds, crf 34, ultrafast -- because the + * point is the shape of the file, not what it looks like. A 4K fixture at a + * sane quality is 25 MB and half a minute of encoding. + */ +function fixture(name: string, width: number, height: number, video: string, audio: string): string { + const path = join(dir, name); + const [cmd, ...rest] = TOOLS.ffmpeg as [string, ...string[]]; + const result = spawnSync(cmd, [ + ...rest, + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", `testsrc2=size=${width}x${height}:rate=25`, + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-t", "2", + "-c:v", video, "-preset", "ultrafast", "-crf", "34", "-pix_fmt", "yuv420p", "-g", "25", + ...(video === "libx265" ? ["-x265-params", "log-level=error"] : []), + "-c:a", audio, "-b:a", "128k", + "-f", "mpegts", path, + ], { encoding: "utf8", timeout: 300_000 }); + assert.equal(result.status, 0, `ffmpeg could not write ${name}: ${result.stderr}`); + return path; +} + +let made: Record | null = null; +/** The fixtures, made once and shared: three encodes is enough to pay for. */ +function fixtures(): Record { + if (made) return made; + made = { + "1080p": fixture("hd.ts", 1920, 1080, "libx264", "aac"), + "2160p": fixture("uhd.ts", 3840, 2160, "libx264", "aac"), + ...(hevcHere ? { hevc: fixture("uhd-hevc.ts", 3840, 2160, "libx265", "ac3") } : {}), + }; + return made; +} + +test("three sync bytes at one packet's spacing, and nothing else, is a transport stream", () => { + const packets = (stride: number): Buffer => { + const bytes = Buffer.alloc(stride * 3 + 10, 0x11); + for (let at = 0; at < 3; at++) bytes[at * stride] = 0x47; + return bytes; + }; + for (const stride of [188, 192, 204]) { + assert.equal(sniffTransportStream(packets(stride)), true, `${stride}-byte packets`); + } + // A recording cut mid-packet does not start on a boundary. + assert.equal(sniffTransportStream(Buffer.concat([Buffer.alloc(57, 0x22), packets(188)])), true); + // One G in a text file is not a stream, and neither is nothing. + assert.equal(sniffTransportStream(Buffer.from("export function G() { return 0x47; }\n".repeat(40))), false); + assert.equal(sniffTransportStream(Buffer.alloc(0)), false); + assert.equal(sniffTransportStream(Buffer.alloc(2048)), false); +}); + +test("a .ts is opened rather than guessed at, and .m2ts is taken on its name", () => { + assert.equal(isAmbiguousTransportName("/films/rec.ts"), true); + assert.equal(isAmbiguousTransportName("/src/server.mts"), false); + assert.equal(isTransportName("/films/rec.m2ts"), true); + assert.equal(isTransportName("/films/rec.ts"), false, "a .ts is never taken on its name"); + + const source = join(dir, "server.ts"); + writeFileSync(source, "export const x = 1;\n".repeat(200)); + assert.equal(looksLikeTransportStream(source), false); + assert.equal(isTransportStream(source), false); + // The library must not list a checkout, and `isAudio` is what the old list + // was: an extension that means TypeScript far more often than television. + assert.equal(isAudio(source), false); + assert.equal(playable(source), false); + assert.equal(hasPicture(source), false); +}); + +test("a real .ts recording is a film, whatever its name suggests", { skip: !ffmpegHere, timeout: 300_000 }, () => { + const hd = fixtures()["1080p"] as string; + assert.equal(looksLikeTransportStream(hd), true); + assert.equal(isTransportStream(hd), true); + assert.equal(playable(hd), true, "a recording belongs in the library"); + assert.equal(hasPicture(hd), true, "and it is something to watch, not to listen to"); + + // The walk finds the recording and leaves the source file where it is. + const library = mkdtempSync(join(tmpdir(), "nixamp-lib-")); + try { + writeFileSync(join(library, "notes.ts"), "export const x = 1;\n".repeat(200)); + writeFileSync(join(library, "film.ts"), readFileSync(hd)); + assert.deepEqual(findAudio(library), [join(library, "film.ts")]); + } finally { + rmSync(library, { recursive: true, force: true }); + } +}); + +test("a transport stream is read further into before anything decides what is in it", () => { + assert.deepEqual(transportProbeArgs("/films/rec.m2ts").slice(0, 1), ["-probesize"]); + assert.deepEqual(transportProbeArgs("/films/song.mp3"), [], "a file with an index needs none of this"); + // An IPTV address says nothing in its name; what the probe found says it. + assert.deepEqual(transportProbeArgs("http://box/tipoff/KEY/301", "mpegts").slice(0, 1), ["-probesize"]); + const opening = transportInputArgs("/films/rec.m2ts"); + assert.ok(opening.includes("-analyzeduration")); + // A recording cut mid-stream has no timestamps on its first frames, and a + // fragmented MP4 built out of those has a seek bar that never moves. + assert.deepEqual(opening.slice(-2), ["-fflags", "+genpts+discardcorrupt"]); +}); + +test("ffprobe says what is in a 1080p and a 4K recording", { skip: !ffmpegHere || !ffprobeHere, timeout: 300_000 }, async () => { + const hd = await codecsOf(TOOLS, fixtures()["1080p"] as string); + assert.equal(hd.video, "h264"); + assert.equal(hd.audio, "aac"); + assert.ok(hd.container.includes("mpegts")); + assert.deepEqual([hd.width, hd.height], [1920, 1080]); + + const uhd = await codecsOf(TOOLS, fixtures()["2160p"] as string); + assert.deepEqual([uhd.width, uhd.height], [3840, 2160]); + // A recording has an end, which is what tells a film from a live channel. + assert.ok((uhd.duration ?? 0) > 0); +}); + +test("4K H.264 is copied, at 4K, and only its ADTS audio is redone", () => { + const uhd = videoArgs({ video: "h264", audio: "aac", container: "mpegts", width: 3840, height: 2160 }); + assert.deepEqual(uhd.slice(0, 2), ["-c:v", "copy"], "copying does not cost more because the picture is bigger"); + assert.ok(!uhd.includes("-vf"), "a copied stream is never resized"); + assert.ok(!uhd.includes("libx264")); + // Transport-stream AAC is ADTS-framed and MP4 refuses it outright. + assert.deepEqual(uhd.slice(2, 4), ["-c:a", "aac"]); +}); + +test("an H.265 recording is copied when the other end can decode it, and comes down to 1080p when it cannot", () => { + const uhd = { video: "hevc", audio: "ac3", container: "mpegts", width: 3840, height: 2160 }; + + const kept = videoArgs(uhd, 0, { allowHevc: true }); + assert.deepEqual(kept.slice(0, 2), ["-c:v", "copy"]); + // hev1 is what ffmpeg writes by default and what Safari plays as a black + // panel; hvc1 is what every player actually wants. + assert.deepEqual(kept.slice(2, 4), ["-tag:v", "hvc1"]); + assert.ok(!kept.includes("-vf")); + + const redone = videoArgs(uhd, 0, {}); + assert.deepEqual(redone.slice(0, 2), ["-c:v", "libx264"]); + const filter = redone[redone.indexOf("-vf") + 1] ?? ""; + assert.match(filter, new RegExp(`min\\(${MAX_TRANSCODE_HEIGHT},ih\\)`), "a 4K encode does not keep up with playing it"); + + // 1080p is already at the ceiling, so it is re-encoded at its own size. + const hd = videoArgs({ video: "hevc", audio: "ac3", container: "mpegts", width: 1920, height: 1080 }, 0, {}); + assert.ok(!hd.includes("-vf"), "nothing is scaled that is already small enough"); + + // A source whose size nobody asked about is left alone too: guessing that + // an unmeasured picture is 4K would shrink every film that was not. + assert.ok(!videoArgs({ video: "hevc", audio: "ac3", container: "mpegts" }, 0, {}).includes("-vf")); +}); + +test("what videoArgs asks for actually remuxes a 4K recording", { skip: !ffmpegHere || !ffprobeHere, timeout: 300_000 }, async () => { + const source = fixtures()["2160p"] as string; + const codecs = await codecsOf(TOOLS, source); + const out = join(dir, "copied.mp4"); + const [cmd, ...rest] = TOOLS.ffmpeg as [string, ...string[]]; + const result = spawnSync(cmd, [ + ...rest, "-hide_banner", "-loglevel", "error", "-y", + ...transportInputArgs(source, codecs.container), + "-i", source, + ...videoArgs(codecs), + out, + ], { encoding: "utf8", timeout: 300_000 }); + assert.equal(result.status, 0, `the remux failed: ${result.stderr}`); + + // A fragmented MP4: it opens with the boxes that describe the tracks, which + // is what a late joiner is handed before any live bytes. + const head = readFileSync(out).subarray(0, 4096).toString("latin1"); + assert.ok(head.includes("ftyp"), "no ftyp"); + assert.ok(head.includes("moov"), "no moov"); + + // Still 4K, still H.264, and the audio is now something a browser opens. + const after = await codecsOf(TOOLS, out); + assert.equal(after.video, "h264"); + assert.deepEqual([after.width, after.height], [3840, 2160]); + assert.equal(after.audio, "aac"); +}); + +test("an H.265 4K recording survives the copy with the tag a player wants", { skip: !hevcHere || !ffprobeHere, timeout: 300_000 }, async () => { + const source = fixtures()["hevc"] as string; + const codecs = await codecsOf(TOOLS, source); + assert.equal(codecs.video, "hevc"); + const out = join(dir, "hevc.mp4"); + const [cmd, ...rest] = TOOLS.ffmpeg as [string, ...string[]]; + const result = spawnSync(cmd, [ + ...rest, "-hide_banner", "-loglevel", "error", "-y", + ...transportInputArgs(source, codecs.container), + "-i", source, + ...videoArgs(codecs, 0, { allowHevc: true }), + out, + ], { encoding: "utf8", timeout: 300_000 }); + assert.equal(result.status, 0, `the remux failed: ${result.stderr}`); + const tags = spawnSync(TOOLS.ffprobe[0] as string, [ + ...TOOLS.ffprobe.slice(1), "-v", "quiet", "-show_entries", "stream=codec_tag_string", "-of", "csv=p=0", out, + ], { encoding: "utf8", timeout: 60_000 }); + assert.match(tags.stdout, /hvc1/, `written as ${tags.stdout.trim()}`); + + const after = await codecsOf(TOOLS, out); + assert.deepEqual([after.width, after.height], [3840, 2160], "copied at its own size"); + // AC-3 is not a track any browser plays, so the sound is redone either way. + assert.equal(after.audio, "aac"); +}); + +test("an H.265 channel is packaged as fMP4, because HLS in transport segments is H.264 only", () => { + const ts = packagerArgs("/tmp/x"); + assert.equal(ts[ts.indexOf("-hls_segment_type") + 1], "mpegts"); + assert.equal(ts[ts.length - 1], "/tmp/x/index.m3u8"); + + const fmp4 = packagerArgs("/tmp/x", true); + assert.equal(fmp4[fmp4.indexOf("-hls_segment_type") + 1], "fmp4"); + assert.equal(fmp4[fmp4.indexOf("-hls_fmp4_init_filename") + 1], "init.mp4"); + assert.equal(fmp4[fmp4.indexOf("-hls_segment_filename") + 1], "/tmp/x/seg%05d.m4s"); + // Copying either way: packaging is never an encode. + assert.deepEqual(fmp4.slice(fmp4.indexOf("-c"), fmp4.indexOf("-c") + 2), ["-c", "copy"]); + + // The names those two produce are served, and nothing else is. + assert.equal(segmentName("seg00003.m4s"), "seg00003.m4s"); + assert.equal(segmentName("init.mp4"), "init.mp4"); + assert.equal(segmentName("../../etc/passwd"), ""); + assert.equal(segmentName("init.mp4/../x"), ""); + assert.equal(segmentType("seg00003.ts"), "video/mp2t"); + assert.equal(segmentType("seg00003.m4s"), "video/mp4"); + assert.equal(segmentType("init.mp4"), "video/mp4"); +}); + +test("a 1080p recording goes live as a channel, copied rather than re-encoded", { skip: !ffmpegHere || !ffprobeHere, timeout: 300_000 }, async () => { + // The whole path: what going live with a file in the library does. The + // source is probed, the streams decide the encode, and a listener is handed + // a fragmented MP4 that still has the original picture in it. + const channels = new Channels({ ffmpeg: TOOLS.ffmpeg }); + const source = fixtures()["1080p"] as string; + try { + const channel = await pullChannel(channels, TOOLS.ffprobe, "rec", "A recording", source); + assert.ok(channel, "the channel did not start"); + assert.equal(channel?.info.kind, "video", "a recording is something to watch"); + assert.equal(channel?.info.codecs?.video, "h264"); + assert.deepEqual([channel?.info.codecs?.width, channel?.info.codecs?.height], [1920, 1080]); + // A file has an end, so it is a film with a place to go back to rather + // than a live source that is wherever it is now. + assert.equal(channel?.info.live, false); + assert.equal(channels.contentType("rec"), "video/mp4"); + // What comes out of the channel, which is what an HLS packager has to + // ask: copied H.264 in, copied H.264 out. + assert.equal(channel?.info.emits, "h264"); + + const chunks: Buffer[] = []; + const detach = channels.listen("rec", { write: (chunk) => { chunks.push(chunk); return true; }, end: () => undefined }); + assert.ok(detach); + // Paced to real time, as a channel always is, so this waits about as long + // as the recording lasts. + await new Promise((done) => setTimeout(done, 3500)); + detach?.(); + const got = Buffer.concat(chunks); + assert.ok(got.byteLength > 0, "the channel produced nothing"); + const head = got.subarray(0, 4096).toString("latin1"); + assert.ok(head.includes("ftyp") && head.includes("moov"), "a listener joins on the boxes that describe the stream"); + + const played = join(dir, "channel.mp4"); + writeFileSync(played, got); + const after = await codecsOf(TOOLS, played); + assert.equal(after.video, "h264"); + // The picture came through at its own size: a 1080p recording that arrives + // as 1080p was copied, not re-encoded. + assert.deepEqual([after.width, after.height], [1920, 1080]); + } finally { + channels.stopAll(); + } +}); + +test("an H.265 channel packages into fMP4 segments a phone can play", { skip: !hevcHere, timeout: 180_000 }, async () => { + // The other half of the HEVC story: Safari is the browser that can decode + // H.265 and the one that needs HLS, and HLS in transport segments is + // defined for H.264 only. So an HEVC channel is cut into fMP4 -- an init + // file plus .m4s parts -- which is what `#EXT-X-MAP` in the playlist says. + const channels = new Channels({ ffmpeg: TOOLS.ffmpeg }); + const hls = new HlsPackagers({ + ffmpeg: TOOLS.ffmpeg, + listen: (id, listener) => channels.listen(id, listener), + onEvent: () => undefined, + firstPlaylistMs: 60_000, + }); + try { + // Small and endless: what is being tested is the packaging, not x265. + const channel = channels.pull( + "hevc", "An H.265 pattern", "testsrc2=size=320x240:rate=25", + [ + "-c:v", "libx265", "-preset", "ultrafast", "-crf", "34", "-pix_fmt", "yuv420p", + "-x265-params", "log-level=error:keyint=25:min-keyint=25", "-tag:v", "hvc1", "-an", + "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", + ], + "video", true, 60_000, ["-f", "lavfi", "-re"], + ); + assert.ok(channel); + const playlist = await hls.playlist("hevc", true); + assert.ok(playlist, "an H.265 channel could not be packaged"); + assert.match(playlist ?? "", /#EXT-X-MAP:URI="init\.mp4"/, "no init segment, so nothing describes the track"); + const part = (playlist ?? "").split("\n").find((line) => line.endsWith(".m4s")) ?? ""; + assert.match(part, /^seg\d{5}\.m4s$/); + assert.notEqual(hls.segment("hevc", part), "", "the segment named in the playlist is not there"); + assert.notEqual(hls.segment("hevc", "init.mp4"), "", "the init segment is not served"); + } finally { + hls.stopAll(); + channels.stopAll(); + } +}); diff --git a/web/src/app.ts b/web/src/app.ts index 3dceb47..d2cbbd8 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -3933,7 +3933,9 @@ export function start(): void { : `re-streamed from the web · ${restream.tracks} tracks`, onPlay: () => { void playAt(restream.at); }, link: "", - direct: remote.media(restream.at), + // For VLC, mpv or another page, which decode H.265 whatever this + // browser does -- so this address asks for the film untouched. + direct: remote.media(restream.at, 0, true), })); } diff --git a/web/src/format.ts b/web/src/format.ts index b836373..aacc391 100644 --- a/web/src/format.ts +++ b/web/src/format.ts @@ -24,7 +24,14 @@ export function titleFromFilename(name: string): string { return dot > 0 ? base.slice(0, dot) : base; } -const VIDEO = new Set(["mp4", "webm", "mkv", "mov", "m4v", "ogv", "avi"]); +const VIDEO = new Set([ + "mp4", "webm", "mkv", "mov", "m4v", "ogv", "avi", + // Raw transport streams: what a recorder, a receiver or a capture card + // writes, and what 1080p and 4K television arrives as. A `.ts` among picked + // files is one of these -- a TypeScript file is not something a person + // drags into a player. + "ts", "m2ts", "mts", "m2t", "trp", "tp", +]); /** Video needs a