From 0bcd816d5b79c4b037ab351acec331b0fad7e55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 2 Nov 2026 17:53:57 +0000 Subject: [PATCH] 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 -- --- packages/engine/src/config.ts | 4 + packages/engine/src/index.ts | 12 +++ .../services/chunkStreamingEncoder.test.ts | 102 ++++++++++++++++++ .../src/services/chunkStreamingEncoder.ts | 59 ++++++++++ .../services/inPageEncodeEligibility.test.ts | 36 +++++++ .../src/services/inPageEncodeEligibility.ts | 32 ++++++ .../engine/src/services/streamingEncoder.ts | 36 +++++-- 7 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 packages/engine/src/services/chunkStreamingEncoder.test.ts create mode 100644 packages/engine/src/services/chunkStreamingEncoder.ts create mode 100644 packages/engine/src/services/inPageEncodeEligibility.test.ts create mode 100644 packages/engine/src/services/inPageEncodeEligibility.ts diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index ce09bc682e..078f3dab4b 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -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 { "HF_DE_WORKER_ENCODE", DEFAULT_CONFIG.enableDrawElementWorkerEncode, ), + useInPageEncode: envBool("HF_INPAGE_ENCODE", DEFAULT_CONFIG.useInPageEncode), lowMemoryMode: resolveLowMemoryMode(), enablePageSideCompositing: envBool( "HF_PAGE_SIDE_COMPOSITING", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index f4b7019e0e..b7084b2653 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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 { diff --git a/packages/engine/src/services/chunkStreamingEncoder.test.ts b/packages/engine/src/services/chunkStreamingEncoder.test.ts new file mode 100644 index 0000000000..5e3106e880 --- /dev/null +++ b/packages/engine/src/services/chunkStreamingEncoder.test.ts @@ -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", + }); + }); +}); diff --git a/packages/engine/src/services/chunkStreamingEncoder.ts b/packages/engine/src/services/chunkStreamingEncoder.ts new file mode 100644 index 0000000000..dd9ca5997b --- /dev/null +++ b/packages/engine/src/services/chunkStreamingEncoder.ts @@ -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>, +): StreamingEncoder { + return spawnFfmpegStream(outputPath, buildChunkStreamingArgs(options, outputPath), { + signal, + config, + }); +} diff --git a/packages/engine/src/services/inPageEncodeEligibility.test.ts b/packages/engine/src/services/inPageEncodeEligibility.test.ts new file mode 100644 index 0000000000..ab302da05d --- /dev/null +++ b/packages/engine/src/services/inPageEncodeEligibility.test.ts @@ -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 }); + }); +}); diff --git a/packages/engine/src/services/inPageEncodeEligibility.ts b/packages/engine/src/services/inPageEncodeEligibility.ts new file mode 100644 index 0000000000..705c533831 --- /dev/null +++ b/packages/engine/src/services/inPageEncodeEligibility.ts @@ -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 }; +} diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 23e9f484ea..278ff742e1 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -515,9 +515,6 @@ export async function spawnStreamingEncoder( signal?: AbortSignal, config?: Partial>, ): Promise { - 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>; + 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`