mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(engine): hide ffmpeg console windows on Windows (#3381)
ffmpeg and ffprobe are console-subsystem binaries and Node defaults windowsHide to false, so every spawn opened a visible console window on Windows. A render shells out dozens of times across parallel workers, which flashed a burst of windows across the user's desktop. Applied at every production spawn site rather than only the two named in the report, since they all share the cause: runFfmpeg, both gpuEncoder probes, ffprobe, streamingEncoder, audioExtractor and the distributed version check. windowsHide is a no-op on macOS and Linux. The dev-only parity and regression harnesses are left alone; they never run on a user's desktop. Closes #3379
This commit is contained in:
@@ -457,6 +457,8 @@ export async function spawnStreamingEncoder(
|
|||||||
|
|
||||||
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
|
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ async function runFfprobe(
|
|||||||
// Nothing is ever written to the child's stdin; leaving it as a pipe is
|
// Nothing is ever written to the child's stdin; leaving it as a pipe is
|
||||||
// what lets a stdin-reading invocation block indefinitely.
|
// what lets a stdin-reading invocation block indefinitely.
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
trackChildProcess(proc);
|
trackChildProcess(proc);
|
||||||
// Decoded through StringDecoder rather than per-chunk toString(): a
|
// Decoded through StringDecoder rather than per-chunk toString(): a
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export async function selectUsableGpuEncoder(
|
|||||||
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
||||||
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -146,6 +148,7 @@ export function getProbeArgs(encoder: ConcreteGpuEncoder): string[] {
|
|||||||
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
||||||
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
const outcome = await new ManagedChildProcess(ffmpeg, {
|
const outcome = await new ManagedChildProcess(ffmpeg, {
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export function formatFfmpegError(
|
|||||||
|
|
||||||
export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult> {
|
export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult> {
|
||||||
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
||||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
// windowsHide: ffmpeg/ffprobe are console-subsystem binaries, so without
|
||||||
|
// this Node opens a visible console window per spawn on Windows. A render
|
||||||
|
// shells out dozens of times across parallel workers, which flashes a burst
|
||||||
|
// of windows across the user's desktop. No-op on macOS and Linux.
|
||||||
|
const ffmpeg = spawn(getFfmpegBinary(), args, { windowsHide: true });
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
const managed = new ManagedChildProcess(ffmpeg, {
|
const managed = new ManagedChildProcess(ffmpeg, {
|
||||||
signal: opts?.signal,
|
signal: opts?.signal,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Hoisted so the mock factory below can reach it without a top-level variable.
|
||||||
|
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
|
||||||
|
vi.mock("child_process", () => ({ spawn: spawnMock }));
|
||||||
|
|
||||||
|
/** Minimal stand-in for the ChildProcess runFfmpeg awaits. */
|
||||||
|
function fakeFfmpeg() {
|
||||||
|
const proc = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
||||||
|
proc.stdout = new EventEmitter();
|
||||||
|
proc.stderr = new EventEmitter();
|
||||||
|
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||||
|
proc.kill = vi.fn();
|
||||||
|
proc.pid = 4242;
|
||||||
|
queueMicrotask(() => proc.emit("close", 0, null));
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runFfmpeg spawn options", () => {
|
||||||
|
it("hides the console window so Windows renders do not flash terminals", async () => {
|
||||||
|
// Regression for the Windows popup report: ffmpeg is a console-subsystem
|
||||||
|
// binary, and Node defaults `windowsHide` to false, so every spawn opened a
|
||||||
|
// visible window. A render shells out dozens of times across parallel
|
||||||
|
// workers, which produced a burst of windows on the user's desktop.
|
||||||
|
// Asserted on the options actually handed to spawn rather than on the
|
||||||
|
// source text, so a future call site that drops the flag is caught by
|
||||||
|
// behaviour.
|
||||||
|
spawnMock.mockImplementation(() => fakeFfmpeg());
|
||||||
|
|
||||||
|
const { runFfmpeg } = await import("./runFfmpeg.js");
|
||||||
|
await runFfmpeg(["-version"]);
|
||||||
|
|
||||||
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||||
|
const options = spawnMock.mock.calls[0]?.[2] as { windowsHide?: boolean } | undefined;
|
||||||
|
expect(options?.windowsHide).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,7 +88,8 @@ 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(getFfmpegBinary(), args);
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
||||||
|
const ffmpeg = spawn(getFfmpegBinary(), args, { windowsHide: true });
|
||||||
trackChildProcess(ffmpeg);
|
trackChildProcess(ffmpeg);
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ let cachedFfmpegVersion: string | null = null;
|
|||||||
*/
|
*/
|
||||||
export async function readFfmpegVersion(): Promise<string> {
|
export async function readFfmpegVersion(): Promise<string> {
|
||||||
if (cachedFfmpegVersion !== null) return cachedFfmpegVersion;
|
if (cachedFfmpegVersion !== null) return cachedFfmpegVersion;
|
||||||
const { stdout } = await execFile("ffmpeg", ["-version"], { maxBuffer: 1024 * 1024 });
|
const { stdout } = await execFile("ffmpeg", ["-version"], {
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
const firstLine = stdout.split(/\r?\n/)[0]?.trim() ?? "";
|
const firstLine = stdout.split(/\r?\n/)[0]?.trim() ?? "";
|
||||||
if (!firstLine) {
|
if (!firstLine) {
|
||||||
throw new Error("ffmpeg -version returned empty output");
|
throw new Error("ffmpeg -version returned empty output");
|
||||||
|
|||||||
Reference in New Issue
Block a user