fix(producer): honor FFMPEG_PROCESS_TIMEOUT_MS during video-frame extraction (#3918)

* fix: honor FFMPEG_PROCESS_TIMEOUT_MS during video-frame extraction

FFMPEG_PROCESS_TIMEOUT_MS resolves correctly into EngineConfig and is
correctly forwarded to the audio-extraction ffmpeg call, but
runExtractVideosStage (shared by both the in-process renderer and
distributed plan()) built the config object it passes to
extractAllVideoFrames with only { extractCacheDir, extractCacheMaxBytes },
silently dropping ffmpegProcessTimeout even though the full EngineConfig
was already in scope. That config flows straight into
extractVideoFramesRange, which falls back to a hardcoded 300s deadline
whenever the field is undefined -- so video-frame extraction always
used a fixed 300s timeout no matter what the env var was set to.

Threads cfg.ffmpegProcessTimeout into that config literal the same way
the two cache fields already are.

Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>

* fix(producer): pass the whole engine config to video-frame extraction

The stage hand-picked fields from its resolved config, so any field it forgot
(the ffmpeg process timeout) silently fell back to the engine default.

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
This commit is contained in:
miga-heygen
2026-10-02 04:26:10 -07:00
committed by GitHub
co-authored by Miguel Angel
parent 9d6ca45857
commit e0d4c96003
2 changed files with 110 additions and 1 deletions
@@ -0,0 +1,109 @@
import { resolveConfig, type EngineConfig, type ExtractionResult } from "@hyperframes/engine";
import { beforeEach, describe, expect, it, vi } from "vitest";
/** The optional engine-config argument the stage passes to `extractAllVideoFrames`. */
type ExtractionConfigArg = Partial<EngineConfig> | undefined;
const capturedConfigs = vi.hoisted(() => new Array<ExtractionConfigArg>());
vi.mock("@hyperframes/engine", async (importOriginal) => {
const real = await importOriginal<typeof import("@hyperframes/engine")>();
return {
...real,
extractAllVideoFrames: async (
_videos: unknown,
_baseDir: unknown,
_options: unknown,
_signal: unknown,
config: ExtractionConfigArg,
): Promise<ExtractionResult> => {
capturedConfigs.push(config);
return {
success: true,
extracted: [],
errors: [],
totalFramesExtracted: 0,
durationMs: 0,
phaseBreakdown: {
resolveMs: 0,
cachePublishFailures: 0,
cacheGcEvictions: 0,
cacheGcBytesFreed: 0,
cacheAgedPartialsCleared: 0,
hdrProbeMs: 0,
hdrPreflightMs: 0,
hdrPreflightCount: 0,
vfrProbeMs: 0,
vfrPreflightMs: 0,
vfrPreflightCount: 0,
extractMs: 0,
cacheHits: 0,
cacheMisses: 0,
transientRetries: 0,
},
};
},
};
});
import { createRenderJob } from "../../renderOrchestrator.js";
import { runExtractVideosStage } from "./extractVideosStage.js";
async function runStage(cfg: EngineConfig): Promise<void> {
const composition = {
duration: 5,
videos: [
{
id: "root-video",
src: "clip.mp4",
start: 0,
end: 5,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
audios: [],
images: [],
width: 1920,
height: 1080,
};
await runExtractVideosStage({
projectDir: "/tmp/hf-ffmpeg-timeout-project",
compiledDir: "/tmp/hf-ffmpeg-timeout-compiled",
job: createRenderJob({
fps: { num: 30, den: 1 },
quality: "standard",
hdrMode: "force-sdr",
}),
cfg,
composition,
abortSignal: undefined,
assertNotAborted: () => {},
materializeSymlinks: false,
});
}
describe("video extraction ffmpeg timeout threading", () => {
beforeEach(() => {
capturedConfigs.splice(0);
});
it("forwards every resolved extraction setting, including FFMPEG_PROCESS_TIMEOUT_MS", async () => {
await runStage(
resolveConfig({
ffmpegProcessTimeout: 5_000,
extractCacheDir: "/tmp/hf-ffmpeg-timeout-cache",
extractCacheMaxBytes: 1234,
}),
);
expect(capturedConfigs).toEqual([
expect.objectContaining({
ffmpegProcessTimeout: 5_000,
extractCacheDir: "/tmp/hf-ffmpeg-timeout-cache",
extractCacheMaxBytes: 1234,
}),
]);
});
});
@@ -503,7 +503,7 @@ export async function runExtractVideosStage(
collectProbeFailures: extractionPolicy.failureMode === "enforce",
},
abortSignal,
{ extractCacheDir: cfg.extractCacheDir, extractCacheMaxBytes: cfg.extractCacheMaxBytes },
cfg,
compiledDir,
);
extractionResult.phaseBreakdown.transientRetries =