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
@@ -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);
+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 // 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
+3
View File
@@ -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, {
+5 -1
View File
@@ -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");