Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/deploy/migrating-to-hyperframes-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Most adopters' render config maps directly:
| `fps` | `--fps=30` (CLI) or `config.fps` (SDK) | 24, 30, 60 only — non-integer NTSC rationals are an in-process-only feature. |
| `width` / `height` | `--width` / `--height` flags, or `config.width` / `config.height` | Even integers ≤ 7680 (yuv420p parity). |
| `codec: 'h264' / 'h265'` | `--codec=h264` or `--codec=h265` (mp4 only) | h265 uses libx265 with closed-GOP keyint params so chunked concat-copy round-trips losslessly. |
| Output format | `--format=mp4 / mov / png-sequence` | Distributed mode refuses webm + HDR at plan time. |
| Output format | `--format=mp4 / mov / webm / png-sequence` | webm uses libvpx-vp9 + closed-GOP concat-copy. Distributed mode still refuses HDR mp4 at plan time. |
| Quality preset | `--quality=draft / standard / high` | Maps onto ffmpeg encoder presets. |
| Chunk size in frames | `--chunk-size=240` (default 240) | ~8s at 30 fps; sized to fit Lambda's 15-min cap with headroom. |
| Max parallel chunks | `--max-parallel-chunks=16` (default 16) | Caps the Map state's fan-out. |
Expand All @@ -64,9 +64,11 @@ HyperFrames refuses `data-gpu-mode="hardware"` in distributed mode — hardware

`hdrMode: 'force-hdr'` is rejected at plan time. The v1.5 backlog covers HDR mp4 via `-bsf:v hevc_metadata` re-application; for now, HDR renders use the in-process renderer outside Lambda.

### No webm distributed
### webm uses closed-GOP VP9

VP9 in matroska doesn't round-trip cleanly through concat-copy (the moov-atom keyframe assumptions don't hold). webm renders use the in-process renderer or accept a controlled re-encode at the assemble stage — coming in v1.5. The Lambda handler refuses webm with `FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED` so the failure is loud.
webm distributed renders go through libvpx-vp9 with `-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, and `-cpu-used 2`. The alt-ref disable is the load-bearing bit: libvpx-vp9's default non-displayable alt-ref frames can land anywhere in a GOP, which breaks concat-copy at chunk seams. Closed-GOP forces a keyframe at every chunk boundary so `ffmpeg -f concat -c copy` round-trips losslessly. Output is `yuva420p` to preserve alpha. Audio is muxed as Opus.

Distributed webm files are typically ~10-25% larger than the same composition rendered in-process at the same CRF, because closed-GOP forces more keyframes than the in-process single-pass would emit. Per-chunk encode is also slower than libvpx-vp9's default speed/quality tradeoff (`-cpu-used 2` is more conservative than the default for `-deadline good`). The single-machine in-process renderer remains the right choice for short webm renders; distributed pays for itself once a render's wall-clock exceeds what one machine delivers.

### State files are local by default

Expand All @@ -78,7 +80,7 @@ The default policy doc emitted by `hyperframes lambda policies user/role` uses `

## Migration checklist

1. **Inventory** the compositions you want to migrate. Filter out anything that needs HDR or webm — those stay on your current framework for now.
1. **Inventory** the compositions you want to migrate. Filter out anything that needs HDR — that stays on your current framework for now. webm renders distributed via closed-GOP VP9 + concat-copy (see the webm section above).
2. **Translate** each composition to plain HTML. The `[Concepts](/concepts)` page covers the data-attribute conventions; the `/hyperframes` skill (`npx skills add heygen-com/hyperframes`) makes Claude / Cursor / Codex aware of them too.
3. **Wire** the new composition into your build pipeline alongside the old one. HyperFrames doesn't need an external bundler — you can `npx hyperframes preview` against the HTML directly.
4. **Deploy** in a separate AWS account or with a `--stack-name=hyperframes-staging` first. Run a real render with `--wait`; verify the output bytes.
Expand Down
8 changes: 4 additions & 4 deletions packages/aws-lambda/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* results per §2.4).
*/

import type { DistributedRenderConfig } from "@hyperframes/producer/distributed";
import type { DistributedFormat, DistributedRenderConfig } from "@hyperframes/producer/distributed";

/** Discriminator for the three roles the one Lambda image fulfills. */
export type LambdaAction = "plan" | "renderChunk" | "assemble";
Expand Down Expand Up @@ -65,7 +65,7 @@ export interface RenderChunkEvent {
/** S3 URI prefix where the chunk output should be uploaded (`s3://bucket/{prefix}/`). */
ChunkOutputS3Prefix: string;
/** Output container format from the plan's encoder.json; drives file vs frame-dir handling. */
Format: "mp4" | "mov" | "png-sequence" | "webm";
Format: DistributedFormat;
}

/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
Expand All @@ -80,7 +80,7 @@ export interface AssembleEvent {
/** Final output S3 URI (`s3://bucket/key.mp4`). */
OutputS3Uri: string;
/** Output container format; drives file vs frame-dir handling. */
Format: "mp4" | "mov" | "png-sequence" | "webm";
Format: DistributedFormat;
}

/**
Expand All @@ -106,7 +106,7 @@ export interface PlanLambdaResult {
Fps: 24 | 30 | 60;
Width: number;
Height: number;
Format: "mp4" | "mov" | "png-sequence" | "webm";
Format: DistributedFormat;
HasAudio: boolean;
AudioS3Uri: string | null;
FfmpegVersion: string;
Expand Down
4 changes: 3 additions & 1 deletion packages/aws-lambda/src/formatExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* looks like vs a png-sequence.
*/

export type DistributedFormat = "mp4" | "mov" | "png-sequence" | "webm";
import type { DistributedFormat } from "@hyperframes/producer/distributed";

export type { DistributedFormat } from "@hyperframes/producer/distributed";

// Closed-enum lookup table. TS enforces exhaustiveness via the
// `Record<DistributedFormat, string>` annotation — adding a format to
Expand Down
4 changes: 2 additions & 2 deletions packages/aws-lambda/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
renderChunk,
} from "@hyperframes/producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js";
import { formatExtension } from "./formatExtension.js";
import { type DistributedFormat, formatExtension } from "./formatExtension.js";
import type {
AssembleEvent,
AssembleLambdaResult,
Expand Down Expand Up @@ -433,7 +433,7 @@ async function downloadChunkObjects(
s3: S3Client,
uris: string[],
workDir: string,
format: "mp4" | "mov" | "png-sequence" | "webm",
format: DistributedFormat,
): Promise<string[]> {
const chunksDir = join(workDir, "chunks");
mkdirSync(chunksDir, { recursive: true });
Expand Down
1 change: 1 addition & 0 deletions packages/aws-lambda/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export {
} from "./costAccounting.js";
export { InvalidConfigError, validateDistributedRenderConfig } from "./validateConfig.js";
export type { SerializableDistributedRenderConfig } from "../events.js";
export type { DistributedFormat } from "../formatExtension.js";
8 changes: 7 additions & 1 deletion packages/aws-lambda/src/sdk/validateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* size cap, GPU mode at runtime) needs the actual planner.
*/

import type { DistributedFormat } from "../formatExtension.js";
import type { SerializableDistributedRenderConfig } from "../events.js";

/** Thrown for any client-side `SerializableDistributedRenderConfig` violation. */
Expand All @@ -32,7 +33,12 @@ export class InvalidConfigError extends Error {
}

const ALLOWED_FPS = [24, 30, 60] as const;
const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence", "webm"] as const;
const ALLOWED_FORMATS = [
"mp4",
"mov",
"png-sequence",
"webm",
] as const satisfies readonly DistributedFormat[];
const ALLOWED_CODECS = ["h264", "h265"] as const;
const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const;
const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const;
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { defineCommand } from "citty";
import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";

Expand Down Expand Up @@ -100,7 +101,7 @@ export default defineCommand({
width: { type: "string", description: "Render width in pixels" },
height: { type: "string", description: "Render height in pixels" },
fps: { type: "string", description: "Render fps (24 | 30 | 60)" },
format: { type: "string", description: "mp4 | mov | png-sequence (default: mp4)" },
format: { type: "string", description: "mp4 | mov | png-sequence | webm (default: mp4)" },
codec: { type: "string", description: "h264 | h265 (mp4 only)" },
quality: { type: "string", description: "draft | standard | high" },
"chunk-size": { type: "string", description: "Frames per chunk (default: 240)" },
Expand Down Expand Up @@ -325,7 +326,12 @@ function parseEnum<T extends string>(
throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
}

const FORMATS = ["mp4", "mov", "png-sequence"] as const;
const FORMATS = [
"mp4",
"mov",
"png-sequence",
"webm",
] as const satisfies readonly DistributedFormat[];
const CODECS = ["h264", "h265"] as const;
const QUALITIES = ["draft", "standard", "high"] as const;
const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const;
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/lambda/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
*/

import { resolve as resolvePath } from "node:path";
import type { SerializableDistributedRenderConfig } from "@hyperframes/aws-lambda/sdk";
import type {
DistributedFormat,
SerializableDistributedRenderConfig,
} from "@hyperframes/aws-lambda/sdk";
import { c } from "../../ui/colors.js";
import { requireStack, stateFilePath } from "./state.js";

Expand All @@ -23,7 +26,7 @@ export interface RenderArgs {
fps: 24 | 30 | 60;
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence";
format: DistributedFormat;
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
chunkSize?: number;
Expand Down
11 changes: 0 additions & 11 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,10 +551,6 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
expect(args.indexOf("-x264-params")).toBe(-1);
});

// Closed-GOP for libvpx-vp9 is required to make `ffmpeg -f concat -c copy`
// stitch VP9 chunks losslessly: every chunk's first frame must be an
// independently-decodable keyframe with no alt-ref references reaching
// back across the seam.
it("true appends closed-GOP args for libvpx-vp9", () => {
const args = buildEncoderArgs(
{
Expand All @@ -570,16 +566,9 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
);
expect(args[args.indexOf("-g") + 1]).toBe("240");
expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
// Alt-ref frames are non-displayable references that break concat-copy
// at chunk seams; closed-GOP must disable them.
expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0");
// cpu-used is locked so workers with different libvpx-vp9 defaults
// produce visually consistent output across chunk boundaries.
expect(args[args.indexOf("-cpu-used") + 1]).toBe("2");
// libvpx-vp9 uses `-deadline good` for non-ultrafast presets — the
// closed-GOP path doesn't change that.
expect(args[args.indexOf("-deadline") + 1]).toBe("good");
// x264/x265-only params must not leak into the VP9 branch.
expect(args.indexOf("-x264-params")).toBe(-1);
expect(args.indexOf("-x265-params")).toBe(-1);
expect(args.indexOf("-sc_threshold")).toBe(-1);
Expand Down
32 changes: 9 additions & 23 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,25 +255,13 @@ export function buildEncoderArgs(
args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
args.push("-row-mt", "1");

// Closed-GOP args for distributed chunk concat-copy. Mirrors the
// libx264/libx265 branch above: `lockGopForChunkConcat=true` lays a
// keyframe at every chunk boundary so `ffmpeg -f concat -c copy` can
// stitch sibling chunks losslessly.
//
// VP9-specific: `-auto-alt-ref 0` is mandatory. Alt-ref (a.k.a.
// "ARNR") frames are non-displayable references libvpx-vp9 inserts
// anywhere in the GOP for compression; they break concat-copy at
// chunk seams because the boundary frame is no longer the first
// displayable reference. The alpha branch below already disables
// alt-ref for an unrelated reason (alpha + alt-ref is unsupported);
// closed-GOP extends that to every pixel format.
//
// `-cpu-used 2` pins the libvpx-vp9 speed/quality tradeoff so chunks
// encoded on workers with different default cpu-used values still
// produce visually consistent output across seams. libvpx-vp9's
// default with `-deadline good` has drifted across versions
// historically — locking it makes the planHash round-trip
// deterministic.
// `-auto-alt-ref 0` is mandatory for chunk concat-copy: libvpx-vp9's
// alt-ref frames can reference frames in either direction inside a
// GOP, so a chunk-boundary frame is not guaranteed to be the first
// displayable reference when alt-ref is on. `-cpu-used 2` pins the
// speed/quality tradeoff against libvpx-vp9 default drift across
// versions, so the planHash round-trips deterministically across
// worker images.
const lockGopVp9 = options.lockGopForChunkConcat === true;
if (lockGopVp9) {
if (
Expand All @@ -299,10 +287,8 @@ export function buildEncoderArgs(
}
if (pixelFormat === "yuva420p") {
// Alpha + alt-ref is unsupported by libvpx-vp9. The closed-GOP
// branch above already disables alt-ref; only push the flag for
// the non-locked alpha case to keep the args list clean (a second
// `-auto-alt-ref 0` is harmless but noisier in `ffmpeg -loglevel`
// diagnostics).
// branch above already emits `-auto-alt-ref 0`, so skip the
// duplicate push.
if (!lockGopVp9) {
args.push("-auto-alt-ref", "0");
}
Expand Down
5 changes: 5 additions & 0 deletions packages/producer/src/distributed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export {
// ── Assemble (Activity C) ───────────────────────────────────────────────────
export { assemble, type AssembleResult } from "./services/distributed/assemble.js";

// ── Format union ────────────────────────────────────────────────────────────
// Canonical output-format type. The aws-lambda package re-exports it so
// CLI / adopter SDKs can derive runtime allowlists from one source.
export type { DistributedFormat } from "./services/distributed/shared.js";

// ── Plan-time shared types from `freezePlan` ───────────────────────────────
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`
// or `meta/chunks.json` see the same shapes the producer wrote them as.
Expand Down
5 changes: 3 additions & 2 deletions packages/producer/src/regression-harness-distributed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Fps } from "@hyperframes/core";
import { assemble, plan, renderChunk } from "./distributed.js";
import type { DistributedFormat } from "./services/distributed/shared.js";

/**
* Three-mode contract that backs `--mode=<value>` on the regression
Expand Down Expand Up @@ -81,7 +82,7 @@ export type DistributedSupportResult = { supported: true } | { supported: false;
*/
export function checkDistributedSupport(renderConfig: {
fps: Fps;
format?: "mp4" | "webm" | "mov" | "png-sequence";
format?: DistributedFormat;
hdr?: boolean;
}): DistributedSupportResult {
if (renderConfig.fps.den !== 1) {
Expand Down Expand Up @@ -120,7 +121,7 @@ export interface RunDistributedSimulatedInput {
renderedOutputPath: string;
/** From the fixture's renderConfig — must pass `checkDistributedSupport`. */
fps: 24 | 30 | 60;
format: "mp4" | "mov" | "png-sequence" | "webm";
format: DistributedFormat;
/**
* Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to
* exercise the libx265 closed-GOP path. Ignored for non-mp4 formats —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* type-check pass.
*/

import type { DistributedFormat } from "./services/distributed/shared.js";

/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
export interface RunLambdaLocalInput {
projectDir: string;
Expand All @@ -26,7 +28,7 @@ export interface RunLambdaLocalInput {
*/
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence" | "webm";
format: DistributedFormat;
codec?: "h264" | "h265";
chunkSize?: number;
maxParallelChunks?: number;
Expand Down
3 changes: 2 additions & 1 deletion packages/producer/src/regression-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
// imports) into the program even though the tsconfig `exclude` list
// nominally hides it. `tsx` resolves the path normally at runtime.
import type { RunLambdaLocalRender } from "./regression-harness-lambda-local-types.js";
import type { DistributedFormat } from "./services/distributed/shared.js";

const LAMBDA_LOCAL_MODULE = "./regression-harness-lambda-local.js";

Expand Down Expand Up @@ -97,7 +98,7 @@ type TestMetadata = {
* `"mp4"`. Distributed mode supports all four — webm goes through
* libvpx-vp9 with closed-GOP concat-copy.
*/
format?: "mp4" | "webm" | "mov" | "png-sequence";
format?: DistributedFormat;
/**
* Codec selection for `format: "mp4"`, forwarded to
* `DistributedRenderConfig.codec`. The in-process renderer doesn't take
Expand Down
16 changes: 14 additions & 2 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";

import { parseHTML } from "linkedom";
Expand Down Expand Up @@ -330,7 +330,19 @@
// Google Fonts on-demand fetch + local cache
// ---------------------------------------------------------------------------

const GOOGLE_FONTS_CACHE_DIR = join(homedir(), ".cache", "hyperframes", "fonts");
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's
// read-only; only `/tmp` is writable. Route the cache there when
// running inside Lambda, and honor `HYPERFRAMES_FONT_CACHE_DIR` as
// an explicit override for any environment.
function resolveFontCacheRoot(): string {
return (
process.env.HYPERFRAMES_FONT_CACHE_DIR ??
(process.env.AWS_LAMBDA_FUNCTION_NAME
? join(tmpdir(), "hyperframes", "fonts")
: join(homedir(), ".cache", "hyperframes", "fonts"))
);
}
const GOOGLE_FONTS_CACHE_DIR = resolveFontCacheRoot();

// Chrome UA triggers woff2 responses from Google Fonts CSS API
const WOFF2_USER_AGENT =
Expand Down Expand Up @@ -485,7 +497,7 @@
continue;
}
const buffer = Buffer.from(await fontRes.arrayBuffer());
writeFileSync(cachePath, buffer);

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
} catch (err) {
if (err instanceof FontFetchError) throw err;
if (options.failClosedFontFetch) {
Expand Down
3 changes: 2 additions & 1 deletion packages/producer/src/services/distributed/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engin
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
import type { DistributedFormat } from "./shared.js";

/**
* Result of {@link assemble}. `fileSize` reflects the final file on disk
Expand All @@ -61,7 +62,7 @@ interface PlanJsonForAssemble {
fpsDen: number;
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence" | "webm";
format: DistributedFormat;
};
}

Expand Down
7 changes: 3 additions & 4 deletions packages/producer/src/services/distributed/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,10 +467,9 @@ describe("plan() — webm format (distributed VP9)", () => {
it(
'maps `format: "webm"` to libvpx-vp9-software + yuva420p',
async () => {
// Webm is distributed-supported via closed-GOP concat-copy (PR 8.1
// proved the contract; this test pins the plan-time encoder choice).
// yuva420p preserves the format's reason for existing — alpha video
// for web playback over colored backgrounds.
// Pins the plan-time encoder choice for webm: libvpx-vp9-software
// with yuva420p so the format's alpha-channel contract round-trips
// through chunked rendering.
const planDir = join(runRoot, "plan-webm-vp9");
mkdirSync(planDir, { recursive: true });
const result = await plan(
Expand Down
Loading
Loading