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"; const 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 === "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 }; async function F_probe_t1(arg0: { playback: Omit; context?: Context; just_cmd?: boolean; }) { const { context, playback, just_cmd } = 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 t = 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 * 5}`); // Limit probe size to 5MB (default is 5MB, but explicit) flags.push("-analyzeduration", `${1_000_000 * 5}`); // Analyze only 5 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_) { throw E_; } return R_; } function probe_runner(arg0: { ffprobe_path: string; args: string[]; controller?: AbortController; }) { const { ffprobe_path, args, controller } = arg0; return new Promise((resolve, reject) => { 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; } try { resolve(JSON.parse(dataOutput)); } 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 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; }) { const { playback, subtitles, output_destination, delivery_format, delivery_mode, probe_metadata } = 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") flags.push("-http_persistent", "0"); if (playback.type === "hls") flags.push("-http_multiple", "0"); if (playback.type === "hls") 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 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; }