Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../utils/runFfmpeg.js")>()),
runFfmpeg: runFfmpegMock,
}));

describe("ENCODER_PRESETS", () => {
it("has draft, standard, and high presets", () => {
Expand Down Expand Up @@ -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);
}
});
});
81 changes: 19 additions & 62 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,20 +490,26 @@ export async function encodeFramesChunkedConcat(
options: EncoderOptions,
chunkSizeFrames: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
): Promise<EncodeResult> {
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);
Expand All @@ -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);
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down
Loading