import ffmpeg from "ffmpeg-static"; import ffprobe from "ffprobe-static"; import fluent_ffmpeg, { type FfprobeData } from "fluent-ffmpeg"; import createError from "http-errors"; import { Context } from "elysia"; // import { T_Probe_v2 } from "./types"; import { NS_misc } from "./misc"; import { NS_promise } from "./promise"; import { NS_error } from "./error"; import UserAgent from "user-agents"; import { exec, execSync, spawn } from "node:child_process"; import { T_live, T_playback } from "../sub-router/ffmpeg/validator-v2"; import { NS_runtime } from "../../../../../packages/lib/env.runtime"; const APP_ENV = () => NS_runtime.ENV_VAR_DB?.APP_ENV || process.env.APP_ENV; const FFMPEG_V = process.env.FFMPEG_V; const FFPROBE_V = process.env.FFPROBE_V; const CPU_0V1 = process.env.CPU_0V1; const is_prod = process.env.NODE_ENV === "production" || APP_ENV() === "prod" || APP_ENV() === "pprod" || APP_ENV() === "pre_prod"; const ffmpeg_path = is_prod ? "ffmpeg" : ffmpeg || "ffmpeg"; const ffprobe_path = is_prod ? "ffprobe" : ffprobe.path || "ffprobe"; 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(`🚀 Using hardware encoder: ${hw_encoder}`); 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() { try { const version = execSync(`${ffmpeg_path} -version`, { encoding: "utf8" }); console.log("\n"); 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_scan_raw_probe_metadata, F_build_ffmpeg_flags_t1, proc_error_pattern, F_get_qLevel }; async function F_probe_t1(arg0: { playback: Omit; context?: Context; just_cmd?: boolean; timeout?: number; }) { const { context, playback, just_cmd, timeout } = 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"); // if (playback.type === "hls") flags.push("-allowed_extensions", "ALL"); flags.push("-probesize", `${1_000_000 * 10}`); // Limit probe size to 10MB (default is 5MB) flags.push("-analyzeduration", `${1_000_000 * 10}`); // Analyze only 10 seconds (default is 5s) 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((_, reject) => { setTimeout(() => { clearTimeout(t); reject(new Error("PROBE is taking way to long")); }, _PROBE_TIMEOUT_MS); }); const [E_, R_] = await NS_promise.F_catch_promise({ promise: Promise.race([ probe_runner({ ffprobe_path, args: flags, controller }), timeoutPromise() ]) }); clearTimeout(t); if (E_) { // timeout / hard failure -> this IS your "slow" bucket per your own rule throw E_; } const { data, bytesRead, durationMs } = R_ as any; const throughputMbps = (bytesRead * 8) / (durationMs / 1000) / 1_000_000; console.log({ throughputMbps }); (() => { // SPEED TEST const bit_rate = data?.format?.bit_rate; const streams = data?.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 console.log({ _ }); })(); return data; } 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); } } function F_scan_raw_probe_metadata(arg0: { metadata: Record }) { const { metadata } = arg0; const streams = metadata.streams; if (!streams) return; const audioStreams: Record[] = []; const videoStreams: Record[] = []; const subStreams: Record[] = []; let hasSubs = false; // Single pass through streams for (const stream of streams) { if (stream.codec_type === "video") { // if (!videoStream) videoStream = stream; videoStreams.push(stream); } else if (stream.codec_type === "audio") { // if (!audioStream) audioStream = stream; audioStreams.push(stream); } else if (stream.codec_type === "subtitle") { hasSubs = true; subStreams.push(stream); } } const format = (() => { if (!metadata.format) return; const xx = metadata.format; return { name: xx.format_name, duration: xx.duration, bit_rate: xx.bit_rate, size: xx.size, probe_score: xx.probe_score }; })(); return { audioStreams, videoStreams, subStreams, format }; } async function F_build_ffmpeg_flags_t1(arg0: { playback: Omit; subtitles?: T_live["subtitles"]; output_destination: NonNullable; delivery_mode: T_live["delivery_mode"]; delivery_format: T_live["delivery_format"]; probe_metadata: Record; flash_ping?: boolean; }) { const { playback, subtitles, output_destination, delivery_format, delivery_mode, probe_metadata } = arg0; let { flash_ping } = arg0; const flags: string[] = ["-hide_banner", "-loglevel", "info", "-stats"]; 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+nobuffer"); // flags.push("-live_start_index", "-2"); // For live streams: start near the end // flags.push("-fflags", "+discardcorrupt"); // Don't hang on truncated segments if (playback.type === "hls") { if (flash_ping === undefined || flash_ping === true) { flags.push("-http_persistent", "1"); flags.push("-http_multiple", "1"); flags.push("-http_seekable", "1"); flash_ping = true; } else { flags.push("-http_persistent", "0"); flags.push("-http_multiple", "0"); flags.push("-http_seekable", "0"); } } flags.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); if (playback.type === "hls") flags.push("-allowed_extensions", "ALL"); (() => { 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); const clean_metadata = NS_ffmpeg_v2.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]; 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; } })(); const must_re_encode = !supported_codecs.video.includes(videoCodec) || !supported_codecs.audio.includes(audioCodec); 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) { flags.push("-c:v", "copy"); flags.push("-c:a", "copy"); } else { flags.push("-c:v", hw_encoder || "libx264"); flags.push("-c:a", "aac", "-b:a", "256k"); } if (playback.type === "hls") 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 || delivery_mode === "stream") { const use_ultrafast = delivery_mode === "stream"; if (hw_encoder.includes("h264_qsv")) { flags.push("-preset", use_ultrafast ? "7" : "7"); // QSV uses numbers: 7 is fastest speed (ultrafast), 4 is balanced (medium) } else { flags.push("-crf", "23"); flags.push("-preset", use_ultrafast ? "ultrafast" : "ultrafast"); // ultrafast veryfast faster medium } } if (must_re_encode) flags.push("-pix_fmt", "yuv420p"); flags.push("-max_interleave_delta", "0"); if (delivery_mode === "stream") { // flags.push("-f", "ismv"); // } else { 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"); flags.push("-movflags", "+faststart"); flags.push("-y", output_destination); } else { flags.push("-fps_mode", "passthrough"); flags.push("-movflags", "frag_keyframe+dash+delay_moov"); 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; } function F_get_qLevel(arg0: { metadata: Record }) { const { metadata } = arg0; if (!NS_misc.is_plain_object(metadata)) { return; } const clean_metadata = F_scan_raw_probe_metadata({ metadata: metadata }); const videoStreams = clean_metadata?.videoStreams; if (!videoStreams) return; const seen = new Set(); const uniqueByDimensions = videoStreams.filter((xx) => { const key = `${xx.width}x${xx.height}`; if (seen.has(key)) { return false; } else { seen.add(key); return true; } }); if ( !uniqueByDimensions || !Array.isArray(uniqueByDimensions) || !uniqueByDimensions.length || uniqueByDimensions.length > 1 ) { return; } const _ = uniqueByDimensions[0]; if (_.width <= 0) { return; } switch (_.width) { case 3840: { return "4K"; } break; case 2048: { return "2K"; } break; case 1920: { return "1080P"; } break; case 1280: { return "720P"; } break; case 854: { return "480P"; } break; case 640: { return "360P"; } break; case 480: { return "320P"; } break; case 426: { return "240P"; } break; default: { return `${_.height}P`; } break; } }