From 8e63faf8462d677661fccd9f1f1661ba07575729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 28 Oct 2026 08:44:28 +0000 Subject: [PATCH] perf(engine): a --gpu render probes the GPU encoders once, not twice (#6638) * perf(engine,cli): a machine's GPU encoder probe runs once, not on every render * refactor(engine): one shared probe per process, no answer kept on disk --- packages/cli/src/browser/ffmpeg.test.ts | 15 +++++++++++++++ packages/cli/src/browser/ffmpeg.ts | 4 ++-- packages/engine/src/index.ts | 1 + packages/engine/src/services/chunkEncoder.ts | 2 +- packages/engine/src/utils/gpuEncoder.test.ts | 11 +++++++++++ packages/engine/src/utils/gpuEncoder.ts | 12 +++++++----- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 4116504f2a..99830a2a78 100644 --- a/packages/cli/src/browser/ffmpeg.test.ts +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -19,6 +19,16 @@ vi.mock("./linuxDeps.js", async (importOriginal) => ({ vi.mock("../utils/cancellableProcess.js", () => ({ runCancellableProcess: vi.fn() })); const mockRun = vi.mocked(runCancellableProcess); +// The render's own GPU probe and the engine's must be one answer, so the encoder check asks the shared one. +const gpuProbes = vi.hoisted(() => ({ + shared: vi.fn(async () => "nvenc"), + fresh: vi.fn(async () => "nvenc"), +})); +vi.mock("@hyperframes/engine", () => ({ + getCachedGpuEncoder: gpuProbes.shared, + detectGpuEncoder: gpuProbes.fresh, +})); + afterEach(() => { vi.clearAllMocks(); delete process.env.HYPERFRAMES_FFMPEG_PATH; @@ -111,6 +121,11 @@ describe("resolveH264EncoderMode", () => { ).rejects.toThrow(H264EncoderUnavailableError); expect(probeGpu).toHaveBeenCalledOnce(); + mockRun.mockResolvedValue({ stdout: " V....D h264_nvenc H.264\n" } as never); + expect(await detectH264EncoderModeForRender("/custom/ffmpeg", true, signal)).toBe("gpu"); + expect(gpuProbes.shared).toHaveBeenCalledOnce(); + expect(gpuProbes.fresh).not.toHaveBeenCalled(); + const timeout = new Error("ETIMEDOUT"); mockRun.mockRejectedValueOnce(timeout); await expect( diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index bfa96f4201..c7fa224998 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -1,4 +1,4 @@ -import { detectGpuEncoder } from "@hyperframes/engine"; +import { getCachedGpuEncoder } from "@hyperframes/engine"; import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js"; import { runCancellableProcess } from "../utils/cancellableProcess.js"; @@ -40,7 +40,7 @@ export async function detectH264EncoderModeForRender( ffmpegPath: string, gpuRequested: boolean, signal: AbortSignal, - probeGpu: () => Promise = detectGpuEncoder, + probeGpu: () => Promise = getCachedGpuEncoder, ): Promise { const result = await runCancellableProcess(ffmpegPath, ["-hide_banner", "-encoders"], { signal, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 265f114b34..3af00b61b0 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -189,6 +189,7 @@ export { applyFaststart, packageHls, detectGpuEncoder, + getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 7f9d8367c0..77c762aaf7 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -186,7 +186,7 @@ export function getEncoderPreset( } // Re-export GPU utilities so existing consumers that import from chunkEncoder still work. -export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js"; +export { detectGpuEncoder, getCachedGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js"; /** The `lockGopForChunkConcat` / `gopSize` pair, shared by every encoder entry point. */ export interface LockedGopOptions { diff --git a/packages/engine/src/utils/gpuEncoder.test.ts b/packages/engine/src/utils/gpuEncoder.test.ts index 86548a267c..1b07b6ab8d 100644 --- a/packages/engine/src/utils/gpuEncoder.test.ts +++ b/packages/engine/src/utils/gpuEncoder.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + getCachedGpuEncoder, getCompiledGpuEncoders, getGpuEncoderName, getProbeArgs, mapPresetForGpuEncoder, + resetCachedGpuEncoder, selectUsableGpuEncoder, } from "./gpuEncoder.js"; @@ -146,3 +148,12 @@ describe("getProbeArgs", () => { expect(args[args.indexOf("-allow_sw") + 1]).toBe("1"); }); }); + +describe("getCachedGpuEncoder", () => { + afterEach(() => resetCachedGpuEncoder()); + + it("is one probe however many callers ask while it runs", () => { + const first = getCachedGpuEncoder(); + expect(getCachedGpuEncoder()).toBe(first); + }); +}); diff --git a/packages/engine/src/utils/gpuEncoder.ts b/packages/engine/src/utils/gpuEncoder.ts index 93a2f4d85c..535a16508b 100644 --- a/packages/engine/src/utils/gpuEncoder.ts +++ b/packages/engine/src/utils/gpuEncoder.ts @@ -80,15 +80,17 @@ export async function detectGpuEncoder(): Promise { return selectUsableGpuEncoder(candidates, canUseGpuEncoder).catch(() => null); } -let cachedGpuEncoder: GpuEncoder | undefined = undefined; +let cachedGpuEncoder: Promise | undefined = undefined; -export async function getCachedGpuEncoder(): Promise { - if (cachedGpuEncoder === undefined) { - cachedGpuEncoder = await detectGpuEncoder(); - } +export function getCachedGpuEncoder(): Promise { + cachedGpuEncoder ??= detectGpuEncoder(); return cachedGpuEncoder; } +export function resetCachedGpuEncoder() { + cachedGpuEncoder = undefined; +} + export function getGpuEncoderName(encoder: GpuEncoder, codec: "h264" | "h265"): string { if (!encoder) return codec === "h264" ? "libx264" : "libx265"; switch (encoder) {