| 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 { 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(); |
| |
|
|
| console.log(`🚀 Using hardware encoder: ${hw_encoder}`); |
|
|
| const TIMEOUT_MS = 30_000; |
| const PROBE_TIMEOUT_MS = 30_000; |
|
|
| function get_hardware_encoder() { |
| check_if_fFmpeg_active(); |
|
|
| return new Promise((resolve) => { |
| |
| 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<T_playback, "picture_quality">; |
| 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"); |
| |
|
|
| flags.push("-probesize", `${1_000_000 * 5}`); |
| flags.push("-analyzeduration", `${1_000_000 * 5}`); |
|
|
| 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 [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) => { |
| |
| 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<string, any> }) { |
| const { metadata } = arg0; |
|
|
| const streams = metadata.streams; |
|
|
| if (!streams) return; |
|
|
| const audioStreams: Record<string, any>[] = []; |
| const videoStreams: Record<string, any>[] = []; |
| const subStreams: Record<string, any>[] = []; |
|
|
| let hasSubs = false; |
|
|
| |
| for (const stream of streams) { |
| if (stream.codec_type === "video") { |
| |
| videoStreams.push(stream); |
| } else if (stream.codec_type === "audio") { |
| |
| 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<T_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>; |
| }) { |
| 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", "500,502,503,504"); |
|
|
| |
| |
| |
|
|
| 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"); |
| } else { |
| flags.push("-crf", "23"); |
| flags.push("-preset", use_ultrafast ? "ultrafast" : "ultrafast"); |
| } |
| } |
|
|
| if (must_re_encode) flags.push("-pix_fmt", "yuv420p"); |
|
|
| flags.push("-max_interleave_delta", "0"); |
|
|
| if (delivery_mode === "stream") { |
| |
| |
| 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, |
| /unrecognized/i |
| ]; |
|
|
| return errorPatterns; |
| } |
|
|