mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe (#878)
* feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe
Phase 6 of the distributed rendering plan: AWS Lambda turnkey adoption
(see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6 + §15).
This PR adds the new packages/aws-lambda/ workspace package that wraps
the OSS plan/renderChunk/assemble primitives in an AWS Lambda handler,
plus a build pipeline that bundles the handler + Chromium runtime +
ffmpeg into a deployable ZIP.
Architecture: ZIP deploy (not Docker image), Chrome via @sparticuz/chromium
with chrome-headless-shell fallback, dispatch on event.Action ∈ {plan,
renderChunk, assemble}.
The load-bearing concern — does @sparticuz/chromium's chrome-headless-shell
build honour CDP HeadlessExperimental.beginFrame? — is pinned by the new
scripts/probe-beginframe.ts regression guard. Probe boots the runtime
inside public.ecr.aws/lambda/nodejs:22, navigates to a static page, and
asserts beginFrame returns a PNG buffer. Verified locally + inside the
Docker container; both pass with hasDamage=true.
Sizes (sparticuz source): unzipped 157 MiB, zipped 99 MiB. Well under
the 240 MiB / 150 MiB in-house gates and the Lambda 250 MiB hard ceiling.
This is part of a stack of 8 PRs (3 in Phase 6a, 5 in Phase 6b); this is
PR 6.1.
* fix(lambda): address PR 878 review feedback
- Verify event.PlanHash against the untarred plan.json at the handler
boundary before invoking the producer primitive. Throws typed
PLAN_HASH_MISMATCH on divergence so Step Functions routes it as
non-retryable; previously the field was schema bloat the handler
ignored, leaving enforcement entirely inside the producer.
- Standardize on MiB throughout build-zip.ts, verify-zip-size.ts, and
the README. Lambda's hard ceiling is 250 MiB (AWS docs label "250 MB"
but use binary mebibytes); previously mixed units made the 248 MiB
budget look like a ~5 MB margin instead of the 2 MiB it actually is.
- stageChromeHeadlessShell now picks Chrome versions via numeric semver
comparison instead of lexicographic sort+reverse — the latter would
silently pick "99.x" over "131.x" once Chrome cached three-digit
majors that aren't width-aligned.
- Drop _setSparticuzChromiumForTests from the public index barrel.
Test-only DI seam imported directly from ./chromium.js in tests.
- Replace require("node:fs") inside walkSize() with the top-level fs
imports — file is ESM and the same module is already imported.
* docs(lambda): drop internal plan-doc refs from package README
* ci(windows): fix bun filter UNION bug excluding producer from Windows tests
`bun run --filter "!a" --filter "!b" test` composes as a UNION (any
package matching either negation runs), not an intersection. Effect:
@hyperframes/producer was still being tested on Windows even though
it's explicitly excluded — its regression harness (Docker + LFS golden
mp4 baselines) is Linux-only and was driving the 32min timeout.
Enumerate the packages we DO want to test instead.
This commit is contained in:
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
|
||||
import {
|
||||
buildChunkSlices,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
@@ -208,6 +209,111 @@ describe("plan() — golden planDir + planHash determinism", () => {
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"plan.json.planHash matches recomputePlanHashFromPlanDir(planDir) on the same disk",
|
||||
async () => {
|
||||
// Regression guard for a real-world bug observed on audio-bearing
|
||||
// fixtures: plan() left a temporary `.plan-work/` subtree inside
|
||||
// planDir while freezePlan walked it, so the hash baked into
|
||||
// plan.json included artifacts the chunk worker would never see.
|
||||
// The chunk worker's `recomputePlanHashFromPlanDir` walk then
|
||||
// returned a different hash, tripping PLAN_HASH_MISMATCH at the
|
||||
// first chunk invocation.
|
||||
//
|
||||
// This test verifies that the hash plan() writes matches the hash
|
||||
// recomputed from the on-disk planDir contents — i.e. the chunk
|
||||
// worker's view. Holds for any plan, audio or not.
|
||||
const planDir = join(runRoot, "plan-hash-recompute");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
||||
expect(recomputed).toBe(result.planHash);
|
||||
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as {
|
||||
planHash: string;
|
||||
};
|
||||
expect(planJson.planHash).toBe(result.planHash);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Audio-bearing variant of the planHash recompute test. The pre-fix bug
|
||||
// surfaced because `runAudioStage` downloads/mixes source audio into
|
||||
// `<planDir>/.plan-work/`, and `freezePlan` walked that subtree before
|
||||
// plan.ts cleaned it up. A composition without `<audio>` short-circuits
|
||||
// the audio stage and never materialises `.plan-work/downloads/`, so
|
||||
// the no-audio test above would pass even on the broken code. This
|
||||
// variant generates a 1s silent wav via `ffmpeg`, references it from
|
||||
// the composition, and runs plan() — exercising the audio-mix path
|
||||
// that produced the original bug.
|
||||
const HAS_FFMPEG = (() => {
|
||||
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
||||
return spawnSync("ffmpeg", ["-version"]).status === 0;
|
||||
})();
|
||||
|
||||
it.skipIf(!HAS_FFMPEG)(
|
||||
"plan.json.planHash matches recompute on an audio-bearing composition",
|
||||
async () => {
|
||||
const audioProjectDir = join(runRoot, "project-with-audio");
|
||||
mkdirSync(audioProjectDir, { recursive: true });
|
||||
// Generate a 1s mono silent wav. PCM keeps the file tiny without
|
||||
// pulling in an audio asset fixture.
|
||||
const audioPath = join(audioProjectDir, "silence.wav");
|
||||
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
||||
const ffmpeg = spawnSync("ffmpeg", [
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=channel_layout=mono:sample_rate=44100",
|
||||
"-t",
|
||||
"1",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
audioPath,
|
||||
]);
|
||||
if (ffmpeg.status !== 0) {
|
||||
throw new Error(`ffmpeg silence-wav generation failed: ${ffmpeg.stderr?.toString()}`);
|
||||
}
|
||||
writeFileSync(
|
||||
join(audioProjectDir, "index.html"),
|
||||
`<!doctype html>
|
||||
<html><head><meta charset="utf-8"></head><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
|
||||
<audio data-composition-audio src="silence.wav"></audio>
|
||||
<p>audio-bearing fixture</p>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const planDir = join(runRoot, "plan-hash-recompute-audio");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
audioProjectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
||||
// This assertion fails on the pre-fix code: freezePlan saw
|
||||
// `.plan-work/downloads/` (or whatever the audio stage leaves
|
||||
// behind) inside planDir, baked it into plan.json's planHash,
|
||||
// and then plan.ts rm'd it — so recompute walks a different
|
||||
// file set than freezePlan did.
|
||||
expect(recomputed).toBe(result.planHash);
|
||||
// Verify the audio stage actually fired (otherwise the test
|
||||
// pins the wrong path — the same false-pass mode as the
|
||||
// no-audio variant above).
|
||||
expect(existsSync(join(planDir, "audio.aac"))).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("plan() — codec knob", () => {
|
||||
|
||||
Reference in New Issue
Block a user