diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 545c56024f..04cf4b3189 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -457,6 +457,8 @@ export async function spawnStreamingEncoder( const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, { stdio: ["pipe", "pipe", "pipe"], + // See runFfmpeg.ts: keeps a console window off the user's desktop on Windows. + windowsHide: true, }); trackChildProcess(ffmpeg); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index f4f6ccc3f9..14d5b35bf1 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -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 diff --git a/packages/engine/src/utils/gpuEncoder.ts b/packages/engine/src/utils/gpuEncoder.ts index bf5c3a5d30..03b006fb68 100644 --- a/packages/engine/src/utils/gpuEncoder.ts +++ b/packages/engine/src/utils/gpuEncoder.ts @@ -64,6 +64,8 @@ export async function selectUsableGpuEncoder( export async function detectGpuEncoder(): Promise { 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 { const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), { stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, }); trackChildProcess(ffmpeg); const outcome = await new ManagedChildProcess(ffmpeg, { diff --git a/packages/engine/src/utils/runFfmpeg.ts b/packages/engine/src/utils/runFfmpeg.ts index 44aac4ba6e..ce81203872 100644 --- a/packages/engine/src/utils/runFfmpeg.ts +++ b/packages/engine/src/utils/runFfmpeg.ts @@ -92,7 +92,11 @@ export function formatFfmpegError( export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise { 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, diff --git a/packages/engine/src/utils/runFfmpeg.windowsHide.test.ts b/packages/engine/src/utils/runFfmpeg.windowsHide.test.ts new file mode 100644 index 0000000000..7a28a5b209 --- /dev/null +++ b/packages/engine/src/utils/runFfmpeg.windowsHide.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index ccddf32055..fafc735b32 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -88,7 +88,8 @@ export function parseAudioElements(html: string): AudioElement[] { */ function runFFmpeg(args: string[]): Promise { 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); let stderr = ""; diff --git a/packages/producer/src/services/distributed/shared.ts b/packages/producer/src/services/distributed/shared.ts index 0217686ac2..eec68cd248 100644 --- a/packages/producer/src/services/distributed/shared.ts +++ b/packages/producer/src/services/distributed/shared.ts @@ -333,7 +333,11 @@ let cachedFfmpegVersion: string | null = null; */ export async function readFfmpegVersion(): Promise { 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() ?? ""; if (!firstLine) { throw new Error("ffmpeg -version returned empty output");