Download src/lib/ffmpeg-v2.ts from slapbox/S1: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/spaces/slapbox/S1/resolve/main/src/lib/ffmpeg-v2.ts
- Command line
-
hf download hf://spaces/slapbox/S1/src/lib/ffmpeg-v2.ts
-
curl -L -o ffmpeg-v2.ts https://huggingface.co/spaces/slapbox/S1/resolve/main/src/lib/ffmpeg-v2.ts
28.4 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 && !IS_GH() ? "ffmpeg" : ffmpeg || "ffmpeg"; | |
| const ffprobe_path = | |
| is_prod && !IS_GH() ? "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"); // 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("-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" }; | |
| } | |
| } | |