mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
co-authored by
Claude Opus 5
parent
0a1c6f5a45
commit
462f9db73d
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user