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:
Miguel Ángel
2026-08-21 11:37:21 -04:00
committed by GitHub
parent a9ea07edde
commit 315a7b758c
7 changed files with 59 additions and 3 deletions
+2
View File
@@ -69,6 +69,8 @@ async function runFfprobe(
// Nothing is ever written to the child's stdin; leaving it as a pipe is
// what lets a stdin-reading invocation block indefinitely.
stdio: ["ignore", "pipe", "pipe"],
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
windowsHide: true,
});
trackChildProcess(proc);
// Decoded through StringDecoder rather than per-chunk toString(): a
+3
View File
@@ -64,6 +64,8 @@ export async function selectUsableGpuEncoder(
export async function detectGpuEncoder(): Promise<GpuEncoder> {
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
stdio: ["pipe", "pipe", "pipe"],
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
windowsHide: true,
});
trackChildProcess(ffmpeg);
let stdout = "";
@@ -146,6 +148,7 @@ export function getProbeArgs(encoder: ConcreteGpuEncoder): string[] {
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true,
});
trackChildProcess(ffmpeg);
const outcome = await new ManagedChildProcess(ffmpeg, {
+5 -1
View File
@@ -92,7 +92,11 @@ export function formatFfmpegError(
export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult> {
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);
const managed = new ManagedChildProcess(ffmpeg, {
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);
});
});