SIAF / src /lib /ffmpeg.ts
GUI-STUDIO
Update ffmpeg.ts
cfceaa5
Raw
History Blame Contribute Delete
45 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 { Context } from "elysia";
import { T_Probe } 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 } from "node:child_process";
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";
// const ffmpeg_path = ffmpeg || "";
// const ffprobe_path = ffprobe.path;
// const ffmpeg_path = "/usr/local/bin/ffmpeg";
// const ffprobe_path = "/usr/local/bin/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 = 20_000; // probe + transcode timeout
const PROBE_TIMEOUT_MS = 30_000; // probe + transcode timeout
function getFfmpegEncoderConfig() {
try {
// Quick 0.5-second test to see if Intel QSV hardware initializes successfully
execSync(`${ffmpeg_path} -y -f lavfi -i testsrc=duration=0.5 -c:v h264_qsv -f null -`, {
stdio: 'ignore',
timeout: 2000 // 2 second safety timeout
});
// QSV is active and working
return {
videoCodec: 'h264_qsv',
preset: '7', // QSV fastest speed numeric preset
extraFlags: []
};
} catch (error) {
console.error({error});
// Fallback to CPU software encoding if hardware check fails
return {
videoCodec: 'libx264',
preset: 'ultrafast', // Software fastest speed preset
extraFlags: ['-crf', '23'] // CRF is ideal for libx264
};
}
}
function get_hardware_encoder() {
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);
});
});
}
async function F_probe_v1(arg0: {
playback_headers?: Record<string, any> | null;
playback_url: URL;
context?: Context;
}) {
const { playback_headers, playback_url, context } = arg0;
const [E_, R_] = await NS_promise.F_catch_promise({
promise: NS_misc.F_test_connection({
url: playback_url,
headers: playback_headers
})
});
if (E_) {
const { status, message } = NS_error.F_collect_error_status_n_message({
ERR: E_
});
if (["522", "510", "502", "503", "404", "410"].includes(`${status}`))
throw E_;
}
const command = fluent_ffmpeg();
// Add network options
command.addOption("-timeout", (30 * 1_000_000).toString()); // 30 seconds
command.addOption("-reconnect", "1"); // Auto-reconnect
command.addOption("-reconnect_streamed", "1");
command.addOption("-reconnect_delay_max", "5");
command.addOption("-multiple_requests", "1"); // For HLS/DASH
// command.addOption("-user_agent", "Mozilla/5.0");
if (is_prod && FFPROBE_V === "latest") {
command.addOption("-allowed_segment_extensions", "ALL"); // Explicitly allow 'none' and 'jpg' in addition to ALL "ts,m4s,jpg,jpeg,none,ALL"
command.addOption("-allowed_extensions", "ALL");
command.addOption("-hls_flags", "+nocheck_content_type"); // This tells the HLS demuxer to stop validating extensions against content
command.addOption("-extension_picky", "0");
command.addOption("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
command.addOption("-strict", "experimental");
}
command.input(playback_url.href);
if (playback_headers && Object.keys(playback_headers).length > 0) {
const header_string = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.addOption("-headers", header_string);
}
command.addOption("-v", "error");
command.addOption("-probesize", "5000000");
command.addOption("-analyzeduration", "5000000");
command.addOption(
"-show_entries",
"format=duration,size,format_name:stream=index,codec_type,codec_name,height,width,bit_rate"
);
command.addOption("-show_entries", "stream_tags=language");
command.addOption("-of", "json");
const metadata = await new Promise<FfprobeData>((resolve, reject) => {
command.ffprobe((err, data) => {
if (err) {
console.error("[PROBE ERROR]:", err.message);
// Better error message
if (err.message.includes("resolve hostname")) {
return reject(
createError(
502,
`Cannot resolve hostname: ${playback_url.hostname}. Check network/DNS.`
)
);
}
if (err.message.includes("I/O error")) {
return reject(
createError(
502,
`Network I/O error: Cannot reach ${playback_url.href}`
)
);
}
return reject(createError(500, `Probe failed: ${err.message}`));
}
resolve(data);
});
});
const streams = metadata.streams;
let hasSubs = false;
const audioStreams: any[] = [];
const videoStreams: any[] = [];
const subStreams: any[] = [];
// 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 videoStream = streams.find((s) => s.codec_type === "video");
const audioStream = streams.find((s) => s.codec_type === "audio");
// --- Track Selection Logic ---
// Find English Audio or default to first audio (index 0 of audio type)
const englishAudio = audioStreams.find((s) => s.tags?.language === "eng");
const audioIndex = englishAudio?.index ?? audioStreams[0]?.index ?? 1;
// Find English Subtitles or default to first subtitles
const englishSub = subStreams.find((s) => s.tags?.language === "eng");
const subIndex = englishSub?.index ?? subStreams[0]?.index ?? null;
// Find 720p video or highest resolution
const target720 = videoStreams.find((s) => s.height === 720);
const bestVideo =
target720 ||
videoStreams.reduce(
(best, current) =>
(current.height || 0) > (best.height || 0) ? current : best,
videoStreams[0]
);
const bestVideoIndex = bestVideo?.index;
const probe_data: T_Probe = {
videoCodec: videoStream?.codec_name || "none",
audioCodec: audioStream?.codec_name || "none",
hasSubs,
duration: metadata.format.duration,
size: metadata.format.size,
format_name: metadata.format.format_name,
audioIndex,
subIndex,
bestVideoIndex
};
return {
probe_data,
metadata
};
}
async function F_probe_v2(arg0: {
playback_headers?: Record<string, any> | null;
playback_url: URL;
playback_type?: string;
context?: Context;
_log?: boolean;
file_mode?: {
path: string;
};
}) {
const { playback_url, context, file_mode } = arg0;
let { playback_headers, _log } = arg0;
if (typeof _log !== "boolean") {
_log = true;
}
if (!playback_headers) {
playback_headers = {};
}
if (!playback_url && !file_mode?.path) {
throw Error("Invalid playback endpoint");
}
if (playback_url && !file_mode?.path) {
const [E_, R_] = await NS_promise.F_catch_promise({
promise: NS_misc.F_test_connection({
url: playback_url,
headers: playback_headers
})
});
if (E_) {
const { status, message } = NS_error.F_collect_error_status_n_message({
ERR: E_
});
if (
["522", "510", "502", "503", "404", "410", "429"].includes(`${status}`)
)
throw E_;
}
}
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;
/* forward browser abort -> our controller */
if (abort_signal) {
if (abort_signal.aborted) {
kill_controller();
} else {
abort_signal.addEventListener(
"abort",
() => {
kill_controller();
},
{
once: true
}
);
}
}
console.log("\n", "PING PROBE NOW", "\n");
const command = [ffprobe_path, "-v", "error"];
command.push("-probesize", "5000000"); // Limit probe size to 5MB (default is 5MB, but explicit)
command.push("-analyzeduration", "5000000"); // Analyze only 5 seconds (default is 5s)
command.push(
"-show_entries",
"format=duration,size,format_name:stream=index,codec_type,codec_name,height,width,bit_rate"
); // Show format and stream information
command.push("-show_entries", "stream_tags=language"); // Get language tags
command.push("-of", "json"); // Output format as JSON
if (is_prod && FFPROBE_V === "latest") {
command.push("-allowed_segment_extensions", "ALL"); // Explicitly allow 'none' and 'jpg' in addition to ALL "ts,m4s,jpg,jpeg,none,ALL"
command.push("-allowed_extensions", "ALL");
command.push("-hls_flags", "+nocheck_content_type"); // This tells the HLS demuxer to stop validating extensions against content
command.push("-extension_picky", "0");
command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
command.push("-strict", "experimental");
}
// Add headers if needed
function add_headers_if_needed() {
// Helper to find a header key ignoring case
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
if (!ua) {
try {
// Generate a realistic desktop Chrome User-Agent
const userAgent =
UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent();
if (!playback_headers) {
playback_headers = {};
}
playback_headers["User-Agent"] = userAgent.toString();
} catch (err: any) {}
}
if (playback_headers && Object.keys(playback_headers).length > 0) {
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
const ref = NS_misc.F_get_obj_kv({
name: "Referer",
obj: playback_headers
});
// Separate specific flags for better reliability (Pass Headers to Sub-demuxers)
// if (ua) command.push("-user_agent", ua);
// if (ref) command.push("-referer", ref);
// Filter out UA/Referer and join the rest for the -headers flag
const other_headers = Object.entries(playback_headers)
.filter(([k]) => !["user-agentx", "refererx"].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
// .map(([k, v]) => `${k}: ${v}\r\n`)
.join("\r\n");
if (other_headers) {
command.push("-headers", other_headers + "\r\n");
}
/*
const header_string = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.push("-headers", header_string);
*/
}
}
if (!file_mode) add_headers_if_needed();
// add the input flag and URL
command.push("-i", file_mode?.path || playback_url.href);
if (_log) console.log("[PROBE CMD]", command, "\n");
const proc = Bun.spawn(command, {
stdout: "pipe",
stderr: "pipe",
signal: controller.signal
});
// 🚀 Use Promise.race to timeout faster
const probePromise = (async () => {
const [txtResponse, stderrResponse, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited
]);
return { txt: txtResponse, stderr: stderrResponse, exitCode };
})();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Probe timeout")), TIMEOUT_MS);
});
let result;
try {
result = await Promise.race([probePromise, timeoutPromise]);
} catch (err) {
clearTimeout(t);
kill_controller();
throw err;
}
clearTimeout(t);
const { txt, stderr, exitCode } = result;
if (exitCode !== 0) {
clearTimeout(t);
throw new Error(
JSON.stringify({
message: `Probe failed with code [${exitCode}]: ${stderr}`,
code: exitCode,
stderr: stderr
})
);
}
const metadata: FfprobeData = JSON.parse(txt);
const streams = metadata.streams;
let hasSubs = false;
const audioStreams: any[] = [];
const videoStreams: any[] = [];
const subStreams: any[] = [];
// 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 videoStream = streams.find((s) => s.codec_type === "video");
const audioStream = streams.find((s) => s.codec_type === "audio");
// --- Track Selection Logic ---
// Find English Audio or default to first audio (index 0 of audio type)
const englishAudio = audioStreams.find((s) => s.tags?.language === "eng");
const audioIndex = englishAudio?.index ?? audioStreams[0]?.index ?? 1;
// Find English Subtitles or default to first subtitles
const englishSub = subStreams.find((s) => s.tags?.language === "eng");
const subIndex = englishSub?.index ?? subStreams[0]?.index ?? null;
// Find 720p video or highest resolution
const target720 = videoStreams.find(
(s) => s.height === 720 || s.width === 1280
);
const bestVideo =
target720 ||
videoStreams.reduce(
(best, current) =>
(current.height || 0) > (best.height || 0) ? current : best,
videoStreams[0]
);
const bestVideoIndex = bestVideo?.index;
const bestVideoBox = bestVideo
? {
height: bestVideo.height,
width: bestVideo.width
}
: undefined;
const probe_data: T_Probe = {
videoCodec: videoStream?.codec_name || "none",
audioCodec: audioStream?.codec_name || "none",
hasSubs,
duration: metadata.format.duration,
size: metadata.format.size,
format_name: metadata.format.format_name,
audioIndex,
subIndex,
bestVideoIndex,
bestVideoBox
};
return {
probe_data,
metadata
};
}
async function F_build_ffmpeg_command_v1(arg0: {
playback_headers?: Record<string, any>;
playback_url: URL;
context?: Context;
is_net_src?: boolean;
has_tcp_progress?: boolean;
subtitles?: Record<string, any>[];
probe_data: T_Probe;
output_format: string;
live: string;
}) {
const {
playback_url,
context,
is_net_src,
has_tcp_progress,
subtitles,
probe_data,
output_format,
live
} = arg0;
let { playback_headers } = arg0;
// -------------------------------
// Logging options
// -------------------------------
const command: string[] = ["-hide_banner", "-loglevel", "info", "-stats"];
// -------------------------------
// Network reconnect options (network resilience)
// -------------------------------
if (is_net_src) {
command.push("-reconnect", "1");
command.push("-reconnect_streamed", "1");
command.push("-reconnect_delay_max", "5");
if (has_tcp_progress) {
command.push("-timeout", "0");
command.push("-rw_timeout", "0");
command.push("-multiple_requests", "1");
} else {
command.push("-rw_timeout", (120 * 1_000_000).toString());
command.push("-timeout", (120 * 1_000_000).toString());
}
}
if (is_prod && FFMPEG_V === "latest") {
command.push("-allowed_segment_extensions", "ALL");
command.push("-allowed_extensions", "ALL");
command.push("-extension_picky", "0");
command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
command.push("-strict", "experimental");
}
// -------------------------------
// Playback headers
// -------------------------------
function add_headers_if_needed() {
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
if (!ua) {
try {
// Generate a realistic desktop Chrome User-Agent
const userAgent =
UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent();
if (!playback_headers) {
playback_headers = {};
}
playback_headers["User-Agent"] = userAgent.toString();
} catch (err: any) {}
}
if (playback_headers && Object.keys(playback_headers).length > 0) {
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
const ref = NS_misc.F_get_obj_kv({
name: "Referer",
obj: playback_headers
});
// if (ua) command.push("-user_agent", ua);
// if (ref) command.push("-referer", ref);
const other_headers = Object.entries(playback_headers)
.filter(([k]) => !["user-agentx", "refererx"].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
// .map(([k, v]) => `${k}: ${v}\r\n`).join("")
.join("\r\n");
if (other_headers) {
command.push("-headers", other_headers + "\r\n");
}
/*
const header_string = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.push("-headers", header_string);
*/
}
}
add_headers_if_needed();
command.push("-http_persistent", "0");
/// -------------------------------
// Input
// -------------------------------
command.push("-i", playback_url.href);
// -------------------------------
// Subtitle Logic
// -------------------------------
let has_native_subs = false;
await handle_subtitles();
async function handle_subtitles() {
if (typeof probe_data.subIndex === "number") {
// SOFT/NONE: Use 'copy' if possible to save CPU
// Note: Browsers usually ignore these in a fragmented MP4 stream
command.push("-map", `0:${probe_data.subIndex}`);
command.push(
"-c:s",
output_format === "ts" ? "dvb_subtitle" : "mov_text"
); // "mov_text" MP4 compatible soft-sub format
has_native_subs = true;
}
if (!(!has_native_subs && subtitles && subtitles.length)) return;
let default_sub_index = subtitles.findIndex((xx) => xx.default);
default_sub_index = default_sub_index < 0 ? 0 : default_sub_index;
let burnt = false;
const limit = 1;
for (var i = 0; i < subtitles.length; i++) {
if (i > limit) {
break;
}
const subtitle = subtitles[i];
const burn = subtitle.burn;
const path = subtitle.file_path;
const url = subtitle.url;
let lang = subtitle.lang; // iso 639_2 prefeered
const title = subtitle.title || subtitle.name;
const is_default = default_sub_index === i;
const subtitle_headers = subtitle.subtitle_headers;
const n_ = i + 1;
if (typeof lang === "string") {
lang = lang.trim().toLowerCase();
if (lang.length < 2) {
lang = undefined;
}
if (lang.length > 3) {
lang = lang.slice(0, 3);
}
} else {
lang = undefined;
}
if (burn && path && (!burnt || is_default)) {
// burn (local file only)
const ext = path.extname(path).toLowerCase();
if ([".vtt", ".srt", ".ass", ".ssa"].includes(ext)) {
// Always normalize separators first
let normalized = path
.normalize(path)
.replace(/\\/g, "/") // back-slashes → forward
.replace(":", "\\:"); // escape the drive colon
command.push("-i", path);
command.push("-sub_charenc", "UTF-8");
command.push("-vf", `subtitles='${normalized}'`);
has_native_subs = true;
burnt = true;
continue;
}
}
if (
!url ||
!NS_misc.isValidUrlFormat(url) ||
typeof lang !== "string" ||
!(lang.length > 0 && lang.length < 4)
) {
continue;
}
if (subtitle_headers && Object.keys(subtitle_headers).length) {
const headers_string = Object.entries(subtitle_headers)
.map(([key, value]) => `${key}: ${value}`)
.join("\r\n");
command.push("-headers", headers_string);
}
command.push("-i", url);
command.push("-map", `${i}:0`);
command.push(
`-c:s:${i}`,
output_format === "ts" ? "dvb_subtitle" : "mov_text"
);
command.push(`-metadata:s:s:${i}`, `language=${lang}`);
if (title) {
command.push(`-metadata:s:s:${i}`, `title=${title}`);
}
if (is_default) {
command.push(`-disposition:s:${i}`, `default`);
}
}
}
// -------------------------------
// Video Audio Logic
// -------------------------------
if (
probe_data.videoCodec === "h264" &&
probe_data.audioCodec === "aac" &&
!has_native_subs &&
(output_format === "mp4" || output_format === "ts") &&
typeof probe_data.audioIndex !== "number"
) {
command.push("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc");
} else {
if (typeof probe_data.bestVideoIndex === "number") {
command.push("-map", `0:${probe_data.bestVideoIndex}?`);
} else {
if (typeof probe_data.audioIndex === "number") {
command.push("-map", "0:v:0?");
}
}
// command.push("-c:v", "libx264");
command.push("-c:v", hw_encoder || "libx264");
}
if (typeof probe_data.audioIndex === "number") {
command.push("-map", `0:${probe_data.audioIndex}?`);
}
command.push("-tune", "zerolatency");
command.push("-crf", "23");
command.push("-preset", live === "stream" ? "ultrafast" : "medium");
command.push("-pix_fmt", "yuv420p");
command.push("-c:a", "aac");
command.push("-b:a", "256k");
command.push("-max_interleave_delta", "0");
// -------------------------------
// Output format
// -------------------------------
command.push("-f", output_format === "ts" ? "mpegts" : output_format);
// -------------------------------
// Flags
// -------------------------------
command.push("-avoid_negative_ts", "make_zero");
command.push("-fflags", "+genpts+nobuffer");
// command.push("-fflags", "+genpts+igndts"); // Generate PTS and ignore invalid DTS
if (live === "stream") {
// command.push("-use_wallclock_as_timestamps", "1"); // Use wallclock for live streams
}
command.push("-fps_mode", "passthrough"); // Don't duplicate/drop frames for timestamp sync
if (is_net_src) {
command.push("-movflags", "frag_keyframe+empty_moov+default_base_moof");
} else {
command.push("-movflags", "+faststart");
}
return command;
}
async function F_build_ffmpeg_command_v2(arg0: {
playback_headers?: Record<string, any>;
playback_url: URL;
context?: Context;
is_net_src?: boolean;
has_tcp_progress?: boolean;
subtitle?: Record<string, any>;
probe_data: T_Probe;
output_format: string;
live: string;
}) {
const {
playback_headers,
playback_url,
context,
is_net_src,
has_tcp_progress,
subtitle,
probe_data,
output_format,
live
} = arg0;
const command = fluent_ffmpeg();
// -------------------------------
// Logging options
// -------------------------------
command
.addOption("-hide_banner")
.addOption("-loglevel", "info")
.addOption("-stats");
// -------------------------------
// Network reconnect options (network resilience)
// -------------------------------
if (is_net_src) {
command.addOption("-reconnect", "1");
command.addOption("-reconnect_streamed", "1");
command.addOption("-reconnect_delay_max", "5");
if (has_tcp_progress) {
command.addOption("-timeout", "0");
command.addOption("-rw_timeout", "0");
command.addOption("-multiple_requests", "1");
} else {
command.addOption("-rw_timeout", (120 * 1_000_000).toString()); // 1 second=1,000,000 microseconds
command.addOption("-timeout", (120 * 1_000_000).toString()); // 1 second=1,000,000 microseconds
}
}
if (is_prod && FFMPEG_V === "latest") {
command.addOption("-allowed_segment_extensions", "ALL");
command.addOption("-allowed_extensions", "ALL");
command.addOption("-extension_picky", "0");
command.addOption("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
command.addOption("-strict", "experimental");
}
// -------------------------------
// Playback headers
// -------------------------------
if (playback_headers && Object.keys(playback_headers).length > 0) {
const header_string = Object.entries(playback_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.addOption("-headers", header_string);
}
command.addOption("-http_persistent", "0");
// -------------------------------
// Input
// -------------------------------
command.input(playback_url.href);
// -------------------------------
// Subtitle Logic
// -------------------------------
let has_native_subs = false;
if (typeof probe_data.subIndex === "number") {
// SOFT/NONE: Use 'copy' if possible to save CPU
// Note: Browsers usually ignore these in a fragmented MP4 stream
command.addOption("-map", `0:${probe_data.subIndex}`);
command.addOption(
"-c:s",
output_format === "ts" ? "dvb_subtitle" : "mov_text"
); // "mov_text" MP4 compatible soft-sub format
has_native_subs = true;
}
await (async () => {
if (!(!has_native_subs && subtitle)) return;
const burn = subtitle.burn;
const path = subtitle.path;
const url = subtitle.url;
const lang = subtitle.lang;
const name = subtitle.name;
const subtitle_headers = subtitle.subtitle_headers;
if (burn && path) {
// burn (local file only)
const ext = path.extname(path).toLowerCase();
if ([".vtt", ".srt", ".ass", ".ssa"].includes(ext)) {
// Always normalize separators first
let normalized = path
.normalize(path)
.replace(/\\/g, "/") // back-slashes → forward
.replace(":", "\\:"); // escape the drive colon
command.addOption("-sub_charenc", "UTF-8");
command.addOption("-i", path);
command.addOption("-vf", `subtitles='${normalized}'`);
has_native_subs = true;
return;
}
}
if (
!url ||
!NS_misc.isValidUrlFormat(url) ||
typeof lang !== "string" ||
!(lang.trim().length > 0 && lang.trim().length < 4)
) {
return;
}
if (subtitle_headers && Object.keys(subtitle_headers).length) {
const headers_string = Object.entries(subtitle_headers)
.map(([key, value]) => `${key}: ${value}`)
.join("\r\n");
command.addOption("-headers", headers_string);
}
command.addOption("-metadata:s:s:0", `language=${lang.trim()}`);
command.addOption(
"-c:s",
output_format === "ts" ? "dvb_subtitle" : "mov_text"
);
command.addOption("-i", url);
})();
// -------------------------------
// Video Audio Logic
// -------------------------------
if (
probe_data.videoCodec === "h264" &&
probe_data.audioCodec === "aac" &&
!has_native_subs &&
(output_format === "mp4" || output_format === "ts") &&
typeof probe_data.audioIndex !== "number"
) {
command.addOption(
"-c:v",
"copy",
"-c:a",
"copy",
"-bsf:a",
"aac_adtstoasc"
);
} else {
if (typeof probe_data.bestVideoIndex === "number") {
command.addOption("-map", `0:${probe_data.bestVideoIndex}`);
} else {
if (typeof probe_data.audioIndex === "number") {
command.addOption("-map", "0:v:0");
}
}
command.addOption("-c:v", "libx264");
}
if (typeof probe_data.audioIndex === "number") {
command.addOption("-map", `0:${probe_data.audioIndex}`);
}
command.addOption("-tune", "zerolatency"); // tells the encoder to minimize the delay between capturing a frame and outputting it
command.addOption("-crf", "23");
command.addOption("-preset", live === "stream" ? "ultrafast" : "medium");
command.addOption("-pix_fmt", "yuv420p");
command.addOption("-c:a", "aac");
command.addOption("-b:a", "256k");
command.addOption("-max_interleave_delta", "0");
// -------------------------------
// Output format
// -------------------------------
command.format(output_format === "ts" ? "mpegts" : output_format);
// -------------------------------
// Flags
// -------------------------------
command.addOption("-avoid_negative_ts", "make_zero");
command.addOption("-fflags", "+genpts+nobuffer");
// command.addOption("-fflags", "+genpts+igndts"); // Generate PTS and ignore invalid DTS
if (live === "stream") {
// command.addOption("-use_wallclock_as_timestamps", "1"); // Use wallclock for live streams
}
command.addOption("-fps_mode", "passthrough"); // Don't duplicate/drop frames for timestamp sync
if (is_net_src) {
command.addOption(
"-movflags",
"frag_keyframe+empty_moov+default_base_moof"
);
} else {
command.addOption("-movflags", "+faststart");
}
return command;
}
async function F_build_ffmpeg_command_v3(arg0: {
playback_headers?: Record<string, any>;
playback_url: URL;
playback_type?: string;
context?: Context;
is_net_src?: boolean;
has_tcp_progress?: boolean;
subtitles?: Record<string, any>[];
probe_data: T_Probe;
output_format: "ts" | "mp4" | "mkv";
live: string;
file_mode?: boolean;
re_encode_audio?: boolean;
}) {
const {
playback_url,
context,
is_net_src,
has_tcp_progress,
subtitles,
probe_data,
output_format,
live,
playback_type,
file_mode,
re_encode_audio
} = arg0;
let { playback_headers } = arg0;
// -------------------------------
// Logging options
// -------------------------------
const command: string[] = ["-hide_banner", "-loglevel", "info", "-stats"];
// -------------------------------
// ADD THREAD LIMIT (Crucial for 0.25 vCPU)
// -------------------------------
// if (!CPU_0V1 || `${CPU_0V1}` !== "1") command.push("-threads", "1");
// -------------------------------
// Network reconnect options (network resilience)
// -------------------------------
const timeoutVal = has_tcp_progress ? "0" : (80 * 1_000_000).toString();
if (is_net_src) {
command.push("-reconnect", "1");
command.push("-reconnect_streamed", "1");
command.push("-reconnect_delay_max", "20");
command.push("-reconnect_on_network_error", "1");
command.push("-reconnect_on_http_error", "429,500,502,503,504");
// command.push("-rw_timeout", timeoutVal, "-timeout", timeoutVal);
// command.push("-rw_timeout", timeoutVal);
command.push("-rw_timeout", timeoutVal, "-timeout", timeoutVal);
if (has_tcp_progress) {
command.push("-multiple_requests", "1");
}
}
if (is_prod && FFMPEG_V === "latest") {
command.push("-allowed_segment_extensions", "ALL");
command.push("-allowed_extensions", "ALL");
command.push("-extension_picky", "0");
command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto");
command.push("-strict", "experimental");
// command.push("-demuxer", "hls");
}
// -------------------------------
// MAIN VIDEO INPUT (Input 0)
// -------------------------------
function add_headers_if_needed() {
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
if (!ua) {
try {
const userAgent =
UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent();
if (!playback_headers) {
playback_headers = {};
}
playback_headers["User-Agent"] = userAgent.toString();
} catch (err: any) {}
}
if (playback_headers && Object.keys(playback_headers).length > 0) {
const ua = NS_misc.F_get_obj_kv({
name: "User-Agent",
obj: playback_headers
});
const ref = NS_misc.F_get_obj_kv({
name: "Referer",
obj: playback_headers
});
if (ua) command.push("-user_agent", ua);
if (ref) command.push("-referer", ref);
const other_headers = Object.entries(playback_headers)
.filter(([k]) => !["user-agent", "referer"].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
if (other_headers) {
command.push("-headers", other_headers + "\r\n");
}
}
}
add_headers_if_needed();
// command.push("-http_multiple", "0");
// command.push("-analyzeduration", "10M");
// command.push("-probesize", "10M");
// command.push("-seg_max_retry", "5");
// command.push("-max_reload", "100");
command.push("-fflags", "+discardcorrupt+genpts+nobuffer");
// command.push("-err_detect", "ignore_err");
if (
playback_type === "hls" ||
(typeof playback_type === "string" && playback_type.includes("hls")) ||
(typeof probe_data.format_name === "string" &&
probe_data.format_name.includes("hls"))
) {
command.push("-http_persistent", "1");
}
command.push("-i", playback_url.href);
// -------------------------------
// SUBTITLE INPUTS (Input 1...N)
// -------------------------------
const all_subs = Array.isArray(subtitles) ? subtitles : [];
const valid_external_subs = (() => {
const limit = 4;
let soft_subs = all_subs.filter((s) => s.url && !s.burn);
return get_filtered_subs({ limit, subs: soft_subs });
})();
const valid_bun_subs = (() => {
const limit = 1;
let burn_subs = all_subs.filter((s) => s.path && s.burn);
return get_filtered_subs({ limit, subs: burn_subs });
})();
function get_filtered_subs(arg0: { limit: number; subs: any[] }) {
const { limit, subs: originalSubs } = arg0;
let subs = [...originalSubs];
if (!limit || !(subs.length > limit)) return subs;
const defaultIdx = subs.findIndex((s) => {
const hasValidUrl =
typeof s?.url === "string" && NS_misc.isValidUrlFormat(s.url);
return hasValidUrl && s.default;
});
let defaultSub = null;
if (defaultIdx > -1) {
[defaultSub] = subs.splice(defaultIdx, 1);
}
const englishRegex = /\b(en|eng|english)\b/i;
const englishSubs = [];
const otherSubs = [];
for (const s of subs) {
const matchesEnglish = [s.lang, s.title, s.name].some(
(field) => typeof field === "string" && englishRegex.test(field)
);
const hasValidUrl =
typeof s?.url === "string" && NS_misc.isValidUrlFormat(s.url);
if (!hasValidUrl) continue;
if (matchesEnglish) {
englishSubs.push(s);
} else {
otherSubs.push(s);
}
}
const result = [];
if (defaultSub) result.push(defaultSub);
let combined = [...result, ...englishSubs, ...otherSubs];
if (limit) {
combined = combined.slice(0, limit);
}
return combined;
}
for (const sub of valid_external_subs) {
const subtitle_headers = sub.subtitle_headers;
const url = sub.url;
if (subtitle_headers && Object.keys(subtitle_headers).length) {
const h_str = Object.entries(subtitle_headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
command.push("-headers", h_str);
}
command.push("-i", url);
}
// ---------------------------------------------------------
// MAPPING SECTION (The "Output" configuration starts here)
// ---------------------------------------------------------
const is_burning =
valid_bun_subs &&
Array.isArray(valid_bun_subs) &&
valid_bun_subs.length > 0;
const must_re_encode =
valid_bun_subs?.length ||
probe_data.videoCodec !== "h264" ||
probe_data.audioCodec !== "aac" ||
typeof probe_data.subIndex === "number" ||
(output_format !== "mp4" && output_format !== "ts");
/* VIDEO MAPPING */
if (typeof probe_data.bestVideoIndex === "number") {
command.push("-map", `0:${probe_data.bestVideoIndex}?`);
} else {
command.push("-map", "0:v:0?");
}
if (must_re_encode) {
command.push("-c:v", hw_encoder || "libx264");
if (is_burning && valid_bun_subs[0].path) {
const safePath = valid_bun_subs[0].path
.replace(/\\/g, "/")
.replace(/:/g, "\\:");
command.push("-vf", `subtitles='${safePath}'`);
}
} else {
command.push("-c:v", "copy");
}
/* AUDIO MAPPING */
if (typeof probe_data.audioIndex === "number") {
command.push("-map", `0:${probe_data.audioIndex}?`);
} else {
command.push("-map", "0:a:0");
}
if (must_re_encode || re_encode_audio) {
command.push("-c:a", "aac", "-b:a", "256k");
// command.push("-async", "1");
command.push("-af", "loudnorm=I=-14:TP=-1.5:LRA=11,aresample=async=1");
} else {
command.push("-c:a", "copy", "-bsf:a", "aac_adtstoasc");
}
/* SOFT SUBTITLE MAPPING */
let out_sub_idx = 0;
let has_assigned_default = false;
const sub_claims_default =
valid_external_subs.some((s) => s.default) ||
typeof probe_data.subIndex === "number";
// Native Subs (from Input 0)
if (typeof probe_data.subIndex === "number") {
command.push("-map", `0:${probe_data.subIndex}`);
command.push(
`-c:s:${out_sub_idx}`,
output_format === "ts" ? "dvb_subtitle" : "mov_text"
);
command.push(`-disposition:s:${out_sub_idx}`, "default");
has_assigned_default = true;
out_sub_idx++;
}
// External Subs (starting from Input 1)
for (let i = 0; i < valid_external_subs.length; i++) {
const sub = valid_external_subs[i];
const input_idx = i + 1; // Because video is 0
const title = sub.title || sub.name;
const is_default = sub.default;
let lang = (sub.lang || "und").trim();
lang = lang.slice(0, 3).toLowerCase();
const should_be_default =
!has_assigned_default && (is_default || (!sub_claims_default && i === 0));
command.push("-map", `${input_idx}:0`);
command.push(
`-c:s:${out_sub_idx}`,
output_format === "ts" ? "dvb_subtitle" : "mov_text"
);
// Metadata
command.push(`-metadata:s:s:${out_sub_idx}`, `language=${lang}`);
if (title) {
command.push(`-metadata:s:s:${out_sub_idx}`, `title=${title}`);
}
// Default Toggle
if (should_be_default) {
command.push(`-disposition:s:${out_sub_idx}`, "default");
has_assigned_default = true;
} else {
command.push(`-disposition:s:${out_sub_idx}`, "0");
}
out_sub_idx++;
}
const use_ultrafast = live === "stream" || valid_bun_subs.length;
/* ENCODING & TUNING */
if (live === "stream") {
command.push("-tune", "zerolatency");
}
// if (must_re_encode) {
if(hw_encoder.includes("h264_qsv")) {
command.push("-preset", use_ultrafast ? "7" : "7"); // QSV uses numbers: 7 is fastest speed (ultrafast), 4 is balanced (medium)
} else {
command.push("-crf", "23");
command.push("-preset", use_ultrafast ? "ultrafast" : "ultrafast"); // ultrafast veryfast faster medium
}
// }
if (must_re_encode) command.push("-pix_fmt", "yuv420p");
command.push("-max_interleave_delta", "0");
/* OUTPUT FORMAT & FLAGS */
if (live === "stream") {
command.push("-f", "ismv");
} else {
command.push("-f", output_format === "ts" ? "mpegts" : output_format);
}
command.push("-avoid_negative_ts", "make_zero");
if (live === "stream") {
// command.push("-use_wallclock_as_timestamps", "1");
}
if (file_mode) {
// command.push("-fps_mode", "cfr"); // passthrough cfr
command.push("-max_muxing_queue_size", "9999");
} else {
command.push("-fps_mode", "passthrough");
}
if (is_net_src && !file_mode) {
// command.push("-movflags", "frag_keyframe+empty_moov+default_base_moof");
command.push("-movflags", "frag_keyframe+dash+delay_moov");
} else {
command.push("-movflags", "+faststart");
}
return command;
}
function F_stderr_data_read(arg0: { output: string }) {
const { output } = arg0;
let duration = 0;
let current_time = 0;
let current_size_bytes = 0;
let progress = 0;
// Extract duration with flexible whitespace
const durationMatch = output.match(
/Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d{2})/
);
if (durationMatch) {
const [, hours, minutes, seconds] = durationMatch;
duration =
parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds);
}
// Extract current time with flexible whitespace
const timeMatch = output.match(/time\s*=\s*(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
if (timeMatch) {
const [, hours, minutes, seconds] = timeMatch;
current_time =
parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds);
}
// Extract size with flexible whitespace (captures both 'size' and 'Lsize')
const sizeMatch = output.match(
/[Ll]?size\s*=\s*(\d+(?:\.\d+)?)\s*([kmgtKMGT]?[bB]?)/
);
if (sizeMatch) {
const [, sizeValue, unit] = sizeMatch;
let sizeInBytes = parseFloat(sizeValue);
// Convert to bytes based on unit
const unitLower = unit.toLowerCase();
switch (unitLower) {
case "kb":
sizeInBytes *= 1024;
break;
case "mb":
sizeInBytes *= 1024 * 1024;
break;
case "gb":
sizeInBytes *= 1024 * 1024 * 1024;
break;
case "tb":
sizeInBytes *= 1024 * 1024 * 1024 * 1024;
break;
// 'b' or no unit means bytes already
}
current_size_bytes = Math.round(sizeInBytes);
}
// Calculate and log progress if we have the necessary data
if (duration && current_time) {
progress = Math.min(
parseFloat(((current_time / duration) * 100).toFixed(2)),
100
);
let progress_info = `${progress}%`;
if (current_size_bytes !== undefined) {
// Format size for display
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
};
// progress_info += `, size = ${formatSize(current_size_bytes)}`;
}
}
return {
video_duration: duration,
current_out_time: current_time,
current_size_bytes
};
}
function F_format_duration(seconds?: number): string {
if (typeof seconds !== "number") {
return "";
}
if (isNaN(seconds) || seconds < 0) return "0:00";
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
let data = "";
if (hrs > 0) {
data = `${hrs}:${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
} else {
data = `${mins}:${secs.toString().padStart(2, "0")}`;
}
return data;
}
function F_format_size(bytes?: number) {
if (typeof bytes !== "number") {
return;
}
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export const NS_ffmpeg = {
fluent_ffmpeg,
ffmpeg_path,
ffprobe_path,
TIMEOUT_MS,
PROBE_TIMEOUT_MS,
F_probe_v1,
F_probe_v2,
F_build_ffmpeg_command_v1,
F_build_ffmpeg_command_v2,
F_build_ffmpeg_command_v3,
F_format_duration,
F_format_size,
F_stderr_data_read,
get_hardware_encoder
};