mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
feat(engine): a chunk-fed encoder and the rule for which films may use it, off by default (#7029)
* feat(engine): a chunk-fed encoder and the rule for which films may use it, off by default * test(engine): end the chunk encoder's ffprobe call with --
This commit is contained in:
@@ -90,6 +90,7 @@ export interface EngineConfig {
|
||||
* true. Kill switch: `HF_DE_WORKER_ENCODE=false`.
|
||||
*/
|
||||
enableDrawElementWorkerEncode: boolean;
|
||||
useInPageEncode: boolean;
|
||||
/**
|
||||
* INTERNAL. Set by resolveConfig when it disabled enablePageSideCompositing
|
||||
* solely because drawElement was on. Lets the producer's compile-time gates
|
||||
@@ -306,6 +307,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
|
||||
videoLayerPassthrough: false,
|
||||
useDrawElement: true,
|
||||
enableDrawElementWorkerEncode: true,
|
||||
useInPageEncode: false,
|
||||
// Auto-detected per host in `resolveConfig`; defaults off for the raw
|
||||
// DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
|
||||
lowMemoryMode: false,
|
||||
@@ -360,6 +362,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
|
||||
"videoLayerPassthrough",
|
||||
"useDrawElement",
|
||||
"enableDrawElementWorkerEncode",
|
||||
"useInPageEncode",
|
||||
"lowMemoryMode",
|
||||
"enablePageSideCompositing",
|
||||
"enableChunkedEncode",
|
||||
@@ -889,6 +892,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
"HF_DE_WORKER_ENCODE",
|
||||
DEFAULT_CONFIG.enableDrawElementWorkerEncode,
|
||||
),
|
||||
useInPageEncode: envBool("HF_INPAGE_ENCODE", DEFAULT_CONFIG.useInPageEncode),
|
||||
lowMemoryMode: resolveLowMemoryMode(),
|
||||
enablePageSideCompositing: envBool(
|
||||
"HF_PAGE_SIDE_COMPOSITING",
|
||||
|
||||
@@ -226,6 +226,18 @@ export {
|
||||
type StreamingEncoderResult,
|
||||
type FrameReorderBuffer,
|
||||
} from "./services/streamingEncoder.js";
|
||||
export {
|
||||
spawnChunkStreamingEncoder,
|
||||
buildChunkStreamingArgs,
|
||||
BT709_LIMITED,
|
||||
type ChunkColour,
|
||||
type ChunkStreamingOptions,
|
||||
} from "./services/chunkStreamingEncoder.js";
|
||||
export {
|
||||
resolveInPageEncode,
|
||||
type InPageEncodeDecision,
|
||||
type InPageEncodeInput,
|
||||
} from "./services/inPageEncodeEligibility.js";
|
||||
|
||||
// ── Media processing ───────────────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js";
|
||||
import {
|
||||
buildChunkStreamingArgs,
|
||||
spawnChunkStreamingEncoder,
|
||||
BT709_LIMITED,
|
||||
} from "./chunkStreamingEncoder.js";
|
||||
|
||||
const fps = { num: 30, den: 1 };
|
||||
|
||||
describe("buildChunkStreamingArgs", () => {
|
||||
it("copies an Annex-B stream, tags it BT.709 and runs no filter", () => {
|
||||
const args = buildChunkStreamingArgs({ fps }, "/tmp/out.mp4");
|
||||
|
||||
expect(args.slice(0, 6)).toEqual(["-f", "h264", "-framerate", "30", "-i", "-"]);
|
||||
expect(args).toContain("copy");
|
||||
expect(args).not.toContain("-vf");
|
||||
expect(args[args.indexOf("-bsf:v") + 1]).toBe(
|
||||
"h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag=0",
|
||||
);
|
||||
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
||||
expect(args.at(-1)).toBe("/tmp/out.mp4");
|
||||
});
|
||||
|
||||
it("writes the range the page encoder reported", () => {
|
||||
const args = buildChunkStreamingArgs(
|
||||
{ fps, colour: { ...BT709_LIMITED, fullRange: true } },
|
||||
"o.mp4",
|
||||
);
|
||||
|
||||
expect(args[args.indexOf("-bsf:v") + 1]).toContain("video_full_range_flag=1");
|
||||
expect(args[args.indexOf("-color_range") + 1]).toBe("pc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnChunkStreamingEncoder", () => {
|
||||
let dir = "";
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-chunk-enc-"));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("muxes encoded chunks into an mp4 with one frame per tick and BT.709 tags", async () => {
|
||||
const source = spawnSync(
|
||||
getFfmpegBinary(),
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=d=1:r=30:s=160x90",
|
||||
].concat(["-c:v", "libx264", "-bf", "0", "-pix_fmt", "yuv420p", "-f", "h264", "-"]),
|
||||
{ maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
expect(source.status).toBe(0);
|
||||
const stream = source.stdout;
|
||||
const out = join(dir, "out.mp4");
|
||||
|
||||
const encoder = spawnChunkStreamingEncoder(out, { fps });
|
||||
const third = Math.ceil(stream.length / 3);
|
||||
for (let at = 0; at < stream.length; at += third) {
|
||||
expect(await encoder.writeFrame(stream.subarray(at, at + third))).toBe(true);
|
||||
}
|
||||
const result = await encoder.close();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const entries =
|
||||
"stream=nb_read_frames,r_frame_rate,color_space,color_primaries,color_transfer,color_range";
|
||||
const probe = spawnSync(
|
||||
getFfprobeBinary(),
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-count_frames",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
entries,
|
||||
"-of",
|
||||
"json",
|
||||
"--",
|
||||
out,
|
||||
],
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
const stream0 = JSON.parse(probe.stdout).streams[0];
|
||||
expect(stream0).toMatchObject({
|
||||
nb_read_frames: "30",
|
||||
r_frame_rate: "30/1",
|
||||
color_space: "bt709",
|
||||
color_primaries: "bt709",
|
||||
color_transfer: "bt709",
|
||||
color_range: "tv",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import { fpsToFfmpegArg } from "@hyperframes/core";
|
||||
import type { EngineConfig } from "../config.js";
|
||||
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
||||
import { spawnFfmpegStream, type StreamingEncoder } from "./streamingEncoder.js";
|
||||
|
||||
export interface ChunkColour {
|
||||
primaries: number;
|
||||
transfer: number;
|
||||
matrix: number;
|
||||
fullRange: boolean;
|
||||
}
|
||||
|
||||
export const BT709_LIMITED: ChunkColour = {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix: 1,
|
||||
fullRange: false,
|
||||
};
|
||||
|
||||
export interface ChunkStreamingOptions {
|
||||
fps: Fps;
|
||||
colour?: ChunkColour;
|
||||
}
|
||||
|
||||
export function buildChunkStreamingArgs(
|
||||
options: ChunkStreamingOptions,
|
||||
outputPath: string,
|
||||
): string[] {
|
||||
const { primaries, transfer, matrix, fullRange } = options.colour ?? BT709_LIMITED;
|
||||
const vui = [
|
||||
`colour_primaries=${primaries}`,
|
||||
`transfer_characteristics=${transfer}`,
|
||||
`matrix_coefficients=${matrix}`,
|
||||
`video_full_range_flag=${fullRange ? 1 : 0}`,
|
||||
].join(":");
|
||||
const args = ["-f", "h264", "-framerate", fpsToFfmpegArg(options.fps), "-i", "-"];
|
||||
args.push("-c:v", "copy", "-bsf:v", `h264_metadata=${vui}`);
|
||||
args.push(
|
||||
...["-colorspace:v", "bt709", "-color_primaries:v", "bt709", "-color_trc:v", "bt709"],
|
||||
...["-color_range", fullRange ? "pc" : "tv"],
|
||||
...["-video_track_timescale", "90000", "-avoid_negative_ts", "make_zero"],
|
||||
);
|
||||
appendRenderProvenanceArgs(args, outputPath);
|
||||
args.push("-y", outputPath);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnChunkStreamingEncoder(
|
||||
outputPath: string,
|
||||
options: ChunkStreamingOptions,
|
||||
signal?: AbortSignal,
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>,
|
||||
): StreamingEncoder {
|
||||
return spawnFfmpegStream(outputPath, buildChunkStreamingArgs(options, outputPath), {
|
||||
signal,
|
||||
config,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInPageEncode, type InPageEncodeInput } from "./inPageEncodeEligibility.js";
|
||||
|
||||
const ready: InPageEncodeInput = {
|
||||
enabled: true,
|
||||
captureMode: "drawelement",
|
||||
format: "mp4",
|
||||
codec: "h264",
|
||||
hdr: false,
|
||||
transparent: false,
|
||||
workers: 1,
|
||||
hls: false,
|
||||
videoLayer: false,
|
||||
encoderSupported: true,
|
||||
};
|
||||
|
||||
describe("resolveInPageEncode", () => {
|
||||
it("takes a plain h264 mp4 drawElement film", () => {
|
||||
expect(resolveInPageEncode(ready)).toEqual({ eligible: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ enabled: false }, "disabled"],
|
||||
[{ captureMode: "screenshot" as const }, "capture is not drawElement"],
|
||||
[{ format: "webm" }, "output is not mp4"],
|
||||
[{ codec: "h265" }, "codec is not h264"],
|
||||
[{ hdr: true }, "hdr"],
|
||||
[{ transparent: true }, "transparent"],
|
||||
[{ workers: 3 }, "parallel workers"],
|
||||
[{ hls: true }, "hls"],
|
||||
[{ videoLayer: true }, "video layer passthrough"],
|
||||
[{ encoderSupported: false }, "page encoder unsupported"],
|
||||
])("leaves %j to today's path: %s", (change, reason) => {
|
||||
expect(resolveInPageEncode({ ...ready, ...change })).toEqual({ eligible: false, reason });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface InPageEncodeInput {
|
||||
enabled: boolean;
|
||||
captureMode: "drawelement" | "screenshot" | "beginframe";
|
||||
format: string;
|
||||
codec: string;
|
||||
hdr: boolean;
|
||||
transparent: boolean;
|
||||
workers: number;
|
||||
hls: boolean;
|
||||
videoLayer: boolean;
|
||||
encoderSupported: boolean;
|
||||
}
|
||||
|
||||
export type InPageEncodeDecision = { eligible: true } | { eligible: false; reason: string };
|
||||
|
||||
export function resolveInPageEncode(input: InPageEncodeInput): InPageEncodeDecision {
|
||||
const blocker = (
|
||||
[
|
||||
[!input.enabled, "disabled"],
|
||||
[input.captureMode !== "drawelement", "capture is not drawElement"],
|
||||
[input.format !== "mp4", "output is not mp4"],
|
||||
[input.codec !== "h264", "codec is not h264"],
|
||||
[input.hdr, "hdr"],
|
||||
[input.transparent, "transparent"],
|
||||
[input.workers !== 1, "parallel workers"],
|
||||
[input.hls, "hls"],
|
||||
[input.videoLayer, "video layer passthrough"],
|
||||
[!input.encoderSupported, "page encoder unsupported"],
|
||||
] as const
|
||||
).find(([blocked]) => blocked);
|
||||
return blocker ? { eligible: false, reason: blocker[1] } : { eligible: true };
|
||||
}
|
||||
@@ -515,9 +515,6 @@ export async function spawnStreamingEncoder(
|
||||
signal?: AbortSignal,
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>,
|
||||
): Promise<StreamingEncoder> {
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
let gpuEncoder: GpuEncoder = null;
|
||||
if (options.useGpu) {
|
||||
gpuEncoder = await getCachedGpuEncoder();
|
||||
@@ -525,6 +522,34 @@ export async function spawnStreamingEncoder(
|
||||
|
||||
const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
|
||||
const args = buildStreamingArgs(options, outputPath, gpuEncoder, nvencPresets);
|
||||
const checksJpeg = !options.rawInputFormat && (options.imageFormat ?? "jpeg") === "jpeg";
|
||||
return spawnFfmpegStream(outputPath, args, {
|
||||
signal,
|
||||
config,
|
||||
checkFrame: checksJpeg
|
||||
? (frame, index) => {
|
||||
const error = jpegInputError(frame);
|
||||
if (error) throw new Error(`Invalid JPEG input at frame ${index}: ${error}`);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnFfmpegStream(
|
||||
outputPath: string,
|
||||
args: string[],
|
||||
{
|
||||
signal,
|
||||
config,
|
||||
checkFrame,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>;
|
||||
checkFrame?: (frame: Buffer, index: number) => void;
|
||||
},
|
||||
): StreamingEncoder {
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
@@ -638,10 +663,7 @@ export async function spawnStreamingEncoder(
|
||||
// so without this copy the pipe would read partially-overwritten data
|
||||
// and flicker.
|
||||
const copy = Buffer.from(buffer);
|
||||
if (!options.rawInputFormat && (options.imageFormat ?? "jpeg") === "jpeg") {
|
||||
const error = jpegInputError(copy);
|
||||
if (error) throw new Error(`Invalid JPEG input at frame ${inputFrameIndex}: ${error}`);
|
||||
}
|
||||
checkFrame?.(copy, inputFrameIndex);
|
||||
const accepted = stdin.write(copy);
|
||||
inputFrameIndex++;
|
||||
// Reset inactivity timer immediately ONLY on `accepted === true`. `true`
|
||||
|
||||
Reference in New Issue
Block a user