From a3105a389dd60814443505c15028492a70072a5a Mon Sep 17 00:00:00 2001 From: Bryant Tay Date: Thu, 10 Sep 2026 14:57:37 +0800 Subject: [PATCH 1/2] fix: hide console windows spawned by ffmpeg and nvidia-smi on Windows Node's `windowsHide` defaults to false, so every child process that is a console application pops a window on the user's desktop. During a render or a regression run that is a stream of black windows stealing focus. The repo already sets `windowsHide: true` across cli, studio-server and most of engine; these were the remaining gaps, all on paths that run during a normal render: - core/mediaGradeAnalyzer: ffprobe + ffmpeg, once per analyzed media file - engine/browserManager: `nvidia-smi` VRAM probe. This one goes through a shell (`execSync`), so it flashes a cmd.exe window even on machines with no NVIDIA GPU, where the command exists only to fail - producer: the regression harness ffmpeg runner, PSNR/audio comparison helpers, fixture synthesis, and the ffprobe call in audioPadTrim No behavior change on POSIX, where the flag is ignored. Co-Authored-By: Claude Opus 5 --- packages/core/src/mediaGradeAnalyzer.ts | 3 ++- packages/engine/src/services/browserManager.ts | 4 ++++ packages/producer/src/parity-harness.ts | 2 +- packages/producer/src/plan-parity-analysis.ts | 3 ++- packages/producer/src/regression-harness.ts | 1 + packages/producer/src/services/mediaTypeTestFixtures.ts | 4 +++- packages/producer/src/services/render/audioPadTrim.ts | 5 ++++- packages/producer/src/utils/audioRegression.ts | 4 ++-- 8 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/core/src/mediaGradeAnalyzer.ts b/packages/core/src/mediaGradeAnalyzer.ts index 522f68bde3..0d918793e2 100644 --- a/packages/core/src/mediaGradeAnalyzer.ts +++ b/packages/core/src/mediaGradeAnalyzer.ts @@ -124,7 +124,7 @@ function probeMedia(mediaPath: string, ffprobePath: string): GradeMediaProbe { "--", mediaPath, ], - { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] }, + { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, ); const parsed = asRecord(JSON.parse(raw)); const streams = Array.isArray(parsed.streams) ? parsed.streams : []; @@ -341,6 +341,7 @@ export function analyzeMediaGrade( encoding: "utf8", timeout: Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }, ); return summarizeMediaTreatmentAnalysis(probe, parseMediaTreatmentSignalStats(raw)); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index d77ad0dcd9..9652924c79 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -809,6 +809,10 @@ function probeNvidiaVramMb(): number | null { timeout: 3000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], + // execSync goes through a shell, so without this every probe flashes a + // cmd.exe window on the user's desktop — including on the machines with + // no NVIDIA GPU, where the command only exists to fail. + windowsHide: true, }).trim(); const mb = parseInt(out.split("\n")[0] ?? "", 10); if (Number.isFinite(mb) && mb > 0) { diff --git a/packages/producer/src/parity-harness.ts b/packages/producer/src/parity-harness.ts index e390ac81f8..b0b7eb696f 100644 --- a/packages/producer/src/parity-harness.ts +++ b/packages/producer/src/parity-harness.ts @@ -116,7 +116,7 @@ function writeImageDiff(basePath: string, comparePath: string, outputPath: strin "[0:v][1:v]blend=all_mode=difference", outputPath, ], - { stdio: "pipe" }, + { stdio: "pipe", windowsHide: true }, ); if (ffmpeg.status !== 0) { const stderr = (ffmpeg.stderr || Buffer.from("")).toString("utf-8"); diff --git a/packages/producer/src/plan-parity-analysis.ts b/packages/producer/src/plan-parity-analysis.ts index 1d7579fc4c..bc15285ca8 100644 --- a/packages/producer/src/plan-parity-analysis.ts +++ b/packages/producer/src/plan-parity-analysis.ts @@ -40,6 +40,7 @@ function requireCommandSuccess( encoding: "buffer", maxBuffer, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); if (result.error) throw result.error; if (result.status !== 0) { @@ -218,7 +219,7 @@ async function canonicalPcmAudio( "s16le", "-", ], - { stdio: ["ignore", "pipe", "pipe"] }, + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, ); const hash = createHash("sha256"); const stderr: Buffer[] = []; diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 57eee5c9da..7d168e69e7 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -544,6 +544,7 @@ function runFfmpeg(args: string[], label: string): { stdout: Buffer; stderr: str stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, encoding: "buffer", + windowsHide: true, }); const stderr = result.stderr.toString("utf-8"); if (result.status !== 0) { diff --git a/packages/producer/src/services/mediaTypeTestFixtures.ts b/packages/producer/src/services/mediaTypeTestFixtures.ts index a658c04791..d0fec208b4 100644 --- a/packages/producer/src/services/mediaTypeTestFixtures.ts +++ b/packages/producer/src/services/mediaTypeTestFixtures.ts @@ -1,7 +1,9 @@ import { spawnSync } from "node:child_process"; export function synthesizeMediaFixture(args: string[]): void { - const result = spawnSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args]); + const result = spawnSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args], { + windowsHide: true, + }); if (result.status !== 0) { throw new Error(`ffmpeg fixture synthesis failed: ${result.stderr.toString().slice(-400)}`); } diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index e4ecb4e5d1..1b344a1c6d 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -634,7 +634,10 @@ async function runFfprobeJson(args: string[], signal?: AbortSignal): Promise< if (!args.includes("--")) { throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".'); } - const proc = spawn(getFfprobeBinary(), args, { stdio: ["ignore", "pipe", "pipe"] }); + const proc = spawn(getFfprobeBinary(), args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); trackChildProcess(proc); let stdout = ""; proc.stdout.on("data", (data: Buffer) => { diff --git a/packages/producer/src/utils/audioRegression.ts b/packages/producer/src/utils/audioRegression.ts index 8017b0298a..2fc3bfc971 100644 --- a/packages/producer/src/utils/audioRegression.ts +++ b/packages/producer/src/utils/audioRegression.ts @@ -200,7 +200,7 @@ export function computeAudioResidualRmsDb( "null", "-", ], - { encoding: "utf-8" }, + { encoding: "utf-8", windowsHide: true }, ); // `spawnSync` swallows `ENOENT`, signal kills, and non-zero exits @@ -318,7 +318,7 @@ function probeAudioDuration(file: string): { seconds: number; error?: string } { "--", file, ], - { encoding: "utf-8" }, + { encoding: "utf-8", windowsHide: true }, ); if (proc.error) { return { From fb81b9740abf948510459d78333c579b16a893aa Mon Sep 17 00:00:00 2001 From: Bryant Tay Date: Thu, 10 Sep 2026 15:00:05 +0800 Subject: [PATCH 2/2] fix(producer): escape PSNR stats_file for ffmpeg's two-pass filtergraph parse An ffmpeg filtergraph argument is unescaped twice: once when the graph is split into filters and their options, then again when the option value is read. `psnrAtFrames` escaped `\` and `:` a single time, which only survives the first pass. On POSIX that is invisible, because mkdtemp under tmpdir() produces neither character. On Windows the stats path is always `C:\Users\...`, so it reaches the option parser with a bare `:`, which starts a new option and fails the whole graph: [AVFilterGraph] No option name near '\Users\...\psnr.log' Error parsing filterchain '[rv][gv]psnr=...:stats_file=C\:\Users\...' Every suite that got as far as quality validation died there, so a Windows regression run reported 0 passes with only 2 genuine compilation failures. Escaping for both passes fixes it and stays a no-op on POSIX. Co-Authored-By: Claude Opus 5 --- packages/producer/src/regression-harness.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 7d168e69e7..429df25734 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -631,10 +631,15 @@ export function psnrAtFrames( const statsDir = mkdtempSync(join(tmpdir(), "hf-psnr-")); const statsFile = join(statsDir, "psnr.log"); try { - // ffmpeg treats `:` and `\` in filter option values as syntax, so a temp - // path containing either would break the filtergraph. mkdtemp under - // tmpdir() does not produce those on POSIX, but escape defensively. - const escaped = statsFile.replace(/\\/g, "\\\\").replace(/:/g, "\\:"); + // ffmpeg treats `:` and `\` in filter option values as syntax, and a + // filtergraph argument is unescaped TWICE: once when the graph is split + // into filters and their options, then again when the option value itself + // is read. One round of escaping only survives the first pass, so on + // Windows `C:\Users\...` reaches the option parser as `C:\Users\...`, + // whose bare `:` starts a new option and fails the whole graph with + // "No option name near '\Users\...'". Escaping for both passes is a no-op + // on POSIX, where mkdtemp under tmpdir() produces neither character. + const escaped = statsFile.replace(/\\/g, "\\\\\\\\").replace(/:/g, "\\\\:"); const selectExpr = wanted.map((frame) => `eq(n\\,${frame})`).join("+"); const stream = (index: number, label: string) => `[${index}:v]select='${selectExpr}',settb=1/1,setpts=N[${label}]`;