feat(producer): add hls output format (#4014)

* feat(engine): add packageHls for HLS VOD packaging

Adds `packageHls` next to `muxVideoWithAudio`: stream-copies an
already-encoded H.264 video and optional AAC sidecar into an HLS VOD
directory (`master.m3u8` + split video/audio renditions, MPEG-TS
segments) with `-c copy`, so no re-encode happens at package time.

Depends on the locked GOP from the previous commit. `-hls_time` can only
cut at existing keyframes, so `gopSize = segmentSeconds x fps` is what
makes every segment exactly `segmentSeconds` long and IDR-aligned. A
test packages an unlocked encode to pin that: the same call yields one
4s segment instead of four 1s ones.

Deliberately omits the mp4 mux's provenance tags, which MPEG-TS drops
and whose `-movflags` is invalid for `-f hls`, and `-avoid_negative_ts`,
which would discard the AAC priming and shift audio against video (see
issue 3487). A real-ffmpeg test asserts the first presentable audio
sample lands on the first video frame.

No caller yet; `format: "hls"` in the producer follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(engine): zero HLS mux base and reject % in packageHls outputDir

- Pass -muxdelay 0 -muxpreload 0 so the first segment starts at PTS 0
  instead of the mpegts muxer's 1.4 s base; A/V alignment through the
  AAC priming packet is unchanged
- Throw on an outputDir containing %, which ffmpeg expands as a printf
  conversion in -hls_segment_filename and fails with a misleading
  "No such file or directory"
- Assert alignment on relative spacing rather than on how one ffmpeg
  build clamps the priming packet's negative PTS
- Encode the test fixture with the full software GOP lock
  (lockedGopCodecParams), not just appendLockedGopArgs
- Trim comments to the non-obvious rationale

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(engine): satisfy the ffprobe argv contract in packageHls test

`producer/src/utils/ffprobeArgvContract.test.ts` sweeps every package at
the source level and requires `--` to be the argv entry immediately before
an ffprobe input path. The `probe()` helper here passed
`join(outputDir, HLS_VIDEO_PLAYLIST)` in that slot, which the contract's
comma-splitting parser reads as two entries, so the producer suite was red
on this branch while the engine suite stayed green.

`probe()` now takes the input path as a parameter and appends the
terminator itself. The sibling ffmpeg `run()` helper switches from a
spread-in-array-literal to `.concat`, because a verbosity-flag-then-spread
literal is indistinguishable from the ffprobe wrapper shape the contract
demands a terminator on. The matcher reads raw source including comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(engine): drop the audio-only variant from the HLS master playlist

With `a:0,agroup:aud` in `-var_stream_map`, ffmpeg lists the audio
rendition twice in `master.m3u8`: as the `#EXT-X-MEDIA:TYPE=AUDIO` group
the video variant references, and again as a standalone `#EXT-X-STREAM-INF`
variant with no `RESOLUTION`. That is valid HLS, but a player choosing by
bandwidth can select it and play sound with no picture, and the VOD
consumer asked for a single rendition.

`stripAudioOnlyVariants` removes any variant tag without a `RESOLUTION`
attribute together with its URI line; `packageHls` applies it to the
master after a successful audio+video run. The rendition group and the
audio playlist are untouched, so `audio.m3u8` is still referenced from the
master. Pinned by a unit test on the playlist text and by the real-ffmpeg
test, which now asserts exactly one `#EXT-X-STREAM-INF`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(producer): extract hdrMode warning and decision-log helpers

`resolveEffectiveHdrMode` was already over the fallow complexity gate on
`main` (21 cyclomatic / 25 cognitive / CRAP 462) and only surfaces now
because the HLS format work needs to touch its downgrade warning. Pull the
downgrade message into `formatDowngradeWarning` and the "which mode and
why" info line into `logHdrDecision`, which puts the function under the
threshold.

No behavior change: every log string is byte-identical and `hdrMode.test.ts`
asserts them unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(producer): add hls output format

Adds `format: "hls"` to `@hyperframes/producer`. An HLS render is the same
opaque H.264 + AAC encode as `mp4`, stream-copied at assemble time (no
re-encode) into a directory: `master.m3u8`, `video.m3u8` + `video_%05d.ts`,
and `audio.m3u8` + `audio_%05d.ts` when the composition has audio.

The load-bearing part is that `-c copy -f hls` can only cut a segment at an
existing keyframe, so the encoder's GOP is locked to
`hlsSegmentSeconds x fps` frames. `resolveHlsEncoderGopLock` returns that
pair as one value spread into both encoder inputs, so the streaming and
disk/chunked paths cannot disagree — a render reaching only one of them
still succeeds, it just emits variable-length segments silently.

- `renderFormat.ts`: `hls` is opaque (`outputNeedsAlpha` is now
  `!== "mp4" && !== "hls"` — missing this flips HLS to RGBA screenshot
  capture with nothing failing), supports page-side compositing, and
  `outputUsesH264Pipeline` replaces the three `outputFormat === "mp4"`
  router gates.
- `hlsConfig.ts`: `hlsSegmentSeconds` (default 4, positive integer), the
  GOP derivation, and the fail-fast gates. `force-hdr` is rejected (v1 is
  SDR-only; HDR10 needs HEVC in fMP4), and so is `useGpu` — the GOP lock is
  software-only, so a GPU HLS render segments correctly by accident on
  videotoolbox and wrong on nvenc.
- `assembleStage.ts`: `hls` branches to `packageHls` instead of
  mux/faststart, after the same `padOrTrimAudioToVideoFrameCount` the mp4
  mux uses.
- `artifactTransaction.ts`: a directory artifact can now carry a
  `probeTarget` + `requiredFiles`, so the #3395 truncation gate still runs
  for HLS against `video.m3u8`. `expectedFrames` is inert there (ffprobe
  reports no frame count through the hls demuxer); duration carries it.
- `renderRequest.ts`: accepts `hls` + `hlsSegmentSeconds`; distributed
  conversion rejects it alongside `gif`. `DistributedFormat` is unchanged.
  `RENDER_REQUEST_VERSION` stays at 1: the new field is optional, the
  parser ignores unknown option keys, and an older worker already rejects
  `format: "hls"` with "Render request format is invalid".
- `hdrMode.ts`: the non-mp4 SDR downgrade warning no longer claims
  "HDR + alpha" for `hls`; the helper extraction landed in the preceding
  refactor commit.

Verified: `hlsRender.integration.test.ts` renders a real 10 s composition
and asserts 3 segments, the first two exactly 4.000 s, every segment
starting on an IDR frame, and PTS 0. Removing the GOP wiring collapses it
to 2 segments, so the test is load-bearing. Both producer lanes (unit and
integration) run on every PR in `ci.yml`, so that wiring check is on the
PR gate.

CLI flags and docs are deliberately left to the next PR in the series.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(engine): close the HLS master playlist rewrite race

CodeQL flagged two high-severity alerts on the `stripAudioOnlyVariants`
rewrite: a check-then-use race (`existsSync` then a separate path-based
read and write) and insecure creation of a file at a predictable path in
the OS temp dir.

Both came from resolving `master.m3u8` three times. Open it once with
`r+` and owner-only mode — which neither creates the playlist nor widens
its permissions, ffmpeg owns that — then read, truncate and write through
that one descriptor. Absence is still tolerated for the argument-level
tests that stub ffmpeg, now via ENOENT rather than a prior existence
check.

No behavior change: `hlsRender.integration.test.ts` still renders a real
composition with audio and asserts the stripped master playlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya Poudyal
2026-09-16 20:33:55 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 0a1c6f5a45
commit 462f9db73d
19 changed files with 1847 additions and 82 deletions
+5
View File
@@ -161,10 +161,15 @@ export {
encodeFramesChunkedConcat,
muxVideoWithAudio,
applyFaststart,
packageHls,
detectGpuEncoder,
ENCODER_PRESETS,
getEncoderPreset,
HLS_MASTER_PLAYLIST,
HLS_VIDEO_PLAYLIST,
HLS_AUDIO_PLAYLIST,
type GpuEncoder,
type PackageHlsOptions,
} from "./services/chunkEncoder.js";
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
@@ -7,13 +7,18 @@
*/
import {
closeSync,
copyFileSync,
existsSync,
ftruncateSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
writeSync,
} from "fs";
import { join, dirname, extname } from "path";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
@@ -781,6 +786,154 @@ export async function muxVideoWithAudio(
};
}
export const HLS_MASTER_PLAYLIST = "master.m3u8";
export const HLS_VIDEO_PLAYLIST = "video.m3u8";
export const HLS_AUDIO_PLAYLIST = "audio.m3u8";
/**
* Drop the standalone audio-only variant ffmpeg's `-var_stream_map` adds to
* the master playlist.
*
* With `a:0,agroup:aud` the hls muxer lists the audio rendition twice: as the
* `#EXT-X-MEDIA:TYPE=AUDIO` entry the video variant references (wanted), and
* again as its own `#EXT-X-STREAM-INF` variant with no `RESOLUTION` (not
* wanted). That is valid HLS, but a player choosing variants by bandwidth can
* pick it and play sound with no picture, and the VOD consumer asked for a
* single rendition. A variant tag without a `RESOLUTION` attribute is
* audio-only; its URI is always the following line, so both go.
*/
export function stripAudioOnlyVariants(masterPlaylist: string): string {
const lines = masterPlaylist.split("\n");
const kept: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (line.startsWith("#EXT-X-STREAM-INF:") && !line.includes("RESOLUTION=")) {
// ffmpeg separates variants with a blank line; drop the one before this
// variant so the master does not end up with two in a row.
if (kept.at(-1) === "") kept.pop();
i += 1;
continue;
}
kept.push(line);
}
return kept.join("\n");
}
export interface PackageHlsOptions extends Partial<Pick<EngineConfig, "ffmpegProcessTimeout">> {
/** Whole seconds, so it matches the integer `EXT-X-TARGETDURATION` ffmpeg writes. */
segmentSeconds: number;
signal?: AbortSignal;
}
/**
* Stream-copy an H.264 video (and optional AAC sidecar) into an HLS VOD
* directory: `master.m3u8`, `video.m3u8` + `video_%05d.ts`, and `audio.m3u8` +
* `audio_%05d.ts` when audio is given. `outputPath` in the result is the directory.
* The master carries exactly one `#EXT-X-STREAM-INF` variant (the video, with
* the audio attached as a rendition group); see `stripAudioOnlyVariants`.
*
* `-hls_time` cuts at the first keyframe at or after each target, so the input
* must be encoded with the GOP lock (`gopSize = segmentSeconds × fps`). The lock
* is software-encoder only; a GPU encode will not segment on time.
*/
export async function packageHls(
videoPath: string,
audioPath: string | null,
outputDir: string,
options: PackageHlsOptions,
): Promise<MuxResult> {
const { segmentSeconds, signal } = options;
if (!Number.isInteger(segmentSeconds) || segmentSeconds <= 0) {
throw new Error(
`[chunkEncoder] packageHls requires a positive integer segmentSeconds (received ${String(segmentSeconds)})`,
);
}
// `-hls_segment_filename` is a printf template and the hls muxer does not honor `%%`.
if (outputDir.includes("%")) {
throw new Error(`[chunkEncoder] packageHls outputDir must not contain "%": ${outputDir}`);
}
// ffmpeg does not create the directory for the segment pattern.
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const hasAudio = audioPath !== null;
const args = hasAudio
? ["-i", videoPath, "-i", audioPath, "-map", "0:v:0", "-map", "1:a:0"]
: ["-i", videoPath, "-map", "0:v:0"];
args.push(
"-c",
"copy",
"-f",
"hls",
"-hls_time",
String(segmentSeconds),
"-hls_playlist_type",
"vod",
"-hls_flags",
"independent_segments",
"-hls_segment_type",
"mpegts",
"-var_stream_map",
hasAudio ? "v:0,agroup:aud,name:video a:0,agroup:aud,name:audio" : "v:0,name:video",
"-master_pl_name",
HLS_MASTER_PLAYLIST,
"-hls_segment_filename",
join(outputDir, "%v_%05d.ts"),
// Without these the mpegts muxer starts the stream at PTS 1.4 s.
"-muxdelay",
"0",
"-muxpreload",
"0",
);
// No provenance tags (MPEG-TS drops them; `-movflags` is invalid for `-f hls`)
// and no `-avoid_negative_ts`, which would drop the AAC priming (#3487).
args.push("-y", join(outputDir, "%v.m3u8"));
const processTimeout = options.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
if (signal?.aborted) {
return {
success: false,
outputPath: outputDir,
durationMs: result.durationMs,
error: "FFmpeg HLS packaging cancelled",
};
}
if (result.success && hasAudio) {
const masterPath = join(outputDir, HLS_MASTER_PLAYLIST);
// One descriptor for the read-modify-write: re-resolving the path to write
// it back races anything else in this predictable temp dir, and `r+` with
// owner-only mode neither creates nor widens the playlist ffmpeg wrote.
// A missing one is fine — the argument-level tests stub ffmpeg.
let master: number | undefined;
try {
master = openSync(masterPath, "r+", 0o600);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (master !== undefined) {
try {
const stripped = stripAudioOnlyVariants(readFileSync(master, "utf-8"));
// Stripping only shortens the playlist; truncate or the tail survives.
ftruncateSync(master, 0);
writeSync(master, stripped, 0, "utf-8");
} finally {
closeSync(master);
}
}
}
return {
success: result.success,
outputPath: outputDir,
durationMs: result.durationMs,
error: !result.success ? formatFfmpegError(result.exitCode, result.stderr) : undefined,
failureReason: result.failureReason,
};
}
export async function applyFaststart(
inputPath: string,
outputPath: string,
@@ -0,0 +1,501 @@
import { spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js";
import {
HLS_AUDIO_PLAYLIST,
HLS_MASTER_PLAYLIST,
HLS_VIDEO_PLAYLIST,
appendLockedGopArgs,
lockedGopCodecParams,
stripAudioOnlyVariants,
} from "./chunkEncoder.js";
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
vi.resetModules();
vi.doUnmock("child_process");
});
function makeTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-package-hls-"));
tempDirs.push(dir);
return dir;
}
type FakeProc = EventEmitter & { stderr: EventEmitter; kill: ReturnType<typeof vi.fn> };
type SpawnCall = { command: string; args: readonly string[]; proc: FakeProc };
function createSpawnSpy(): {
spawn: (command: string, args: readonly string[]) => FakeProc;
calls: SpawnCall[];
} {
const calls: SpawnCall[] = [];
const spawn = (command: string, args: readonly string[]): FakeProc => {
const proc = new EventEmitter() as FakeProc;
proc.stderr = new EventEmitter();
proc.kill = vi.fn(() => true);
calls.push({ command, args, proc });
return proc;
};
return { spawn, calls };
}
async function capturePackageHlsArgs(
audioPath: string | null,
options: { segmentSeconds: number },
exitCode = 0,
stderr?: string,
): Promise<{
args: readonly string[];
outputDir: string;
result: Awaited<ReturnType<typeof import("./chunkEncoder.js").packageHls>>;
}> {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(makeTempDir(), "hls");
const promise = packageHls("/tmp/video-only.mp4", audioPath, outputDir, options);
const proc = calls[0]!.proc;
if (stderr !== undefined) proc.stderr.emit("data", Buffer.from(stderr));
proc.emit("exit", exitCode);
proc.emit("close", exitCode);
return { args: calls[0]!.args, outputDir, result: await promise };
}
function argValue(args: readonly string[], flag: string): string | undefined {
const index = args.indexOf(flag);
return index === -1 ? undefined : args[index + 1];
}
describe("packageHls arguments", () => {
it("stream-copies both inputs as split renditions when audio is present", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 4 });
expect(args.filter((a) => a === "-i")).toHaveLength(2);
expect(args).toContain("/tmp/video-only.mp4");
expect(args).toContain("/tmp/audio.m4a");
expect(argValue(args, "-map")).toBe("0:v:0");
expect(args.slice(args.indexOf("-map") + 2)).toContain("1:a:0");
expect(argValue(args, "-var_stream_map")).toBe(
"v:0,agroup:aud,name:video a:0,agroup:aud,name:audio",
);
});
it("maps video only, with no audio group, when audioPath is null", async () => {
const { args } = await capturePackageHlsArgs(null, { segmentSeconds: 4 });
expect(args.filter((a) => a === "-i")).toHaveLength(1);
expect(argValue(args, "-var_stream_map")).toBe("v:0,name:video");
expect(args.join(" ")).not.toContain("agroup");
expect(args.join(" ")).not.toContain("1:a:0");
});
it("never re-encodes", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 4 });
expect(argValue(args, "-c")).toBe("copy");
expect(args).not.toContain("-c:v");
expect(args).not.toContain("-c:a");
expect(args).not.toContain("-crf");
expect(args).not.toContain("-b:v");
});
it("requests VOD playlists with independent mpegts segments", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 4 });
expect(argValue(args, "-f")).toBe("hls");
expect(argValue(args, "-hls_playlist_type")).toBe("vod");
expect(argValue(args, "-hls_flags")).toBe("independent_segments");
expect(argValue(args, "-hls_segment_type")).toBe("mpegts");
expect(argValue(args, "-master_pl_name")).toBe(HLS_MASTER_PLAYLIST);
});
it("zeroes the mpegts mux base", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 4 });
expect(argValue(args, "-muxdelay")).toBe("0");
expect(argValue(args, "-muxpreload")).toBe("0");
});
it("passes segmentSeconds through to -hls_time", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 6 });
expect(argValue(args, "-hls_time")).toBe("6");
});
it("writes the %v-templated playlists and segments into the output directory", async () => {
const { args, outputDir } = await capturePackageHlsArgs("/tmp/audio.m4a", {
segmentSeconds: 4,
});
expect(argValue(args, "-hls_segment_filename")).toBe(join(outputDir, "%v_%05d.ts"));
expect(args.at(-1)).toBe(join(outputDir, "%v.m3u8"));
expect(argValue(args, "-y")).toBe(join(outputDir, "%v.m3u8"));
});
it("omits the mp4-only mux args that MPEG-TS cannot carry", async () => {
const { args } = await capturePackageHlsArgs("/tmp/audio.m4a", { segmentSeconds: 4 });
expect(args).not.toContain("-movflags");
expect(args).not.toContain("-r");
expect(args).not.toContain("-avoid_negative_ts");
});
it("creates the output directory, which ffmpeg will not do itself", async () => {
const { outputDir } = await capturePackageHlsArgs(null, { segmentSeconds: 4 });
expect(existsSync(outputDir)).toBe(true);
});
it("reports the directory as the output path", async () => {
const { outputDir, result } = await capturePackageHlsArgs(null, { segmentSeconds: 4 });
expect(result).toMatchObject({ success: true, outputPath: outputDir });
});
it("surfaces an ffmpeg failure as an error string", async () => {
const { result } = await capturePackageHlsArgs(
null,
{ segmentSeconds: 4 },
1,
"Invalid data found when processing input\n",
);
expect(result.success).toBe(false);
expect(result.error).toContain("FFmpeg exited with code 1");
expect(result.error).toContain("Invalid data found");
});
it("preserves an external interruption so the job can retry on a fresh host", async () => {
const { result } = await capturePackageHlsArgs(
null,
{ segmentSeconds: 4 },
255,
"Exiting normally, received signal 15.\n",
);
expect(result).toMatchObject({ success: false, failureReason: "external_interruption" });
});
});
describe("stripAudioOnlyVariants", () => {
// Shape ffmpeg 7 writes for `v:0,agroup:aud a:0,agroup:aud`, trimmed.
const MEDIA =
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="group_aud",NAME="audio_1",DEFAULT=YES,URI="audio.m3u8"';
const VIDEO_VARIANT =
'#EXT-X-STREAM-INF:BANDWIDTH=49256,RESOLUTION=320x180,CODECS="avc1.42c00d,mp4a.40.2",AUDIO="group_aud"';
const AUDIO_VARIANT = '#EXT-X-STREAM-INF:BANDWIDTH=231375,CODECS="mp4a.40.2",AUDIO="group_aud"';
it("removes the audio-only variant and its URI, keeping the rendition group", () => {
const master = [
"#EXTM3U",
"#EXT-X-VERSION:3",
MEDIA,
VIDEO_VARIANT,
"video.m3u8",
"",
AUDIO_VARIANT,
"audio.m3u8",
"",
].join("\n");
expect(stripAudioOnlyVariants(master)).toBe(
["#EXTM3U", "#EXT-X-VERSION:3", MEDIA, VIDEO_VARIANT, "video.m3u8", ""].join("\n"),
);
});
it("leaves a master with only a video variant untouched", () => {
const master = ["#EXTM3U", "#EXT-X-VERSION:3", VIDEO_VARIANT, "video.m3u8", ""].join("\n");
expect(stripAudioOnlyVariants(master)).toBe(master);
});
});
describe("packageHls segmentSeconds validation", () => {
it.each([0, -4, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
"throws on segmentSeconds=%s",
async (segmentSeconds) => {
const { packageHls } = await import("./chunkEncoder.js");
await expect(
packageHls("/tmp/video.mp4", null, join(makeTempDir(), "hls"), { segmentSeconds }),
).rejects.toThrow(/positive integer segmentSeconds/);
},
);
});
describe("packageHls outputDir validation", () => {
it("rejects a directory containing % before spawning ffmpeg", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(makeTempDir(), "pct%20dir");
await expect(
packageHls("/tmp/video.mp4", null, outputDir, { segmentSeconds: 4 }),
).rejects.toThrow(/outputDir must not contain "%"/);
expect(calls).toHaveLength(0);
expect(existsSync(outputDir)).toBe(false);
});
});
describe.skipIf(!HAS_FFMPEG)("packageHls against real ffmpeg", () => {
const FPS = 30;
const DURATION = 4;
const SEGMENT_SECONDS = 1;
let dir: string;
beforeEach(() => {
dir = makeTempDir();
});
// `.concat` rather than a spread inside an array literal: this is an FFMPEG
// wrapper, whose trailing positional is an OUTPUT path and therefore takes no
// `--` terminator, but a verbosity-flag-then-spread array literal is
// indistinguishable from the ffPROBE wrapper shape that must have one. See
// the `SPREAD_WRAPPER` matcher in
// `producer/src/utils/ffprobeArgvContract.test.ts`, which reads raw source —
// so an example argv in a comment trips it too.
const run = (args: string[]): void => {
const argv = ["-v", "error"].concat(args);
const res = spawnSync(getFfmpegBinary(), argv, { encoding: "utf-8" });
if (res.status !== 0) throw new Error(`ffmpeg failed: ${res.stderr ?? ""}`);
};
/**
* `inputPath` is a parameter rather than the caller's last argv entry so the
* `--` terminator is the penultimate literal token, which is what
* `producer/src/utils/ffprobeArgvContract.test.ts` checks at the source level
* across the whole tree — a `join(dir, name)` call in that slot reads as two
* comma-separated entries to its parser and trips the gate.
*/
const probe = (inputPath: string, args: readonly string[]): string => {
const argv = ["-v", "error", ...args, "--", inputPath];
const res = spawnSync(getFfprobeBinary(), argv, { encoding: "utf-8" });
if (res.status !== 0) throw new Error(`ffprobe failed: ${res.stderr ?? ""}`);
return res.stdout.trim();
};
/** Same GOP lock args `buildEncoderArgs` emits for the software path. */
const lockedVideo = (): string => {
const path = join(dir, "video-only.mp4");
const gop = SEGMENT_SECONDS * FPS;
const gopArgs: string[] = [];
appendLockedGopArgs(gopArgs, gop);
gopArgs.push("-x264-params", lockedGopCodecParams("h264", gop));
run([
"-f",
"lavfi",
"-i",
`testsrc2=size=160x120:rate=${FPS}:duration=${DURATION}`,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
...gopArgs,
"-bf",
"0",
"-y",
path,
]);
return path;
};
const aacSidecar = (): string => {
const path = join(dir, "audio.m4a");
run([
"-f",
"lavfi",
"-i",
`sine=frequency=440:duration=${DURATION}`,
"-c:a",
"aac",
"-b:a",
"64k",
"-y",
path,
]);
return path;
};
const extinfs = (playlist: string): number[] =>
readFileSync(playlist, "utf-8")
.split("\n")
.filter((line) => line.startsWith("#EXTINF:"))
.map((line) => Number.parseFloat(line.slice("#EXTINF:".length)));
it("produces a VOD master with split audio and video renditions", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(dir, "out");
const result = await packageHls(lockedVideo(), aacSidecar(), outputDir, {
segmentSeconds: SEGMENT_SECONDS,
});
expect(result.success).toBe(true);
const master = readFileSync(join(outputDir, HLS_MASTER_PLAYLIST), "utf-8");
expect(master).toContain(HLS_VIDEO_PLAYLIST);
expect(master).toContain(HLS_AUDIO_PLAYLIST);
expect(master).toContain("#EXT-X-MEDIA:TYPE=AUDIO");
// One selectable variant: the video, with audio attached as a rendition
// group. ffmpeg also emits the audio as its own variant; that is stripped
// so a bandwidth-driven player can never pick sound without picture.
const variants = master.split("\n").filter((line) => line.startsWith("#EXT-X-STREAM-INF:"));
expect(variants).toHaveLength(1);
expect(variants[0]).toContain("RESOLUTION=");
expect(variants[0]).toContain('AUDIO="');
expect(master).toMatch(/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*URI="audio\.m3u8"/);
const videoPlaylist = readFileSync(join(outputDir, HLS_VIDEO_PLAYLIST), "utf-8");
expect(videoPlaylist).toContain("#EXT-X-PLAYLIST-TYPE:VOD");
expect(videoPlaylist).toContain("#EXT-X-INDEPENDENT-SEGMENTS");
expect(videoPlaylist).toContain("#EXT-X-ENDLIST");
});
it("cuts fixed-length segments and keeps the full duration", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(dir, "out");
await packageHls(lockedVideo(), aacSidecar(), outputDir, {
segmentSeconds: SEGMENT_SECONDS,
});
const segments = readdirSync(outputDir).filter((f) => f.startsWith("video_"));
expect(segments).toHaveLength(Math.ceil(DURATION / SEGMENT_SECONDS));
for (const extinf of extinfs(join(outputDir, HLS_VIDEO_PLAYLIST))) {
expect(extinf).toBeCloseTo(SEGMENT_SECONDS, 3);
}
const probedDuration = Number.parseFloat(
probe(join(outputDir, HLS_VIDEO_PLAYLIST), [
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
]),
);
expect(Math.abs(probedDuration - DURATION)).toBeLessThanOrEqual(1 / FPS);
});
it("starts every video segment on a keyframe", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(dir, "out");
await packageHls(lockedVideo(), null, outputDir, { segmentSeconds: SEGMENT_SECONDS });
const segments = readdirSync(outputDir)
.filter((f) => f.startsWith("video_"))
.sort();
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
const firstFrame = probe(join(outputDir, segment), [
"-select_streams",
"v",
"-show_frames",
"-show_entries",
"frame=key_frame",
"-of",
"csv=p=0",
"-read_intervals",
"%+#1",
]);
expect(firstFrame.split(",")[0]).toBe("1");
}
});
it("writes a master and no audio playlist when there is no audio", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const outputDir = join(dir, "out");
const result = await packageHls(lockedVideo(), null, outputDir, {
segmentSeconds: SEGMENT_SECONDS,
});
expect(result.success).toBe(true);
expect(existsSync(join(outputDir, HLS_MASTER_PLAYLIST))).toBe(true);
expect(existsSync(join(outputDir, HLS_VIDEO_PLAYLIST))).toBe(true);
expect(existsSync(join(outputDir, HLS_AUDIO_PLAYLIST))).toBe(false);
expect(readdirSync(outputDir).filter((f) => f.startsWith("audio"))).toHaveLength(0);
});
it("starts at PTS 0 with the AAC priming packet one frame ahead of video", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const AAC_FRAME_SECONDS = 1024 / 44100;
const outputDir = join(dir, "out");
await packageHls(lockedVideo(), aacSidecar(), outputDir, {
segmentSeconds: SEGMENT_SECONDS,
});
const audioPts = probe(join(outputDir, HLS_AUDIO_PLAYLIST), [
"-select_streams",
"a",
"-show_entries",
"packet=pts_time",
"-of",
"csv=p=0",
"-read_intervals",
"%+#3",
])
.split("\n")
.map((line) => Number.parseFloat(line));
const videoPts = Number.parseFloat(
probe(join(outputDir, HLS_VIDEO_PLAYLIST), [
"-select_streams",
"v",
"-show_entries",
"packet=pts_time",
"-of",
"csv=p=0",
"-read_intervals",
"%+#1",
]).split("\n")[0]!,
);
expect(audioPts[0]).toBeCloseTo(0, 3);
expect(videoPts).toBeCloseTo(AAC_FRAME_SECONDS, 3);
expect(audioPts[1]).toBeCloseTo(videoPts, 3);
});
it("cannot cut on time without the GOP lock", async () => {
const { packageHls } = await import("./chunkEncoder.js");
const unlocked = join(dir, "unlocked.mp4");
run([
"-f",
"lavfi",
"-i",
`testsrc2=size=160x120:rate=${FPS}:duration=${DURATION}`,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-bf",
"0",
"-y",
unlocked,
]);
const outputDir = join(dir, "out-unlocked");
await packageHls(unlocked, null, outputDir, { segmentSeconds: SEGMENT_SECONDS });
const durations = extinfs(join(outputDir, HLS_VIDEO_PLAYLIST));
expect(durations).toHaveLength(1);
expect(durations[0]).toBeCloseTo(DURATION, 3);
});
});
@@ -25,6 +25,7 @@ const INTEGRATION_TEST_FILES = new Set([
"src/services/assetMediaType.test.ts",
"src/services/htmlCompiler.mediaType.test.ts",
"src/services/htmlCompiler.naturalDuration.test.ts",
"src/services/render/hlsRender.integration.test.ts",
"src/services/render/stages/compileStage.mediaType.test.ts",
"src/utils/audioRegression.test.ts",
"src/utils/streamDurationParity.test.ts",
@@ -261,4 +261,85 @@ describe("RenderRequest", () => {
}),
).toThrow("must be even");
});
describe("format: hls", () => {
function hlsRequest(options: { hlsSegmentSeconds?: number } = {}) {
return createRenderRequest({
projectDir: "/project",
outputPath: "/output/video-hls",
engineConfig: DEFAULT_CONFIG,
options: {
fps: { num: 30, den: 1 },
quality: "standard",
format: "hls",
...options,
},
});
}
it("round-trips hls and its segment length", () => {
const value = hlsRequest({ hlsSegmentSeconds: 6 });
expect(value.options.format).toBe("hls");
expect(value.options.hlsSegmentSeconds).toBe(6);
expect(parseRenderRequest(serializeRenderRequest(value))).toEqual(value);
});
it("carries the segment length through to the local render config", () => {
expect(renderConfigFromRequest(hlsRequest({ hlsSegmentSeconds: 2 }))).toMatchObject({
format: "hls",
hlsSegmentSeconds: 2,
});
});
it("omits hlsSegmentSeconds when unset so the orchestrator default applies", () => {
expect(hlsRequest().options).not.toHaveProperty("hlsSegmentSeconds");
});
it("rejects a non-positive or fractional segment length at the wire boundary", () => {
const value = hlsRequest();
for (const hlsSegmentSeconds of [0, -4, 4.5]) {
expect(() =>
parseRenderRequest({ ...value, options: { ...value.options, hlsSegmentSeconds } }),
).toThrow("hlsSegmentSeconds must be an integer >= 1");
}
});
// v1 is in-process only. `DistributedFormat` has no "hls" member, so this
// gate is also what makes the adapter's `format:` assignment typecheck.
it("refuses to convert an hls request to a distributed config", () => {
const value = createRenderRequest({
projectDir: "/project",
outputPath: "/output/video-hls",
engineConfig: DEFAULT_CONFIG,
options: {
fps: { num: 30, den: 1 },
quality: "standard",
format: "hls",
distributed: { width: 1920, height: 1080 },
},
});
expect(() => distributedConfigFromRequest(value)).toThrow(
"Distributed render does not support hls",
);
});
it("still names gif in the same rejection", () => {
const value = createRenderRequest({
projectDir: "/project",
outputPath: "/output/video.gif",
engineConfig: DEFAULT_CONFIG,
options: {
fps: { num: 30, den: 1 },
quality: "standard",
format: "gif",
distributed: { width: 1920, height: 1080 },
},
});
expect(() => distributedConfigFromRequest(value)).toThrow(
"Distributed render does not support gif",
);
});
});
});
+9 -2
View File
@@ -37,6 +37,7 @@ export interface RenderRequestOptions {
quality: "draft" | "standard" | "high";
format: NonNullable<RenderConfig["format"]>;
gifLoop?: number;
hlsSegmentSeconds?: number;
workers?: number;
useGpu?: boolean;
debug?: boolean;
@@ -158,6 +159,7 @@ function assertDistributedOptions(value: unknown): void {
function assertRequestOptionScalars(options: Record<string, unknown>): void {
assertOptionalInteger(options, "gifLoop");
assertOptionalInteger(options, "hlsSegmentSeconds", 1);
assertOptionalInteger(options, "workers", 1);
assertOptionalInteger(options, "crf");
for (const field of ["useGpu", "debug", "outputResolutionAspectAgnostic"] as const) {
@@ -190,7 +192,7 @@ function assertRequestOptions(options: unknown): asserts options is RenderReques
if (!["draft", "standard", "high"].includes(String(options.quality))) {
throw new Error("Render request quality is invalid");
}
if (!["mp4", "webm", "mov", "png-sequence", "gif"].includes(String(options.format))) {
if (!["mp4", "webm", "mov", "png-sequence", "gif", "hls"].includes(String(options.format))) {
throw new Error("Render request format is invalid");
}
assertRequestOptionScalars(options);
@@ -268,7 +270,12 @@ export function distributedConfigFromRequest(
const options = request.options;
const distributed = options.distributed;
if (!distributed) throw new Error("Render request is missing distributed options");
if (options.format === "gif") throw new Error("Distributed render does not support gif");
// Both are in-process only, and the narrowing matters: `DistributedFormat`
// does not carry either member, so `format: options.format` below only
// typechecks once they are thrown out.
if (options.format === "gif" || options.format === "hls") {
throw new Error(`Distributed render does not support ${options.format}`);
}
if (options.hdrMode === "force-hdr") {
throw new Error("Distributed render does not support force-hdr");
}
@@ -12,7 +12,11 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { ArtifactTransaction, type ArtifactDurationProbe } from "./artifactTransaction.js";
import {
ArtifactTransaction,
buildArtifactExpectation,
type ArtifactDurationProbe,
} from "./artifactTransaction.js";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-artifact-transaction-"));
@@ -353,4 +357,158 @@ describe("ArtifactTransaction", () => {
transaction.rollback();
});
// ── HLS: a directory artifact that still has to pass the duration gate ────
// png-sequence directories have no probeable container, so the directory
// branch historically stopped at "non-empty readable files". An HLS package
// does have one — its video playlist — and shipping a truncated playlist is
// exactly the #3395 failure mode in a new container.
function stageHlsDirectory(transaction: ArtifactTransaction): void {
mkdirSync(transaction.stagingPath);
writeFileSync(join(transaction.stagingPath, "master.m3u8"), "#EXTM3U\n");
writeFileSync(join(transaction.stagingPath, "video.m3u8"), "#EXTM3U\n#EXTINF:4.000000,\n");
writeFileSync(join(transaction.stagingPath, "video_00000.ts"), "ts-bytes");
}
it("probes the named playlist inside an HLS directory", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const probed: string[] = [];
const probe: ArtifactDurationProbe = async (path) => {
probed.push(path);
return { durationSeconds: 4.0 };
};
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe);
stageHlsDirectory(transaction);
await expect(
transaction.validate({
expectedDurationSeconds: 4.0,
fps: 30,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
}),
).resolves.toBeUndefined();
expect(probed).toEqual([join(transaction.stagingPath, "video.m3u8")]);
transaction.rollback();
});
it("rejects a truncated HLS package", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 8.0 });
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe);
stageHlsDirectory(transaction);
await expect(
transaction.validate({
expectedDurationSeconds: 20.0,
fps: 30,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
}),
).rejects.toThrow(/is truncated: expected 20\.000s, probed 8\.000s/);
transaction.rollback();
});
// ffprobe reports `nb_frames=N/A` through the hls demuxer, so the frame
// check is inert for a playlist probe. Assert it stays inert rather than
// failing a package whose duration is correct.
it("passes an HLS package whose probe reports no frame count", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4.0 });
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe);
stageHlsDirectory(transaction);
await expect(
transaction.validate({
expectedDurationSeconds: 4.0,
fps: 30,
expectedFrames: 120,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
}),
).resolves.toBeUndefined();
transaction.rollback();
});
it("rejects an HLS package with no master playlist before probing", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const transaction = new ArtifactTransaction(destination, "directory", undefined, neverCalled);
mkdirSync(transaction.stagingPath);
writeFileSync(join(transaction.stagingPath, "video.m3u8"), "#EXTM3U\n");
await expect(
transaction.validate({
expectedDurationSeconds: 4.0,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
}),
).rejects.toThrow(/missing master\.m3u8/);
transaction.rollback();
});
it("surfaces a failed probe of the HLS playlist instead of passing the gate", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const probe: ArtifactDurationProbe = async () => {
throw new Error("ffprobe: Invalid data found when processing input");
};
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe);
stageHlsDirectory(transaction);
await expect(
transaction.validate({
expectedDurationSeconds: 4.0,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
}),
).rejects.toThrow(/duration probe failed for .*video\.m3u8/);
transaction.rollback();
});
});
describe("buildArtifactExpectation", () => {
const base = { durationSeconds: 4, fps: 30, expectedFrames: 120 };
it("returns no expectation for the formats with nothing probeable", () => {
expect(buildArtifactExpectation({ ...base, outputFormat: "png-sequence" })).toBeUndefined();
expect(buildArtifactExpectation({ ...base, outputFormat: "gif" })).toBeUndefined();
});
it("returns no expectation when the pipeline has no usable duration", () => {
for (const durationSeconds of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(
buildArtifactExpectation({ ...base, durationSeconds, outputFormat: "mp4" }),
).toBeUndefined();
}
});
it("probes the staged file itself for single-file formats", () => {
for (const outputFormat of ["mp4", "webm", "mov"] as const) {
expect(buildArtifactExpectation({ ...base, outputFormat })).toEqual({
expectedDurationSeconds: 4,
fps: 30,
expectedFrames: 120,
});
}
});
it("names the video playlist and requires the master for hls", () => {
expect(buildArtifactExpectation({ ...base, outputFormat: "hls" })).toEqual({
expectedDurationSeconds: 4,
fps: 30,
expectedFrames: 120,
probeTarget: "video.m3u8",
requiredFiles: ["master.m3u8"],
});
});
});
@@ -10,7 +10,9 @@ import {
rmSync,
} from "node:fs";
import { basename, dirname, extname, join, resolve } from "node:path";
import { HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST } from "@hyperframes/engine";
import { extractMediaMetadata } from "../../utils/ffprobe.js";
import type { RenderOutputFormat } from "./renderFormat.js";
export type ArtifactKind = "file" | "directory";
@@ -83,12 +85,57 @@ async function defaultArtifactDurationProbe(path: string): Promise<ArtifactDurat
* is unknown) so that a normal container-level last-frame rounding does not
* trip the gate. Multi-frame drops (e.g. the 326-frame / 11s truncation in
* #3395) still fail.
*
* `probeTarget` / `requiredFiles` exist for directory artifacts. A `"file"`
* artifact is its own probe target; a directory has no single one, so an HLS
* output names its video playlist (`probeTarget: "video.m3u8"`) and the files
* that must be present (`requiredFiles: ["master.m3u8"]`). Without
* `probeTarget` the directory branch keeps its historical behavior — readable
* non-empty files only, no duration gate (png-sequence).
*/
export interface ArtifactValidationExpectation {
expectedDurationSeconds: number;
fps?: number;
expectedFrames?: number;
toleranceSeconds?: number;
/** Path relative to the staging directory to run the duration probe against. */
probeTarget?: string;
/** Paths relative to the staging directory that must exist as non-empty files. */
requiredFiles?: readonly string[];
}
/**
* The per-format expectation the render pipeline hands `validate()`, or
* `undefined` when the format has nothing probeable.
*
* - `png-sequence` / `gif`: no expectation. A PNG directory has no container
* duration, and the GIF encoder writes `outputPath` itself without a
* frame-accurate duration the pipeline could compare against.
* - `hls`: the directory's `video.m3u8` is the probe target and `master.m3u8`
* must exist. `expectedFrames` is passed through but is inert — ffprobe
* reports no frame count through the hls demuxer — so duration carries the
* gate.
* - everything else: the staged file itself, duration + frame count.
*/
export function buildArtifactExpectation(input: {
outputFormat: RenderOutputFormat;
durationSeconds: number;
fps: number;
expectedFrames: number | undefined;
}): ArtifactValidationExpectation | undefined {
if (input.outputFormat === "png-sequence" || input.outputFormat === "gif") return undefined;
if (!Number.isFinite(input.durationSeconds) || input.durationSeconds <= 0) return undefined;
const expectation: ArtifactValidationExpectation = {
expectedDurationSeconds: input.durationSeconds,
fps: input.fps,
expectedFrames: input.expectedFrames,
};
if (input.outputFormat !== "hls") return expectation;
return {
...expectation,
probeTarget: HLS_VIDEO_PLAYLIST,
requiredFiles: [HLS_MASTER_PLAYLIST],
};
}
function createSiblingTransactionDirectory(destination: string): string {
@@ -201,10 +248,11 @@ function assertFrameCountWithinTolerance(
* ordinary failures.
*
* When the caller passes an `expected` expectation to `validate()`, the
* transaction additionally probes the staged file's container duration (and
* decoded frame count when available) and rejects any artifact that is
* significantly shorter than what the pipeline asked for. The readable-non-
* empty check is unchanged; this is a second gate on top.
* transaction additionally probes the staged artifact's container duration
* (and decoded frame count when available) and rejects any artifact that is
* significantly shorter than what the pipeline asked for. A directory artifact
* is probed only when `expected.probeTarget` names a file inside it. The
* readable-non-empty check is unchanged; this is a second gate on top.
*/
export class ArtifactTransaction {
readonly destinationPath: string;
@@ -233,6 +281,11 @@ export class ArtifactTransaction {
if (expected) await this.assertArtifactDuration(expected);
return;
}
this.assertReadableNonEmptyDirectory();
if (expected) await this.assertDirectoryExpectation(expected);
}
private assertReadableNonEmptyDirectory(): void {
let files: string[];
try {
files = collectDirectoryFiles(this.stagingPath);
@@ -247,35 +300,59 @@ export class ArtifactTransaction {
for (const file of files) assertReadableNonEmptyFile(file);
}
private async assertArtifactDuration(expected: ArtifactValidationExpectation): Promise<void> {
/**
* The named-entry and duration gates a directory artifact opts into. A
* directory has no single probe target, so an expectation without
* `probeTarget` (png-sequence) keeps the historical behavior: structure only.
*/
private async assertDirectoryExpectation(expected: ArtifactValidationExpectation): Promise<void> {
for (const required of expected.requiredFiles ?? []) {
const path = join(this.stagingPath, required);
if (!existsSync(path)) {
throw new Error(`Render artifact directory is missing ${required}: ${this.stagingPath}`);
}
assertReadableNonEmptyFile(path);
}
if (expected.probeTarget === undefined) return;
await this.assertArtifactDuration(expected, join(this.stagingPath, expected.probeTarget));
}
private async assertArtifactDuration(
expected: ArtifactValidationExpectation,
probePath: string = this.stagingPath,
): Promise<void> {
const expectedSeconds = expected.expectedDurationSeconds;
if (!Number.isFinite(expectedSeconds) || expectedSeconds <= 0) return;
const probed = await this.runDurationProbe();
assertUsableProbedDuration(probed.durationSeconds, expectedSeconds, this.stagingPath);
const probed = await this.runDurationProbe(probePath);
assertUsableProbedDuration(probed.durationSeconds, expectedSeconds, probePath);
const toleranceSeconds = durationToleranceSeconds(expected);
assertDurationWithinTolerance(
expectedSeconds,
probed.durationSeconds,
toleranceSeconds,
this.stagingPath,
probePath,
);
if (expected.expectedFrames !== undefined) {
assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, this.stagingPath);
// ffprobe reports `nb_frames=N/A` through the hls demuxer, so for a
// playlist probe this assertion is a no-op by construction (the helper
// short-circuits on an undefined frame count) and the duration check
// above carries the gate on its own.
assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, probePath);
}
}
private async runDurationProbe(): Promise<ArtifactDurationProbeResult> {
private async runDurationProbe(probePath: string): Promise<ArtifactDurationProbeResult> {
try {
return await this.durationProbe(this.stagingPath);
return await this.durationProbe(probePath);
} catch (error) {
// Probe failure means we cannot assert; do not silently pass a gate the
// caller asked for. Surfacing the probe error keeps the truncate-then-
// succeed failure mode from regressing back into "validation passes".
throw new Error(
`Render artifact duration probe failed for ${this.stagingPath}: ${
`Render artifact duration probe failed for ${probePath}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error },
@@ -123,9 +123,34 @@ describe("resolveEffectiveHdrMode", () => {
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining(`format is "${fmt}" — falling back to SDR`),
);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("HDR + alpha is not supported"),
);
}
});
// hls is the one non-mp4 format downgraded for a reason other than alpha:
// HDR10 would need HEVC in fMP4 segments. `force-hdr` + `hls` is rejected
// before the render starts, so only auto-detect reaches this gate.
it("downgrades auto-detected HDR on hls with an SDR-only reason, not the alpha one", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
hdrMode: "auto",
outputFormat: "hls",
extractionResult: extractionWith([HDR_PQ]),
imageColorSpaces: [],
log,
});
expect(result).toBeUndefined();
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining('format is "hls" — falling back to SDR'),
);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("HLS output is SDR-only (H.264 in MPEG-TS)"),
);
expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("HDR + alpha"));
});
it("force-hdr without sources + non-mp4 format: still downgrades, two warns fire", () => {
const log = makeLog();
const result = resolveEffectiveHdrMode({
@@ -30,6 +30,31 @@ type HdrModeInput = {
log: ProducerLogger;
};
/**
* The "format can't carry HDR, falling back to SDR" warning.
*
* `hls` is the one non-mp4 format refused for a reason other than alpha: HDR10
* would need HEVC in fMP4 segments, and v1 packages H.264 into MPEG-TS.
* `force-hdr` + `hls` is rejected outright before the render starts (see
* `validateHlsRenderConfig`), so only auto-detected HDR reaches this path.
*/
function formatDowngradeWarning(
outputFormat: NonNullable<RenderConfig["format"]>,
forcedHdrWithoutSources: boolean,
): string {
const hdrSourceReason = forcedHdrWithoutSources
? "HDR was forced without detected HDR sources"
: "HDR source detected";
const formatReason =
outputFormat === "hls"
? "HLS output is SDR-only (H.264 in MPEG-TS)"
: "HDR + alpha is not supported";
return (
`[Render] ${hdrSourceReason}, but format is "${outputFormat}" — falling back to SDR. ` +
`${formatReason}. Use --format mp4 for HDR10 output.`
);
}
export function findRenderHdrAutoPromotionTrigger(input: {
extractionResult: ExtractionResult | null | undefined;
videos: readonly { id: string; src: string }[];
@@ -107,13 +132,7 @@ export function resolveEffectiveHdrMode(input: HdrModeInput): EffectiveHdr {
}
if (effectiveHdr && input.outputFormat !== "mp4") {
const hdrSourceReason = forcedHdrWithoutSources
? "HDR was forced without detected HDR sources"
: "HDR source detected";
input.log.warn(
`[Render] ${hdrSourceReason}, but format is "${input.outputFormat}" — falling back to SDR. ` +
`HDR + alpha is not supported. Use --format mp4 for HDR10 output.`,
);
input.log.warn(formatDowngradeWarning(input.outputFormat, forcedHdrWithoutSources));
effectiveHdr = undefined;
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "bun:test";
import {
DEFAULT_HLS_SEGMENT_SECONDS,
resolveHlsEncoderGopLock,
resolveHlsGopSize,
resolveHlsSegmentSeconds,
validateHlsRenderConfig,
} from "./hlsConfig.js";
describe("validateHlsRenderConfig", () => {
it("is a no-op for every other format, including the invalid-for-HLS combinations", () => {
expect(() =>
validateHlsRenderConfig({ format: "mp4", hdrMode: "force-hdr", useGpu: true }),
).not.toThrow();
expect(() => validateHlsRenderConfig({ format: "webm", hlsSegmentSeconds: 0 })).not.toThrow();
expect(() => validateHlsRenderConfig({})).not.toThrow();
});
it("accepts the default and any positive integer segment length", () => {
expect(() => validateHlsRenderConfig({ format: "hls" })).not.toThrow();
expect(() => validateHlsRenderConfig({ format: "hls", hlsSegmentSeconds: 1 })).not.toThrow();
expect(() => validateHlsRenderConfig({ format: "hls", hlsSegmentSeconds: 10 })).not.toThrow();
});
it("rejects a fractional or non-positive segment length", () => {
for (const hlsSegmentSeconds of [0, -4, 0.5, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => validateHlsRenderConfig({ format: "hls", hlsSegmentSeconds })).toThrow(
/hlsSegmentSeconds must be a positive integer/,
);
}
});
it("rejects force-hdr because HLS v1 packages SDR H.264 into MPEG-TS", () => {
expect(() => validateHlsRenderConfig({ format: "hls", hdrMode: "force-hdr" })).toThrow(
/does not support hdrMode "force-hdr"/,
);
});
it("accepts the HDR modes that resolve to SDR", () => {
expect(() => validateHlsRenderConfig({ format: "hls", hdrMode: "auto" })).not.toThrow();
expect(() => validateHlsRenderConfig({ format: "hls", hdrMode: "force-sdr" })).not.toThrow();
});
// The GOP lock is software-only, so a GPU encode emits its own keyframe
// cadence: nvenc's ~250-frame default would give 8 s segments at 30 fps,
// while videotoolbox's dense keyframes segment correctly by accident. That
// asymmetry passes on a Mac and ships wrong segments from Linux.
it("rejects GPU encoding, which ignores the forced-keyframe lock", () => {
expect(() => validateHlsRenderConfig({ format: "hls", useGpu: true })).toThrow(
/does not support GPU encoding/,
);
});
it("accepts an explicitly disabled or unset GPU", () => {
expect(() => validateHlsRenderConfig({ format: "hls", useGpu: false })).not.toThrow();
expect(() => validateHlsRenderConfig({ format: "hls" })).not.toThrow();
});
});
describe("resolveHlsSegmentSeconds", () => {
it("defaults to 4 s and honors an explicit value", () => {
expect(DEFAULT_HLS_SEGMENT_SECONDS).toBe(4);
expect(resolveHlsSegmentSeconds({ format: "hls" })).toBe(4);
expect(resolveHlsSegmentSeconds({ format: "hls", hlsSegmentSeconds: 6 })).toBe(6);
});
});
describe("resolveHlsGopSize", () => {
it("is segmentSeconds x fps on integer frame rates", () => {
expect(resolveHlsGopSize({ num: 30, den: 1 }, 4)).toBe(120);
expect(resolveHlsGopSize({ num: 60, den: 1 }, 4)).toBe(240);
expect(resolveHlsGopSize({ num: 24, den: 1 }, 2)).toBe(48);
});
// 30000/1001 x 4 = 119.88 -> 120 frames -> 120 x 1001/30000 = 4.004 s
// segments against `#EXT-X-TARGETDURATION:4`. Compliant (the spec rounds to
// nearest), and rounding beats flooring: 119 frames would drift the other way
// every segment.
it("rounds to the nearest whole frame on fractional rates", () => {
expect(resolveHlsGopSize({ num: 30_000, den: 1001 }, 4)).toBe(120);
expect(resolveHlsGopSize({ num: 24_000, den: 1001 }, 4)).toBe(96);
expect(resolveHlsGopSize({ num: 60_000, den: 1001 }, 1)).toBe(60);
});
// The engine throws on a non-positive gopSize; a pathological fps must not
// turn that into a mid-render crash.
it("never returns a non-positive GOP", () => {
expect(resolveHlsGopSize({ num: 1, den: 10 }, 1)).toBe(1);
});
});
describe("resolveHlsEncoderGopLock", () => {
const fps = { num: 30, den: 1 };
it("locks the GOP for hls at the default and at an explicit segment length", () => {
expect(resolveHlsEncoderGopLock("hls", fps, undefined)).toEqual({
lockGopForChunkConcat: true,
gopSize: 120,
});
expect(resolveHlsEncoderGopLock("hls", fps, 6)).toEqual({
lockGopForChunkConcat: true,
gopSize: 180,
});
});
// Regression guard on the mp4 byte-for-byte promise: a GOP lock leaking onto
// any other format changes its encoder args, and nothing downstream would
// complain.
it("leaves every other format's open-GOP output untouched", () => {
for (const format of ["mp4", "webm", "mov", "png-sequence", "gif"]) {
expect(resolveHlsEncoderGopLock(format, fps, 4)).toEqual({
lockGopForChunkConcat: false,
gopSize: undefined,
});
}
});
});
@@ -0,0 +1,108 @@
/**
* HLS render-config resolution and the fail-fast gates for `format: "hls"`.
*
* Kept out of `renderOrchestrator.ts` so the three things HLS needs from the
* config — a validated segment length, the derived GOP size, and the
* combinations it refuses — are testable without standing up a render.
*
* The load-bearing invariant: `-c copy -f hls` can only cut a segment at an
* existing keyframe, so the encode must place an IDR exactly every
* `segmentSeconds × fps` frames. `resolveHlsGopSize` computes that number and
* the orchestrator hands it to the encoder as
* `lockGopForChunkConcat: true, gopSize`.
*/
import { fpsToNumber, type Fps } from "@hyperframes/core";
/** ffmpeg's own `-hls_time` default is 2 s; 4 s is the VOD consumer's ask. */
export const DEFAULT_HLS_SEGMENT_SECONDS = 4;
export interface HlsRenderConfig {
format?: string;
hlsSegmentSeconds?: number;
hdrMode?: "auto" | "force-hdr" | "force-sdr";
useGpu?: boolean;
}
/**
* Reject the config combinations HLS cannot honor. No-op for every other
* format, so this runs unconditionally at the top of `executeRenderJob`.
*
* Both rejections are hard errors rather than warnings-with-fallback: each one
* would otherwise produce a playable-looking directory that violates the
* segment contract the consumer relies on, with nothing in the output to say so.
*/
export function validateHlsRenderConfig(config: HlsRenderConfig): void {
if (config.format !== "hls") return;
const segmentSeconds = config.hlsSegmentSeconds;
if (segmentSeconds !== undefined && (!Number.isInteger(segmentSeconds) || segmentSeconds <= 0)) {
throw new Error(
`[Render] hlsSegmentSeconds must be a positive integer (got ${String(segmentSeconds)}). ` +
`Whole seconds keep the integer #EXT-X-TARGETDURATION ffmpeg writes honest.`,
);
}
if (config.hdrMode === "force-hdr") {
throw new Error(
`[Render] format "hls" does not support hdrMode "force-hdr". HLS v1 is SDR only ` +
`(H.264 + AAC in MPEG-TS); HDR10 would need fMP4 segments and HEVC. ` +
`Use --format mp4 for HDR10 output, or drop --hdr.`,
);
}
// The GOP lock is software-encoder only (see `appendLockedGopArgs` in
// @hyperframes/engine). A GPU encode ignores it and emits its own keyframe
// cadence — nvenc's ~250-frame default gives 8 s segments at 30 fps instead
// of 4 s, while videotoolbox's much denser keyframes happen to segment
// correctly. That asymmetry means a GPU HLS render passes on a Mac and ships
// wrong segment lengths from Linux, so refuse the combination outright.
if (config.useGpu === true) {
throw new Error(
`[Render] format "hls" does not support GPU encoding. Fixed-length segments require ` +
`the software encoder's forced-keyframe lock, which GPU encoders ignore. ` +
`Re-run without --gpu.`,
);
}
}
/** The validated segment length, or the 4 s default. */
export function resolveHlsSegmentSeconds(config: HlsRenderConfig): number {
return config.hlsSegmentSeconds ?? DEFAULT_HLS_SEGMENT_SECONDS;
}
/**
* Frames per segment. `Math.round` (not floor) so fractional rates land on the
* nearest whole frame: 30000/1001 × 4 → 120 frames → 4.004 s segments, which
* still reports `#EXT-X-TARGETDURATION:4` because the spec rounds to nearest.
* Clamped to >= 1 — the engine throws on a non-positive `gopSize`, and a
* pathologically low fps must not turn that into a render-time crash.
*/
export function resolveHlsGopSize(fps: Fps, segmentSeconds: number): number {
return Math.max(1, Math.round(fpsToNumber(fps) * segmentSeconds));
}
/**
* The `EncoderOptions` pair that forces the keyframe cadence, ready to spread
* into both encoder inputs.
*
* Returned as one object rather than two locals on purpose: the streaming
* encoder and the disk/chunked encoder must receive *identical* values, and a
* render that reaches only one of them still succeeds — it just produces
* variable-length segments with nothing in the output to say so. Spreading one
* value at both call sites makes them unable to disagree.
*
* Non-HLS formats get `lockGopForChunkConcat: false` and no `gopSize`, which
* is what every encoder path already did before HLS existed.
*/
export function resolveHlsEncoderGopLock(
outputFormat: string,
fps: Fps,
segmentSeconds: number | undefined,
): { lockGopForChunkConcat: boolean; gopSize: number | undefined } {
if (outputFormat !== "hls") return { lockGopForChunkConcat: false, gopSize: undefined };
return {
lockGopForChunkConcat: true,
gopSize: resolveHlsGopSize(fps, segmentSeconds ?? DEFAULT_HLS_SEGMENT_SECONDS),
};
}
@@ -0,0 +1,261 @@
/**
* One real `format: "hls"` render, end to end.
*
* This is the only test in the series that proves the whole contract the VOD
* consumer asked for, and every piece of it is something a mocked test cannot
* see: that the orchestrator's GOP lock actually reaches libx264, that
* `-hls_time` therefore cuts on the segment boundary rather than at whatever
* keyframe happened to be nearby, that every segment starts on an IDR frame,
* and that the artifact transaction publishes a directory whose playlist
* probes to the right duration.
*
* Needs Chrome + ffmpeg, so it lives in the integration lane
* (`scripts/test-classification.mjs`). `render` is ~10 s of 320x180 frames to
* keep it affordable.
*/
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getFfmpegBinary, getFfprobeBinary, resolveConfig } from "@hyperframes/engine";
import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js";
const FFMPEG = getFfmpegBinary();
const FFPROBE = getFfprobeBinary();
const HAS_FFMPEG = spawnSync(FFMPEG, ["-version"], { encoding: "utf-8" }).status === 0;
const DURATION_SECONDS = 10;
const SEGMENT_SECONDS = 4;
const FPS = 30;
// `-hls_time` closes a segment at the first keyframe at or after the target,
// so the last segment holds the remainder: 4 + 4 + 2.
const EXPECTED_VIDEO_SEGMENTS = Math.ceil(DURATION_SECONDS / SEGMENT_SECONDS);
const COMPOSITION = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
.scene { position: absolute; inset: 0; }
#a { background: #1a1a2e; }
#b { background: #0f3460; }
</style>
</head>
<body>
<div
id="root"
data-composition-id="hls-vod"
data-start="0"
data-duration="${DURATION_SECONDS}"
data-width="320"
data-height="180"
data-fps="${FPS}"
data-no-timeline
>
<audio src="assets/tone.wav" data-start="0" data-duration="${DURATION_SECONDS}" data-volume="0.5"></audio>
<section id="a" class="scene clip" data-start="0" data-duration="5" data-track-index="1"></section>
<section id="b" class="scene clip" data-start="5" data-duration="5" data-track-index="1"></section>
</div>
</body>
</html>
`;
/**
* `inputPath` is a separate parameter, not the argv's last element, so the
* `--` terminator stays the penultimate literal token — see
* `src/utils/ffprobeArgvContract.test.ts`, which checks that position at the
* source level across the tree.
*/
function ffprobe(inputPath: string, args: readonly string[]): string {
const argv = ["-v", "error", ...args, "--", inputPath];
const result = spawnSync(FFPROBE, argv, { encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(`ffprobe failed (${String(result.status)}): ${result.stderr}`);
}
return result.stdout.trim();
}
/** `#EXTINF:` durations, in playlist order. */
function extinfDurations(playlistPath: string): number[] {
return readFileSync(playlistPath, "utf-8")
.split("\n")
.filter((line) => line.startsWith("#EXTINF:"))
.map((line) => Number.parseFloat(line.slice("#EXTINF:".length)));
}
describe.skipIf(!HAS_FFMPEG)("format: hls — real render", () => {
let root: string;
let outputDir: string;
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hf-hls-render-"));
mkdirSync(join(root, "assets"), { recursive: true });
writeFileSync(join(root, "index.html"), COMPOSITION);
const tone = spawnSync(
FFMPEG,
[
"-v",
"error",
"-f",
"lavfi",
"-i",
`sine=frequency=440:duration=${DURATION_SECONDS}`,
"-ar",
"48000",
"-y",
join(root, "assets", "tone.wav"),
],
{ encoding: "utf-8" },
);
if (tone.status !== 0) throw new Error(`tone fixture failed: ${tone.stderr}`);
outputDir = join(root, "out-hls");
const job = createRenderJob({
fps: FPS,
quality: "draft",
format: "hls",
hlsSegmentSeconds: SEGMENT_SECONDS,
hdrMode: "force-sdr",
workers: 1,
logger: { info() {}, warn() {}, error() {}, debug() {} },
producerConfig: resolveConfig({ browserGpuMode: "software" }),
});
await executeRenderJob(job, root, outputDir);
}, 600_000);
afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it("publishes a directory with a master playlist referencing both renditions", () => {
const master = readFileSync(join(outputDir, "master.m3u8"), "utf-8");
expect(existsSync(join(outputDir, "video.m3u8"))).toBe(true);
expect(existsSync(join(outputDir, "audio.m3u8"))).toBe(true);
expect(master).toContain("video.m3u8");
expect(master).toContain("audio.m3u8");
});
it("marks the playlists as VOD with independent segments", () => {
const video = readFileSync(join(outputDir, "video.m3u8"), "utf-8");
expect(video).toContain("#EXT-X-PLAYLIST-TYPE:VOD");
expect(video).toContain("#EXT-X-INDEPENDENT-SEGMENTS");
expect(video).toContain(`#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`);
expect(video.trimEnd().endsWith("#EXT-X-ENDLIST")).toBe(true);
});
it("cuts fixed-length segments, the last one holding the remainder", () => {
const durations = extinfDurations(join(outputDir, "video.m3u8"));
const segments = readdirSync(outputDir).filter((name) => name.startsWith("video_"));
expect(segments).toHaveLength(EXPECTED_VIDEO_SEGMENTS);
expect(durations).toHaveLength(EXPECTED_VIDEO_SEGMENTS);
// Every segment but the last is exactly `SEGMENT_SECONDS`, within a frame.
for (const duration of durations.slice(0, -1)) {
expect(Math.abs(duration - SEGMENT_SECONDS)).toBeLessThanOrEqual(1 / FPS);
}
expect(durations.at(-1)).toBeGreaterThan(0);
expect(durations.at(-1)).toBeLessThanOrEqual(SEGMENT_SECONDS + 1 / FPS);
});
// The load-bearing assertion for PR 1's GOP lock: without it, libx264's
// default keyint puts the only IDR at frame 0 and the whole clip collapses
// into one segment. A segment that does not start on an IDR is not
// independently decodable, whatever the playlist claims.
it("starts every video segment on an IDR frame", () => {
const segments = readdirSync(outputDir)
.filter((name) => name.startsWith("video_") && name.endsWith(".ts"))
.sort();
expect(segments.length).toBe(EXPECTED_VIDEO_SEGMENTS);
for (const segment of segments) {
// `csv=p=0` still emits the field separator, so the row reads "1,".
const firstFrame = ffprobe(join(outputDir, segment), [
"-select_streams",
"v:0",
"-show_entries",
"frame=key_frame",
"-of",
"csv=p=0",
"-read_intervals",
"%+#1",
]).replace(/,+$/, "");
expect(firstFrame).toBe("1");
}
});
it("probes the video playlist to the composition duration", () => {
const probed = Number.parseFloat(
ffprobe(join(outputDir, "video.m3u8"), [
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
]),
);
// Same single-frame tolerance the artifact gate uses.
expect(Math.abs(probed - DURATION_SECONDS)).toBeLessThanOrEqual(1 / FPS);
});
it("carries the audio rendition at the same start time as the video", () => {
const firstVideoPts = Number.parseFloat(
ffprobe(join(outputDir, "video.m3u8"), [
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time",
"-of",
"csv=p=0",
"-read_intervals",
"%+#1",
]).split("\n")[0]!,
);
// `packageHls` passes `-muxdelay 0 -muxpreload 0` to keep the mpegts muxer
// from applying its default 1.4 s PTS base; the video must start at zero.
expect(firstVideoPts).toBeLessThan(1 / FPS);
});
/**
* Packaging an AAC sidecar directly (the engine's `packageHls` fixtures) puts
* one extra sub-frame segment on the audio rendition — a 23 ms priming tail
* past the last video segment. Through the real pipeline it does not happen,
* because `padOrTrimAudioToVideoFrameCount` trims the sidecar to the video
* frame count first. Pinned because a regression here is invisible: the
* playlist stays valid and only the rendition lengths diverge.
*/
it("keeps the audio rendition segment-aligned with the video rendition", () => {
const videoDurations = extinfDurations(join(outputDir, "video.m3u8"));
const audioDurations = extinfDurations(join(outputDir, "audio.m3u8"));
expect(audioDurations).toHaveLength(videoDurations.length);
// One AAC frame is 1024 samples (21.3 ms at 48 kHz), so each audio segment
// lands within a frame of its video counterpart rather than exactly on it.
for (const [index, audio] of audioDurations.entries()) {
expect(Math.abs(audio - videoDurations[index]!)).toBeLessThanOrEqual(0.025);
}
});
it("reports an integer target duration on both renditions", () => {
for (const playlist of ["video.m3u8", "audio.m3u8"]) {
const text = readFileSync(join(outputDir, playlist), "utf-8");
expect(text).toContain(`#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`);
expect(text).toContain("#EXT-X-ENDLIST");
}
});
});
@@ -1,5 +1,9 @@
import { describe, expect, it } from "bun:test";
import { outputNeedsAlpha, outputSupportsPageSideShaderCompositing } from "./renderFormat.js";
import {
outputNeedsAlpha,
outputSupportsPageSideShaderCompositing,
outputUsesH264Pipeline,
} from "./renderFormat.js";
describe("outputNeedsAlpha", () => {
it("uses alpha-aware capture for transparent-capable formats", () => {
@@ -12,6 +16,14 @@ describe("outputNeedsAlpha", () => {
it("preserves opaque capture for mp4", () => {
expect(outputNeedsAlpha("mp4")).toBe(false);
});
// Regression guard: `outputNeedsAlpha` used to be `format !== "mp4"`, so a
// new opaque format defaults to alpha and silently flips HLS to RGBA PNG
// screenshot capture — slower, and it disables BeginFrame. Nothing fails; the
// render just takes the wrong path.
it("keeps HLS on the opaque capture path", () => {
expect(outputNeedsAlpha("hls")).toBe(false);
});
});
describe("outputSupportsPageSideShaderCompositing", () => {
@@ -20,9 +32,27 @@ describe("outputSupportsPageSideShaderCompositing", () => {
expect(outputSupportsPageSideShaderCompositing("gif")).toBe(true);
});
it("supports HLS, which captures exactly like mp4", () => {
expect(outputSupportsPageSideShaderCompositing("hls")).toBe(true);
});
it("keeps the alpha video and PNG sequence formats on their existing paths", () => {
expect(outputSupportsPageSideShaderCompositing("webm")).toBe(false);
expect(outputSupportsPageSideShaderCompositing("mov")).toBe(false);
expect(outputSupportsPageSideShaderCompositing("png-sequence")).toBe(false);
});
});
describe("outputUsesH264Pipeline", () => {
it("groups mp4 and hls, which share the encode and differ only in container", () => {
expect(outputUsesH264Pipeline("mp4")).toBe(true);
expect(outputUsesH264Pipeline("hls")).toBe(true);
});
it("excludes every format with its own encode path", () => {
expect(outputUsesH264Pipeline("webm")).toBe(false);
expect(outputUsesH264Pipeline("mov")).toBe(false);
expect(outputUsesH264Pipeline("png-sequence")).toBe(false);
expect(outputUsesH264Pipeline("gif")).toBe(false);
});
});
@@ -1,9 +1,20 @@
export type RenderOutputFormat = "mp4" | "webm" | "mov" | "png-sequence" | "gif";
export type RenderOutputFormat = "mp4" | "webm" | "mov" | "png-sequence" | "gif" | "hls";
export function outputNeedsAlpha(format: RenderOutputFormat): boolean {
return format !== "mp4";
return format !== "mp4" && format !== "hls";
}
export function outputSupportsPageSideShaderCompositing(format: RenderOutputFormat): boolean {
return format === "mp4" || format === "gif";
return format === "mp4" || format === "gif" || format === "hls";
}
/**
* `mp4` and `hls` share the same opaque H.264/H.265 encode: identical capture
* mode, encoder preset, and `video-only.mp4` intermediate. Only the delivery
* container differs, and only at assemble time. Every capture/encode router
* gate that used to test `format === "mp4"` must test this instead, or HLS
* silently falls off the fast paths mp4 renders take.
*/
export function outputUsesH264Pipeline(format: RenderOutputFormat): boolean {
return format === "mp4" || format === "hls";
}
@@ -1,15 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AssembleStageInput } from "./assembleStage.js";
const { applyFaststartMock, muxVideoWithAudioMock, padOrTrimAudioMock } = vi.hoisted(() => ({
applyFaststartMock: vi.fn(),
muxVideoWithAudioMock: vi.fn(),
padOrTrimAudioMock: vi.fn(),
}));
const { applyFaststartMock, muxVideoWithAudioMock, packageHlsMock, padOrTrimAudioMock } =
vi.hoisted(() => ({
applyFaststartMock: vi.fn(),
muxVideoWithAudioMock: vi.fn(),
packageHlsMock: vi.fn(),
padOrTrimAudioMock: vi.fn(),
}));
vi.mock("@hyperframes/engine", () => ({
applyFaststart: applyFaststartMock,
muxVideoWithAudio: muxVideoWithAudioMock,
packageHls: packageHlsMock,
}));
vi.mock("../audioPadTrim.js", () => ({
@@ -44,21 +47,25 @@ function makeInput(overrides: Partial<AssembleStageInput> = {}): AssembleStageIn
};
}
describe("runAssembleStage audio duration parity", () => {
beforeEach(() => {
applyFaststartMock.mockReset();
muxVideoWithAudioMock.mockReset();
padOrTrimAudioMock.mockReset();
applyFaststartMock.mockResolvedValue({ success: true });
muxVideoWithAudioMock.mockResolvedValue({ success: true });
padOrTrimAudioMock.mockResolvedValue({
success: true,
outputPath: "/tmp/audio.duration-normalized.m4a",
targetDurationSeconds: 1,
sourceDurationSeconds: 1.024,
operation: "trim",
});
function resetMocks(): void {
applyFaststartMock.mockReset();
muxVideoWithAudioMock.mockReset();
packageHlsMock.mockReset();
padOrTrimAudioMock.mockReset();
applyFaststartMock.mockResolvedValue({ success: true });
muxVideoWithAudioMock.mockResolvedValue({ success: true });
packageHlsMock.mockResolvedValue({ success: true });
padOrTrimAudioMock.mockResolvedValue({
success: true,
outputPath: "/tmp/audio.duration-normalized.m4a",
targetDurationSeconds: 1,
sourceDurationSeconds: 1.024,
operation: "trim",
});
}
describe("runAssembleStage audio duration parity", () => {
beforeEach(resetMocks);
it("normalizes mixed AAC to the encoded video frame duration before muxing", async () => {
await runAssembleStage(makeInput());
@@ -137,4 +144,97 @@ describe("runAssembleStage audio duration parity", () => {
EncoderInterruptedError,
);
});
it("leaves every non-hls format on the mux/faststart path", async () => {
for (const format of ["mp4", "webm", "mov"] as const) {
resetMocks();
await runAssembleStage(makeInput({ format }));
expect(muxVideoWithAudioMock).toHaveBeenCalledTimes(1);
expect(packageHlsMock).not.toHaveBeenCalled();
}
});
});
describe("runAssembleStage HLS packaging", () => {
beforeEach(resetMocks);
function hlsInput(overrides: Partial<AssembleStageInput> = {}): AssembleStageInput {
return makeInput({ format: "hls", outputPath: "/tmp/output-hls", ...overrides });
}
it("packages the normalized audio alongside the video and skips the mp4 mux", async () => {
await runAssembleStage(hlsInput({ hlsSegmentSeconds: 6 }));
// The audio sidecar is normalized to the video frame count first — the
// packager stream-copies, so it cannot fix a length mismatch itself.
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
videoPath: "/tmp/video-only.mp4",
audioPath: "/tmp/audio.m4a",
outputPath: "/tmp/audio.duration-normalized.m4a",
signal: undefined,
});
expect(packageHlsMock).toHaveBeenCalledWith(
"/tmp/video-only.mp4",
"/tmp/audio.duration-normalized.m4a",
"/tmp/output-hls",
{ segmentSeconds: 6, signal: undefined },
);
expect(muxVideoWithAudioMock).not.toHaveBeenCalled();
expect(applyFaststartMock).not.toHaveBeenCalled();
});
it("passes a null audio path and skips faststart when the composition is silent", async () => {
await runAssembleStage(hlsInput({ hasAudio: false }));
expect(padOrTrimAudioMock).not.toHaveBeenCalled();
expect(packageHlsMock).toHaveBeenCalledWith("/tmp/video-only.mp4", null, "/tmp/output-hls", {
segmentSeconds: 4,
signal: undefined,
});
expect(applyFaststartMock).not.toHaveBeenCalled();
});
it("defaults to 4 s segments", async () => {
await runAssembleStage(hlsInput());
expect(packageHlsMock.mock.calls[0]?.[3]).toEqual({ segmentSeconds: 4, signal: undefined });
});
it("forwards the abort signal to the packager", async () => {
const abortSignal = new AbortController().signal;
await runAssembleStage(hlsInput({ abortSignal }));
expect(packageHlsMock.mock.calls[0]?.[3]).toEqual({ segmentSeconds: 4, signal: abortSignal });
});
it("throws 'HLS packaging failed' with the ffmpeg error appended", async () => {
packageHlsMock.mockResolvedValue({ success: false, error: "Invalid var_stream_map" });
await expect(runAssembleStage(hlsInput())).rejects.toThrow(
"HLS packaging failed: Invalid var_stream_map",
);
});
it("preserves an external interruption from HLS packaging", async () => {
packageHlsMock.mockResolvedValue({
success: false,
error: "FFmpeg exited with code 255\nprivate stderr",
failureReason: "external_interruption",
});
await expect(runAssembleStage(hlsInput())).rejects.toBeInstanceOf(EncoderInterruptedError);
});
it("fails instead of packaging an unnormalized AAC tail", async () => {
padOrTrimAudioMock.mockResolvedValue({
success: false,
outputPath: "/tmp/audio.duration-normalized.m4a",
error: "ffmpeg trim failed",
});
await expect(runAssembleStage(hlsInput())).rejects.toThrow(
"Audio duration normalization failed: ffmpeg trim failed",
);
expect(packageHlsMock).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,10 @@
* `applyFaststart(videoOnlyPath, outputPath)` to move the `moov` atom to
* the front so the file plays from a partial download.
*
* `format: "hls"` replaces both with a single `packageHls` stream-copy into an
* HLS VOD directory. The audio normalization above it is unchanged — the
* packager needs the same frame-exact AAC sidecar the mp4 mux does.
*
* Hard constraints preserved verbatim:
* - The "Assembling final video" `updateJobStatus` payload fires at
* 90% at the start of the stage.
@@ -16,11 +20,13 @@
* verbatim on the respective `success: false` results.
*/
import { applyFaststart, muxVideoWithAudio } from "@hyperframes/engine";
import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
import { extname } from "node:path";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { padOrTrimAudioToVideoFrameCount } from "../audioPadTrim.js";
import { encoderFailureError } from "../encoderInterruption.js";
import { DEFAULT_HLS_SEGMENT_SECONDS } from "../hlsConfig.js";
import type { RenderOutputFormat } from "../renderFormat.js";
import { updateJobStatus } from "../shared.js";
export interface AssembleStageInput {
@@ -29,9 +35,16 @@ export interface AssembleStageInput {
videoOnlyPath: string;
/** Mixed audio path (only read when `hasAudio` is true). */
audioOutputPath: string;
/** Final on-disk output. */
/** Final on-disk output. A directory when `format` is `"hls"`. */
outputPath: string;
hasAudio: boolean;
/**
* Output container. Only `"hls"` changes this stage's behavior; every other
* format (and `undefined`, for direct callers) takes the mux/faststart path.
*/
format?: RenderOutputFormat;
/** Segment length for `format: "hls"`. Defaults to {@link DEFAULT_HLS_SEGMENT_SECONDS}. */
hlsSegmentSeconds?: number;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
@@ -49,10 +62,12 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
audioOutputPath,
outputPath,
hasAudio,
format,
abortSignal,
assertNotAborted,
onProgress,
} = input;
const isHls = format === "hls";
const stage6Start = Date.now();
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
@@ -73,20 +88,26 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
if (!normalizeResult.success) {
throw encoderFailureError("Audio duration normalization failed", normalizeResult);
}
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
normalizeResult.outputPath,
outputPath,
abortSignal,
{
audioCodec: "aac",
},
job.config.fps,
);
assertNotAborted();
if (!muxResult.success) {
throw encoderFailureError("Audio muxing failed", muxResult);
if (isHls) {
await runHlsPackaging(input, normalizeResult.outputPath);
} else {
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
normalizeResult.outputPath,
outputPath,
abortSignal,
{
audioCodec: "aac",
},
job.config.fps,
);
assertNotAborted();
if (!muxResult.success) {
throw encoderFailureError("Audio muxing failed", muxResult);
}
}
} else if (isHls) {
await runHlsPackaging(input, null);
} else {
const faststartResult = await applyFaststart(
videoOnlyPath,
@@ -103,3 +124,27 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
return { assembleMs: Date.now() - stage6Start };
}
/**
* Stream-copy the encoded video (plus the already-normalized AAC sidecar, when
* there is one) into the HLS VOD directory at `outputPath`. No re-encode, so
* this runs at copy speed regardless of composition length.
*/
async function runHlsPackaging(
input: AssembleStageInput,
normalizedAudioPath: string | null,
): Promise<void> {
const packageResult = await packageHls(
input.videoOnlyPath,
normalizedAudioPath,
input.outputPath,
{
segmentSeconds: input.hlsSegmentSeconds ?? DEFAULT_HLS_SEGMENT_SECONDS,
signal: input.abortSignal,
},
);
input.assertNotAborted();
if (!packageResult.success) {
throw encoderFailureError("HLS packaging failed", packageResult);
}
}
@@ -94,13 +94,20 @@ export interface EncodeStageInput {
onProgress?: ProgressCallback;
/**
* Pass-through of `EncoderOptions.lockGopForChunkConcat`. When `true`,
* the encode emits closed-GOP keyframes at every `gopSize` boundary so
* downstream `ffmpeg -f concat -c copy` round-trips losslessly. Only the
* distributed chunk worker (`renderChunk`) sets this — the in-process
* renderer's call site omits it, preserving the existing open-GOP output.
* the encode emits closed-GOP keyframes at every `gopSize` boundary so a
* downstream `-c copy` stream-copy can cut the stream on those boundaries.
*
* Two callers set it: the distributed chunk worker (`renderChunk`), so
* `ffmpeg -f concat -c copy` round-trips losslessly, and an in-process
* `format: "hls"` render, so `-hls_time` segments land exactly on the
* segment boundary. Every other render omits it, preserving the existing
* open-GOP output.
*/
lockGopForChunkConcat?: boolean;
/** Required when `lockGopForChunkConcat === true`. Number of frames per GOP — set to the chunk's frame count by `renderChunk`. */
/**
* Required when `lockGopForChunkConcat === true`. Frames per GOP — the
* chunk's frame count for `renderChunk`, `hlsSegmentSeconds × fps` for HLS.
*/
gopSize?: number;
}
@@ -109,14 +109,24 @@ import { defaultLogger, type ProducerLogger } from "../logger.js";
import {
outputNeedsAlpha,
outputSupportsPageSideShaderCompositing,
outputUsesH264Pipeline,
type RenderOutputFormat,
} from "./render/renderFormat.js";
import {
resolveHlsEncoderGopLock,
resolveHlsSegmentSeconds,
validateHlsRenderConfig,
} from "./render/hlsConfig.js";
import { createMemorySampler, type MemorySampler, updateJobStatus } from "./render/shared.js";
import { buildRenderErrorDetails } from "./render/cleanup.js";
import { publishRenderFailure } from "./render/renderEventPublisher.js";
import { EncoderInterruptedError } from "./render/encoderInterruption.js";
import { RenderExecutionContext } from "./render/renderExecutionContext.js";
import { ArtifactTransaction, commitArtifactTransaction } from "./render/artifactTransaction.js";
import {
ArtifactTransaction,
buildArtifactExpectation,
commitArtifactTransaction,
} from "./render/artifactTransaction.js";
import {
createCapturePlan,
replanAfterFailure,
@@ -304,6 +314,15 @@ export interface RenderConfig {
* / Fusion ingest, or when frames need post-processing before
* encoding. `outputPath` is treated as a directory; it is created if
* it doesn't exist.
* - `"hls"`: HLS VOD package — the same opaque H.264 + AAC encode as
* `"mp4"`, stream-copied (no re-encode) into a directory of MPEG-TS
* segments: `master.m3u8`, `video.m3u8` + `video_%05d.ts`, and
* `audio.m3u8` + `audio_%05d.ts` when the composition has audio.
* Segments are fixed-length (see {@link RenderConfig.hlsSegmentSeconds})
* and every one starts on an IDR frame, because the encoder's GOP is
* locked to `hlsSegmentSeconds × fps`. Like `"png-sequence"`,
* `outputPath` is treated as a directory. SDR only, software encoder
* only, and not available in distributed / Lambda / Cloud Run mode.
*
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`, `"gif"`) automatically
* forces screenshot capture (Chrome's BeginFrame compositor does not
@@ -317,6 +336,16 @@ export interface RenderConfig {
format?: RenderOutputFormat;
/** GIF Netscape loop count. 0 means infinite looping. Only used with `format: "gif"`. */
gifLoop?: number;
/**
* HLS target segment length in whole seconds. Defaults to 4. Only used with
* `format: "hls"`; ignored for every other format.
*
* Must be a positive integer — it is both ffmpeg's `-hls_time` and the
* encoder's GOP size (`round(fps × hlsSegmentSeconds)` frames), and whole
* seconds keep the integer `#EXT-X-TARGETDURATION` in the playlist honest.
* A non-integer value throws at the start of `executeRenderJob`.
*/
hlsSegmentSeconds?: number;
workers?: number;
useGpu?: boolean;
debug?: boolean;
@@ -1716,7 +1745,7 @@ export function shouldPreferSingleWorkerDrawElement(args: {
args.useDrawElement &&
!args.deCompileGate &&
!args.forceScreenshot &&
args.outputFormat === "mp4" &&
outputUsesH264Pipeline(args.outputFormat) &&
args.minFrames > 0 &&
args.totalFrames >= args.minFrames &&
args.singleWorkerStreamingOk &&
@@ -1856,7 +1885,7 @@ export function shouldPreferParallelDrawElement(args: {
args.useDrawElement &&
!args.deCompileGate &&
!args.forceScreenshot &&
args.outputFormat === "mp4" &&
outputUsesH264Pipeline(args.outputFormat) &&
args.minFrames > 0 &&
args.totalFrames >= args.minFrames &&
!args.layeredOrEffectRoute &&
@@ -2086,7 +2115,7 @@ export function shouldStreamParallelCapture(args: {
args.routerEnabled &&
args.workerCount > 1 &&
!args.useDrawElement &&
args.outputFormat === "mp4" &&
outputUsesH264Pipeline(args.outputFormat) &&
args.streamingOk &&
!args.layeredOrEffectRoute
);
@@ -2218,6 +2247,9 @@ export async function executeRenderJob(
abortSignal?: AbortSignal,
assertRenderActive?: () => void,
): Promise<void> {
// Ahead of the work dir / log file / execution context: a config the format
// cannot honor must fail before anything is written to disk.
validateHlsRenderConfig(job.config);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const producerRoot = process.env.PRODUCER_RENDERS_DIR
? resolve(process.env.PRODUCER_RENDERS_DIR, "..")
@@ -2311,9 +2343,20 @@ async function executeRenderPipeline(input: {
const outputFormat = job.config.format ?? ("mp4" as const);
const isPngSequence = outputFormat === "png-sequence";
const isGif = outputFormat === "gif";
const isHls = outputFormat === "hls";
// Segment length and the matching GOP, resolved once. Both encoder paths
// spread the same `hlsEncoderGopLock`, so they cannot disagree — a GOP that
// is not exactly `segmentSeconds × fps` frames silently produces
// variable-length segments instead of failing.
const hlsSegmentSeconds = isHls ? resolveHlsSegmentSeconds(job.config) : undefined;
const hlsEncoderGopLock = resolveHlsEncoderGopLock(
outputFormat,
job.config.fps,
job.config.hlsSegmentSeconds,
);
const artifactTransaction = new ArtifactTransaction(
outputPath,
isPngSequence ? "directory" : "file",
isPngSequence || isHls ? "directory" : "file",
);
const stagedOutputPath = artifactTransaction.stagingPath;
const needsAlpha = outputNeedsAlpha(outputFormat);
@@ -3501,12 +3544,17 @@ async function executeRenderPipeline(input: {
// the encode/mux/faststart stages are skipped entirely. The empty extension
// keeps `videoOnlyPath` (which is constructed below) sensible even though
// it will not be written.
//
// hls is a directory output too, but it does run encode + assemble: the
// intermediate stays an MP4 and only the assemble stage changes container,
// stream-copying it into segments.
const FORMAT_EXT: Record<string, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
hls: ".mp4",
};
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
@@ -3815,6 +3863,11 @@ async function executeRenderPipeline(input: {
useGpu: job.config.useGpu,
imageFormat: captureOptions.format || "jpeg",
hdr: preset.hdr,
// HLS only: force an IDR every `gopSize` frames so the
// assemble stage's `-c copy` segmentation cuts exactly on
// the segment boundary. Every other format leaves the
// encoder's open-GOP output untouched.
...hlsEncoderGopLock,
},
buildCaptureOptions,
createRenderVideoFrameInjector,
@@ -4150,6 +4203,9 @@ async function executeRenderPipeline(input: {
enableChunkedEncode,
chunkedEncodeSize,
engineConfig: cfg,
// Same value the streaming encoder above spreads — the disk and
// chunked-concat encoders need an identical GOP lock for HLS.
...hlsEncoderGopLock,
abortSignal: executionSignal,
assertNotAborted,
onProgress,
@@ -4192,7 +4248,8 @@ async function executeRenderPipeline(input: {
// ── Stage 6: Assemble ───────────────────────────────────────────────
// Skipped for formats with no mux/faststart step. png-sequence is a
// directory deliverable, and gif is written directly to outputPath by the
// two-pass palette encoder.
// two-pass palette encoder. hls is a directory deliverable but still runs
// this stage — the HLS packaging IS its assemble step.
if (!isPngSequence && !isGif) {
const assembleRes = await observeRenderStage(
observability,
@@ -4205,6 +4262,8 @@ async function executeRenderPipeline(input: {
audioOutputPath,
outputPath: stagedOutputPath,
hasAudio,
format: outputFormat,
hlsSegmentSeconds,
abortSignal: executionSignal,
assertNotAborted,
onProgress,
@@ -4216,13 +4275,12 @@ async function executeRenderPipeline(input: {
}
await artifactTransaction.validate(
!isPngSequence && !isGif && Number.isFinite(job.duration) && job.duration > 0
? {
expectedDurationSeconds: job.duration,
fps: fpsToNumber(job.config.fps),
expectedFrames: captureTotalFrames,
}
: undefined,
buildArtifactExpectation({
outputFormat,
durationSeconds: job.duration,
fps: fpsToNumber(job.config.fps),
expectedFrames: captureTotalFrames,
}),
);
const totalElapsed = Date.now() - pipelineStart;
@@ -4300,10 +4358,11 @@ async function executeRenderPipeline(input: {
if (job.config.debug) {
// Copy output MP4 (or single-file alpha output) into the debug dir for
// easy access. Skipped for png-sequence: outputPath is a directory, not
// a single file — the captured frames already live in `framesDir` under
// workDir during a debug run anyway.
if (!isPngSequence && existsSync(stagedOutputPath)) {
// easy access. Skipped for the directory formats: outputPath is a
// directory, not a single file — the captured frames already live in
// `framesDir` under workDir during a debug run anyway, and an HLS
// render's pre-segmentation `video-only.mp4` is already in workDir.
if (!isPngSequence && !isHls && existsSync(stagedOutputPath)) {
const debugOutput = join(workDir, `output${videoExt}`);
copyFileSync(stagedOutputPath, debugOutput);
}