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:
Miguel Ángel
2026-11-02 17:53:57 +00:00
committed by GitHub
parent e8ffaa410f
commit 0bcd816d5b
7 changed files with 274 additions and 7 deletions
+4
View File
@@ -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",
+12
View File
@@ -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`