S1 / src /lib /ffmpeg-v2.ts
GUI-STUDIO
p3
703a2c3
Raw History Blame Contribute Delete
28.5 kB
import ffmpeg from "ffmpeg-static";
import ffprobe from "ffprobe-static";
import fluent_ffmpeg, { type FfprobeData } from "fluent-ffmpeg";
import createError from "http-errors";
import { type Context } from "elysia";
import { NS_misc } from "./misc";
import { NS_promise } from "./promise";
import { NS_error } from "./error";
import { exec, execSync, spawn } from "node:child_process";
import { IS_GH, NS_runtime } from "@x/types/env.runtime";
import { type T_live, type T_probe_playback } from "@x/ff-dl/types";
import { NS_ff } from "@x/ff-dl/helper";
const APP_ENV = () => NS_runtime.ENV_VAR_DB?.APP_ENV || process.env.APP_ENV;
const FFMPEG_V = () => NS_runtime.ENV_VAR_DB?.FFMPEG_V || process.env.FFMPEG_V;
const FFPROBE_V = () =>
NS_runtime.ENV_VAR_DB?.FFPROBE_V || process.env.FFPROBE_V;
const CPU_0V1 = () => NS_runtime.ENV_VAR_DB?.CPU_0V1 || process.env.CPU_0V1;
const is_prod =
process.env.NODE_ENV === "production" ||
(APP_ENV() && ["prod", "pprod", "pre_prod"].includes(APP_ENV()));
const ffmpeg_path = is_prod ? "ffmpeg" : ffmpeg || "ffmpeg";
const ffprobe_path = is_prod ? "ffprobe" : ffprobe.path || "ffprobe";
console.log("ffmpeg_path", ffmpeg_path, "\n");
console.log("ffprobe_path", ffprobe_path, "\n");
fluent_ffmpeg.setFfmpegPath(ffmpeg_path);
fluent_ffmpeg.setFfprobePath(ffprobe_path);
let hw_encoder: any = await get_hardware_encoder();
hw_encoder =
typeof hw_encoder !== "string" || !hw_encoder.trim()
? "libx264"
: hw_encoder.trim();
// hw_encoder = "libx264";
console.log("\n");
console.log(`🚀 Using hardware encoder: ${hw_encoder}`, "\n");
const TIMEOUT_MS = 30_000; // probe + transcode timeout
const PROBE_TIMEOUT_MS = 30_000; // probe + transcode timeout
function get_hardware_encoder() {
check_if_fFmpeg_active();
return new Promise((resolve) => {
// We wrap ffmpeg path in quotes in case there are spaces in the path
const checkCmd = `"${ffmpeg_path}" -encoders | grep -E "nvenc|qsv|vaapi"`;
exec(checkCmd, (error, stdout) => {
if (error || !stdout) {
return resolve(null);
}
const output = stdout.toLowerCase();
if (output.includes("nvenc")) return resolve("h264_nvenc");
if (output.includes("qsv")) return resolve("h264_qsv");
if (output.includes("vaapi")) return resolve("h264_vaapi");
resolve(null);
});
});
}
function check_if_fFmpeg_active() {
console.log("\n");
try {
const version = execSync(`${ffmpeg_path} -version`, { encoding: "utf8" });
console.log("⚙️ ", version.split("\n")[0], "\n");
return true;
} catch {
console.error("❗FFmpeg not found in PATH", "\n");
return false;
}
}
export const NS_ffmpeg_v2 = {
F_probe_t1,
F_build_ffmpeg_flags_t1,
proc_error_pattern,
ffmpeg_path,
ffprobe_path,
TIMEOUT_MS
};
async function F_probe_t1(arg0: {
playback: Omit<T_probe_playback, "picture_quality">;
context?: Context;
just_cmd?: boolean;
timeout?: number;
read_intervals?: {
duration: number;
};
}) {
const { context, playback, just_cmd, timeout, read_intervals } = arg0;
if (!NS_misc.isValidUrlFormat(playback.url)) {
if (playback.protocol !== "local") {
throw createError(400, "invalid playback URL");
}
}
const controller = new AbortController();
function kill_controller() {
controller.abort();
console.log("[CONTROLLER] Request aborted");
}
const _PROBE_TIMEOUT_MS =
typeof timeout === "number" ? timeout : PROBE_TIMEOUT_MS;
const t =
_PROBE_TIMEOUT_MS <= 0
? undefined
: setTimeout(() => kill_controller(), _PROBE_TIMEOUT_MS);
const abort_signal = context?.request.signal;
if (abort_signal) {
if (abort_signal.aborted) {
kill_controller();
} else {
abort_signal.addEventListener(
"abort",
() => {
kill_controller();
},
{
once: true
}
);
}
}
const flags = [];
flags.push("-v", "debug");
flags.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
flags.push("-probesize", `${1_000_000 * 5}`); // Limit probe size (default is 5MB)
flags.push("-analyzeduration", `${1_000_000 * 5}`); // Analyze only seconds (default is 5s)
// flags.push("-nobuffer");
// flags.push("-count_packets");
if (read_intervals?.duration) {
const windowSeconds = 30;
const start = Math.max(0, read_intervals.duration - windowSeconds);
flags.push("-read_intervals", `${start}%+${windowSeconds}`);
flags.push("-select_streams", "v:0");
flags.push("-show_packets");
}
flags.push("-show_format");
flags.push("-show_streams");
flags.push("-print_format", "json");
(() => {
const playback_headers = playback.headers;
if (
typeof playback_headers !== "object" ||
Array.isArray(playback_headers)
) {
return;
}
const headers_str = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
flags.push("-headers", headers_str + "\r\n");
})();
flags.push("-i", playback.url);
if (APP_ENV() === "dev") {
console.log("[PROBE FLAGS]", flags, "\n");
}
if (just_cmd) {
clearTimeout(t);
return { cmd: flags.join(" ") };
}
const timeoutPromise = () =>
new Promise<never>((_, reject) => {
setTimeout(() => {
clearTimeout(t);
reject(new Error("PROBE is taking way to long"));
}, _PROBE_TIMEOUT_MS);
});
const _p = probe_runner({
ffprobe_path,
args: flags,
controller: t ? controller : undefined
});
const [E_, R_] = await NS_promise.F_catch_promise({
promise: !t ? _p : Promise.race([_p, timeoutPromise()])
});
clearTimeout(t);
if (E_) {
// timeout / hard failure -> this IS your "slow" bucket per your own rule
throw E_;
}
const { data: probe_info, bytesRead, durationMs } = R_ as any;
const throughputMbps = (bytesRead * 8) / (durationMs / 1000) / 1_000_000;
// PACKET RATIO
(() => {
try {
const format_name = probe_info.format.format_name;
if (format_name && format_name.includes("hls")) return;
const s = probe_info.streams[0];
const declared = parseFloat(probe_info.format.duration);
const [n, d] = s.avg_frame_rate.split("/").map(Number);
const fps = d ? n / d : 0;
const realPackets = parseInt(s.nb_read_packets, 10);
const realDuration = fps ? realPackets / fps : 0;
const ratio = realDuration / declared;
const bitrate =
probe_info.format.bit_rate || (probe_info.format.size * 8) / declared;
const suspicious = bitrate < 50_000;
probe_info.bitrate = bitrate;
probe_info.suspicious_bitrate = suspicious;
} catch {}
})();
// CHECK END PACKETS
(() => {
const declaredDuration = parseFloat(probe_info.format.duration);
const timestamps = (probe_info.packets ?? [])
.map((packet: any) => Number(packet.pts_time))
.filter(Number.isFinite);
if (timestamps.length === 0) {
return;
}
const latestTimestamp = Math.max(...timestamps);
const tolerance = Math.max(5, declaredDuration * 0.01);
const _ = latestTimestamp >= declaredDuration - tolerance;
probe_info.suspicious_end_packets = _;
})();
// SPEED TEST
(() => {
const bit_rate = probe_info?.format?.bit_rate;
const streams = probe_info?.streams;
let safetyFactor = 1.5;
let sourceBitrateMbps = bit_rate ? Number(bit_rate) / 1_000_000 : undefined;
// HLS variant playlists often don't set format.bit_rate — fall back to summing streams
if (!sourceBitrateMbps) {
const streamSum = (streams ?? []).reduce(
(s: number, st: { bit_rate: any }) =>
s + (st.bit_rate ? Number(st.bit_rate) : 0),
0
);
sourceBitrateMbps = streamSum > 0 ? streamSum / 1_000_000 : undefined;
}
const _ = { tier: "unknown", throughputMbps };
if (!sourceBitrateMbps) {
_.tier = "unknown";
return;
}
const requiredMbps = sourceBitrateMbps * safetyFactor;
const tier =
throughputMbps >= requiredMbps * 1.5
? "fast"
: throughputMbps >= requiredMbps
? "medium"
: "slow";
_.tier = tier;
probe_info.speedtest = _;
})();
return probe_info;
}
function probe_runner(arg0: {
ffprobe_path: string;
args: string[];
controller?: AbortController;
}) {
const { ffprobe_path, args, controller } = arg0;
return new Promise((resolve, reject) => {
const start = performance.now();
const probe = spawn(ffprobe_path, args, { signal: controller?.signal });
let dataOutput = "";
let errorOutput = "";
probe.stdout.on("data", (data) => {
dataOutput += data;
});
probe.stderr.on("data", (data) => {
errorOutput += data;
});
probe.on("close", (code) => {
if (code !== 0) {
reject(
new Error(
JSON.stringify({
code,
message: `ffprobe exited with code ${code}: ${errorOutput}`
})
)
);
return;
}
const durationMs = performance.now() - start;
const bytesRead = extract_bytes_read(errorOutput);
try {
resolve({ data: JSON.parse(dataOutput), bytesRead, durationMs });
} catch (err: any) {
reject(
new Error(`PROBE Failed to parse ffprobe output: ${err.message}`)
);
}
});
probe.on("error", (err) => {
// This will be called with err being an AbortError if the controller aborts
const { message } = NS_error.F_collect_error_message({ ERR: err });
reject(new Error(`PROBE FAILED: ${message}`));
});
});
function extract_bytes_read(stderr: string): number {
// robust to ffmpeg version differences in exact line formatting
const matches = [...stderr.matchAll(/(\d+)\s*bytes read/gi)];
return matches.reduce((sum, m) => sum + Number(m[1]), 0);
}
}
async function F_build_ffmpeg_flags_t1(arg0: {
playback: Omit<T_probe_playback, "picture_quality">;
subtitles?: T_live["subtitles"];
output_destination: NonNullable<T_live["delivery_name"]>;
delivery_mode: T_live["delivery_mode"];
delivery_format: T_live["delivery_format"];
probe_metadata: Record<string, any>;
flash_ping?: boolean;
dl_to_s3?: boolean;
input_type?: string;
phase_input?: boolean;
}) {
const {
playback,
subtitles,
output_destination,
delivery_format,
delivery_mode,
probe_metadata,
dl_to_s3,
input_type
} = arg0;
let { flash_ping, phase_input } = arg0;
(() => {
if (typeof phase_input === "boolean") return;
if (
playback.type === "video" &&
delivery_mode === "download" &&
input_type === "ts"
) {
phase_input = true;
}
})();
function force_ts() {
let _ = false;
if (input_type === "ts") _ = true;
if (playback.type === "hls") _ = false;
return _;
}
function force_mp4() {
let _ = false;
if (input_type === "mp4") _ = true;
if (playback.type === "hls") _ = false;
return _;
}
const flags: string[] = ["-hide_banner", "-loglevel", "info", "-stats"];
// -------------------------------
// ADD THREAD LIMIT (Crucial for 0.25 vCPU)
// -------------------------------
flags.push("-threads", "0");
if (force_ts()) {
flags.push("-f", "mpegts");
} else if (force_mp4()) {
flags.push("-f", "mp4");
}
flags.push("-reconnect", "1");
flags.push("-reconnect_streamed", "1");
if (playback.type === "hls" && delivery_mode === "stream") {
flags.push("-reconnect_at_eof", "1");
}
flags.push("-reconnect_delay_max", "30");
flags.push("-rw_timeout", `${1_000_000 * 30}`);
flags.push("-reconnect_on_network_error", "1");
// flags.push("-reconnect_on_http_error", "429,500,502,503,504");
flags.push("-reconnect_on_http_error", "500,502,503,504");
flags.push("-fflags", "+discardcorrupt+genpts");
// flags.push("-fflags", "+discardcorrupt+genpts+igndts"); // Don't hang on truncated segments
// flags.push("-live_start_index", "-2"); // For live streams: start near the end
if (playback.type === "hls") {
if (flash_ping === false) {
flags.push("-http_persistent", "0");
flags.push("-http_multiple", "0");
flags.push("-http_seekable", "0");
} else {
flags.push("-http_persistent", "1");
flags.push("-http_multiple", "1");
flags.push("-http_seekable", "1");
flash_ping = true;
}
}
flags.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
if (playback.type === "hls") flags.push("-allowed_extensions", "ALL");
flags.push("-analyzeduration", "100M");
flags.push("-probesize", "100M");
(() => {
const playback_headers = playback.headers;
if (
typeof playback_headers !== "object" ||
Array.isArray(playback_headers)
) {
return;
}
const headers_str = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
flags.push("-headers", headers_str + "\r\n");
})();
flags.push("-thread_queue_size", "1024");
flags.push("-i", playback.url);
const clean_metadata = NS_ff.F_scan_raw_probe_metadata({
metadata: probe_metadata
});
let subs: any[] | undefined = undefined;
(() => {
(() => {
if (delivery_format === "ts") {
subs = undefined;
}
if (clean_metadata?.subStreams && clean_metadata.subStreams.length) {
subs = clean_metadata.subStreams;
return;
}
if (!subtitles || !subtitles.length) return;
subs = subtitles;
for (let i = 0; i < subtitles.length; i++) {
const sub = subtitles[i];
if (sub?.url) {
flags.push("-f", "webvtt");
flags.push("-i", sub.url);
}
}
})();
if (subs?.length) {
const limit = 5;
subs = subs.slice(0, limit);
}
})();
const videoCodec = clean_metadata?.videoStreams[0]?.codec_name;
const audioCodec = clean_metadata?.audioStreams[0]?.codec_name;
const supported_codecs = (() => {
switch (delivery_format) {
case "mp4":
{
return {
video: ["h264", "hevc", "vp9", "av1", "mpeg2video"],
audio: ["aac", "mp3", "opus", "ac3"],
subtitle: ["mov_text"]
};
}
break;
case "ts":
{
return {
video: ["h264", "hevc", "mpeg2video"],
audio: ["aac", "mp3", "ac3"],
subtitle: []
};
}
break;
case "mkv":
{
return {
video: [
"h264",
"hevc",
"vp9",
"av1",
"mpeg2video",
"vp8",
"theora"
],
audio: [
"aac",
"mp3",
"opus",
"ac3",
"flac",
"vorbis",
"dts",
"truehd"
],
subtitle: ["srt", "ass", "ssa", "vobsub", "pgs", "webvtt"]
};
}
break;
}
})();
let must_re_encode =
!supported_codecs.video.includes(videoCodec) ||
(audioCodec && !supported_codecs.audio.includes(audioCodec));
// const re_encode_va = delivery_format !== "ts";
const re_encode_va = false;
let re_encode_a = false;
let re_encode_v = false;
// const re_encode_va = IS_RENDER_COM() && dl_to_s3;
// const re_encode_va = !IS_RENDER_COM();
if (phase_input) must_re_encode = false;
if (phase_input) re_encode_a = true;
flags.push("-map", "0:v");
flags.push("-map", "0:a?");
if (subs) {
if (clean_metadata?.subStreams.length) {
flags.push("-map", "0:s");
} else {
subs.forEach((_, i) => flags.push("-map", String(i + 1)));
}
}
if (must_re_encode || re_encode_va || (re_encode_v && re_encode_a)) {
flags.push("-c:v", hw_encoder || "libx264");
flags.push("-c:a", "aac", "-b:a", "256k");
} else if (re_encode_v) {
flags.push("-c:v", hw_encoder || "libx264");
flags.push("-c:a", "copy");
} else if (re_encode_a) {
flags.push("-c:v", "copy");
flags.push("-c:a", "aac", "-b:a", "256k");
} else {
flags.push("-c:v", "copy");
flags.push("-c:a", "copy");
}
if (
(playback.type === "hls" && (must_re_encode || re_encode_va)) ||
delivery_format !== "ts"
) {
flags.push("-bsf:a", "aac_adtstoasc");
}
if (subs) {
// flags.push("-async", "1");
flags.push("-c:s", "mov_text");
subs.forEach((s, i) => {
let lang = s.lang || s.tags?.language;
if (!lang) {
lang = "und";
}
lang = lang.slice(0, 3).toLowerCase();
flags.push(`-metadata:s:s:${i}`, `language=${lang}`);
});
}
if (delivery_mode === "stream") {
flags.push("-tune", "zerolatency");
}
if (must_re_encode) {
const use_ultrafast = delivery_mode === "stream";
if (hw_encoder.includes("h264_qsv")) {
flags.push("-preset", use_ultrafast ? "7" : "4"); // QSV uses numbers: 7 is fastest speed (ultrafast), 4 is balanced (medium)
} else {
flags.push("-crf", "23");
flags.push("-preset", use_ultrafast ? "ultrafast" : "medium"); // ultrafast veryfast faster medium
}
}
if (must_re_encode || re_encode_va || delivery_format !== "ts") {
flags.push("-pix_fmt", "yuv420p");
}
// flags.push("-max_interleave_delta", "0");
if (delivery_format) {
flags.push("-f", delivery_format === "ts" ? "mpegts" : delivery_format);
}
flags.push("-avoid_negative_ts", "make_zero");
if (delivery_mode === "download") {
// flags.push("-max_muxing_queue_size", "9999");
if (dl_to_s3) {
if (delivery_format !== "ts") {
flags.push(
"-movflags",
"frag_keyframe+empty_moov+default_base_moof+skip_trailer"
);
flags.push("-min_frag_duration", "5000000");
}
} else {
flags.push("-movflags", "+faststart");
}
if (dl_to_s3) {
flags.push("-y", "pipe:1");
} else {
flags.push("-y", output_destination);
}
} else {
flags.push("-fps_mode", "passthrough");
if (delivery_format !== "ts") {
flags.push("-movflags", "frag_keyframe+dash+delay_moov");
// flags.push("-movflags", "frag_keyframe+empty_moov");
// flags.push("-movflags", "frag_keyframe+empty_moov+default_base_moof");
// flags.push("-movflags", "frag_keyframe+empty_moov+default_base_moof+skip_trailer");
}
flags.push("-y", "pipe:1");
}
return {
flash_ping,
flags
};
}
function proc_error_pattern() {
const errorPatterns = [
/^(\[error\]|\[fatal\]|\[panic\])/i,
/error/i,
/failed/i,
/failure/i,
/invalid/i,
/unknown/i,
/unsupported/i,
/not found/i,
/cannot/i,
/could not/i,
/unable to/i,
/permission denied/i,
/no such file/i,
/broken pipe/i,
/protocol not found/i,
/codec not found/i,
/encoder not found/i,
/decoder not found/i,
/filter not found/i,
/option not found/i,
/conversion failed/i,
/av_interleaved_write_frame/i,
/out of memory/i,
/segmentation fault/i,
/assertion failed/i,
/corrupt/i,
/truncated/i,
/malformed/i,
/incompatible/i,
/mismatch/i,
/overflow/i,
/underflow/i,
/timeout/i,
/connection refused/i,
/connection reset/i,
/network is unreachable/i,
/no route to host/i,
/dns/i, // DNS resolution failures
/unrecognized/i
];
return errorPatterns;
}
interface T_rendition {
width: number;
height: number;
video_bytes: number;
audio_bytes: number;
bytes: number;
}
type T_segment_type = "ts" | "fmp4";
interface T_estimate {
bytes: number;
duration: number;
/** Segment container the estimate was calculated for */
segment_type?: T_segment_type;
renditions: T_rendition[];
/** Where each number came from, useful for judging how much to trust the estimate */
basis: {
segment_type?: "option" | "codec" | "default";
video_bps: "stream" | "packets" | "format" | "none";
audio_bps: "stream" | "format" | "none";
overhead: "packets" | "frame_rate" | "spec_minimum";
};
}
interface T_probe_stream {
index: number;
codec_type?: string;
width?: number;
height?: number;
bit_rate?: string;
duration?: string;
avg_frame_rate?: string;
has_b_frames?: number;
disposition?: { attached_pic?: number };
}
interface T_probe_packet {
stream_index: number;
size?: string;
pts_time?: string;
dts_time?: string;
duration_time?: string;
}
/**
* Estimate total HLS output size from an ffprobe JSON result.
* Works with whatever is present: stream/format bitrates, and (optionally) a sampled packet window.
*/
export function F_estimate_hls_size(arg0: {
probe_info: any;
segment_type?: T_segment_type;
/** Output heights if you plan more than one rendition, e.g. [1080, 720]. Omit for a single rendition. */
heights?: number[];
}): T_estimate {
/*
* Container framing sizes. Defined by the MPEG-TS / fMP4 specs (not tuning values).
*/
const TS_PACKET = 188; // fixed TS packet size
const TS_PAYLOAD = 184; // 188 minus the 4-byte TS header
const PES_BASE = 9; // PES header without timestamps
const PES_TIMESTAMP = 5; // each PTS / DTS field
const FMP4_SAMPLE_ENTRY = 16; // per-sample entry in the fMP4 'trun' box
// ffprobe prints numbers as strings and uses 'N/A' / missing keys freely
const num = (v: unknown): number => {
const n =
typeof v === "number" ? v : typeof v === "string" ? parseFloat(v) : NaN;
return Number.isFinite(n) ? n : 0;
};
function fps_of(rate?: string): number {
if (!rate) return 0;
const [a, b] = rate.split("/");
return num(a) / (num(b ?? "1") || 1);
}
// Bytes a packet occupies once wrapped in the segment container
function wire_bytes(
type: T_segment_type,
size: number,
has_dts: boolean
): number {
if (type === "fmp4") return size + FMP4_SAMPLE_ENTRY;
const pes = PES_BASE + PES_TIMESTAMP * (has_dts ? 2 : 1);
return Math.ceil((size + pes) / TS_PAYLOAD) * TS_PACKET;
}
// Lowest possible framing ratio for the container (no per-packet info available)
const spec_minimum = (type: T_segment_type) =>
type === "ts" ? TS_PACKET / TS_PAYLOAD : 1;
const { heights } = arg0;
const data: any =
typeof arg0.probe_info === "string"
? JSON.parse(arg0.probe_info)
: arg0.probe_info;
const streams = data.streams ?? [];
const fmt = data.format ?? {};
const video = streams.find(
(s: any) => s.codec_type === "video" && !s.disposition?.attached_pic
);
const audio = streams.find((s: any) => s.codec_type === "audio");
/*
* Codecs that HLS players generally expect in fragmented MP4 rather than MPEG-TS.
* The source's codecs are the only hint the probe gives about the container you'd want.
*/
const FMP4_CODECS = new Set(["hevc", "av1", "vp9", "opus", "flac"]);
function detect_segment_type(
video?: T_probe_stream,
audio?: T_probe_stream
): { type: T_segment_type; source: "codec" | "default" } {
const needs_fmp4 = [video, audio].some(
(s: any) => s?.codec_name && FMP4_CODECS.has(s.codec_name)
);
return needs_fmp4
? { type: "fmp4", source: "codec" }
: { type: "ts", source: "default" };
}
// Explicit option wins; otherwise infer from the codecs in the probe data
const detected = detect_segment_type(video, audio);
const segment_type: T_segment_type = arg0.segment_type ?? detected.type;
const segment_src: T_estimate["basis"]["segment_type"] = arg0.segment_type
? "option"
: detected.source;
const duration =
num(fmt.duration) || num(video?.duration) || num(audio?.duration);
if (!duration)
throw new Error("Could not determine duration from probe data");
const video_packets = (data.packets ?? []).filter(
(p: any) => p.stream_index === video?.index
);
const total_bps =
num(fmt.bit_rate) || (num(fmt.size) ? (num(fmt.size) * 8) / duration : 0);
// VIDEO bitrate: whole-file stream value > sampled packets > what's left of the total
let video_bps = 0;
let video_src: T_estimate["basis"]["video_bps"] = "none";
if (video) {
if (num(video.bit_rate)) {
video_bps = num(video.bit_rate);
video_src = "stream";
} else if (packets_bps(video_packets)) {
video_bps = packets_bps(video_packets);
video_src = "packets";
} else if (total_bps) {
video_bps = Math.max(total_bps - num(audio?.bit_rate), 0);
video_src = "format";
}
}
// AUDIO bitrate: stream value, else the leftover of the total.
// (With `-select_streams v:0` the audio stream isn't listed, so the leftover is how we see it.)
let audio_bps = 0;
let audio_src: T_estimate["basis"]["audio_bps"] = "none";
if (num(audio?.bit_rate)) {
audio_bps = num(audio?.bit_rate);
audio_src = "stream";
} else if (total_bps) {
audio_bps = video ? Math.max(total_bps - video_bps, 0) : total_bps;
audio_src = audio_bps ? "format" : "none";
}
if (!video_bps && !audio_bps)
throw new Error("No bitrate information in probe data");
const { ratio: video_ratio, source: overhead_src } = video
? video_overhead(segment_type, video, video_packets, video_bps)
: { ratio: spec_minimum(segment_type), source: "spec_minimum" as const };
// ffmpeg packs many audio frames into each TS PES packet, so audio only pays the base TS framing
const audio_ratio = spec_minimum(segment_type);
const src_width = video?.width ?? 0;
const src_height = video?.height ?? 0;
const targets = heights?.length && src_height ? heights : [src_height];
const renditions: T_rendition[] = targets.map((h) => {
// Same bits-per-pixel as the source: video size scales with pixel count
const scale = src_height ? h / src_height : 1;
const video_bytes = Math.round(
(video_bps / 8) * duration * video_ratio * scale * scale
);
const audio_bytes = Math.round((audio_bps / 8) * duration * audio_ratio);
return {
width: Math.round(src_width * scale),
height: h,
video_bytes,
audio_bytes,
bytes: video_bytes + audio_bytes
};
});
return {
bytes: renditions.reduce((sum, r) => sum + r.bytes, 0),
duration,
segment_type,
renditions,
basis: {
segment_type: segment_src,
video_bps: video_src,
audio_bps: audio_src,
overhead: overhead_src
}
};
// Bitrate measured from the sampled packet window (bits per second)
function packets_bps(packets: T_probe_packet[]): number {
if (packets.length < 2) return 0;
const bytes = packets.reduce((sum, p) => sum + num(p.size), 0);
let span = packets.reduce((sum, p) => sum + num(p.duration_time), 0);
if (!span) {
const pts = packets.map((p) => num(p.pts_time));
span =
((Math.max(...pts) - Math.min(...pts)) * packets.length) /
(packets.length - 1);
}
return span > 0 ? (bytes * 8) / span : 0;
}
// Framing ratio (container bytes / payload bytes) for the video stream
function video_overhead(
type: T_segment_type,
video: T_probe_stream,
packets: T_probe_packet[],
video_bps: number
): { ratio: number; source: T_estimate["basis"]["overhead"] } {
// Best: use the real packet sizes in the sampled window
const sized = packets.filter((p) => num(p.size) > 0);
if (sized.length) {
let payload = 0;
let wire = 0;
for (const p of sized) {
const pts = num(p.pts_time);
const dts = num(p.dts_time);
payload += num(p.size);
wire += wire_bytes(type, num(p.size), Math.abs(pts - dts) > 1e-6);
}
return { ratio: wire / payload, source: "packets" };
}
// Next: average frame size from bitrate and frame rate
const fps = fps_of(video.avg_frame_rate);
if (fps && video_bps) {
const frame = video_bps / 8 / fps;
if (type === "fmp4")
return {
ratio: (frame + FMP4_SAMPLE_ENTRY) / frame,
source: "frame_rate"
};
const pes = PES_BASE + PES_TIMESTAMP * (video.has_b_frames ? 2 : 1);
const wire = ((frame + pes + TS_PAYLOAD / 2) / TS_PAYLOAD) * TS_PACKET; // half a payload of padding on average
return { ratio: wire / frame, source: "frame_rate" };
}
return { ratio: spec_minimum(type), source: "spec_minimum" };
}
}