mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
* 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.
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* BeginFrame regression guard for `@sparticuz/chromium`.
|
|
*
|
|
* The load-bearing assumption of `@hyperframes/aws-lambda` is that the
|
|
* Chromium build shipped by `@sparticuz/chromium` honours CDP
|
|
* `HeadlessExperimental.beginFrame` with `screenshot: true`. This script
|
|
* boots that Chromium build (decompressing into `/tmp` per the library's
|
|
* runtime contract), navigates to a tiny static page, issues one
|
|
* `beginFrame` with a screenshot request, and asserts the response
|
|
* carries a PNG buffer.
|
|
*
|
|
* The script is the contract test, not a one-shot verification — every
|
|
* release should run it inside the Docker container at
|
|
* `scripts/probe-beginframe.dockerfile` to catch any future
|
|
* `@sparticuz/chromium` rebuild that drops `HeadlessExperimental` support.
|
|
*
|
|
* Exits 0 on pass, 1 on fail. Run via:
|
|
*
|
|
* bun run --cwd packages/aws-lambda probe:beginframe # host
|
|
* bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like
|
|
*/
|
|
|
|
import { mkdtempSync, promises as fs } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
interface ProbeResult {
|
|
passed: boolean;
|
|
durationMs: number;
|
|
chromiumPath: string;
|
|
screenshotBytes: number;
|
|
hasDamage: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
const PROBE_HTML = `<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
|
|
<style>html,body{margin:0;background:#173;color:#fff;font:48px/1 sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}</style>
|
|
</head><body><div id="x">hf-beginframe-probe</div></body></html>`;
|
|
|
|
async function main(): Promise<void> {
|
|
const start = Date.now();
|
|
const result = await probe();
|
|
result.durationMs = Date.now() - start;
|
|
console.log(JSON.stringify(result, null, 2));
|
|
if (!result.passed) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function probe(): Promise<ProbeResult> {
|
|
let chromiumPath = "";
|
|
try {
|
|
const { default: chromium } = await import("@sparticuz/chromium");
|
|
chromiumPath = await chromium.executablePath();
|
|
const args = chromium.args;
|
|
|
|
const puppeteer = await import("puppeteer-core");
|
|
|
|
// Write probe HTML to /tmp + serve via file:// — no HTTP server in the
|
|
// probe so we don't add a dependency surface that could mask a
|
|
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
|
|
// an unguessable directory name so two concurrent probes on the same
|
|
// host don't collide and CodeQL's insecure-tempfile rule clears.
|
|
const tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
|
|
const htmlPath = join(tmpHtmlDir, "probe.html");
|
|
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
|
|
|
|
// BeginFrame requires the full compositor-driving flag set. These match
|
|
// the args the engine's `browserManager` passes when `captureMode !==
|
|
// "screenshot"`. Without the surface-synchronization + threaded-disable
|
|
// flags, Chrome's compositor returns `hasDamage: false` and skips the
|
|
// screenshot — the same observation pinned in the hyperframes memory
|
|
// ("Chrome's beginFrame with `screenshot` param always reports
|
|
// hasDamage=true").
|
|
const beginFrameFlags = [
|
|
"--deterministic-mode",
|
|
"--enable-begin-frame-control",
|
|
"--disable-new-content-rendering-timeout",
|
|
"--run-all-compositor-stages-before-draw",
|
|
"--disable-threaded-animation",
|
|
"--disable-threaded-scrolling",
|
|
"--disable-checker-imaging",
|
|
"--disable-image-animation-resync",
|
|
"--enable-surface-synchronization",
|
|
// Software GL — Lambda has no GPU; matches the in-process renderer's
|
|
// software-locked path.
|
|
"--use-gl=angle",
|
|
"--use-angle=swiftshader",
|
|
"--enable-unsafe-swiftshader",
|
|
];
|
|
|
|
const browser = await puppeteer.launch({
|
|
executablePath: chromiumPath,
|
|
headless: "shell",
|
|
args: [...args, ...beginFrameFlags],
|
|
defaultViewport: { width: 800, height: 600 },
|
|
});
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
const session = await page.createCDPSession();
|
|
await session.send("HeadlessExperimental.enable");
|
|
// Warm-up beginFrame with noDisplayUpdates: true — drives the
|
|
// compositor without producing a screenshot, matching how the engine
|
|
// primes a capture loop.
|
|
await session.send("HeadlessExperimental.beginFrame", {
|
|
frameTimeTicks: 0,
|
|
interval: 33,
|
|
noDisplayUpdates: true,
|
|
});
|
|
const response = await session.send("HeadlessExperimental.beginFrame", {
|
|
frameTimeTicks: 1000,
|
|
interval: 33,
|
|
screenshot: { format: "png" },
|
|
});
|
|
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
|
|
const screenshot = response.screenshotData ?? "";
|
|
const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
|
|
const isPng =
|
|
bytes.length >= 8 &&
|
|
bytes[0] === 0x89 &&
|
|
bytes[1] === 0x50 &&
|
|
bytes[2] === 0x4e &&
|
|
bytes[3] === 0x47;
|
|
return {
|
|
passed: isPng && bytes.length > 0,
|
|
durationMs: 0,
|
|
chromiumPath,
|
|
screenshotBytes: bytes.length,
|
|
hasDamage: response.hasDamage,
|
|
detail: isPng
|
|
? "OK — BeginFrame returned a PNG buffer."
|
|
: `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${
|
|
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
|
|
}`,
|
|
};
|
|
} finally {
|
|
await browser.close().catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
passed: false,
|
|
durationMs: 0,
|
|
chromiumPath,
|
|
screenshotBytes: 0,
|
|
hasDamage: false,
|
|
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
void main().catch((err) => {
|
|
console.error("[probe-beginframe] unexpected:", err);
|
|
process.exit(2);
|
|
});
|