mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Summary Add unit tests that mock `child_process.spawn` to drive an in-memory "ffmpeg" through the success/failure paths used by the producer's HDR encoder and by Chunk 5A's defensive `close()` in `renderOrchestrator`. ## Why `Chunk 9D` of `plans/hdr-followups.md`. The contracts the orchestrator's try/finally cleanup (Chunk 5A) and the abort path rely on were entirely uncovered. A regression in `spawnStreamingEncoder`'s lifecycle handling would surface as a leaked ffmpeg process or a hung render, both of which are hard to diagnose after the fact. ## What changed `packages/engine/src/services/streamingEncoder.test.ts`: 7 new specs covering: - Successful exit after explicit `close()`. - Non-zero exit before `close()` returns a failure result (no throw). - `ENOENT` on spawn returns a failure result (no throw). - Abort signal triggers `SIGTERM` and a `"cancelled"` result. - `close()` is idempotent and never throws on a second call. - `writeFrame` returns `false` after the encoder has exited. - `close()` detaches the abort listener so post-close aborts don't re-kill ffmpeg. These contracts are what the `renderOrchestrator` try/finally cleanup added in Chunk 5A relies on, and what the ffprobe-unavailable test (Chunk 9B) hinted at for the encoder side. ## Test plan - [x] All new specs pass. - [x] No production code changes — pure regression coverage of existing lifecycle behavior. ## Stack Chunk 9D of `plans/hdr-followups.md`. Test-only change, complements Chunk 5A.
533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
/**
|
|
* buildStreamingArgs unit tests.
|
|
*
|
|
* These tests focus on the FFmpeg CLI shape rather than spawning the encoder
|
|
* — they're the cheap regression net for the HDR static-metadata bug
|
|
* (side_data=[none] in the encoded MP4) reproduced by
|
|
* packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
|
|
* refactors of the x265-params string can silently strip
|
|
* master-display / max-cll and ship as SDR BT.2020 again.
|
|
*/
|
|
|
|
import { EventEmitter } from "events";
|
|
import { mkdtempSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
buildStreamingArgs,
|
|
createFrameReorderBuffer,
|
|
type StreamingEncoderOptions,
|
|
} from "./streamingEncoder.js";
|
|
import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
|
|
|
|
const baseHdrPq: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 1920,
|
|
height: 1080,
|
|
codec: "h265",
|
|
preset: "medium",
|
|
quality: 23,
|
|
pixelFormat: "yuv420p10le",
|
|
useGpu: false,
|
|
rawInputFormat: "rgb48le",
|
|
hdr: { transfer: "pq" },
|
|
};
|
|
|
|
const baseHdrHlg: StreamingEncoderOptions = {
|
|
...baseHdrPq,
|
|
hdr: { transfer: "hlg" },
|
|
};
|
|
|
|
const baseSdr: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 1920,
|
|
height: 1080,
|
|
codec: "h264",
|
|
preset: "medium",
|
|
quality: 23,
|
|
useGpu: false,
|
|
};
|
|
|
|
function getX265ParamsValue(args: string[]): string | undefined {
|
|
const idx = args.indexOf("-x265-params");
|
|
return idx === -1 ? undefined : args[idx + 1];
|
|
}
|
|
|
|
describe("buildStreamingArgs", () => {
|
|
describe("HDR PQ (libx265)", () => {
|
|
it("emits master-display and max-cll in -x265-params", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toBeDefined();
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
|
expect(x265).toContain("colorprim=bt2020");
|
|
expect(x265).toContain("transfer=smpte2084");
|
|
expect(x265).toContain("colormatrix=bt2020nc");
|
|
});
|
|
|
|
it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
expect(args).toContain("-colorspace:v");
|
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
|
|
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
|
});
|
|
|
|
it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const cvIdx = args.indexOf("-c:v");
|
|
expect(cvIdx).toBeGreaterThan(-1);
|
|
expect(args[cvIdx + 1]).toBe("libx265");
|
|
expect(args).toContain("-tag:v");
|
|
expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
|
|
});
|
|
|
|
it("keeps the aq-mode prefix even with master-display present", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265?.startsWith("aq-mode=3")).toBe(true);
|
|
});
|
|
|
|
it("uses the simpler aq-mode prefix on ultrafast preset", () => {
|
|
const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265?.startsWith("aq-mode=3:")).toBe(true);
|
|
expect(x265).not.toContain("aq-strength");
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
});
|
|
});
|
|
|
|
describe("HDR HLG (libx265)", () => {
|
|
it("emits master-display, max-cll, and the HLG transfer", () => {
|
|
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toContain("transfer=arib-std-b67");
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
|
});
|
|
|
|
it("tags the output stream with arib-std-b67", () => {
|
|
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
|
|
});
|
|
});
|
|
|
|
describe("HDR raw input tagging", () => {
|
|
it("tags the rawvideo input with the matching color metadata", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const inputColorTrcIdx = args.indexOf("-color_trc");
|
|
expect(inputColorTrcIdx).toBeGreaterThan(-1);
|
|
expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
|
|
const inputPrimariesIdx = args.indexOf("-color_primaries");
|
|
expect(inputPrimariesIdx).toBeGreaterThan(-1);
|
|
expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
|
|
// Pix_fmt of the raw input must match the buffer we hand FFmpeg.
|
|
expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
|
|
});
|
|
|
|
it("does not strip the input color tags when bitrate is set instead of CRF", () => {
|
|
const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(args).toContain("-b:v");
|
|
expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
|
|
});
|
|
});
|
|
|
|
describe("SDR fallback", () => {
|
|
it("does NOT emit HDR mastering metadata for SDR encodes", () => {
|
|
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
|
const x264 = args[args.indexOf("-x264-params") + 1];
|
|
expect(x264).toContain("colorprim=bt709");
|
|
expect(x264).toContain("transfer=bt709");
|
|
expect(x264).toContain("colormatrix=bt709");
|
|
expect(x264).not.toContain("master-display");
|
|
expect(x264).not.toContain("max-cll");
|
|
});
|
|
|
|
it("tags SDR output with bt709 and tv range", () => {
|
|
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
|
});
|
|
});
|
|
|
|
describe("output path", () => {
|
|
it("places the output path last after -y", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
|
|
expect(args[args.length - 2]).toBe("-y");
|
|
expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
|
|
});
|
|
});
|
|
|
|
describe("GPU preset mapping", () => {
|
|
const baseGpu: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 1920,
|
|
height: 1080,
|
|
codec: "h264",
|
|
preset: "ultrafast",
|
|
quality: 28,
|
|
useGpu: true,
|
|
};
|
|
|
|
function presetArg(args: string[]): string | undefined {
|
|
const idx = args.indexOf("-preset");
|
|
return idx === -1 ? undefined : args[idx + 1];
|
|
}
|
|
|
|
// Regression for the streaming-encode + --gpu failure: NVENC rejects
|
|
// libx264 `ultrafast` with AVERROR(EINVAL), which previously surfaced
|
|
// as a bare "FFmpeg exited with code -22".
|
|
it("translates ultrafast to NVENC p1", () => {
|
|
const args = buildStreamingArgs(baseGpu, "/tmp/out.mp4", "nvenc");
|
|
expect(presetArg(args)).toBe("p1");
|
|
});
|
|
|
|
it("translates medium to NVENC p4", () => {
|
|
const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "nvenc");
|
|
expect(presetArg(args)).toBe("p4");
|
|
});
|
|
|
|
// Same mapping applies to hevc_nvenc: NVENC's preset vocabulary is
|
|
// codec-agnostic, so the helper must translate for H.265 too.
|
|
it("translates libx264 preset names to NVENC pN for h265 as well", () => {
|
|
for (const [libx264, nvencPreset] of [
|
|
["ultrafast", "p1"],
|
|
["medium", "p4"],
|
|
["veryslow", "p7"],
|
|
] as const) {
|
|
const args = buildStreamingArgs(
|
|
{ ...baseGpu, codec: "h265", preset: libx264 },
|
|
"/tmp/out.mp4",
|
|
"nvenc",
|
|
);
|
|
expect(args[args.indexOf("-c:v") + 1]).toBe("hevc_nvenc");
|
|
expect(presetArg(args)).toBe(nvencPreset);
|
|
}
|
|
});
|
|
|
|
it("rewrites QSV's unsupported ultrafast preset to veryfast", () => {
|
|
const args = buildStreamingArgs(baseGpu, "/tmp/out.mp4", "qsv");
|
|
expect(presetArg(args)).toBe("veryfast");
|
|
});
|
|
|
|
it("passes QSV-supported preset names through unchanged", () => {
|
|
const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "qsv");
|
|
expect(presetArg(args)).toBe("medium");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createFrameReorderBuffer", () => {
|
|
it("fast-paths waitForFrame(cursor) without queueing", async () => {
|
|
const buf = createFrameReorderBuffer(0, 3);
|
|
await buf.waitForFrame(0);
|
|
});
|
|
|
|
it("gates out-of-order writers into cursor order", async () => {
|
|
const buf = createFrameReorderBuffer(0, 4);
|
|
const writeOrder: number[] = [];
|
|
|
|
const writer = async (frame: number) => {
|
|
await buf.waitForFrame(frame);
|
|
writeOrder.push(frame);
|
|
buf.advanceTo(frame + 1);
|
|
};
|
|
|
|
const p3 = writer(3);
|
|
const p1 = writer(1);
|
|
const p2 = writer(2);
|
|
const p0 = writer(0);
|
|
|
|
await Promise.all([p0, p1, p2, p3]);
|
|
expect(writeOrder).toEqual([0, 1, 2, 3]);
|
|
});
|
|
|
|
it("supports multiple waiters registered for the same frame", async () => {
|
|
const buf = createFrameReorderBuffer(0, 2);
|
|
const resolved: string[] = [];
|
|
|
|
const a = buf.waitForFrame(1).then(() => resolved.push("a"));
|
|
const b = buf.waitForFrame(1).then(() => resolved.push("b"));
|
|
|
|
buf.advanceTo(0);
|
|
await Promise.resolve();
|
|
expect(resolved).toEqual([]);
|
|
|
|
buf.advanceTo(1);
|
|
await Promise.all([a, b]);
|
|
expect(resolved.sort()).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("waitForAllDone resolves when cursor reaches endFrame", async () => {
|
|
const buf = createFrameReorderBuffer(0, 3);
|
|
let done = false;
|
|
const allDone = buf.waitForAllDone().then(() => {
|
|
done = true;
|
|
});
|
|
|
|
buf.advanceTo(1);
|
|
await Promise.resolve();
|
|
expect(done).toBe(false);
|
|
|
|
buf.advanceTo(3);
|
|
await allDone;
|
|
expect(done).toBe(true);
|
|
});
|
|
|
|
it("waitForAllDone fast-paths when cursor already past endFrame", async () => {
|
|
const buf = createFrameReorderBuffer(0, 3);
|
|
buf.advanceTo(5);
|
|
await buf.waitForAllDone();
|
|
});
|
|
});
|
|
|
|
interface FakeStdin extends EventEmitter {
|
|
destroyed: boolean;
|
|
end: (cb?: () => void) => void;
|
|
write: (chunk: Buffer) => boolean;
|
|
}
|
|
|
|
interface FakeProc extends EventEmitter {
|
|
stdin: FakeStdin;
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
interface SpawnCall {
|
|
command: string;
|
|
args: readonly string[];
|
|
proc: FakeProc;
|
|
}
|
|
|
|
function createFakeStdin(): FakeStdin {
|
|
const state = { destroyed: false };
|
|
const stdin = new EventEmitter() as FakeStdin;
|
|
Object.defineProperty(stdin, "destroyed", {
|
|
get: () => state.destroyed,
|
|
set: (v: boolean) => {
|
|
state.destroyed = v;
|
|
},
|
|
});
|
|
stdin.end = (cb?: () => void) => {
|
|
state.destroyed = true;
|
|
if (cb) process.nextTick(cb);
|
|
};
|
|
stdin.write = (_chunk: Buffer): boolean => !state.destroyed;
|
|
return stdin;
|
|
}
|
|
|
|
function createFakeProc(): FakeProc {
|
|
const proc = new EventEmitter() as FakeProc;
|
|
proc.stdin = createFakeStdin();
|
|
proc.stdout = new EventEmitter();
|
|
proc.stderr = new EventEmitter();
|
|
proc.kill = vi.fn();
|
|
return proc;
|
|
}
|
|
|
|
function createSpawnSpy(): {
|
|
spawn: (command: string, args: readonly string[]) => FakeProc;
|
|
calls: SpawnCall[];
|
|
} {
|
|
const calls: SpawnCall[] = [];
|
|
const spawn = (command: string, args: readonly string[]): FakeProc => {
|
|
const proc = createFakeProc();
|
|
calls.push({ command, args, proc });
|
|
return proc;
|
|
};
|
|
return { spawn, calls };
|
|
}
|
|
|
|
const baseOptions: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 100,
|
|
height: 100,
|
|
codec: "h264",
|
|
useGpu: false,
|
|
};
|
|
|
|
describe("spawnStreamingEncoder lifecycle and cleanup", () => {
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
vi.doUnmock("child_process");
|
|
});
|
|
|
|
it("returns a success result when ffmpeg exits cleanly after close()", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const dir = mkdtempSync(join(tmpdir(), "se-success-"));
|
|
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.command).toBe("ffmpeg");
|
|
|
|
const proc = calls[0]!.proc;
|
|
const closePromise = encoder.close();
|
|
process.nextTick(() => proc.emit("close", 0));
|
|
|
|
const result = await closePromise;
|
|
expect(result.success).toBe(true);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.fileSize).toBe(0); // No real ffmpeg, no file written
|
|
});
|
|
|
|
it("returns a failure result (does NOT throw) when ffmpeg exits non-zero before close()", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const dir = mkdtempSync(join(tmpdir(), "se-fail-"));
|
|
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
|
|
|
const proc = calls[0]!.proc;
|
|
proc.stderr.emit("data", Buffer.from("Encoder error\n"));
|
|
await new Promise<void>((resolve) => {
|
|
process.nextTick(() => {
|
|
proc.emit("close", 1);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const result = await encoder.close();
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("FFmpeg exited with code 1");
|
|
expect(result.error).toContain("Encoder error");
|
|
});
|
|
|
|
it("returns a failure result (does NOT throw) when ffmpeg fails to spawn (ENOENT)", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const dir = mkdtempSync(join(tmpdir(), "se-enoent-"));
|
|
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
|
|
|
const proc = calls[0]!.proc;
|
|
await new Promise<void>((resolve) => {
|
|
process.nextTick(() => {
|
|
const err = new Error("spawn ffmpeg ENOENT") as NodeJS.ErrnoException;
|
|
err.code = "ENOENT";
|
|
proc.emit("error", err);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const result = await encoder.close();
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/spawn ffmpeg ENOENT/);
|
|
});
|
|
|
|
it("returns a 'cancelled' result and SIGTERMs ffmpeg when the abort signal fires", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const controller = new AbortController();
|
|
const dir = mkdtempSync(join(tmpdir(), "se-abort-"));
|
|
const encoder = await spawnStreamingEncoder(
|
|
join(dir, "out.mp4"),
|
|
baseOptions,
|
|
controller.signal,
|
|
);
|
|
|
|
const proc = calls[0]!.proc;
|
|
controller.abort();
|
|
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
process.nextTick(() => proc.emit("close", null));
|
|
const result = await encoder.close();
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe("Streaming encode cancelled");
|
|
});
|
|
|
|
it("close() is idempotent: a second call still resolves to a result and does not throw", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const dir = mkdtempSync(join(tmpdir(), "se-idempotent-"));
|
|
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
|
|
|
const proc = calls[0]!.proc;
|
|
process.nextTick(() => proc.emit("close", 0));
|
|
|
|
const first = await encoder.close();
|
|
expect(first.success).toBe(true);
|
|
|
|
// Defensive cleanup in renderOrchestrator may call close() again after the
|
|
// explicit call. Verify the second call doesn't reject — it can return
|
|
// either success (cached) or a benign failure result, but must not throw.
|
|
let threw = false;
|
|
try {
|
|
const second = await encoder.close();
|
|
expect(typeof second.success).toBe("boolean");
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
expect(threw).toBe(false);
|
|
});
|
|
|
|
it("writeFrame returns false after ffmpeg has exited", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const dir = mkdtempSync(join(tmpdir(), "se-writefail-"));
|
|
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
|
|
|
expect(encoder.writeFrame(Buffer.from([0]))).toBe(true);
|
|
|
|
const proc = calls[0]!.proc;
|
|
await new Promise<void>((resolve) => {
|
|
process.nextTick(() => {
|
|
proc.emit("close", 0);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
expect(encoder.writeFrame(Buffer.from([0]))).toBe(false);
|
|
});
|
|
|
|
it("close() removes the abort listener so a post-close abort does not re-kill ffmpeg", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
|
const controller = new AbortController();
|
|
const dir = mkdtempSync(join(tmpdir(), "se-detach-"));
|
|
const encoder = await spawnStreamingEncoder(
|
|
join(dir, "out.mp4"),
|
|
baseOptions,
|
|
controller.signal,
|
|
);
|
|
|
|
const proc = calls[0]!.proc;
|
|
process.nextTick(() => proc.emit("close", 0));
|
|
await encoder.close();
|
|
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
controller.abort();
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
});
|
|
});
|