mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
* fix(parsers): validate ffmpeg discovery candidates fixes reported:1785304892.118879:unicode-home-ffmpeg-discovery; PR #2859 remains unmodified. * fix(parsers): avoid Windows console path decoding
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { accessSync, constants, existsSync } from "node:fs";
|
|
import { delimiter, join, resolve } from "node:path";
|
|
|
|
/**
|
|
* Shared FFmpeg/FFprobe binary resolution for every package that shells out
|
|
* to them (engine, cli, lint, studio-server). Node-only: import via the
|
|
* `@hyperframes/parsers/ff-binaries` subpath, never from a browser bundle.
|
|
*/
|
|
|
|
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
|
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
|
|
|
export type FfBinaryName = "ffmpeg" | "ffprobe";
|
|
|
|
const ENV_BY_NAME: Record<FfBinaryName, string> = {
|
|
ffmpeg: FFMPEG_PATH_ENV,
|
|
ffprobe: FFPROBE_PATH_ENV,
|
|
};
|
|
|
|
const pathLookupCache = new Map<FfBinaryName, string | undefined>();
|
|
|
|
function candidateFileName(candidate: string): string {
|
|
return candidate.split(/[\\/]/).at(-1)?.toLowerCase() ?? candidate.toLowerCase();
|
|
}
|
|
|
|
function chooseBestPathCandidate(
|
|
name: FfBinaryName,
|
|
candidates: readonly string[],
|
|
): string | undefined {
|
|
const normalized = candidates.map((candidate) => candidate.trim()).filter(Boolean);
|
|
return (
|
|
normalized.find((candidate) => candidateFileName(candidate) === `${name}.exe`) ??
|
|
normalized.find((candidate) => candidateFileName(candidate) === name) ??
|
|
normalized.find((candidate) => !candidateFileName(candidate).match(/\.(cmd|bat)$/i)) ??
|
|
normalized[0]
|
|
);
|
|
}
|
|
|
|
function isExecutablePathCandidate(candidate: string): boolean {
|
|
if (process.platform === "win32") return existsSync(candidate);
|
|
try {
|
|
accessSync(candidate, constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function scanPath(name: FfBinaryName): string | undefined {
|
|
const pathValue = process.env.PATH;
|
|
const searchDirs = [
|
|
...(process.platform === "win32" ? [process.cwd()] : []),
|
|
...(pathValue ? pathValue.split(delimiter) : []),
|
|
].filter(Boolean);
|
|
if (searchDirs.length === 0) return undefined;
|
|
|
|
const extensions =
|
|
process.platform === "win32"
|
|
? [
|
|
".exe",
|
|
...new Set(
|
|
(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
.split(";")
|
|
.map((ext) => ext.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
),
|
|
"",
|
|
]
|
|
: [""];
|
|
const candidates: string[] = [];
|
|
for (const dir of new Set(searchDirs)) {
|
|
for (const ext of extensions) {
|
|
const candidate = join(dir, `${name}${ext}`);
|
|
if (isExecutablePathCandidate(candidate)) candidates.push(candidate);
|
|
}
|
|
}
|
|
return chooseBestPathCandidate(name, candidates);
|
|
}
|
|
|
|
// GUI/Dock/launchd-spawned processes on macOS don't inherit the shell PATH, so
|
|
// `which ffmpeg` fails even when ffmpeg is installed via Homebrew. Probe the
|
|
// well-known install dirs as a last resort. (No-op on Windows, where `where`
|
|
// and installer-added PATH entries cover it.)
|
|
const COMMON_BIN_DIRS =
|
|
process.platform === "win32"
|
|
? []
|
|
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
|
|
|
|
function findInCommonDirs(name: FfBinaryName): string | undefined {
|
|
for (const dir of COMMON_BIN_DIRS) {
|
|
const candidate = `${dir}/${name}`;
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findInProjectLocalBin(name: FfBinaryName): string | undefined {
|
|
const extension = process.platform === "win32" ? ".exe" : "";
|
|
const candidate = resolve(".hyperframes", "bin", `${name}${extension}`);
|
|
return existsSync(candidate) ? candidate : undefined;
|
|
}
|
|
|
|
function lookupOnSystem(name: FfBinaryName): string | undefined {
|
|
if (pathLookupCache.has(name)) return pathLookupCache.get(name);
|
|
let found: string | undefined;
|
|
if (process.platform === "win32") {
|
|
// `where.exe` writes bytes in the active console code page, while Node
|
|
// decodes its stdout using a caller-selected encoding. Enumerating the
|
|
// same current-directory + PATH search space from Node keeps Unicode
|
|
// paths as native JS strings and avoids mojibake entirely.
|
|
found = scanPath(name);
|
|
} else {
|
|
try {
|
|
const output = execFileSync("which", [name], {
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
timeout: 5000,
|
|
});
|
|
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
|
|
found = candidate && isExecutablePathCandidate(candidate) ? candidate : scanPath(name);
|
|
} catch {
|
|
found = scanPath(name);
|
|
}
|
|
}
|
|
found ??= findInProjectLocalBin(name);
|
|
found ??= findInCommonDirs(name);
|
|
const resolved = found ? resolve(found) : undefined;
|
|
pathLookupCache.set(name, resolved);
|
|
return resolved;
|
|
}
|
|
|
|
export interface FindFfBinaryOptions {
|
|
/**
|
|
* How to treat an env override that points at a missing file: `true`
|
|
* reports the binary as not found (callers that surface an install hint or
|
|
* skip probing), `false`/unset returns the configured path as-is (callers
|
|
* that validate the override separately and want spawn errors to name the
|
|
* path the user configured).
|
|
*/
|
|
configuredMustExist?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve an FFmpeg-family binary: env override first, then a native
|
|
* current-directory/PATH scan on Windows or `which` plus PATH scan on Unix,
|
|
* then a project-local
|
|
* `.hyperframes/bin`, then well-known Unix install dirs. System lookups are
|
|
* cached per binary for the process lifetime; the env override is re-read on
|
|
* every call.
|
|
*/
|
|
export function findFfBinary(
|
|
name: FfBinaryName,
|
|
options: FindFfBinaryOptions = {},
|
|
): string | undefined {
|
|
const configured = process.env[ENV_BY_NAME[name]]?.trim();
|
|
if (configured) {
|
|
if (options.configuredMustExist && !existsSync(configured)) return undefined;
|
|
return resolve(configured);
|
|
}
|
|
return lookupOnSystem(name);
|
|
}
|
|
|
|
/** Test hook: drop cached system lookups so resolution can be re-exercised. */
|
|
export function clearFfBinaryLookupCache(): void {
|
|
pathLookupCache.clear();
|
|
}
|