Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
125 changes: 119 additions & 6 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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.
*
Expand All @@ -468,28 +564,45 @@ 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
// a channel with several of them can be AC-3, which that filter rejects and
// 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",
Expand Down
82 changes: 79 additions & 3 deletions src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) : "";
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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"];
Expand Down
Loading