mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
refactor(parsers): single shared FFmpeg/FFprobe binary resolver
The cli, engine, and lint packages each carried their own copy of the ffmpeg/ffprobe lookup, annotated fallow-ignore code-duplication, and the copies had drifted: the engine copy handled Windows PATHEXT and executed which/where without a shell but lacked the Homebrew-dirs fallback for GUI-spawned processes; the cli copy had the opposite. One resolver in @hyperframes/parsers (the dependency-graph bottom) now carries the union of both hardenings, and all three packages delegate to it. Every consumer gets strictly more robust resolution; env-override semantics per call site are preserved via configuredMustExist.
This commit is contained in:
@@ -57,7 +57,7 @@
|
|||||||
"name": "@hyperframes/cli",
|
"name": "@hyperframes/cli",
|
||||||
"version": "0.7.60",
|
"version": "0.7.60",
|
||||||
"bin": {
|
"bin": {
|
||||||
"hyperframes": "./dist/cli.js",
|
"hyperframes": "./bin/hyperframes.mjs",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.8.0",
|
"@hono/node-server": "^1.8.0",
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
"@hyperframes/engine": "workspace:*",
|
"@hyperframes/engine": "workspace:*",
|
||||||
"@hyperframes/gcp-cloud-run": "workspace:*",
|
"@hyperframes/gcp-cloud-run": "workspace:*",
|
||||||
"@hyperframes/lint": "workspace:*",
|
"@hyperframes/lint": "workspace:*",
|
||||||
|
"@hyperframes/parsers": "workspace:*",
|
||||||
"@hyperframes/producer": "workspace:*",
|
"@hyperframes/producer": "workspace:*",
|
||||||
"@hyperframes/studio": "workspace:*",
|
"@hyperframes/studio": "workspace:*",
|
||||||
"@hyperframes/studio-server": "workspace:*",
|
"@hyperframes/studio-server": "workspace:*",
|
||||||
@@ -133,6 +134,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@hyperframes/core": "workspace:^",
|
"@hyperframes/core": "workspace:^",
|
||||||
|
"@hyperframes/parsers": "workspace:^",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"puppeteer": "^25.2.1",
|
"puppeteer": "^25.2.1",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@hyperframes/engine": "workspace:*",
|
"@hyperframes/engine": "workspace:*",
|
||||||
"@hyperframes/gcp-cloud-run": "workspace:*",
|
"@hyperframes/gcp-cloud-run": "workspace:*",
|
||||||
"@hyperframes/lint": "workspace:*",
|
"@hyperframes/lint": "workspace:*",
|
||||||
|
"@hyperframes/parsers": "workspace:*",
|
||||||
"@hyperframes/producer": "workspace:*",
|
"@hyperframes/producer": "workspace:*",
|
||||||
"@hyperframes/studio": "workspace:*",
|
"@hyperframes/studio": "workspace:*",
|
||||||
"@hyperframes/studio-server": "workspace:*",
|
"@hyperframes/studio-server": "workspace:*",
|
||||||
|
|||||||
@@ -1,71 +1,38 @@
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { tmpdir } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { findFFmpeg, findFFprobe } from "./ffmpeg.js";
|
||||||
|
|
||||||
|
// Only child_process is mocked: the H264 encoder probe shells out, while the
|
||||||
|
// wrapper tests below resolve via env overrides and need the real `existsSync`.
|
||||||
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() }));
|
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() }));
|
||||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
|
||||||
|
|
||||||
const mockExec = vi.mocked(execSync);
|
|
||||||
const mockExecFile = vi.mocked(execFileSync);
|
const mockExecFile = vi.mocked(execFileSync);
|
||||||
const mockExists = vi.mocked(existsSync);
|
|
||||||
|
|
||||||
// The common-dir fallback list is platform-gated (empty on win32), so pin the
|
|
||||||
// platform to a POSIX value to keep the test deterministic on Windows CI.
|
|
||||||
const originalPlatform = process.platform;
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findFFmpeg", () => {
|
// Lookup mechanics (PATH scan, common-dir fallback, Windows shim preference)
|
||||||
it("prefers the real Windows exe when where lists a cmd shim first", async () => {
|
// are covered by @hyperframes/parsers ffBinaries.test.ts. These tests pin the
|
||||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
// CLI wrapper's contract: a configured-but-missing override means "not found"
|
||||||
mockExec.mockReturnValue("C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n");
|
// so callers surface the install hint instead of a spawn error.
|
||||||
|
describe("findFFmpeg / findFFprobe", () => {
|
||||||
|
it("returns undefined when the env override points at a missing file", () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = join(tmpdir(), "missing-ffmpeg");
|
||||||
|
process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "missing-ffprobe");
|
||||||
|
|
||||||
const { findFFmpeg } = await import("./ffmpeg.js");
|
|
||||||
expect(findFFmpeg()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a common install dir when `which` fails (GUI-launched PATH)", async () => {
|
|
||||||
// Simulate a process whose PATH lacks /opt/homebrew/bin: `which ffmpeg` throws.
|
|
||||||
mockExec.mockImplementation(() => {
|
|
||||||
throw new Error("which: no ffmpeg in PATH");
|
|
||||||
});
|
|
||||||
mockExists.mockImplementation((p) => p === "/opt/homebrew/bin/ffmpeg");
|
|
||||||
|
|
||||||
const { findFFmpeg } = await import("./ffmpeg.js");
|
|
||||||
expect(findFFmpeg()).toBe("/opt/homebrew/bin/ffmpeg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when ffmpeg is on neither PATH nor a common dir", async () => {
|
|
||||||
mockExec.mockImplementation(() => {
|
|
||||||
throw new Error("not found");
|
|
||||||
});
|
|
||||||
mockExists.mockReturnValue(false);
|
|
||||||
|
|
||||||
const { findFFmpeg } = await import("./ffmpeg.js");
|
|
||||||
expect(findFFmpeg()).toBeUndefined();
|
expect(findFFmpeg()).toBeUndefined();
|
||||||
|
expect(findFFprobe()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds project-local FFmpeg binaries when they are not on PATH", async () => {
|
it("returns the configured path when the env override exists", () => {
|
||||||
mockExec.mockImplementation(() => {
|
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
|
||||||
throw new Error("not found");
|
|
||||||
});
|
|
||||||
const localFFmpeg = resolve(".hyperframes", "bin", "ffmpeg");
|
|
||||||
const localFFprobe = resolve(".hyperframes", "bin", "ffprobe");
|
|
||||||
mockExists.mockImplementation((path) => path === localFFmpeg || path === localFFprobe);
|
|
||||||
|
|
||||||
const { findFFmpeg, findFFprobe } = await import("./ffmpeg.js");
|
expect(findFFmpeg()).toBe(process.execPath);
|
||||||
expect(findFFmpeg()).toBe(localFFmpeg);
|
|
||||||
expect(findFFprobe()).toBe(localFFprobe);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
// fallow-ignore-file code-duplication
|
import { execFileSync } from "node:child_process";
|
||||||
import { execFileSync, execSync } from "node:child_process";
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
|
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
|
||||||
|
|
||||||
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hyperframes/parsers/ff-binaries";
|
||||||
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
|
||||||
|
|
||||||
export type H264EncoderMode = "software" | "gpu";
|
export type H264EncoderMode = "software" | "gpu";
|
||||||
|
|
||||||
@@ -35,81 +32,15 @@ export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean)
|
|||||||
return resolveH264EncoderMode(encoders, gpuRequested);
|
return resolveH264EncoderMode(encoders, gpuRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseBestPathCandidate(
|
// `configuredMustExist`: the CLI surfaces an install hint when a binary is
|
||||||
name: "ffmpeg" | "ffprobe",
|
// missing, so an env override pointing at a nonexistent file reports as
|
||||||
candidates: string[],
|
// not-found instead of being handed to spawn.
|
||||||
): string | undefined {
|
|
||||||
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
|
|
||||||
if (normalized.length === 0) return undefined;
|
|
||||||
const lowerName = name.toLowerCase();
|
|
||||||
const preferredExe = normalized.find((candidate) =>
|
|
||||||
candidate.toLowerCase().endsWith(`${lowerName}.exe`),
|
|
||||||
);
|
|
||||||
if (preferredExe) return preferredExe;
|
|
||||||
const exact = normalized.find((candidate) => candidate.toLowerCase().endsWith(lowerName));
|
|
||||||
if (exact) return exact;
|
|
||||||
const nonShellShim = normalized.find((candidate) => {
|
|
||||||
const lower = candidate.toLowerCase();
|
|
||||||
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
|
|
||||||
});
|
|
||||||
return nonShellShim ?? normalized[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
|
||||||
try {
|
|
||||||
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
|
|
||||||
const output = execSync(cmd, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
|
|
||||||
return candidate ? resolve(candidate) : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GUI/Dock/launchd-spawned processes on macOS don't inherit the shell PATH, so
|
|
||||||
// `which ffmpeg` fails even when ffmpeg is installed via Homebrew. Probe the
|
|
||||||
// well-known install dirs as a fallback. (No-op on Windows, where `where` and
|
|
||||||
// installer-added PATH entries cover it.)
|
|
||||||
const COMMON_BIN_DIRS =
|
|
||||||
process.platform === "win32"
|
|
||||||
? []
|
|
||||||
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
|
|
||||||
|
|
||||||
function findInCommonDirs(name: "ffmpeg" | "ffprobe"): string | undefined {
|
|
||||||
for (const dir of COMMON_BIN_DIRS) {
|
|
||||||
const candidate = `${dir}/${name}`;
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInProjectLocalBin(name: "ffmpeg" | "ffprobe"): string | undefined {
|
|
||||||
const extension = process.platform === "win32" ? ".exe" : "";
|
|
||||||
const candidate = resolve(".hyperframes", "bin", `${name}${extension}`);
|
|
||||||
return existsSync(candidate) ? candidate : 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) ?? findInProjectLocalBin(binaryName) ?? findInCommonDirs(binaryName)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findFFmpeg(): string | undefined {
|
export function findFFmpeg(): string | undefined {
|
||||||
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
|
return findFfBinary("ffmpeg", { configuredMustExist: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findFFprobe(): string | undefined {
|
export function findFFprobe(): string | undefined {
|
||||||
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
|
return findFfBinary("ffprobe", { configuredMustExist: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFFmpegInstallHint(): string {
|
export function getFFmpegInstallHint(): string {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@hyperframes/core": "workspace:^",
|
"@hyperframes/core": "workspace:^",
|
||||||
|
"@hyperframes/parsers": "workspace:^",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"puppeteer": "^25.2.1",
|
"puppeteer": "^25.2.1",
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// fallow-ignore-file code-duplication
|
import { resolve } from "node:path";
|
||||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join, resolve } from "node:path";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertConfiguredFfmpegBinariesExist,
|
assertConfiguredFfmpegBinariesExist,
|
||||||
@@ -48,39 +45,6 @@ describe("ffmpeg binary env resolution", () => {
|
|||||||
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
|
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers the real Windows exe when PATH lookup lists a cmd shim first", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("child_process", () => ({
|
|
||||||
execFileSync: () => "C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");
|
|
||||||
|
|
||||||
expect(getMockedFfmpegBinary()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to scanning PATH when which/where fails", async () => {
|
|
||||||
const binDir = mkdtempSync(join(tmpdir(), "hyperframes-ffmpeg-path-"));
|
|
||||||
const ffmpegPath = join(binDir, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
|
||||||
const execFileSync = vi.fn(() => {
|
|
||||||
throw new Error("lookup command failed");
|
|
||||||
});
|
|
||||||
writeFileSync(ffmpegPath, "#!/bin/sh\n");
|
|
||||||
chmodSync(ffmpegPath, 0o755);
|
|
||||||
process.env.PATH = binDir;
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("child_process", () => ({ execFileSync }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");
|
|
||||||
|
|
||||||
expect(getMockedFfmpegBinary()).toBe(resolve(ffmpegPath));
|
|
||||||
expect(execFileSync).toHaveBeenCalledOnce();
|
|
||||||
} finally {
|
|
||||||
rmSync(binDir, { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls out a mangled replacement character in configured binary paths", () => {
|
it("calls out a mangled replacement character in configured binary paths", () => {
|
||||||
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg�.exe";
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg�.exe";
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +1,17 @@
|
|||||||
// fallow-ignore-file code-duplication
|
import { existsSync } from "fs";
|
||||||
import { execFileSync } from "child_process";
|
import { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||||
import { accessSync, constants, existsSync } from "fs";
|
|
||||||
import { delimiter, join, resolve } from "path";
|
|
||||||
|
|
||||||
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV };
|
||||||
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
|
||||||
|
|
||||||
const pathCache = new Map<string, string | undefined>();
|
|
||||||
|
|
||||||
function candidateFileName(candidate: string): string {
|
|
||||||
return candidate.split(/[\\/]/).at(-1)?.toLowerCase() ?? candidate.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseBestPathCandidate(
|
|
||||||
name: "ffmpeg" | "ffprobe",
|
|
||||||
candidates: readonly string[],
|
|
||||||
): string | undefined {
|
|
||||||
const normalized = candidates.map((candidate) => candidate.trim()).filter(Boolean);
|
|
||||||
return (
|
|
||||||
normalized.find((candidate) => candidateFileName(candidate) === `${name}.exe`) ??
|
|
||||||
normalized.find((candidate) => candidateFileName(candidate) === name) ??
|
|
||||||
normalized.find((candidate) => !candidateFileName(candidate).match(/\.(cmd|bat)$/i)) ??
|
|
||||||
normalized[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
|
||||||
const pathValue = process.env.PATH;
|
|
||||||
if (!pathValue) return undefined;
|
|
||||||
|
|
||||||
const extensions =
|
|
||||||
process.platform === "win32"
|
|
||||||
? [
|
|
||||||
".exe",
|
|
||||||
...new Set(
|
|
||||||
(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
||||||
.split(";")
|
|
||||||
.map((ext) => ext.trim().toLowerCase())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
: [""];
|
|
||||||
const candidates: string[] = [];
|
|
||||||
for (const dir of pathValue.split(delimiter)) {
|
|
||||||
if (!dir) continue;
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const candidate = join(dir, `${name}${ext}`);
|
|
||||||
if (isExecutablePathCandidate(candidate)) candidates.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chooseBestPathCandidate(name, candidates);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExecutablePathCandidate(candidate: string): boolean {
|
|
||||||
if (process.platform === "win32") return existsSync(candidate);
|
|
||||||
try {
|
|
||||||
accessSync(candidate, constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
|
||||||
if (pathCache.has(name)) return pathCache.get(name);
|
|
||||||
let found: string | undefined;
|
|
||||||
try {
|
|
||||||
const command = process.platform === "win32" ? "where" : "which";
|
|
||||||
const output = execFileSync(command, [name], {
|
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
found = chooseBestPathCandidate(name, output.split(/\r?\n/));
|
|
||||||
} catch {
|
|
||||||
found = scanPath(name);
|
|
||||||
}
|
|
||||||
const resolved = found ? resolve(found) : undefined;
|
|
||||||
pathCache.set(name, resolved);
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
|
|
||||||
const configured = process.env[envName]?.trim();
|
|
||||||
if (configured) return resolve(configured);
|
|
||||||
return findOnPath(binaryName) ?? binaryName;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// The engine hands spawn a bare binary name as the last resort so the spawn
|
||||||
|
// error names what the user must install; a configured-but-missing override
|
||||||
|
// is surfaced separately by assertConfiguredFfmpegBinariesExist below.
|
||||||
export function getFfmpegBinary(): string {
|
export function getFfmpegBinary(): string {
|
||||||
return getConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
|
return findFfBinary("ffmpeg") ?? "ffmpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFfprobeBinary(): string {
|
export function getFfprobeBinary(): string {
|
||||||
return getConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
|
return findFfBinary("ffprobe") ?? "ffprobe";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertConfiguredFfmpegBinariesExist(): void {
|
export function assertConfiguredFfmpegBinariesExist(): void {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// fallow-ignore-file code-duplication
|
import { execFile } from "node:child_process";
|
||||||
import { execFile, execSync } from "node:child_process";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||||
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||||
import {
|
import {
|
||||||
cleanAssetUrl,
|
cleanAssetUrl,
|
||||||
isRemoteOrInlineUrl,
|
isRemoteOrInlineUrl,
|
||||||
@@ -19,66 +17,10 @@ interface HtmlSourceLike {
|
|||||||
compSrcPath?: string;
|
compSrcPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
|
||||||
const PROBE_TIMEOUT_MS = 4000;
|
const PROBE_TIMEOUT_MS = 4000;
|
||||||
// Bounds concurrent ffprobe child processes for compositions referencing many videos.
|
// Bounds concurrent ffprobe child processes for compositions referencing many videos.
|
||||||
const PROBE_CONCURRENCY = 8;
|
const PROBE_CONCURRENCY = 8;
|
||||||
|
|
||||||
// Minimal PATH/env-based ffprobe resolution, duplicated from
|
|
||||||
// packages/cli/src/browser/ffmpeg.ts (findFFprobe). packages/lint must not
|
|
||||||
// depend on packages/cli (the CLI depends on @hyperframes/lint, which would
|
|
||||||
// create an import cycle) or packages/engine (too heavy for a lint check),
|
|
||||||
// so only the ffprobe-lookup half is re-implemented here — no ffmpeg lookup,
|
|
||||||
// no install-hint text, no Linux-distro detection.
|
|
||||||
|
|
||||||
function chooseBestFfprobeCandidate(candidates: string[]): string | undefined {
|
|
||||||
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
|
|
||||||
if (normalized.length === 0) return undefined;
|
|
||||||
const preferredExe = normalized.find((c) => c.toLowerCase().endsWith("ffprobe.exe"));
|
|
||||||
if (preferredExe) return preferredExe;
|
|
||||||
const exact = normalized.find((c) => c.toLowerCase().endsWith("ffprobe"));
|
|
||||||
if (exact) return exact;
|
|
||||||
const nonShellShim = normalized.find((c) => {
|
|
||||||
const lower = c.toLowerCase();
|
|
||||||
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
|
|
||||||
});
|
|
||||||
return nonShellShim ?? normalized[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFFprobeOnPath(): string | undefined {
|
|
||||||
try {
|
|
||||||
const cmd = process.platform === "win32" ? "where ffprobe" : "which ffprobe";
|
|
||||||
const output = execSync(cmd, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
const candidate = chooseBestFfprobeCandidate(output.split(/\r?\n/));
|
|
||||||
return candidate ? resolve(candidate) : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMON_BIN_DIRS =
|
|
||||||
process.platform === "win32"
|
|
||||||
? []
|
|
||||||
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
|
|
||||||
|
|
||||||
function findFFprobeInCommonDirs(): string | undefined {
|
|
||||||
for (const dir of COMMON_BIN_DIRS) {
|
|
||||||
const candidate = `${dir}/ffprobe`;
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFFprobe(): string | undefined {
|
|
||||||
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
|
||||||
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
|
|
||||||
return findFFprobeOnPath() ?? findFFprobeInCommonDirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function execFileAsync(file: string, args: string[]): Promise<string> {
|
function execFileAsync(file: string, args: string[]): Promise<string> {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
|
execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
|
||||||
@@ -174,7 +116,7 @@ export async function lintHevcPreviewCodec(
|
|||||||
): Promise<HyperframeLintFinding[]> {
|
): Promise<HyperframeLintFinding[]> {
|
||||||
if (candidates.size === 0) return [];
|
if (candidates.size === 0) return [];
|
||||||
|
|
||||||
const ffprobePath = findFFprobe();
|
const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true });
|
||||||
if (!ffprobePath) return [];
|
if (!ffprobePath) return [];
|
||||||
|
|
||||||
const entries = [...candidates.entries()];
|
const entries = [...candidates.entries()];
|
||||||
|
|||||||
@@ -93,6 +93,12 @@
|
|||||||
"node": "./dist/subCompositionValidity.js",
|
"node": "./dist/subCompositionValidity.js",
|
||||||
"import": "./src/subCompositionValidity.ts",
|
"import": "./src/subCompositionValidity.ts",
|
||||||
"types": "./src/subCompositionValidity.ts"
|
"types": "./src/subCompositionValidity.ts"
|
||||||
|
},
|
||||||
|
"./ff-binaries": {
|
||||||
|
"bun": "./src/ffBinaries.ts",
|
||||||
|
"node": "./dist/ffBinaries.js",
|
||||||
|
"import": "./src/ffBinaries.ts",
|
||||||
|
"types": "./src/ffBinaries.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -150,6 +156,10 @@
|
|||||||
"./sub-composition-validity": {
|
"./sub-composition-validity": {
|
||||||
"import": "./dist/subCompositionValidity.js",
|
"import": "./dist/subCompositionValidity.js",
|
||||||
"types": "./dist/subCompositionValidity.d.ts"
|
"types": "./dist/subCompositionValidity.d.ts"
|
||||||
|
},
|
||||||
|
"./ff-binaries": {
|
||||||
|
"import": "./dist/ffBinaries.js",
|
||||||
|
"types": "./dist/ffBinaries.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type FfBinariesModule = typeof import("./ffBinaries.js");
|
||||||
|
|
||||||
|
// The module caches system lookups in module state, so each test that
|
||||||
|
// exercises lookup mechanics resets modules and dynamic-imports a fresh copy.
|
||||||
|
async function importFresh(): Promise<FfBinariesModule> {
|
||||||
|
return import("./ffBinaries.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findFfBinary", () => {
|
||||||
|
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
vi.doUnmock("node:fs");
|
||||||
|
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||||
|
if (originalPath === undefined) delete process.env.PATH;
|
||||||
|
else process.env.PATH = originalPath;
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the resolved env override without touching the system", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg";
|
||||||
|
vi.resetModules();
|
||||||
|
const { findFfBinary } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg")).toBe(resolve("/tools/ffmpeg"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing env override as not-found when configuredMustExist is set", async () => {
|
||||||
|
process.env.HYPERFRAMES_FFMPEG_PATH = join(tmpdir(), "definitely-missing-ffmpeg");
|
||||||
|
vi.resetModules();
|
||||||
|
const { findFfBinary } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg", { configuredMustExist: true })).toBeUndefined();
|
||||||
|
expect(findFfBinary("ffmpeg")).toBe(resolve(join(tmpdir(), "definitely-missing-ffmpeg")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the real Windows exe when where lists a cmd shim first", async () => {
|
||||||
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", () => {
|
||||||
|
const mocked = { execFileSync: () => "C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n" };
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
const { findFfBinary } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg")).toBe(resolve("C:\\tools\\ffmpeg.exe"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to scanning PATH when which/where fails", async () => {
|
||||||
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
const binDir = mkdtempSync(join(tmpdir(), "hyperframes-ffbinaries-"));
|
||||||
|
const ffmpegPath = join(binDir, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
||||||
|
writeFileSync(ffmpegPath, "#!/bin/sh\n");
|
||||||
|
chmodSync(ffmpegPath, 0o755);
|
||||||
|
process.env.PATH = binDir;
|
||||||
|
const execFileSync = vi.fn(() => {
|
||||||
|
throw new Error("lookup command failed");
|
||||||
|
});
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", () => ({ execFileSync, default: { execFileSync } }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { findFfBinary } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg")).toBe(resolve(ffmpegPath));
|
||||||
|
expect(execFileSync).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
rmSync(binDir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a common install dir when which and the PATH scan both fail", async () => {
|
||||||
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||||
|
process.env.PATH = "";
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", () => {
|
||||||
|
const mocked = {
|
||||||
|
execFileSync: () => {
|
||||||
|
throw new Error("which: no ffmpeg in PATH");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
vi.doMock("node:fs", () => {
|
||||||
|
const mocked = {
|
||||||
|
existsSync: (candidate: unknown) => candidate === "/opt/homebrew/bin/ffmpeg",
|
||||||
|
accessSync: () => {
|
||||||
|
throw new Error("not executable");
|
||||||
|
},
|
||||||
|
constants: { X_OK: 1 },
|
||||||
|
};
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
const { findFfBinary } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg")).toBe(resolve("/opt/homebrew/bin/ffmpeg"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the binary is nowhere, and caches the miss until cleared", async () => {
|
||||||
|
delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||||
|
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||||
|
process.env.PATH = "";
|
||||||
|
const execFileSync = vi.fn(() => {
|
||||||
|
throw new Error("not found");
|
||||||
|
});
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", () => ({ execFileSync, default: { execFileSync } }));
|
||||||
|
vi.doMock("node:fs", () => {
|
||||||
|
const mocked = {
|
||||||
|
existsSync: () => false,
|
||||||
|
accessSync: () => {
|
||||||
|
throw new Error("not executable");
|
||||||
|
},
|
||||||
|
constants: { X_OK: 1 },
|
||||||
|
};
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
const { findFfBinary, clearFfBinaryLookupCache } = await importFresh();
|
||||||
|
|
||||||
|
expect(findFfBinary("ffmpeg")).toBeUndefined();
|
||||||
|
expect(findFfBinary("ffmpeg")).toBeUndefined();
|
||||||
|
expect(execFileSync).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
clearFfBinaryLookupCache();
|
||||||
|
expect(findFfBinary("ffmpeg")).toBeUndefined();
|
||||||
|
expect(execFileSync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { accessSync, constants, existsSync } from "node:fs";
|
||||||
|
import { delimiter, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared FFmpeg/FFprobe binary resolution for every package that shells out
|
||||||
|
* to them (engine, cli, lint). Node-only: import via the
|
||||||
|
* `@hyperframes/parsers/ff-binaries` subpath, never from a browser bundle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
||||||
|
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||||
|
|
||||||
|
export type FfBinaryName = "ffmpeg" | "ffprobe";
|
||||||
|
|
||||||
|
const ENV_BY_NAME: Record<FfBinaryName, string> = {
|
||||||
|
ffmpeg: FFMPEG_PATH_ENV,
|
||||||
|
ffprobe: FFPROBE_PATH_ENV,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathLookupCache = new Map<FfBinaryName, string | undefined>();
|
||||||
|
|
||||||
|
function candidateFileName(candidate: string): string {
|
||||||
|
return candidate.split(/[\\/]/).at(-1)?.toLowerCase() ?? candidate.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseBestPathCandidate(
|
||||||
|
name: FfBinaryName,
|
||||||
|
candidates: readonly string[],
|
||||||
|
): string | undefined {
|
||||||
|
const normalized = candidates.map((candidate) => candidate.trim()).filter(Boolean);
|
||||||
|
return (
|
||||||
|
normalized.find((candidate) => candidateFileName(candidate) === `${name}.exe`) ??
|
||||||
|
normalized.find((candidate) => candidateFileName(candidate) === name) ??
|
||||||
|
normalized.find((candidate) => !candidateFileName(candidate).match(/\.(cmd|bat)$/i)) ??
|
||||||
|
normalized[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutablePathCandidate(candidate: string): boolean {
|
||||||
|
if (process.platform === "win32") return existsSync(candidate);
|
||||||
|
try {
|
||||||
|
accessSync(candidate, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanPath(name: FfBinaryName): string | undefined {
|
||||||
|
const pathValue = process.env.PATH;
|
||||||
|
if (!pathValue) return undefined;
|
||||||
|
|
||||||
|
const extensions =
|
||||||
|
process.platform === "win32"
|
||||||
|
? [
|
||||||
|
".exe",
|
||||||
|
...new Set(
|
||||||
|
(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
||||||
|
.split(";")
|
||||||
|
.map((ext) => ext.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
: [""];
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const dir of pathValue.split(delimiter)) {
|
||||||
|
if (!dir) continue;
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = join(dir, `${name}${ext}`);
|
||||||
|
if (isExecutablePathCandidate(candidate)) candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chooseBestPathCandidate(name, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUI/Dock/launchd-spawned processes on macOS don't inherit the shell PATH, so
|
||||||
|
// `which ffmpeg` fails even when ffmpeg is installed via Homebrew. Probe the
|
||||||
|
// well-known install dirs as a last resort. (No-op on Windows, where `where`
|
||||||
|
// and installer-added PATH entries cover it.)
|
||||||
|
const COMMON_BIN_DIRS =
|
||||||
|
process.platform === "win32"
|
||||||
|
? []
|
||||||
|
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
|
||||||
|
|
||||||
|
function findInCommonDirs(name: FfBinaryName): string | undefined {
|
||||||
|
for (const dir of COMMON_BIN_DIRS) {
|
||||||
|
const candidate = `${dir}/${name}`;
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupOnSystem(name: FfBinaryName): string | undefined {
|
||||||
|
if (pathLookupCache.has(name)) return pathLookupCache.get(name);
|
||||||
|
let found: string | undefined;
|
||||||
|
try {
|
||||||
|
const command = process.platform === "win32" ? "where" : "which";
|
||||||
|
const output = execFileSync(command, [name], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
found = chooseBestPathCandidate(name, output.split(/\r?\n/));
|
||||||
|
} catch {
|
||||||
|
found = scanPath(name);
|
||||||
|
}
|
||||||
|
found ??= findInCommonDirs(name);
|
||||||
|
const resolved = found ? resolve(found) : undefined;
|
||||||
|
pathLookupCache.set(name, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindFfBinaryOptions {
|
||||||
|
/**
|
||||||
|
* How to treat an env override that points at a missing file: `true`
|
||||||
|
* reports the binary as not found (callers that surface an install hint or
|
||||||
|
* skip probing), `false`/unset returns the configured path as-is (callers
|
||||||
|
* that validate the override separately and want spawn errors to name the
|
||||||
|
* path the user configured).
|
||||||
|
*/
|
||||||
|
configuredMustExist?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an FFmpeg-family binary: env override first, then `which`/`where`,
|
||||||
|
* then a manual PATH scan (covers Windows PATHEXT), then well-known Unix
|
||||||
|
* install dirs. System lookups are cached per binary for the process
|
||||||
|
* lifetime; the env override is re-read on every call.
|
||||||
|
*/
|
||||||
|
export function findFfBinary(
|
||||||
|
name: FfBinaryName,
|
||||||
|
options: FindFfBinaryOptions = {},
|
||||||
|
): string | undefined {
|
||||||
|
const configured = process.env[ENV_BY_NAME[name]]?.trim();
|
||||||
|
if (configured) {
|
||||||
|
if (options.configuredMustExist && !existsSync(configured)) return undefined;
|
||||||
|
return resolve(configured);
|
||||||
|
}
|
||||||
|
return lookupOnSystem(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: drop cached system lookups so resolution can be re-exercised. */
|
||||||
|
export function clearFfBinaryLookupCache(): void {
|
||||||
|
pathLookupCache.clear();
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
composition: "src/composition.ts",
|
composition: "src/composition.ts",
|
||||||
compositionContract: "src/compositionContract.ts",
|
compositionContract: "src/compositionContract.ts",
|
||||||
subCompositionValidity: "src/subCompositionValidity.ts",
|
subCompositionValidity: "src/subCompositionValidity.ts",
|
||||||
|
ffBinaries: "src/ffBinaries.ts",
|
||||||
},
|
},
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user