diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ffmpegTimeout.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.ffmpegTimeout.test.ts new file mode 100644 index 0000000000..388065dcc0 --- /dev/null +++ b/packages/producer/src/services/render/stages/extractVideosStage.ffmpegTimeout.test.ts @@ -0,0 +1,107 @@ +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 | undefined; + +const capturedConfigs = vi.hoisted(() => new Array()); + +vi.mock("@hyperframes/engine", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + extractAllVideoFrames: async ( + _videos: unknown, + _baseDir: unknown, + _options: unknown, + _signal: unknown, + config: ExtractionConfigArg, + ): Promise => { + 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 { + 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 a non-default FFMPEG_PROCESS_TIMEOUT_MS into the video extraction config", async () => { + await runStage(resolveConfig({ ffmpegProcessTimeout: 5_000 })); + + expect(capturedConfigs).toEqual([expect.objectContaining({ ffmpegProcessTimeout: 5_000 })]); + }); + + it("forwards the default timeout unchanged when no override is configured", async () => { + const cfg = resolveConfig(); + + await runStage(cfg); + + expect(capturedConfigs).toEqual([ + expect.objectContaining({ ffmpegProcessTimeout: cfg.ffmpegProcessTimeout }), + ]); + }); +}); diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index 029c9905aa..178deab434 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -501,7 +501,11 @@ export async function runExtractVideosStage( collectProbeFailures: extractionPolicy.failureMode === "enforce", }, abortSignal, - { extractCacheDir: cfg.extractCacheDir, extractCacheMaxBytes: cfg.extractCacheMaxBytes }, + { + ffmpegProcessTimeout: cfg.ffmpegProcessTimeout, + extractCacheDir: cfg.extractCacheDir, + extractCacheMaxBytes: cfg.extractCacheMaxBytes, + }, compiledDir, ); extractionResult.phaseBreakdown.transientRetries =