diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 4300e87a5..7ade6b85f 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -1,5 +1,22 @@ -import { describe, it, expect, vi } from "vitest"; -import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ENCODER_PRESETS, + getEncoderPreset, + buildEncoderArgs, + encodeFramesChunkedConcat, +} from "./chunkEncoder.js"; + +// Spy on runFfmpeg so encodeFramesChunkedConcat tests can assert the timeout +// each ffmpeg invocation receives without spawning real processes. +const { runFfmpegMock } = vi.hoisted(() => ({ runFfmpegMock: vi.fn() })); + +vi.mock("../utils/runFfmpeg.js", async (importOriginal) => ({ + ...(await importOriginal()), + runFfmpeg: runFfmpegMock, +})); describe("ENCODER_PRESETS", () => { it("has draft, standard, and high presets", () => { @@ -862,3 +879,67 @@ describe("buildEncoderArgs HDR color space", () => { expect(args.indexOf("-x265-params")).toBe(-1); }); }); + +describe("encodeFramesChunkedConcat timeout threading", () => { + beforeEach(() => { + runFfmpegMock.mockReset(); + runFfmpegMock.mockResolvedValue({ success: true, exitCode: 0, stderr: "", durationMs: 1 }); + }); + + // 60 frames at chunk size 30 → 2 chunk encodes + 1 concat = 3 invocations. + function makeFramesDir(): string { + const framesDir = mkdtempSync(join(tmpdir(), "hf-chunk-encode-test-")); + for (let i = 1; i <= 60; i++) { + writeFileSync(join(framesDir, `frame_${String(i).padStart(6, "0")}.jpg`), ""); + } + return framesDir; + } + + const options = { + ...getEncoderPreset("standard", "mp4"), + fps: { num: 30, den: 1 }, + width: 640, + height: 360, + useGpu: false, + }; + + it("passes config.ffmpegEncodeTimeout to every chunk encode and the final concat", async () => { + const framesDir = makeFramesDir(); + const outputPath = join(mkdtempSync(join(tmpdir(), "hf-chunk-out-")), "out.mp4"); + + const result = await encodeFramesChunkedConcat( + framesDir, + "frame_%06d.jpg", + outputPath, + options, + 30, + undefined, + { ffmpegEncodeTimeout: 42_000 }, + ); + + expect(result.success).toBe(true); + expect(runFfmpegMock).toHaveBeenCalledTimes(3); + for (const [, opts] of runFfmpegMock.mock.calls) { + expect(opts.timeout).toBe(42_000); + } + }); + + it("falls back to the default timeout when config is absent", async () => { + const framesDir = makeFramesDir(); + const outputPath = join(mkdtempSync(join(tmpdir(), "hf-chunk-out-")), "out.mp4"); + + const result = await encodeFramesChunkedConcat( + framesDir, + "frame_%06d.jpg", + outputPath, + options, + 30, + ); + + expect(result.success).toBe(true); + expect(runFfmpegMock).toHaveBeenCalledTimes(3); + for (const [, opts] of runFfmpegMock.mock.calls) { + expect(opts.timeout).toBe(600_000); + } + }); +}); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index e5f1f3a4d..2ee71f3c2 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -490,20 +490,26 @@ export async function encodeFramesChunkedConcat( options: EncoderOptions, chunkSizeFrames: number, signal?: AbortSignal, + config?: Partial>, ): Promise { const start = Date.now(); + // Applied per ffmpeg invocation (each chunk and the final concat), not to + // the whole sequence — chunks are short, so a hung process is the only + // thing that should ever trip it. + const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout; + const fail = (error: string): EncodeResult => ({ + success: false, + outputPath, + durationMs: Date.now() - start, + framesEncoded: 0, + fileSize: 0, + error: signal?.aborted ? "Chunked encode cancelled" : error, + }); const files = readdirSync(framesDir) .filter((f) => f.match(/\.(jpg|jpeg|png)$/i)) .sort(); if (files.length === 0) { - return { - success: false, - outputPath, - durationMs: Date.now() - start, - framesEncoded: 0, - fileSize: 0, - error: "[FFmpeg] No frame files found in directory", - }; + return fail("[FFmpeg] No frame files found in directory"); } const chunkSize = Math.max(30, Math.floor(chunkSizeFrames)); const chunkCount = Math.ceil(files.length / chunkSize); @@ -513,14 +519,7 @@ export async function encodeFramesChunkedConcat( for (let i = 0; i < chunkCount; i++) { if (signal?.aborted) { - return { - success: false, - outputPath, - durationMs: Date.now() - start, - framesEncoded: 0, - fileSize: 0, - error: "Chunked encode cancelled", - }; + return fail("Chunked encode cancelled"); } const startNumber = i * chunkSize; const framesInChunk = Math.min(chunkSize, files.length - startNumber); @@ -544,30 +543,9 @@ export async function encodeFramesChunkedConcat( let gpuEncoder: GpuEncoder = null; if (options.useGpu) gpuEncoder = await getCachedGpuEncoder(); const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder); - const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => { - const ffmpeg = spawn(getFfmpegBinary(), args); - trackChildProcess(ffmpeg); - let stderr = ""; - ffmpeg.stderr.on("data", (d) => { - stderr += d.toString(); - }); - ffmpeg.on("close", (code) => { - if (code === 0) resolve({ success: true }); - else resolve({ success: false, error: `Chunk ${i} encode failed: ${stderr.slice(-400)}` }); - }); - ffmpeg.on("error", (err) => { - resolve({ success: false, error: `Chunk ${i} encode error: ${err.message}` }); - }); - }); + const chunkResult = await runFfmpeg(args, { signal, timeout: encodeTimeout }); if (!chunkResult.success) { - return { - success: false, - outputPath, - durationMs: Date.now() - start, - framesEncoded: 0, - fileSize: 0, - error: chunkResult.error, - }; + return fail(`Chunk ${i} encode failed: ${chunkResult.stderr.slice(-400)}`); } chunkPaths.push(chunkPath); } @@ -588,31 +566,10 @@ export async function encodeFramesChunkedConcat( "-y", outputPath, ]; - const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => { - const ffmpeg = spawn(getFfmpegBinary(), concatArgs); - trackChildProcess(ffmpeg); - let stderr = ""; - ffmpeg.stderr.on("data", (d) => { - stderr += d.toString(); - }); - ffmpeg.on("close", (code) => { - if (code === 0) resolve({ success: true }); - else resolve({ success: false, error: `Chunk concat failed: ${stderr.slice(-400)}` }); - }); - ffmpeg.on("error", (err) => { - resolve({ success: false, error: `Chunk concat error: ${err.message}` }); - }); - }); + const concatResult = await runFfmpeg(concatArgs, { signal, timeout: encodeTimeout }); if (!concatResult.success) { - return { - success: false, - outputPath, - durationMs: Date.now() - start, - framesEncoded: 0, - fileSize: 0, - error: concatResult.error, - }; + return fail(`Chunk concat failed: ${concatResult.stderr.slice(-400)}`); } const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0; diff --git a/packages/producer/src/services/render/stages/encodeStage-encodeTimeoutConfig.test.ts b/packages/producer/src/services/render/stages/encodeStage-encodeTimeoutConfig.test.ts new file mode 100644 index 000000000..2bfe0abd7 --- /dev/null +++ b/packages/producer/src/services/render/stages/encodeStage-encodeTimeoutConfig.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, mock } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// ── Mocks for runEncodeStage tests ─────────────────────────────────────────── +// Capture the trailing `config` argument passed to encodeFramesFromDir so we +// can assert the encode stage threads `producerConfig ?? resolveConfig()` +// through (regression for #1348 — the call site dropped the 6th argument, so +// ffmpegEncodeTimeout always fell back to the hardcoded default and +// FFMPEG_ENCODE_TIMEOUT_MS was silently ignored). +const encodeCalls: { config: unknown }[] = []; +const chunkedEncodeCalls: { config: unknown }[] = []; +const runFfmpegCalls: { timeout: number | undefined }[] = []; +let resolveConfigCalls = 0; + +const RESOLVED_ENCODE_TIMEOUT = 67_890; + +const successResult = { + success: true, + outputPath: "/tmp/hf-encode-test/out.mp4", + durationMs: 1, + framesEncoded: 1, + fileSize: 1, +}; + +mock.module("@hyperframes/engine", () => ({ + encodeFramesFromDir: async ( + _framesDir: string, + _framePattern: string, + _outputPath: string, + _options: unknown, + _signal: AbortSignal | undefined, + config: unknown, + ) => { + encodeCalls.push({ config }); + return successResult; + }, + encodeFramesChunkedConcat: async ( + _framesDir: string, + _framePattern: string, + _outputPath: string, + _options: unknown, + _chunkSizeFrames: number, + _signal: AbortSignal | undefined, + config: unknown, + ) => { + chunkedEncodeCalls.push({ config }); + return successResult; + }, + runFfmpeg: async (_args: string[], opts: { timeout?: number }) => { + runFfmpegCalls.push({ timeout: opts.timeout }); + return { success: true, exitCode: 0, stderr: "", stdout: "" }; + }, + formatFfmpegError: (exitCode: number, stderr: string) => `exit ${exitCode}: ${stderr}`, + getEncoderPreset: () => ({ + preset: "veryfast", + quality: 23, + codec: "h264", + pixelFormat: "yuv420p", + }), + resolveConfig: () => { + resolveConfigCalls += 1; + return { ffmpegEncodeTimeout: RESOLVED_ENCODE_TIMEOUT }; + }, +})); + +// Minimal EngineConfig for the producerConfig-present case. Same field set as +// probeStage.test.ts — the full interface so the literal satisfies EngineConfig. +function makeProducerConfig(ffmpegEncodeTimeout: number) { + return { + forceScreenshot: false, + lowMemoryMode: false, + fps: 30, + quality: "standard" as const, + format: "jpeg" as const, + jpegQuality: 80, + concurrency: "auto" as const, + coresPerWorker: 2.5, + minParallelFrames: 120, + largeRenderThreshold: 1000, + disableGpu: false, + browserGpuMode: "software" as const, + enableBrowserPool: false, + browserTimeout: 120_000, + protocolTimeout: 300_000, + enableChunkedEncode: false, + chunkSizeFrames: 360, + enableStreamingEncode: false, + streamingEncodeMaxDurationSeconds: 240, + ffmpegEncodeTimeout, + ffmpegProcessTimeout: 300_000, + ffmpegStreamingTimeout: 600_000, + hdr: false, + hdrAutoDetect: true, + audioGain: 1, + frameDataUriCacheLimit: 256, + frameDataUriCacheBytesLimitMb: 1500, + playerReadyTimeout: 45_000, + renderReadyTimeout: 15_000, + verifyRuntime: true, + debug: false, + }; +} + +function makeEncodeInput(overrides: { + producerConfig?: ReturnType; + isGif?: boolean; + framesDir?: string; + enableChunkedEncode?: boolean; +}) { + const workDir = mkdtempSync(join(tmpdir(), "hf-encode-stage-test-")); + return { + job: { + id: "encode-test", + config: { + fps: { num: 30, den: 1 }, + quality: "standard" as const, + producerConfig: overrides.producerConfig, + }, + status: "rendering" as const, + progress: 0, + currentStage: "Encode", + createdAt: new Date(0), + }, + log: { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + }, + // Extension is irrelevant here — the gif/video branch is selected by the + // isGif flag below and every ffmpeg call is mocked. + outputPath: join(workDir, "out.mp4"), + framesDir: overrides.framesDir ?? workDir, + videoOnlyPath: join(workDir, "video-only.mp4"), + width: 640, + height: 360, + needsAlpha: false, + hasAudio: false, + isPngSequence: false, + isGif: overrides.isGif ?? false, + preset: { + preset: "veryfast", + quality: 23, + codec: "h264" as const, + pixelFormat: "yuv420p", + }, + effectiveQuality: 23, + effectiveBitrate: undefined, + enableChunkedEncode: overrides.enableChunkedEncode ?? false, + chunkedEncodeSize: 360, + abortSignal: undefined, + assertNotAborted: () => {}, + }; +} + +function resetCaptures() { + encodeCalls.length = 0; + chunkedEncodeCalls.length = 0; + runFfmpegCalls.length = 0; + resolveConfigCalls = 0; +} + +describe("runEncodeStage — encode timeout config threading (#1348)", () => { + it("passes producerConfig as the trailing config argument when present", async () => { + resetCaptures(); + const { runEncodeStage } = await import("./encodeStage.js"); + + const producerConfig = makeProducerConfig(12_345); + await runEncodeStage(makeEncodeInput({ producerConfig })); + + expect(encodeCalls.length).toBe(1); + const config = encodeCalls[0].config as { ffmpegEncodeTimeout: number }; + expect(config.ffmpegEncodeTimeout).toBe(12_345); + // The pre-resolved distributed-render config wins; env is not re-read. + expect(resolveConfigCalls).toBe(0); + }); + + it("falls back to resolveConfig() when producerConfig is absent (in-process renders)", async () => { + resetCaptures(); + const { runEncodeStage } = await import("./encodeStage.js"); + + await runEncodeStage(makeEncodeInput({})); + + expect(encodeCalls.length).toBe(1); + expect(resolveConfigCalls).toBe(1); + const config = encodeCalls[0].config as { ffmpegEncodeTimeout: number }; + expect(config.ffmpegEncodeTimeout).toBe(RESOLVED_ENCODE_TIMEOUT); + }); + + it("passes the trailing config argument to encodeFramesChunkedConcat on the chunked path", async () => { + resetCaptures(); + const { runEncodeStage } = await import("./encodeStage.js"); + + await runEncodeStage(makeEncodeInput({ enableChunkedEncode: true })); + + expect(encodeCalls.length).toBe(0); + expect(chunkedEncodeCalls.length).toBe(1); + expect(resolveConfigCalls).toBe(1); + const config = chunkedEncodeCalls[0].config as { ffmpegEncodeTimeout: number }; + expect(config.ffmpegEncodeTimeout).toBe(RESOLVED_ENCODE_TIMEOUT); + }); + + it("threads the resolved timeout into the GIF two-pass encode", async () => { + resetCaptures(); + const { runEncodeStage } = await import("./encodeStage.js"); + + // encodeGifFromDir reads the frames dir for real, so seed one frame. + const framesDir = mkdtempSync(join(tmpdir(), "hf-encode-stage-gif-frames-")); + writeFileSync(join(framesDir, "frame_000001.jpg"), ""); + + await runEncodeStage(makeEncodeInput({ isGif: true, framesDir })); + + // Two ffmpeg passes (palettegen + paletteuse), both with the env-aware timeout. + expect(runFfmpegCalls.length).toBe(2); + expect(resolveConfigCalls).toBe(1); + expect(runFfmpegCalls[0].timeout).toBe(RESOLVED_ENCODE_TIMEOUT); + expect(runFfmpegCalls[1].timeout).toBe(RESOLVED_ENCODE_TIMEOUT); + }); +}); diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index c4ef22b7f..8c97a5ffa 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -31,11 +31,11 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { - DEFAULT_CONFIG, encodeFramesChunkedConcat, encodeFramesFromDir, formatFfmpegError, getEncoderPreset, + resolveConfig, runFfmpeg, type EncodeResult, } from "@hyperframes/engine"; @@ -254,7 +254,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise