mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): verify browser/ffmpeg binaries exist before render starts (#1365)
## Problem Windows renders commonly fail with environment errors before any real work starts: - `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists. - `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling. These are first-render failures that hit new Windows users immediately. ## Fix - Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path. - New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints. - Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH. - Map opaque Windows ffmpeg exit codes to actionable messages. ## Testing - New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary. - CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean. - Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
/**
|
/**
|
||||||
* Background-removal rendering pipeline.
|
* Background-removal rendering pipeline.
|
||||||
*
|
*
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { hasFFmpeg, hasFFprobe } from "../whisper/manager.js";
|
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||||
import { createSession, type Session } from "./inference.js";
|
import { createSession, type Session } from "./inference.js";
|
||||||
import { type Device, type ModelId } from "./manager.js";
|
import { type Device, type ModelId } from "./manager.js";
|
||||||
|
|
||||||
@@ -263,8 +264,9 @@ export function resolveRenderTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function render(options: RenderOptions): Promise<RenderResult> {
|
export async function render(options: RenderOptions): Promise<RenderResult> {
|
||||||
if (!hasFFmpeg() || !hasFFprobe()) {
|
const ffmpegPath = findFFmpeg();
|
||||||
throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg");
|
if (!ffmpegPath || !findFFprobe()) {
|
||||||
|
throw new Error(`ffmpeg and ffprobe are required. Install: ${getFFmpegInstallHint()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { format, bgFormat } = resolveRenderTargets(
|
const { format, bgFormat } = resolveRenderTargets(
|
||||||
@@ -291,7 +293,14 @@ export async function render(options: RenderOptions): Promise<RenderResult> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const framesProcessed = await runPipeline(options, session, media, format, bgFormat);
|
const framesProcessed = await runPipeline(
|
||||||
|
options,
|
||||||
|
session,
|
||||||
|
media,
|
||||||
|
format,
|
||||||
|
bgFormat,
|
||||||
|
ffmpegPath,
|
||||||
|
);
|
||||||
const durationSeconds = (Date.now() - start) / 1000;
|
const durationSeconds = (Date.now() - start) / 1000;
|
||||||
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
|
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
|
||||||
|
|
||||||
@@ -321,8 +330,13 @@ interface FfmpegProc {
|
|||||||
type StdioFd = "ignore" | "pipe";
|
type StdioFd = "ignore" | "pipe";
|
||||||
type StdioTuple = [StdioFd, StdioFd, StdioFd];
|
type StdioTuple = [StdioFd, StdioFd, StdioFd];
|
||||||
|
|
||||||
function spawnFfmpeg(args: string[], label: string, stdio: StdioTuple): FfmpegProc {
|
function spawnFfmpeg(
|
||||||
const proc = spawn("ffmpeg", args, { stdio });
|
ffmpegPath: string,
|
||||||
|
args: string[],
|
||||||
|
label: string,
|
||||||
|
stdio: StdioTuple,
|
||||||
|
): FfmpegProc {
|
||||||
|
const proc = spawn(ffmpegPath, args, { stdio });
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
proc.stderr?.on("data", (d: Buffer) => {
|
proc.stderr?.on("data", (d: Buffer) => {
|
||||||
stderrBuf += d.toString();
|
stderrBuf += d.toString();
|
||||||
@@ -343,6 +357,7 @@ async function runPipeline(
|
|||||||
media: MediaInfo,
|
media: MediaInfo,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
bgFormat: OutputFormat | undefined,
|
bgFormat: OutputFormat | undefined,
|
||||||
|
ffmpegPath: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { inputPath, outputPath, backgroundOutputPath } = options;
|
const { inputPath, outputPath, backgroundOutputPath } = options;
|
||||||
const { width, height, fps, frameCount } = media;
|
const { width, height, fps, frameCount } = media;
|
||||||
@@ -350,12 +365,14 @@ async function runPipeline(
|
|||||||
const quality = options.quality ?? DEFAULT_QUALITY;
|
const quality = options.quality ?? DEFAULT_QUALITY;
|
||||||
|
|
||||||
const decoder = spawnFfmpeg(
|
const decoder = spawnFfmpeg(
|
||||||
|
ffmpegPath,
|
||||||
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
|
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
|
||||||
"ffmpeg decoder",
|
"ffmpeg decoder",
|
||||||
["ignore", "pipe", "pipe"],
|
["ignore", "pipe", "pipe"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fg = spawnFfmpeg(
|
const fg = spawnFfmpeg(
|
||||||
|
ffmpegPath,
|
||||||
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
|
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
|
||||||
"ffmpeg encoder",
|
"ffmpeg encoder",
|
||||||
["pipe", "ignore", "pipe"],
|
["pipe", "ignore", "pipe"],
|
||||||
@@ -364,6 +381,7 @@ async function runPipeline(
|
|||||||
const bg =
|
const bg =
|
||||||
backgroundOutputPath && bgFormat
|
backgroundOutputPath && bgFormat
|
||||||
? spawnFfmpeg(
|
? spawnFfmpeg(
|
||||||
|
ffmpegPath,
|
||||||
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
|
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
|
||||||
"ffmpeg background encoder",
|
"ffmpeg background encoder",
|
||||||
["pipe", "ignore", "pipe"],
|
["pipe", "ignore", "pipe"],
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
export function findFFmpeg(): string | undefined {
|
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
||||||
|
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||||
|
|
||||||
|
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
||||||
try {
|
try {
|
||||||
const cmd = process.platform === "win32" ? "where ffmpeg" : "which ffmpeg";
|
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
|
||||||
const output = execSync(cmd, {
|
const output = execSync(cmd, {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
@@ -12,18 +18,37 @@ export function findFFmpeg(): string | undefined {
|
|||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.find(Boolean);
|
.find(Boolean);
|
||||||
return first || undefined;
|
return first ? resolve(first) : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findConfiguredBinary(
|
||||||
|
envName: string,
|
||||||
|
binaryName: "ffmpeg" | "ffprobe",
|
||||||
|
): string | undefined {
|
||||||
|
const configured = process.env[envName]?.trim();
|
||||||
|
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
|
||||||
|
return findOnPath(binaryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFFmpeg(): string | undefined {
|
||||||
|
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFFprobe(): string | undefined {
|
||||||
|
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
|
||||||
|
}
|
||||||
|
|
||||||
export function getFFmpegInstallHint(): string {
|
export function getFFmpegInstallHint(): string {
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return "brew install ffmpeg";
|
return "brew install ffmpeg";
|
||||||
case "linux":
|
case "linux":
|
||||||
return "sudo apt install ffmpeg";
|
return "sudo apt install ffmpeg";
|
||||||
|
case "win32":
|
||||||
|
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
|
||||||
default:
|
default:
|
||||||
return "https://ffmpeg.org/download.html";
|
return "https://ffmpeg.org/download.html";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
/**
|
/**
|
||||||
* Browser-binary resolution tests for `findBrowser()`.
|
* Browser-binary resolution tests for `findBrowser()`.
|
||||||
*
|
*
|
||||||
@@ -72,18 +73,20 @@ function installFsMocks({ existing, dirs }: FsMockOptions) {
|
|||||||
function installPuppeteerBrowsersMock(
|
function installPuppeteerBrowsersMock(
|
||||||
opts: {
|
opts: {
|
||||||
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
|
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
|
||||||
|
installResult?: { executablePath: string };
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
vi.doMock("@puppeteer/browsers", () => ({
|
vi.doMock("@puppeteer/browsers", () => ({
|
||||||
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
|
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
|
||||||
detectBrowserPlatform: () => "linux",
|
detectBrowserPlatform: () => "linux",
|
||||||
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
|
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
|
||||||
install: vi.fn(),
|
install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("findBrowser — cache resolution", () => {
|
describe("findBrowser — cache resolution", () => {
|
||||||
const origPlatform = process.platform;
|
const origPlatform = process.platform;
|
||||||
|
const origArch = process.arch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -91,11 +94,13 @@ describe("findBrowser — cache resolution", () => {
|
|||||||
// `Object.defineProperty` dance is needed because `process.platform` is a
|
// `Object.defineProperty` dance is needed because `process.platform` is a
|
||||||
// getter on Node — direct assignment is silently a no-op.
|
// getter on Node — direct assignment is silently a no-op.
|
||||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||||
|
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
|
||||||
delete process.env["HYPERFRAMES_BROWSER_PATH"];
|
delete process.env["HYPERFRAMES_BROWSER_PATH"];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
||||||
|
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.doUnmock("node:fs");
|
vi.doUnmock("node:fs");
|
||||||
vi.doUnmock("node:os");
|
vi.doUnmock("node:os");
|
||||||
@@ -117,6 +122,28 @@ describe("findBrowser — cache resolution", () => {
|
|||||||
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
|
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
|
||||||
|
const redownloadedBinary = join(
|
||||||
|
HF_CACHE,
|
||||||
|
"chrome-headless-shell",
|
||||||
|
"linux-131.0.6778.85",
|
||||||
|
"chrome-headless-shell-linux64",
|
||||||
|
"redownloaded-chrome-headless-shell",
|
||||||
|
);
|
||||||
|
installFsMocks({ existing: new Set([HF_CACHE]) });
|
||||||
|
installPuppeteerBrowsersMock({
|
||||||
|
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
|
||||||
|
installResult: { executablePath: redownloadedBinary },
|
||||||
|
});
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { findBrowser } = await import("./manager.js");
|
||||||
|
const result = await findBrowser();
|
||||||
|
|
||||||
|
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
|
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
|
||||||
// Empty hyperframes cache, populated puppeteer cache — the regression
|
// Empty hyperframes cache, populated puppeteer cache — the regression
|
||||||
// scenario from the hf#677 spike.
|
// scenario from the hf#677 spike.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
import { execSync, spawnSync } from "node:child_process";
|
import { execSync, spawnSync } from "node:child_process";
|
||||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
@@ -37,6 +38,11 @@ export interface EnsureBrowserOptions {
|
|||||||
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
|
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheLookupResult {
|
||||||
|
result?: BrowserResult;
|
||||||
|
staleHyperframesCachePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Internal helpers -------------------------------------------------------
|
// --- Internal helpers -------------------------------------------------------
|
||||||
|
|
||||||
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
|
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
|
||||||
@@ -75,7 +81,7 @@ function findFromEnv(): BrowserResult | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFromCache(): Promise<BrowserResult | undefined> {
|
async function findFromCache(): Promise<CacheLookupResult> {
|
||||||
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
|
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
|
||||||
// chrome-headless-shell` lands, and where `puppeteer install` from a project
|
// chrome-headless-shell` lands, and where `puppeteer install` from a project
|
||||||
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
|
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
|
||||||
@@ -90,7 +96,7 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
|
|||||||
// newer binary, not the pinned-stale fallback.
|
// newer binary, not the pinned-stale fallback.
|
||||||
const fromPuppeteer = findFromPuppeteerCache();
|
const fromPuppeteer = findFromPuppeteerCache();
|
||||||
if (fromPuppeteer) {
|
if (fromPuppeteer) {
|
||||||
return fromPuppeteer;
|
return { result: fromPuppeteer };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
|
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
|
||||||
@@ -100,12 +106,15 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
|
|||||||
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
|
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
|
||||||
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
||||||
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
|
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
|
||||||
|
if (match && existsSync(match.executablePath)) {
|
||||||
|
return { result: { executablePath: match.executablePath, source: "cache" } };
|
||||||
|
}
|
||||||
if (match) {
|
if (match) {
|
||||||
return { executablePath: match.executablePath, source: "cache" };
|
return { staleHyperframesCachePath: match.executablePath };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,7 +260,21 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
|
|||||||
if (fromEnv) return fromEnv;
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
const fromCache = await findFromCache();
|
const fromCache = await findFromCache();
|
||||||
if (fromCache) return fromCache;
|
if (fromCache.result) return fromCache.result;
|
||||||
|
if (fromCache.staleHyperframesCachePath) {
|
||||||
|
console.warn(
|
||||||
|
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await downloadBrowser();
|
||||||
|
} catch (err) {
|
||||||
|
const cause = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
|
||||||
|
`Run \`hyperframes browser ensure --force\` to re-download.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fromSystem = findFromSystem();
|
const fromSystem = findFromSystem();
|
||||||
if (fromSystem) {
|
if (fromSystem) {
|
||||||
@@ -314,8 +337,31 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
|
|||||||
* Resolution: env var -> cached download -> system Chrome -> auto-download.
|
* Resolution: env var -> cached download -> system Chrome -> auto-download.
|
||||||
*/
|
*/
|
||||||
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
|
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
|
||||||
const existing = await findBrowser();
|
const fromEnv = findFromEnv();
|
||||||
if (existing) return existing;
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
const fromCache = await findFromCache();
|
||||||
|
if (fromCache.result) return fromCache.result;
|
||||||
|
if (fromCache.staleHyperframesCachePath) {
|
||||||
|
console.warn(
|
||||||
|
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
|
||||||
|
);
|
||||||
|
return downloadBrowser(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromSystem = findFromSystem();
|
||||||
|
if (fromSystem) {
|
||||||
|
warnSystemFallbackOnce(fromSystem.executablePath);
|
||||||
|
return fromSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadBrowser(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
|
||||||
|
if (isLinuxArm()) {
|
||||||
|
return ensureLinuxArmBrowser(options);
|
||||||
|
}
|
||||||
|
|
||||||
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
|
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
|
||||||
|
|
||||||
@@ -324,12 +370,6 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
|
|||||||
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
|
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chrome headless shell has no Linux ARM64 build (e.g. DGX Spark, GB10).
|
|
||||||
// Try to auto-install system Chromium via apt, then find it.
|
|
||||||
if (isLinuxArm()) {
|
|
||||||
return ensureLinuxArmBrowser(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const installed = await install({
|
const installed = await install({
|
||||||
cacheDir: CACHE_DIR,
|
cacheDir: CACHE_DIR,
|
||||||
browser: Browser.CHROMEHEADLESSSHELL,
|
browser: Browser.CHROMEHEADLESSSHELL,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { parseToolVersion, runEnvironmentChecks } from "./preflight.js";
|
||||||
|
|
||||||
|
describe("runEnvironmentChecks", () => {
|
||||||
|
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||||
|
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns configured FFmpeg and FFprobe paths when checks pass", async () => {
|
||||||
|
const result = await runEnvironmentChecks();
|
||||||
|
|
||||||
|
expect(result.outcomes.find((outcome) => outcome.name === "FFmpeg")?.ok).toBe(true);
|
||||||
|
expect(result.outcomes.find((outcome) => outcome.name === "FFprobe")?.ok).toBe(true);
|
||||||
|
expect(result.ffmpegPath).toBe(process.execPath);
|
||||||
|
expect(result.ffprobePath).toBe(process.execPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ffprobe as a render-blocking error when the explicit path is missing", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = "/missing/ffprobe.exe";
|
||||||
|
|
||||||
|
const result = await runEnvironmentChecks();
|
||||||
|
|
||||||
|
expect(result.outcomes).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "FFprobe",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "FFprobe not found",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.ffprobePath).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails early when an explicit FFmpeg env override points at a missing file", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
|
||||||
|
|
||||||
|
const result = await runEnvironmentChecks();
|
||||||
|
const ffmpeg = result.outcomes.find((outcome) => outcome.name === "FFmpeg");
|
||||||
|
|
||||||
|
expect(ffmpeg).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
detail: 'Configured path does not exist: HYPERFRAMES_FFMPEG_PATH="/missing/ffmpeg.exe"',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates an explicit browser path without needing browser discovery", async () => {
|
||||||
|
const result = await runEnvironmentChecks({
|
||||||
|
includeBrowser: true,
|
||||||
|
browserPath: process.execPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
path: process.execPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an explicit missing browser path before render starts", async () => {
|
||||||
|
const result = await runEnvironmentChecks({
|
||||||
|
includeBrowser: true,
|
||||||
|
browserPath: "/missing/chrome-headless-shell.exe",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
title: "Chrome not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseToolVersion", () => {
|
||||||
|
it("extracts ffprobe versions with Windows build suffixes", () => {
|
||||||
|
expect(parseToolVersion("ffprobe version 7.1.1-essentials_build-www.gyan.dev Copyright")).toBe(
|
||||||
|
"ffprobe 7.1.1-essentials_build-www.gyan.dev",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { platform } from "node:os";
|
||||||
|
import { findBrowser, type BrowserResult } from "./manager.js";
|
||||||
|
import {
|
||||||
|
FFMPEG_PATH_ENV,
|
||||||
|
FFPROBE_PATH_ENV,
|
||||||
|
findFFmpeg,
|
||||||
|
findFFprobe,
|
||||||
|
getFFmpegInstallHint,
|
||||||
|
} from "./ffmpeg.js";
|
||||||
|
import { getFreeDiskMb } from "../telemetry/system.js";
|
||||||
|
|
||||||
|
export type EnvironmentCheckLevel = "ok" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface EnvironmentCheckOutcome {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
level: EnvironmentCheckLevel;
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentCheckResult {
|
||||||
|
outcomes: EnvironmentCheckOutcome[];
|
||||||
|
ffmpegPath?: string;
|
||||||
|
ffprobePath?: string;
|
||||||
|
browser?: BrowserResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentCheckOptions {
|
||||||
|
projectDir?: string;
|
||||||
|
browserPath?: string;
|
||||||
|
includeBrowser?: boolean;
|
||||||
|
includeDisk?: boolean;
|
||||||
|
includeWindowsUnc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolVersion(raw: string): string {
|
||||||
|
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
|
||||||
|
return m ? `${m[1]} ${m[2]}` : raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredMissingDetail(envName: string): string | undefined {
|
||||||
|
const configured = process.env[envName]?.trim();
|
||||||
|
if (!configured || existsSync(configured)) return undefined;
|
||||||
|
return `Configured path does not exist: ${envName}="${configured}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolVersion(binaryPath: string): string {
|
||||||
|
try {
|
||||||
|
const raw =
|
||||||
|
execFileSync(binaryPath, ["-version"], { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ??
|
||||||
|
"";
|
||||||
|
const version = parseToolVersion(raw);
|
||||||
|
return version ? `${version} at ${binaryPath}` : binaryPath;
|
||||||
|
} catch {
|
||||||
|
return binaryPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFFmpeg(): EnvironmentCheckOutcome {
|
||||||
|
const missingConfigured = configuredMissingDetail(FFMPEG_PATH_ENV);
|
||||||
|
if (missingConfigured) {
|
||||||
|
return {
|
||||||
|
name: "FFmpeg",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "FFmpeg not found",
|
||||||
|
detail: missingConfigured,
|
||||||
|
hint: getFFmpegInstallHint(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = findFFmpeg();
|
||||||
|
if (path) {
|
||||||
|
return { name: "FFmpeg", ok: true, level: "ok", detail: readToolVersion(path), path };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "FFmpeg",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "FFmpeg not found",
|
||||||
|
detail: "FFmpeg is required to encode video. The render cannot proceed without it.",
|
||||||
|
hint: getFFmpegInstallHint(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFFprobe(): EnvironmentCheckOutcome {
|
||||||
|
const missingConfigured = configuredMissingDetail(FFPROBE_PATH_ENV);
|
||||||
|
if (missingConfigured) {
|
||||||
|
return {
|
||||||
|
name: "FFprobe",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "FFprobe not found",
|
||||||
|
detail: missingConfigured,
|
||||||
|
hint: getFFmpegInstallHint(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = findFFprobe();
|
||||||
|
if (path) {
|
||||||
|
return { name: "FFprobe", ok: true, level: "ok", detail: readToolVersion(path), path };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "FFprobe",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "FFprobe not found",
|
||||||
|
detail:
|
||||||
|
"FFprobe is required to probe media assets. It ships with FFmpeg but was not found on PATH.",
|
||||||
|
hint: getFFmpegInstallHint(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcome> {
|
||||||
|
if (browserPath) {
|
||||||
|
if (existsSync(browserPath)) {
|
||||||
|
return {
|
||||||
|
name: "Chrome",
|
||||||
|
ok: true,
|
||||||
|
level: "ok",
|
||||||
|
detail: `explicit: ${browserPath}`,
|
||||||
|
path: browserPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "Chrome",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "Chrome not found",
|
||||||
|
detail: `Chrome binary not found at "${browserPath}".`,
|
||||||
|
hint: "Run: npx hyperframes browser ensure",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await findBrowser();
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
name: "Chrome",
|
||||||
|
ok: true,
|
||||||
|
level: "ok",
|
||||||
|
detail: `${info.source}: ${info.executablePath}`,
|
||||||
|
path: info.executablePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "Chrome",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "Chrome not found",
|
||||||
|
detail: "Chrome Headless Shell is required for local rendering.",
|
||||||
|
hint: "Run: npx hyperframes browser ensure",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDisk(projectDir = "."): EnvironmentCheckOutcome {
|
||||||
|
const freeMb = getFreeDiskMb(projectDir);
|
||||||
|
if (freeMb === null) {
|
||||||
|
return { name: "Disk", ok: true, level: "ok", detail: "Unable to check" };
|
||||||
|
}
|
||||||
|
const freeGb = (freeMb / 1024).toFixed(1);
|
||||||
|
if (freeMb < 1024) {
|
||||||
|
return {
|
||||||
|
name: "Disk",
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
title: "Low disk space",
|
||||||
|
detail: `${freeGb} GB free`,
|
||||||
|
hint: "Renders produce large temp files. Free disk space before rendering.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: "Disk", ok: true, level: "ok", detail: `${freeGb} GB free` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWindowsUncPath(projectDir = process.cwd()): EnvironmentCheckOutcome | undefined {
|
||||||
|
if (platform() !== "win32") return undefined;
|
||||||
|
if (!projectDir.startsWith("\\\\")) return undefined;
|
||||||
|
return {
|
||||||
|
name: "Windows path",
|
||||||
|
ok: true,
|
||||||
|
level: "warn",
|
||||||
|
detail: `UNC path: ${projectDir}`,
|
||||||
|
hint: "Chrome may fail to launch from a network share. Use a local drive if render startup fails.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runEnvironmentChecks(
|
||||||
|
options: EnvironmentCheckOptions = {},
|
||||||
|
): Promise<EnvironmentCheckResult> {
|
||||||
|
const outcomes: EnvironmentCheckOutcome[] = [];
|
||||||
|
|
||||||
|
const ffmpeg = checkFFmpeg();
|
||||||
|
outcomes.push(ffmpeg);
|
||||||
|
|
||||||
|
const ffprobe = checkFFprobe();
|
||||||
|
outcomes.push(ffprobe);
|
||||||
|
|
||||||
|
let browser: BrowserResult | undefined;
|
||||||
|
if (options.includeBrowser) {
|
||||||
|
const chrome = await checkChrome(options.browserPath);
|
||||||
|
outcomes.push(chrome);
|
||||||
|
if (chrome.ok && chrome.path) {
|
||||||
|
browser = {
|
||||||
|
executablePath: chrome.path,
|
||||||
|
source: options.browserPath ? "env" : "cache",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeDisk) {
|
||||||
|
outcomes.push(checkDisk(options.projectDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeWindowsUnc) {
|
||||||
|
const unc = checkWindowsUncPath(options.projectDir);
|
||||||
|
if (unc) outcomes.push(unc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcomes,
|
||||||
|
...(ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}),
|
||||||
|
...(ffprobe.path ? { ffprobePath: ffprobe.path } : {}),
|
||||||
|
...(browser ? { browser } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { platform } from "node:os";
|
import { platform } from "node:os";
|
||||||
import type { Example } from "./_examples.js";
|
import type { Example } from "./_examples.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
import { findBrowser } from "../browser/manager.js";
|
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
|
||||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
|
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
|
||||||
import {
|
import {
|
||||||
@@ -30,61 +30,7 @@ interface CheckResult {
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { parseToolVersion };
|
||||||
* Extract a clean "toolname X.Y.Z" from the verbose first line of
|
|
||||||
* `ffmpeg -version` / `ffprobe -version` output. Falls back to the
|
|
||||||
* trimmed input when the pattern doesn't match.
|
|
||||||
*/
|
|
||||||
export function parseToolVersion(raw: string): string {
|
|
||||||
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
|
|
||||||
return m ? `${m[1]} ${m[2]}` : raw.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkFFmpeg(): CheckResult {
|
|
||||||
const path = findFFmpeg();
|
|
||||||
if (path) {
|
|
||||||
try {
|
|
||||||
const raw =
|
|
||||||
execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
|
|
||||||
return { ok: true, detail: parseToolVersion(raw) };
|
|
||||||
} catch {
|
|
||||||
return { ok: true, detail: path };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
detail: "Not found",
|
|
||||||
hint: getFFmpegInstallHint(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkFFprobe(): CheckResult {
|
|
||||||
// `ffprobe -version` works cross-platform if it's on PATH — no need for
|
|
||||||
// `which`/`where` shell detection, which differs by OS.
|
|
||||||
try {
|
|
||||||
const raw =
|
|
||||||
execSync("ffprobe -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
|
|
||||||
return { ok: true, detail: parseToolVersion(raw) };
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
detail: "Not found",
|
|
||||||
hint: `Installed with ffmpeg — ${getFFmpegInstallHint()}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkChrome(): Promise<CheckResult> {
|
|
||||||
const info = await findBrowser();
|
|
||||||
if (info) {
|
|
||||||
return { ok: true, detail: `${info.source}: ${info.executablePath}` };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
detail: "Not found",
|
|
||||||
hint: "Run: npx hyperframes browser ensure",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkDocker(): CheckResult {
|
function checkDocker(): CheckResult {
|
||||||
try {
|
try {
|
||||||
@@ -252,6 +198,7 @@ export default defineCommand({
|
|||||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
|
const environment = await runEnvironmentChecks({ includeBrowser: true });
|
||||||
const checks: Check[] = [
|
const checks: Check[] = [
|
||||||
{ name: "Version", run: checkVersion },
|
{ name: "Version", run: checkVersion },
|
||||||
{ name: "Node.js", run: checkNode },
|
{ name: "Node.js", run: checkNode },
|
||||||
@@ -265,14 +212,7 @@ export default defineCommand({
|
|||||||
checks.push({ name: "/dev/shm", run: checkShm });
|
checks.push({ name: "/dev/shm", run: checkShm });
|
||||||
}
|
}
|
||||||
|
|
||||||
checks.push(
|
checks.push({ name: "Environment", run: checkEnvironment });
|
||||||
{ name: "Environment", run: checkEnvironment },
|
|
||||||
{ name: "FFmpeg", run: checkFFmpeg },
|
|
||||||
{ name: "FFprobe", run: checkFFprobe },
|
|
||||||
{ name: "Chrome", run: checkChrome },
|
|
||||||
{ name: "Docker", run: checkDocker },
|
|
||||||
{ name: "Docker running", run: checkDockerRunning },
|
|
||||||
);
|
|
||||||
|
|
||||||
const outcomes: CheckOutcome[] = [];
|
const outcomes: CheckOutcome[] = [];
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
@@ -284,6 +224,26 @@ export default defineCommand({
|
|||||||
...(result.hint ? { hint: result.hint } : {}),
|
...(result.hint ? { hint: result.hint } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const result of environment.outcomes) {
|
||||||
|
outcomes.push({
|
||||||
|
name: result.name,
|
||||||
|
ok: result.ok,
|
||||||
|
detail: result.detail,
|
||||||
|
...(result.hint ? { hint: result.hint } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const check of [
|
||||||
|
{ name: "Docker", run: checkDocker },
|
||||||
|
{ name: "Docker running", run: checkDockerRunning },
|
||||||
|
]) {
|
||||||
|
const result = await check.run();
|
||||||
|
outcomes.push({
|
||||||
|
name: check.name,
|
||||||
|
ok: result.ok,
|
||||||
|
detail: result.detail,
|
||||||
|
...(result.hint ? { hint: result.hint } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
const allOk = outcomes.every((o) => o.ok);
|
const allOk = outcomes.every((o) => o.ok);
|
||||||
|
|
||||||
if (args.json) {
|
if (args.json) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { fetchRemoteTemplate } from "../templates/remote.js";
|
import { fetchRemoteTemplate } from "../templates/remote.js";
|
||||||
import { trackInitTemplate } from "../telemetry/events.js";
|
import { trackInitTemplate } from "../telemetry/events.js";
|
||||||
import { hasFFmpeg } from "../whisper/manager.js";
|
import { hasFFmpeg } from "../whisper/manager.js";
|
||||||
|
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
CANVAS_DIMENSIONS,
|
CANVAS_DIMENSIONS,
|
||||||
@@ -75,8 +76,10 @@ const TAILWIND_BROWSER_INTEGRITY =
|
|||||||
|
|
||||||
function probeVideo(filePath: string): VideoMeta | undefined {
|
function probeVideo(filePath: string): VideoMeta | undefined {
|
||||||
try {
|
try {
|
||||||
|
const ffprobePath = findFFprobe();
|
||||||
|
if (!ffprobePath) return undefined;
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"ffprobe",
|
ffprobePath,
|
||||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||||
{ encoding: "utf-8", timeout: 15_000 },
|
{ encoding: "utf-8", timeout: 15_000 },
|
||||||
);
|
);
|
||||||
@@ -134,8 +137,13 @@ function isWebCompatible(codec: string): boolean {
|
|||||||
|
|
||||||
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
|
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
|
||||||
return new Promise((resolvePromise) => {
|
return new Promise((resolvePromise) => {
|
||||||
|
const ffmpegPath = findFFmpeg();
|
||||||
|
if (!ffmpegPath) {
|
||||||
|
resolvePromise(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
"ffmpeg",
|
ffmpegPath,
|
||||||
[
|
[
|
||||||
"-i",
|
"-i",
|
||||||
inputPath,
|
inputPath,
|
||||||
@@ -345,8 +353,7 @@ async function handleVideoFile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg =
|
const msg = `ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: ${getFFmpegInstallHint()}`;
|
||||||
"ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: brew install ffmpeg";
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
clack.log.warn(msg);
|
clack.log.warn(msg);
|
||||||
} else {
|
} else {
|
||||||
@@ -409,10 +416,10 @@ async function handleVideoFile(
|
|||||||
} else {
|
} else {
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
|
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
|
||||||
clack.log.info(c.accent("Install: brew install ffmpeg"));
|
clack.log.info(c.accent(`Install: ${getFFmpegInstallHint()}`));
|
||||||
} else {
|
} else {
|
||||||
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
|
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
|
||||||
console.log(c.dim("Install: ") + c.accent("brew install ffmpeg"));
|
console.log(c.dim("Install: ") + c.accent(getFFmpegInstallHint()));
|
||||||
}
|
}
|
||||||
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const producerState = vi.hoisted(() => ({
|
const producerState = vi.hoisted(() => ({
|
||||||
@@ -5,6 +6,31 @@ const producerState = vi.hoisted(() => ({
|
|||||||
resolveConfigCalls: [] as Array<Record<string, unknown>>,
|
resolveConfigCalls: [] as Array<Record<string, unknown>>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const preflightState = vi.hoisted(() => ({
|
||||||
|
result: {
|
||||||
|
outcomes: [
|
||||||
|
{ name: "FFmpeg", ok: true, level: "ok", detail: "/usr/bin/ffmpeg", path: "/usr/bin/ffmpeg" },
|
||||||
|
{
|
||||||
|
name: "FFprobe",
|
||||||
|
ok: true,
|
||||||
|
level: "ok",
|
||||||
|
detail: "/usr/bin/ffprobe",
|
||||||
|
path: "/usr/bin/ffprobe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Chrome",
|
||||||
|
ok: true,
|
||||||
|
level: "ok",
|
||||||
|
detail: "cache: /mock/chrome",
|
||||||
|
path: "/mock/chrome",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ffmpegPath: "/usr/bin/ffmpeg",
|
||||||
|
ffprobePath: "/usr/bin/ffprobe",
|
||||||
|
browser: { executablePath: "/mock/chrome", source: "cache" },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../utils/producer.js", () => ({
|
vi.mock("../utils/producer.js", () => ({
|
||||||
loadProducer: vi.fn(async () => ({
|
loadProducer: vi.fn(async () => ({
|
||||||
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
|
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
|
||||||
@@ -29,6 +55,10 @@ vi.mock("../browser/ffmpeg.js", () => ({
|
|||||||
getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"),
|
getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../browser/preflight.js", () => ({
|
||||||
|
runEnvironmentChecks: vi.fn(async () => preflightState.result),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("renderLocal browser GPU config", () => {
|
describe("renderLocal browser GPU config", () => {
|
||||||
const savedEnv = new Map<string, string | undefined>();
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
|
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
|
||||||
@@ -46,7 +76,7 @@ describe("renderLocal browser GPU config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function setEnv(key: string, value: string) {
|
function setEnv(key: string, value: string) {
|
||||||
savedEnv.set(key, process.env[key]);
|
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
|
||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +84,12 @@ describe("renderLocal browser GPU config", () => {
|
|||||||
producerState.createdJobs = [];
|
producerState.createdJobs = [];
|
||||||
producerState.resolveConfigCalls = [];
|
producerState.resolveConfigCalls = [];
|
||||||
savedEnv.clear();
|
savedEnv.clear();
|
||||||
|
savedEnv.set("HYPERFRAMES_FFMPEG_PATH", process.env.HYPERFRAMES_FFMPEG_PATH);
|
||||||
|
savedEnv.set("HYPERFRAMES_FFPROBE_PATH", process.env.HYPERFRAMES_FFPROBE_PATH);
|
||||||
|
savedEnv.set("PRODUCER_HEADLESS_SHELL_PATH", process.env.PRODUCER_HEADLESS_SHELL_PATH);
|
||||||
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -125,6 +161,22 @@ describe("renderLocal browser GPU config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes preflight-resolved FFmpeg, FFprobe, and browser paths through env", async () => {
|
||||||
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
||||||
|
fps: { num: 30, den: 1 },
|
||||||
|
quality: "standard",
|
||||||
|
format: "mp4",
|
||||||
|
gpu: false,
|
||||||
|
browserGpuMode: "software",
|
||||||
|
hdrMode: "auto",
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(process.env.HYPERFRAMES_FFMPEG_PATH).toBe("/usr/bin/ffmpeg");
|
||||||
|
expect(process.env.HYPERFRAMES_FFPROBE_PATH).toBe("/usr/bin/ffprobe");
|
||||||
|
expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe("/mock/chrome");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => {
|
it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => {
|
||||||
// Default (no flag, no env): auto — engine probes and chooses.
|
// Default (no flag, no env): auto — engine probes and chooses.
|
||||||
expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto");
|
expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import { VERSION } from "../version.js";
|
|||||||
import { isDevMode } from "../utils/env.js";
|
import { isDevMode } from "../utils/env.js";
|
||||||
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
|
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
|
||||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
import { runEnvironmentChecks } from "../browser/preflight.js";
|
||||||
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
|
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
|
||||||
import {
|
import {
|
||||||
normalizeResolutionFlag,
|
normalizeResolutionFlag,
|
||||||
@@ -967,30 +967,42 @@ export async function renderLocal(
|
|||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: RenderOptions,
|
options: RenderOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const producer = await loadProducer();
|
const preflight = await runEnvironmentChecks({
|
||||||
|
projectDir,
|
||||||
if (!findFFmpeg()) {
|
browserPath: options.browserPath,
|
||||||
errorBox(
|
includeBrowser: true,
|
||||||
"FFmpeg not found",
|
includeDisk: true,
|
||||||
"FFmpeg is required to encode video. The render cannot proceed without it.",
|
includeWindowsUnc: true,
|
||||||
getFFmpegInstallHint(),
|
});
|
||||||
);
|
const failedChecks = preflight.outcomes.filter((outcome) => !outcome.ok);
|
||||||
|
if (failedChecks.length > 0) {
|
||||||
|
for (const check of failedChecks) {
|
||||||
|
errorBox(check.title ?? `${check.name} check failed`, check.detail, check.hint);
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if (!options.quiet) {
|
||||||
|
for (const outcome of preflight.outcomes) {
|
||||||
|
if (outcome.level === "warn") {
|
||||||
|
console.warn(c.warn(` ${outcome.name}: ${outcome.detail}`));
|
||||||
|
if (outcome.hint) console.warn(c.dim(` ${outcome.hint}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preflight.ffmpegPath) process.env.HYPERFRAMES_FFMPEG_PATH = preflight.ffmpegPath;
|
||||||
|
if (preflight.ffprobePath) process.env.HYPERFRAMES_FFPROBE_PATH = preflight.ffprobePath;
|
||||||
|
if (preflight.browser?.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||||
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = preflight.browser.executablePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const producer = await loadProducer();
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const logger = createRenderTelemetryLogger(
|
const logger = createRenderTelemetryLogger(
|
||||||
producer.createConsoleLogger?.("info") ?? createNoopProducerLogger(),
|
producer.createConsoleLogger?.("info") ?? createNoopProducerLogger(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pass the resolved browser path to the producer via env var so
|
|
||||||
// resolveConfig() picks it up. This bridges the CLI's ensureBrowser()
|
|
||||||
// (which knows about system Chrome on macOS) with the engine's
|
|
||||||
// acquireBrowser() (which only checks the puppeteer cache).
|
|
||||||
if (options.browserPath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
||||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = options.browserPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = producer.createRenderJob({
|
const job = producer.createRenderJob({
|
||||||
fps: options.fps,
|
fps: options.fps,
|
||||||
quality: options.quality,
|
quality: options.quality,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
@@ -7,6 +8,7 @@ import { resolveProject } from "../utils/project.js";
|
|||||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
|
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||||
import type { Example } from "./_examples.js";
|
import type { Example } from "./_examples.js";
|
||||||
|
|
||||||
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
|
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
|
||||||
@@ -28,6 +30,8 @@ async function extractVideoFrameToBuffer(
|
|||||||
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
|
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
|
||||||
const outPath = join(tmp, "frame.png");
|
const outPath = join(tmp, "frame.png");
|
||||||
try {
|
try {
|
||||||
|
const ffmpegPath = findFFmpeg();
|
||||||
|
if (!ffmpegPath) return null;
|
||||||
const result = await new Promise<{ code: number | null; stderr: string; timedOut: boolean }>(
|
const result = await new Promise<{ code: number | null; stderr: string; timedOut: boolean }>(
|
||||||
(resolvePromise) => {
|
(resolvePromise) => {
|
||||||
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
|
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
|
||||||
@@ -48,7 +52,7 @@ async function extractVideoFrameToBuffer(
|
|||||||
"-y",
|
"-y",
|
||||||
outPath,
|
outPath,
|
||||||
);
|
);
|
||||||
const ff = spawn("ffmpeg", args);
|
const ff = spawn(ffmpegPath, args);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// fallow-ignore-file code-duplication complexity
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { homedir, platform } from "node:os";
|
import { homedir, platform } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||||
import { downloadFile } from "../utils/download.js";
|
import { downloadFile } from "../utils/download.js";
|
||||||
|
|
||||||
const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models");
|
const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models");
|
||||||
@@ -205,20 +207,7 @@ export async function ensureModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasFFmpeg(): boolean {
|
export function hasFFmpeg(): boolean {
|
||||||
return hasBinary("ffmpeg");
|
return findFFmpeg() !== undefined;
|
||||||
}
|
|
||||||
|
|
||||||
export function hasFFprobe(): boolean {
|
|
||||||
return hasBinary("ffprobe");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasBinary(name: string): boolean {
|
|
||||||
try {
|
|
||||||
execFileSync(name, ["-version"], { stdio: "ignore", timeout: 5000 });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MODELS_DIR, DEFAULT_MODEL };
|
export { MODELS_DIR, DEFAULT_MODEL };
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
||||||
import { join, extname } from "node:path";
|
import { join, extname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||||
import { ensureWhisper, ensureModel, hasFFmpeg, DEFAULT_MODEL } from "./manager.js";
|
import { ensureWhisper, ensureModel, hasFFmpeg, DEFAULT_MODEL } from "./manager.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,9 +126,15 @@ function isVideoFile(filePath: string): boolean {
|
|||||||
* Extract audio from a video file as 16kHz mono WAV (whisper requirement).
|
* Extract audio from a video file as 16kHz mono WAV (whisper requirement).
|
||||||
*/
|
*/
|
||||||
function extractAudio(videoPath: string): string {
|
function extractAudio(videoPath: string): string {
|
||||||
|
const ffmpegPath = findFFmpeg();
|
||||||
|
if (!ffmpegPath) {
|
||||||
|
throw new Error(
|
||||||
|
`ffmpeg is required to extract audio from video. Install: ${getFFmpegInstallHint()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
|
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ffmpeg",
|
ffmpegPath,
|
||||||
["-i", videoPath, "-vn", "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
|
["-i", videoPath, "-vn", "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
|
||||||
{ stdio: "ignore", timeout: 120_000 },
|
{ stdio: "ignore", timeout: 120_000 },
|
||||||
);
|
);
|
||||||
@@ -138,8 +146,10 @@ function extractAudio(videoPath: string): string {
|
|||||||
*/
|
*/
|
||||||
function isWav16kMono(filePath: string): boolean {
|
function isWav16kMono(filePath: string): boolean {
|
||||||
try {
|
try {
|
||||||
|
const ffprobePath = findFFprobe();
|
||||||
|
if (!ffprobePath) return false;
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"ffprobe",
|
ffprobePath,
|
||||||
["-v", "quiet", "-print_format", "json", "-show_streams", filePath],
|
["-v", "quiet", "-print_format", "json", "-show_streams", filePath],
|
||||||
{ encoding: "utf-8", timeout: 10_000 },
|
{ encoding: "utf-8", timeout: 10_000 },
|
||||||
);
|
);
|
||||||
@@ -166,9 +176,13 @@ function prepareAudio(audioPath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to whisper-compatible WAV
|
// Convert to whisper-compatible WAV
|
||||||
|
const ffmpegPath = findFFmpeg();
|
||||||
|
if (!ffmpegPath) {
|
||||||
|
throw new Error(`ffmpeg is required to prepare audio. Install: ${getFFmpegInstallHint()}`);
|
||||||
|
}
|
||||||
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
|
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ffmpeg",
|
ffmpegPath,
|
||||||
["-i", audioPath, "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
|
["-i", audioPath, "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
|
||||||
{ stdio: "ignore", timeout: 120_000 },
|
{ stdio: "ignore", timeout: 120_000 },
|
||||||
);
|
);
|
||||||
@@ -205,7 +219,7 @@ export async function transcribe(
|
|||||||
} else if (isVideoFile(inputPath)) {
|
} else if (isVideoFile(inputPath)) {
|
||||||
if (!hasFFmpeg()) {
|
if (!hasFFmpeg()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"ffmpeg is required to extract audio from video. Install: brew install ffmpeg",
|
`ffmpeg is required to extract audio from video. Install: ${getFFmpegInstallHint()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
options?.onProgress?.("Extracting audio from video...");
|
options?.onProgress?.("Extracting audio from video...");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
const SAMPLE_RATE = 4000;
|
const SAMPLE_RATE = 4000;
|
||||||
const PEAK_COUNT = 4000;
|
const PEAK_COUNT = 4000;
|
||||||
@@ -27,10 +27,16 @@ function computePeaks(floats: Float32Array, count: number): number[] {
|
|||||||
return peaks.map((p) => p / maxPeak);
|
return peaks.map((p) => p / maxPeak);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ffmpegBinary(): string {
|
||||||
|
const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
|
||||||
|
if (configured) return resolve(configured);
|
||||||
|
return "ffmpeg";
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
|
export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
const proc = spawn(
|
const proc = spawn(
|
||||||
"ffmpeg",
|
ffmpegBinary(),
|
||||||
[
|
[
|
||||||
"-i",
|
"-i",
|
||||||
audioPath,
|
audioPath,
|
||||||
@@ -62,7 +68,7 @@ export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
|
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
|
||||||
resolve(computePeaks(new Float32Array(ab), PEAK_COUNT));
|
resolvePromise(computePeaks(new Float32Array(ab), PEAK_COUNT));
|
||||||
});
|
});
|
||||||
proc.on("error", reject);
|
proc.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ export {
|
|||||||
type RunFfmpegOptions,
|
type RunFfmpegOptions,
|
||||||
type RunFfmpegResult,
|
type RunFfmpegResult,
|
||||||
} from "./utils/runFfmpeg.js";
|
} from "./utils/runFfmpeg.js";
|
||||||
|
export {
|
||||||
|
assertConfiguredFfmpegBinariesExist,
|
||||||
|
getFfmpegBinary,
|
||||||
|
getFfprobeBinary,
|
||||||
|
FFMPEG_PATH_ENV,
|
||||||
|
FFPROBE_PATH_ENV,
|
||||||
|
} from "./utils/ffmpegBinaries.js";
|
||||||
|
|
||||||
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
drainBrowserPool,
|
drainBrowserPool,
|
||||||
forceReleaseBrowser,
|
forceReleaseBrowser,
|
||||||
releaseBrowser,
|
releaseBrowser,
|
||||||
|
resolveHeadlessShellPath,
|
||||||
resolveBrowserGpuMode,
|
resolveBrowserGpuMode,
|
||||||
} from "./browserManager.js";
|
} from "./browserManager.js";
|
||||||
|
|
||||||
@@ -137,6 +138,23 @@ describe("resolveBrowserGpuMode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveHeadlessShellPath", () => {
|
||||||
|
const originalHeadlessShellPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalHeadlessShellPath === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
||||||
|
else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalHeadlessShellPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary", () => {
|
||||||
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = "/missing/chrome-headless-shell.exe";
|
||||||
|
|
||||||
|
expect(() => resolveHeadlessShellPath({})).toThrow(
|
||||||
|
/Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("forceReleaseBrowser", () => {
|
describe("forceReleaseBrowser", () => {
|
||||||
it("kills the browser process and disconnects", () => {
|
it("kills the browser process and disconnects", () => {
|
||||||
const killFn = vi.fn(() => true);
|
const killFn = vi.fn(() => true);
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ export function resolveHeadlessShellPath(
|
|||||||
return config.chromePath;
|
return config.chromePath;
|
||||||
}
|
}
|
||||||
if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||||
return process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
const envPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`[BrowserManager] Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH="${envPath}". ` +
|
||||||
|
"Run `hyperframes browser ensure` to re-download.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return envPath;
|
||||||
}
|
}
|
||||||
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
|
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
|
||||||
if (!existsSync(baseDir)) return undefined;
|
if (!existsSync(baseDir)) return undefined;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication complexity
|
||||||
/**
|
/**
|
||||||
* Chunk Encoder Service
|
* Chunk Encoder Service
|
||||||
*
|
*
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from "../utils/gpuEncoder.js";
|
} from "../utils/gpuEncoder.js";
|
||||||
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
|
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
|
||||||
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
|
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
|
||||||
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||||
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
|
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
|
||||||
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
||||||
|
|
||||||
@@ -411,7 +413,7 @@ export async function encodeFramesFromDir(
|
|||||||
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
|
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ffmpeg = spawn("ffmpeg", args);
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
@@ -543,7 +545,7 @@ export async function encodeFramesChunkedConcat(
|
|||||||
if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
|
if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
|
||||||
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
|
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
|
||||||
const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||||
const ffmpeg = spawn("ffmpeg", args);
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
ffmpeg.stderr.on("data", (d) => {
|
ffmpeg.stderr.on("data", (d) => {
|
||||||
@@ -587,7 +589,7 @@ export async function encodeFramesChunkedConcat(
|
|||||||
outputPath,
|
outputPath,
|
||||||
];
|
];
|
||||||
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||||
const ffmpeg = spawn("ffmpeg", concatArgs);
|
const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
ffmpeg.stderr.on("data", (d) => {
|
ffmpeg.stderr.on("data", (d) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file unused-type code-duplication complexity
|
||||||
/**
|
/**
|
||||||
* Streaming Encoder Service
|
* Streaming Encoder Service
|
||||||
*
|
*
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
mapPresetForGpuEncoder,
|
mapPresetForGpuEncoder,
|
||||||
} from "../utils/gpuEncoder.js";
|
} from "../utils/gpuEncoder.js";
|
||||||
import { formatFfmpegError } from "../utils/runFfmpeg.js";
|
import { formatFfmpegError } from "../utils/runFfmpeg.js";
|
||||||
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||||
import { getHdrEncoderColorParams } from "../utils/hdr.js";
|
import { getHdrEncoderColorParams } from "../utils/hdr.js";
|
||||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||||
import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
|
import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
|
||||||
@@ -380,7 +382,7 @@ export async function spawnStreamingEncoder(
|
|||||||
const args = buildStreamingArgs(options, outputPath, gpuEncoder);
|
const args = buildStreamingArgs(options, outputPath, gpuEncoder);
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const ffmpeg: ChildProcess = spawn("ffmpeg", args, {
|
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file unused-class-member code-duplication complexity
|
||||||
/**
|
/**
|
||||||
* Video Frame Extractor Service
|
* Video Frame Extractor Service
|
||||||
*
|
*
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "../utils/hdr.js";
|
} from "../utils/hdr.js";
|
||||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||||
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||||
import {
|
import {
|
||||||
@@ -259,7 +261,7 @@ export async function extractVideoFramesRange(
|
|||||||
args.push("-y", outputPattern);
|
args.push("-y", outputPattern);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ffmpeg = spawn("ffmpeg", args);
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assertConfiguredFfmpegBinariesExist,
|
||||||
|
getFfmpegBinary,
|
||||||
|
getFfprobeBinary,
|
||||||
|
} from "./ffmpegBinaries.js";
|
||||||
|
|
||||||
|
describe("ffmpeg binary env resolution", () => {
|
||||||
|
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||||
|
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured absolute paths when env vars are set", () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
|
||||||
|
|
||||||
|
expect(getFfmpegBinary()).toBe(resolve("/tools/ffmpeg.exe"));
|
||||||
|
expect(getFfprobeBinary()).toBe(resolve("/tools/ffprobe.exe"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error when a configured FFmpeg path is missing", () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
|
||||||
|
|
||||||
|
expect(() => assertConfiguredFfmpegBinariesExist()).toThrow(
|
||||||
|
/FFmpeg binary not found at HYPERFRAMES_FFMPEG_PATH/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts existing configured paths", () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
|
||||||
|
|
||||||
|
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
|
import { execFileSync } from "child_process";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
||||||
|
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||||
|
|
||||||
|
const pathCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
||||||
|
if (pathCache.has(name)) return pathCache.get(name);
|
||||||
|
try {
|
||||||
|
const command = process.platform === "win32" ? "where" : "which";
|
||||||
|
const output = execFileSync(command, [name], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const first = output
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
const resolved = first ? resolve(first) : undefined;
|
||||||
|
pathCache.set(name, resolved);
|
||||||
|
return resolved;
|
||||||
|
} catch {
|
||||||
|
pathCache.set(name, undefined);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
|
||||||
|
const configured = process.env[envName]?.trim();
|
||||||
|
if (configured) return resolve(configured);
|
||||||
|
return findOnPath(binaryName) ?? binaryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFfmpegBinary(): string {
|
||||||
|
return getConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFfprobeBinary(): string {
|
||||||
|
return getConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertConfiguredFfmpegBinariesExist(): void {
|
||||||
|
const ffmpegPath = process.env[FFMPEG_PATH_ENV]?.trim();
|
||||||
|
if (ffmpegPath && !existsSync(ffmpegPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`[FFmpeg] FFmpeg binary not found at ${FFMPEG_PATH_ENV}="${ffmpegPath}". ` +
|
||||||
|
"Install FFmpeg or unset the override.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffprobePath = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||||
|
if (ffprobePath && !existsSync(ffprobePath)) {
|
||||||
|
throw new Error(
|
||||||
|
`[FFmpeg] FFprobe binary not found at ${FFPROBE_PATH_ENV}="${ffprobePath}". ` +
|
||||||
|
"Install FFmpeg or unset the override.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
@@ -164,9 +165,35 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ffprobe missing-binary fallback", () => {
|
describe("ffprobe missing-binary fallback", () => {
|
||||||
|
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doUnmock("child_process");
|
vi.doUnmock("child_process");
|
||||||
|
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
|
||||||
|
const { spawn, calls } = createSpawnSpy([
|
||||||
|
{
|
||||||
|
kind: "exit",
|
||||||
|
code: 0,
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
|
||||||
|
format: { duration: "1.25", bit_rate: "128000" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("child_process", () => ({ spawn }));
|
||||||
|
|
||||||
|
const { extractAudioMetadata } = await import("./ffprobe.js");
|
||||||
|
const meta = await extractAudioMetadata("/tmp/uses-configured-ffprobe.wav");
|
||||||
|
|
||||||
|
expect(meta.durationSeconds).toBe(1.25);
|
||||||
|
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
// fallow-ignore-file code-duplication complexity
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { extname } from "path";
|
import { extname } from "path";
|
||||||
|
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
|
||||||
|
|
||||||
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
|
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
|
||||||
function runFfprobe(args: string[]): Promise<string> {
|
function runFfprobe(args: string[]): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn("ffprobe", args);
|
const command = getFfprobeBinary();
|
||||||
|
const proc = spawn(command, args);
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
proc.stdout.on("data", (data) => {
|
proc.stdout.on("data", (data) => {
|
||||||
@@ -23,7 +26,14 @@ function runFfprobe(args: string[]): Promise<string> {
|
|||||||
});
|
});
|
||||||
proc.on("error", (err) => {
|
proc.on("error", (err) => {
|
||||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
|
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
configured
|
||||||
|
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
|
||||||
|
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
/**
|
/**
|
||||||
* GPU Encoder Detection
|
* GPU Encoder Detection
|
||||||
*
|
*
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||||
|
|
||||||
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
|
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
|
||||||
export type GpuEncoder = ConcreteGpuEncoder | null;
|
export type GpuEncoder = ConcreteGpuEncoder | null;
|
||||||
@@ -59,7 +61,7 @@ export async function selectUsableGpuEncoder(
|
|||||||
|
|
||||||
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ffmpeg = spawn("ffmpeg", ["-encoders"], {
|
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -147,7 +149,7 @@ async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
|||||||
if (killTimer) clearTimeout(killTimer);
|
if (killTimer) clearTimeout(killTimer);
|
||||||
resolve(usable);
|
resolve(usable);
|
||||||
};
|
};
|
||||||
const ffmpeg = spawn("ffmpeg", getProbeArgs(encoder), {
|
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { formatFfmpegError } from "./runFfmpeg.js";
|
import { formatFfmpegError } from "./runFfmpeg.js";
|
||||||
|
|
||||||
describe("formatFfmpegError", () => {
|
describe("formatFfmpegError", () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("reports exit code alone when stderr is empty", () => {
|
it("reports exit code alone when stderr is empty", () => {
|
||||||
expect(formatFfmpegError(-22, "")).toBe("FFmpeg exited with code -22");
|
expect(formatFfmpegError(-22, "")).toBe("FFmpeg exited with code -22");
|
||||||
});
|
});
|
||||||
@@ -43,4 +51,52 @@ describe("formatFfmpegError", () => {
|
|||||||
it("wraps stderr in [FFmpeg] prefix when exit code is null (spawn failure)", () => {
|
it("wraps stderr in [FFmpeg] prefix when exit code is null (spawn failure)", () => {
|
||||||
expect(formatFfmpegError(null, "spawn ffmpeg ENOENT")).toBe("[FFmpeg] spawn ffmpeg ENOENT");
|
expect(formatFfmpegError(null, "spawn ffmpeg ENOENT")).toBe("[FFmpeg] spawn ffmpeg ENOENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps Windows invalid-image exit codes to an actionable architecture hint", () => {
|
||||||
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||||
|
|
||||||
|
expect(formatFfmpegError(3221225595, "")).toContain("wrong architecture");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSpawnSpy() {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const spawn = vi.fn((command: string, args: string[]) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
|
stderr: EventEmitter;
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
|
killed: boolean;
|
||||||
|
};
|
||||||
|
proc.stderr = new EventEmitter();
|
||||||
|
proc.kill = vi.fn();
|
||||||
|
proc.killed = false;
|
||||||
|
process.nextTick(() => proc.emit("close", 0));
|
||||||
|
return proc;
|
||||||
|
});
|
||||||
|
return { spawn, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runFfmpeg binary resolution", () => {
|
||||||
|
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doUnmock("child_process");
|
||||||
|
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns the configured absolute FFmpeg path when HYPERFRAMES_FFMPEG_PATH is set", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
|
||||||
|
const { spawn, calls } = createSpawnSpy();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("child_process", () => ({ spawn }));
|
||||||
|
|
||||||
|
const { runFfmpeg } = await import("./runFfmpeg.js");
|
||||||
|
const result = await runFfmpeg(["-version"]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(calls[0]).toEqual({ command: resolve("/tools/ffmpeg.exe"), args: ["-version"] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
/**
|
/**
|
||||||
* Shared FFmpeg process runner.
|
* Shared FFmpeg process runner.
|
||||||
*
|
*
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||||
import { trackChildProcess } from "./processTracker.js";
|
import { trackChildProcess } from "./processTracker.js";
|
||||||
|
|
||||||
export interface RunFfmpegOptions {
|
export interface RunFfmpegOptions {
|
||||||
@@ -25,6 +27,23 @@ const DEFAULT_TIMEOUT = 300_000;
|
|||||||
|
|
||||||
const DEFAULT_STDERR_TAIL_LINES = 15;
|
const DEFAULT_STDERR_TAIL_LINES = 15;
|
||||||
|
|
||||||
|
function formatWindowsFfmpegExit(exitCode: number | null): string | undefined {
|
||||||
|
if (process.platform !== "win32" || exitCode === null) return undefined;
|
||||||
|
if (exitCode === 3221225595 || exitCode === -1073741701) {
|
||||||
|
return (
|
||||||
|
"[FFmpeg] Windows could not start ffmpeg.exe (STATUS_INVALID_IMAGE_FORMAT). " +
|
||||||
|
"The binary may be corrupted or the wrong architecture. Reinstall a 64-bit Windows FFmpeg build."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (exitCode === 3221225794 || exitCode === -1073741502) {
|
||||||
|
return (
|
||||||
|
"[FFmpeg] Windows failed while initializing ffmpeg.exe. " +
|
||||||
|
"The binary may be corrupted, blocked, or missing runtime DLLs. Reinstall a 64-bit Windows FFmpeg build."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a user-facing error message for a failed ffmpeg invocation.
|
* Build a user-facing error message for a failed ffmpeg invocation.
|
||||||
*
|
*
|
||||||
@@ -48,6 +67,10 @@ export function formatFfmpegError(
|
|||||||
if (exitCode === null) {
|
if (exitCode === null) {
|
||||||
return tail ? `[FFmpeg] ${tail}` : "[FFmpeg] process error";
|
return tail ? `[FFmpeg] ${tail}` : "[FFmpeg] process error";
|
||||||
}
|
}
|
||||||
|
const windowsMessage = formatWindowsFfmpegExit(exitCode);
|
||||||
|
if (windowsMessage) {
|
||||||
|
return tail ? `${windowsMessage}\nffmpeg stderr (tail):\n${tail}` : windowsMessage;
|
||||||
|
}
|
||||||
return tail
|
return tail
|
||||||
? `FFmpeg exited with code ${exitCode}\nffmpeg stderr (tail):\n${tail}`
|
? `FFmpeg exited with code ${exitCode}\nffmpeg stderr (tail):\n${tail}`
|
||||||
: `FFmpeg exited with code ${exitCode}`;
|
: `FFmpeg exited with code ${exitCode}`;
|
||||||
@@ -60,7 +83,7 @@ export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promis
|
|||||||
const onStderr = opts?.onStderr;
|
const onStderr = opts?.onStderr;
|
||||||
|
|
||||||
return new Promise<RunFfmpegResult>((resolve) => {
|
return new Promise<RunFfmpegResult>((resolve) => {
|
||||||
const ffmpeg = spawn("ffmpeg", args);
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
|
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
|
||||||
|
import { getFfmpegBinary } from "@hyperframes/engine";
|
||||||
import { isHttpUrl } from "../utils/urlDownloader.js";
|
import { isHttpUrl } from "../utils/urlDownloader.js";
|
||||||
|
|
||||||
const PREPARED_GIF_SUBDIR = "_animated_gif";
|
const PREPARED_GIF_SUBDIR = "_animated_gif";
|
||||||
@@ -244,7 +246,7 @@ export function buildAnimatedGifTranscodeArgs(input: {
|
|||||||
|
|
||||||
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
|
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
|
||||||
await new Promise<void>((resolvePromise, reject) => {
|
await new Promise<void>((resolvePromise, reject) => {
|
||||||
const proc = spawn("ffmpeg", request.args);
|
const proc = spawn(getFfmpegBinary(), request.args);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const timeout = request.timeoutMs ?? 300_000;
|
const timeout = request.timeoutMs ?? 300_000;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file unused-file code-duplication complexity
|
||||||
/**
|
/**
|
||||||
* Audio Extractor Service
|
* Audio Extractor Service
|
||||||
*
|
*
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { trackChildProcess } from "@hyperframes/engine";
|
import { getFfmpegBinary, trackChildProcess } from "@hyperframes/engine";
|
||||||
|
|
||||||
export interface AudioElement {
|
export interface AudioElement {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -82,7 +83,7 @@ export function parseAudioElements(html: string): AudioElement[] {
|
|||||||
*/
|
*/
|
||||||
function runFFmpeg(args: string[]): Promise<void> {
|
function runFFmpeg(args: string[]): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ffmpeg = spawn("ffmpeg", args);
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file complexity
|
||||||
/**
|
/**
|
||||||
* audioPadTrim — pad-or-trim an `audio.aac` file so its exact duration
|
* audioPadTrim — pad-or-trim an `audio.aac` file so its exact duration
|
||||||
* matches the assembled video's frame count divided by fps.
|
* matches the assembled video's frame count divided by fps.
|
||||||
@@ -20,6 +21,7 @@ import { spawn } from "node:child_process";
|
|||||||
import {
|
import {
|
||||||
extractAudioMetadata,
|
extractAudioMetadata,
|
||||||
formatFfmpegError,
|
formatFfmpegError,
|
||||||
|
getFfprobeBinary,
|
||||||
runFfmpeg,
|
runFfmpeg,
|
||||||
type AudioMetadata,
|
type AudioMetadata,
|
||||||
} from "@hyperframes/engine";
|
} from "@hyperframes/engine";
|
||||||
@@ -321,7 +323,7 @@ async function defaultRunFfmpeg(args: string[]): Promise<{ success: boolean; err
|
|||||||
|
|
||||||
function runFfprobeJson<T>(args: string[]): Promise<T> {
|
function runFfprobeJson<T>(args: string[]): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn("ffprobe", args);
|
const proc = spawn(getFfprobeBinary(), args);
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
proc.stdout.on("data", (data: Buffer) => {
|
proc.stdout.on("data", (data: Buffer) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file unused-export unused-type circular-dependency code-duplication complexity
|
||||||
/**
|
/**
|
||||||
* Render Orchestrator Service
|
* Render Orchestrator Service
|
||||||
*
|
*
|
||||||
@@ -78,6 +79,7 @@ import {
|
|||||||
type HfTransitionMeta,
|
type HfTransitionMeta,
|
||||||
getSystemTotalMb,
|
getSystemTotalMb,
|
||||||
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
||||||
|
assertConfiguredFfmpegBinariesExist,
|
||||||
} from "@hyperframes/engine";
|
} from "@hyperframes/engine";
|
||||||
import { join, dirname, resolve } from "path";
|
import { join, dirname, resolve } from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
@@ -1566,6 +1568,7 @@ export async function executeRenderJob(
|
|||||||
|
|
||||||
job.startedAt = new Date();
|
job.startedAt = new Date();
|
||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
assertConfiguredFfmpegBinariesExist();
|
||||||
|
|
||||||
log.info("[Render] Pipeline started", {
|
log.info("[Render] Pipeline started", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|||||||
Reference in New Issue
Block a user