From be06d79fb234fa552d77e6184ca2db8e206ef2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 11:12:11 -0400 Subject: [PATCH 1/5] feat(aws-lambda): arm64 video function image from the handler ZIP The image is the release handler ZIP with its x86 ffmpeg and Chrome swapped for the native ffmpeg tree and the sha256-pinned Sparticuz arm64 Chrome pack, so the x86 and arm64 functions run the same handler code. The image stage only copies, so any host builds it. CI builds it and runs ffmpeg, a real Chrome render and the Lambda runtime on an ARM runner. --- .fallowrc.jsonc | 2 + .github/workflows/lambda-arm64-image.yml | 98 +++++++++++++++ .github/workflows/lambda-ffmpeg.yml | 47 -------- packages/aws-lambda/image/Dockerfile | 7 ++ packages/aws-lambda/image/smoke-video.mjs | 22 ++++ .../scripts/stage-arm64-image.test.ts | 101 ++++++++++++++++ .../aws-lambda/scripts/stage-arm64-image.ts | 112 ++++++++++++++++++ 7 files changed, 342 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/lambda-arm64-image.yml delete mode 100644 .github/workflows/lambda-ffmpeg.yml create mode 100644 packages/aws-lambda/image/smoke-video.mjs create mode 100644 packages/aws-lambda/scripts/stage-arm64-image.test.ts create mode 100644 packages/aws-lambda/scripts/stage-arm64-image.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 0e3a4cc3805..6a3787fba7e 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -30,6 +30,8 @@ "packages/cli/scripts/generate-font-data.ts", "packages/engine/scripts/test-fitTextFontSize-browser.ts", "packages/aws-lambda/scripts/*.ts", + // Run inside the arm64 video image by the lambda-arm64-image workflow. + "packages/aws-lambda/image/smoke-video.mjs", // Built as standalone IIFE for the browser-side sandbox runtime; // referenced by file path (not import) in build-hyperframes-runtime-artifact.ts. "packages/core/src/runtime/entry.ts", diff --git a/.github/workflows/lambda-arm64-image.yml b/.github/workflows/lambda-arm64-image.yml new file mode 100644 index 00000000000..0a1338d4f21 --- /dev/null +++ b/.github/workflows/lambda-arm64-image.yml @@ -0,0 +1,98 @@ +name: Lambda arm64 image + +permissions: + contents: read + +# Builds the arm64 Lambda video function image: the native ffmpeg on two separate ARM runners (each runs +# the codec check, the tarballs must match), then the image from the x86 handler ZIP, smoke-run on ARM. +on: + pull_request: + paths: + - "packages/aws-lambda/image/**" + - "packages/aws-lambda/scripts/stage-arm64-image.ts" + - ".github/workflows/lambda-arm64-image.yml" + workflow_dispatch: + +concurrency: + group: lambda-arm64-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + ffmpeg: + name: ffmpeg ${{ matrix.copy }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + strategy: + matrix: + copy: [a, b] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Build and check + run: >- + docker buildx build --progress=plain --platform linux/arm64 --target artifact + --output type=local,dest=out packages/aws-lambda/image + - run: cat out/hf-media-check.txt && sha256sum out/*.tar.gz + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: hf-media-${{ matrix.copy }} + path: out/ + retention-days: 7 + + reproducible: + name: ffmpeg builds match + needs: ffmpeg + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - run: cmp hf-media-a/hf-media-linux-aarch64.tar.gz hf-media-b/hf-media-linux-aarch64.tar.gz + + handler-zip: + name: Handler ZIP + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run --cwd packages/aws-lambda build:zip + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: handler-zip + path: packages/aws-lambda/dist/handler.zip + retention-days: 7 + + image: + name: Image smoke + needs: [ffmpeg, handler-zip] + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: artifacts + - name: Stage /var/task + run: >- + bun packages/aws-lambda/scripts/stage-arm64-image.ts + --zip=artifacts/handler-zip/handler.zip + --ffmpeg=artifacts/hf-media-a/hf-media-linux-aarch64.tar.gz --out=staged + - name: Build image + run: >- + docker buildx build --progress=plain --platform linux/arm64 --target video + --build-context task=staged/task --load -t hf-video:ci packages/aws-lambda/image + - name: ffmpeg and Chrome run in the image + run: >- + docker run --rm --entrypoint /var/lang/bin/node + -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" + hf-video:ci /var/task/smoke-video.mjs + - name: The Lambda runtime loads the handler + run: | + docker run -d --name hf-video -p 9000:8080 hf-video:ci + reply=$(curl -sS --retry 20 --retry-connrefused --retry-delay 1 \ + -d '{"Action":"not-an-action"}' http://localhost:9000/2015-03-31/functions/function/invocations) + docker logs hf-video + echo "$reply" + grep -q 'no recognised Action' <<<"$reply" diff --git a/.github/workflows/lambda-ffmpeg.yml b/.github/workflows/lambda-ffmpeg.yml deleted file mode 100644 index 00c0ce4edc0..00000000000 --- a/.github/workflows/lambda-ffmpeg.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Lambda arm64 ffmpeg - -permissions: - contents: read - -# Builds the native ffmpeg for the arm64 Lambda video function on two separate ARM runners. -# Each build runs the codec check on the bare Lambda base; the tarballs must be byte-identical. -on: - pull_request: - paths: - - "packages/aws-lambda/image/**" - - ".github/workflows/lambda-ffmpeg.yml" - workflow_dispatch: - -concurrency: - group: lambda-ffmpeg-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build ${{ matrix.copy }} - runs-on: ubuntu-24.04-arm - timeout-minutes: 90 - strategy: - matrix: - copy: [a, b] - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Build and check - run: >- - docker buildx build --progress=plain --platform linux/arm64 --target artifact - --output type=local,dest=out packages/aws-lambda/image - - run: cat out/hf-media-check.txt && sha256sum out/*.tar.gz - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: hf-media-${{ matrix.copy }} - path: out/ - retention-days: 7 - - reproducible: - name: Builds match - needs: build - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - - run: cmp hf-media-a/hf-media-linux-aarch64.tar.gz hf-media-b/hf-media-linux-aarch64.tar.gz diff --git a/packages/aws-lambda/image/Dockerfile b/packages/aws-lambda/image/Dockerfile index 1cc7b124ba4..36548885c8b 100644 --- a/packages/aws-lambda/image/Dockerfile +++ b/packages/aws-lambda/image/Dockerfile @@ -32,3 +32,10 @@ RUN cp /certs/ca.crt /etc/pki/ca-trust/source/anchors/ && update-ca-trust && bas FROM scratch AS artifact COPY --from=build /out/ / COPY --from=check /check.txt /hf-media-check.txt + +# The video function. /var/task comes from scripts/stage-arm64-image.ts as the `task` build context: +# docker buildx build --platform linux/arm64 --target video --build-context task=/task packages/aws-lambda/image +# Copy only, so any host builds it without emulation. +FROM ${BASE} AS video +COPY --from=task / /var/task/ +CMD ["handler.handler"] diff --git a/packages/aws-lambda/image/smoke-video.mjs b/packages/aws-lambda/image/smoke-video.mjs new file mode 100644 index 00000000000..6f4337efcfd --- /dev/null +++ b/packages/aws-lambda/image/smoke-video.mjs @@ -0,0 +1,22 @@ +// Runs inside the video image, mounted into /var/task: the bundled ffmpeg answers and the arm64 Chrome +// renders a page, launched the way src/chromium.ts launches it. +import { execFileSync } from "node:child_process"; +import chromium from "@sparticuz/chromium"; +import puppeteer from "puppeteer-core"; + +const ffmpeg = execFileSync("/var/task/bin/ffmpeg", ["-hide_banner", "-version"], { + encoding: "utf-8", +}); +console.log(ffmpeg.split("\n")[0]); +const browser = await puppeteer.launch({ + executablePath: await chromium.executablePath(), + args: chromium.args, +}); +try { + const page = await browser.newPage(); + await page.setContent("
"); + const shot = await page.screenshot({ clip: { x: 0, y: 0, width: 64, height: 64 } }); + console.log(`${await browser.version()} on ${process.arch}: ${shot.length} byte screenshot`); +} finally { + await browser.close(); +} diff --git a/packages/aws-lambda/scripts/stage-arm64-image.test.ts b/packages/aws-lambda/scripts/stage-arm64-image.test.ts new file mode 100644 index 00000000000..c25fbc73cdd --- /dev/null +++ b/packages/aws-lambda/scripts/stage-arm64-image.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { CHROMIUM_ARM64_PACK_SHA256, stageArm64Image } from "./stage-arm64-image.js"; + +const X86_64 = 0x3e; +const AARCH64 = 0xb7; +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function elf(machine: number): Buffer { + const head = Buffer.alloc(64); + head.writeUInt32BE(0x7f454c46, 0); + head[4] = 2; + head.writeUInt16LE(machine, 18); + return head; +} + +function tree(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "stage-arm64-")); + dirs.push(dir); + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), body); + } + return dir; +} + +function pack(cmd: string, args: string[], cwd: string): void { + expect(spawnSync(cmd, args, { cwd }).status).toBe(0); +} + +function fixture(opts: { ffmpegMachine?: number; zipExtra?: Record } = {}) { + const zipDir = tree({ + "handler.mjs": "export const handler = () => {};", + "bin/ffmpeg": elf(X86_64), + "bin/ffprobe": elf(X86_64), + "node_modules/@sparticuz/chromium/package.json": JSON.stringify({ version: "148.0.0" }), + "node_modules/@sparticuz/chromium/bin/chromium.br": "x64 chrome", + ...opts.zipExtra, + }); + pack("zip", ["-qr", "handler.zip", "."], zipDir); + const machine = opts.ffmpegMachine ?? AARCH64; + const mediaDir = tree({ + "hf-media/bin/ffmpeg": elf(machine), + "hf-media/bin/ffprobe": elf(machine), + "hf-media/lib/libgnutls.so.30": "gnutls", + }); + pack("tar", ["-czf", "hf-media.tar.gz", "hf-media"], mediaDir); + const packDir = tree({ "chromium.br": "arm64 chrome", "al2023.tar.br": "arm64 libs" }); + pack("tar", ["-cf", "pack.tar", "chromium.br", "al2023.tar.br"], packDir); + const packBytes = readFileSync(join(packDir, "pack.tar")); + return { + handlerZip: join(zipDir, "handler.zip"), + ffmpegTarball: join(mediaDir, "hf-media.tar.gz"), + outDir: join(tree({}), "out"), + fetchPack: async () => packBytes, + packPins: { "148.0.0": createHash("sha256").update(packBytes).digest("hex") }, + }; +} + +describe("stageArm64Image", () => { + it("swaps the handler ZIP's x86 ffmpeg and Chrome for the arm64 ones", async () => { + const task = await stageArm64Image(fixture()); + expect(readFileSync(join(task, "bin/ffmpeg")).readUInt16LE(18)).toBe(AARCH64); + expect(readFileSync(join(task, "bin/ffprobe")).readUInt16LE(18)).toBe(AARCH64); + expect(readFileSync(join(task, "lib/libgnutls.so.30"), "utf-8")).toBe("gnutls"); + const chromeBin = join(task, "node_modules/@sparticuz/chromium/bin"); + expect(readFileSync(join(chromeBin, "chromium.br"), "utf-8")).toBe("arm64 chrome"); + expect(readFileSync(join(chromeBin, "al2023.tar.br"), "utf-8")).toBe("arm64 libs"); + expect(readFileSync(join(task, "handler.mjs"), "utf-8")).toContain("handler"); + }); + + it("refuses an ffmpeg tarball that is not arm64", async () => { + await expect(stageArm64Image(fixture({ ffmpegMachine: X86_64 }))).rejects.toThrow( + "is not a 64-bit arm64 ELF binary", + ); + }); + + it("refuses a Chrome pack whose sha256 is not the pinned one", async () => { + const opts = { ...fixture(), packPins: CHROMIUM_ARM64_PACK_SHA256 }; + await expect(stageArm64Image(opts)).rejects.toThrow("pinned c18a5241"); + }); + + it("refuses a @sparticuz/chromium version with no pinned arm64 pack", async () => { + await expect(stageArm64Image({ ...fixture(), packPins: {} })).rejects.toThrow( + "no arm64 Chrome pack pinned for @sparticuz/chromium 148.0.0", + ); + }); + + it("refuses a handler ZIP built with chrome-headless-shell", async () => { + const opts = fixture({ zipExtra: { "bin/chrome-headless-shell": "x86 chrome" } }); + await expect(stageArm64Image(opts)).rejects.toThrow("sparticuz Chrome only"); + }); +}); diff --git a/packages/aws-lambda/scripts/stage-arm64-image.ts b/packages/aws-lambda/scripts/stage-arm64-image.ts new file mode 100644 index 00000000000..9e34f01a8fc --- /dev/null +++ b/packages/aws-lambda/scripts/stage-arm64-image.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env bun +// Stages /var/task for the arm64 video function image: the handler ZIP with its x86 ffmpeg and Chrome +// swapped for the native ffmpeg tree (image/Dockerfile's artifact) and Sparticuz's arm64 Chrome pack. +// bun packages/aws-lambda/scripts/stage-arm64-image.ts --ffmpeg= [--zip=..] [--out=..] +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// The npm package ships x64 Chrome only. Bumping @sparticuz/chromium fails here until its arm64 pack is pinned. +export const CHROMIUM_ARM64_PACK_SHA256: Record = { + "148.0.0": "c18a5241b0a460cba233fff3abccd6678ebdcf855b8548bb7c58240a477f5f0e", +}; + +export interface StageOptions { + handlerZip: string; + ffmpegTarball: string; + outDir: string; + fetchPack?: (url: string) => Promise; + packPins?: Record; +} + +async function fetchBytes(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`[stage-arm64-image] GET ${url} answered ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +function run(cmd: string, args: string[]): void { + const result = spawnSync(cmd, args, { stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`[stage-arm64-image] ${cmd} ${args.join(" ")} exited ${result.status}`); + } +} + +export function assertArm64Elf(path: string): void { + const head = Buffer.alloc(20); + const fd = openSync(path, "r"); + try { + readSync(fd, head, 0, head.length, 0); + } finally { + closeSync(fd); + } + const elf64 = head.readUInt32BE(0) === 0x7f454c46 && head[4] === 2; + if (!elf64 || head.readUInt16LE(18) !== 0xb7) { + throw new Error(`[stage-arm64-image] ${path} is not a 64-bit arm64 ELF binary`); + } +} + +async function stageChromiumArm64(chromiumDir: string, opts: StageOptions) { + const { version } = JSON.parse(readFileSync(join(chromiumDir, "package.json"), "utf-8")) as { + version: string; + }; + const pinned = (opts.packPins ?? CHROMIUM_ARM64_PACK_SHA256)[version]; + if (!pinned) { + throw new Error( + `[stage-arm64-image] no arm64 Chrome pack pinned for @sparticuz/chromium ${version}; ` + + "add its sha256 to CHROMIUM_ARM64_PACK_SHA256", + ); + } + const url = `https://github.com/Sparticuz/chromium/releases/download/v${version}/chromium-v${version}-pack.arm64.tar`; + const pack = await (opts.fetchPack ?? fetchBytes)(url); + const actual = createHash("sha256").update(pack).digest("hex"); + if (actual !== pinned) { + throw new Error(`[stage-arm64-image] ${url} has sha256 ${actual}, pinned ${pinned}`); + } + const tar = join(chromiumDir, "pack.arm64.tar"); + writeFileSync(tar, pack); + run("tar", ["-xf", tar, "-C", join(chromiumDir, "bin")]); + rmSync(tar); +} + +export async function stageArm64Image(opts: StageOptions): Promise { + const task = join(opts.outDir, "task"); + rmSync(opts.outDir, { recursive: true, force: true }); + mkdirSync(task, { recursive: true }); + run("unzip", ["-q", opts.handlerZip, "-d", task]); + if (existsSync(join(task, "bin", "chrome-headless-shell"))) { + throw new Error("[stage-arm64-image] the arm64 image bundles sparticuz Chrome only"); + } + rmSync(join(task, "bin", "ffmpeg")); + rmSync(join(task, "bin", "ffprobe")); + run("tar", ["-xzf", opts.ffmpegTarball, "-C", task, "--strip-components=1"]); + for (const bin of ["ffmpeg", "ffprobe"]) assertArm64Elf(join(task, "bin", bin)); + await stageChromiumArm64(join(task, "node_modules", "@sparticuz", "chromium"), opts); + return task; +} + +if (import.meta.main) { + const distDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist"); + const flag = (name: string) => + process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); + const ffmpegTarball = flag("ffmpeg"); + if (!ffmpegTarball) + throw new Error("[stage-arm64-image] --ffmpeg= is required"); + const task = await stageArm64Image({ + handlerZip: flag("zip") ?? join(distDir, "handler.zip"), + ffmpegTarball, + outDir: flag("out") ?? join(distDir, "arm64-image"), + }); + console.log(`[stage-arm64-image] staged ${task}`); +} From 8f31035f5110026dadcc877f5d4fc423c1ad1207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 11:36:00 -0400 Subject: [PATCH 2/5] fix(aws-lambda): launch Chrome in the image smoke the way Lambda does @sparticuz/chromium inflates its AL2023 libraries only when AWS_EXECUTION_ENV names a Node runtime; Lambda sets it, a bare docker run does not. The workflow also runs on @sparticuz/chromium bumps and handler ZIP layout changes, which re-stage the image. --- .github/workflows/lambda-arm64-image.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lambda-arm64-image.yml b/.github/workflows/lambda-arm64-image.yml index 0a1338d4f21..f66129a21c3 100644 --- a/.github/workflows/lambda-arm64-image.yml +++ b/.github/workflows/lambda-arm64-image.yml @@ -10,6 +10,9 @@ on: paths: - "packages/aws-lambda/image/**" - "packages/aws-lambda/scripts/stage-arm64-image.ts" + # A @sparticuz/chromium bump or a ZIP layout change must re-stage the image. + - "packages/aws-lambda/package.json" + - "packages/aws-lambda/scripts/build-zip.ts" - ".github/workflows/lambda-arm64-image.yml" workflow_dispatch: @@ -85,7 +88,7 @@ jobs: --build-context task=staged/task --load -t hf-video:ci packages/aws-lambda/image - name: ffmpeg and Chrome run in the image run: >- - docker run --rm --entrypoint /var/lang/bin/node + docker run --rm --entrypoint /var/lang/bin/node -e AWS_EXECUTION_ENV=AWS_Lambda_nodejs22.x -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" hf-video:ci /var/task/smoke-video.mjs - name: The Lambda runtime loads the handler From 325339f8babe96fd3d3393964ce61b8089cb7cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 11:38:52 -0400 Subject: [PATCH 3/5] fix(aws-lambda): smoke the arm64 image through its own Lambda runtime The smoke is now a handler invoked through the image's entrypoint, whose bootstrap sets AWS_EXECUTION_ENV the way Lambda does, so @sparticuz/chromium takes its Lambda branch with nothing set by hand. It returns the value it saw. --- .github/workflows/lambda-arm64-image.yml | 17 +++++---- packages/aws-lambda/image/smoke-video.mjs | 44 +++++++++++++---------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.github/workflows/lambda-arm64-image.yml b/.github/workflows/lambda-arm64-image.yml index f66129a21c3..1f330694b2a 100644 --- a/.github/workflows/lambda-arm64-image.yml +++ b/.github/workflows/lambda-arm64-image.yml @@ -86,15 +86,20 @@ jobs: run: >- docker buildx build --progress=plain --platform linux/arm64 --target video --build-context task=staged/task --load -t hf-video:ci packages/aws-lambda/image - - name: ffmpeg and Chrome run in the image - run: >- - docker run --rm --entrypoint /var/lang/bin/node -e AWS_EXECUTION_ENV=AWS_Lambda_nodejs22.x - -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" - hf-video:ci /var/task/smoke-video.mjs + - name: ffmpeg and Chrome run under the image's own Lambda runtime + run: | + docker run -d --name hf-smoke -p 9001:8080 \ + -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" \ + hf-video:ci smoke-video.handler + reply=$(curl -sS --retry 30 --retry-all-errors --retry-delay 1 \ + -d '{}' http://localhost:9001/2015-03-31/functions/function/invocations) + docker logs hf-smoke + echo "$reply" + jq -e '.arch == "arm64" and .screenshotBytes > 0 and (.ffmpeg | startswith("ffmpeg version 7.0.2"))' <<<"$reply" - name: The Lambda runtime loads the handler run: | docker run -d --name hf-video -p 9000:8080 hf-video:ci - reply=$(curl -sS --retry 20 --retry-connrefused --retry-delay 1 \ + reply=$(curl -sS --retry 30 --retry-all-errors --retry-delay 1 \ -d '{"Action":"not-an-action"}' http://localhost:9000/2015-03-31/functions/function/invocations) docker logs hf-video echo "$reply" diff --git a/packages/aws-lambda/image/smoke-video.mjs b/packages/aws-lambda/image/smoke-video.mjs index 6f4337efcfd..94b61d00271 100644 --- a/packages/aws-lambda/image/smoke-video.mjs +++ b/packages/aws-lambda/image/smoke-video.mjs @@ -1,22 +1,30 @@ -// Runs inside the video image, mounted into /var/task: the bundled ffmpeg answers and the arm64 Chrome -// renders a page, launched the way src/chromium.ts launches it. +// A handler mounted into the video image and invoked through its own Lambda bootstrap, which sets +// AWS_EXECUTION_ENV as Lambda does: the bundled ffmpeg answers and arm64 Chrome renders, launched as +// src/chromium.ts launches it. import { execFileSync } from "node:child_process"; import chromium from "@sparticuz/chromium"; import puppeteer from "puppeteer-core"; -const ffmpeg = execFileSync("/var/task/bin/ffmpeg", ["-hide_banner", "-version"], { - encoding: "utf-8", -}); -console.log(ffmpeg.split("\n")[0]); -const browser = await puppeteer.launch({ - executablePath: await chromium.executablePath(), - args: chromium.args, -}); -try { - const page = await browser.newPage(); - await page.setContent("
"); - const shot = await page.screenshot({ clip: { x: 0, y: 0, width: 64, height: 64 } }); - console.log(`${await browser.version()} on ${process.arch}: ${shot.length} byte screenshot`); -} finally { - await browser.close(); -} +export const handler = async () => { + const ffmpeg = execFileSync("/var/task/bin/ffmpeg", ["-hide_banner", "-version"], { + encoding: "utf-8", + }).split("\n")[0]; + const browser = await puppeteer.launch({ + executablePath: await chromium.executablePath(), + args: chromium.args, + }); + try { + const page = await browser.newPage(); + await page.setContent("
"); + const shot = await page.screenshot({ clip: { x: 0, y: 0, width: 64, height: 64 } }); + return { + executionEnv: process.env.AWS_EXECUTION_ENV, + arch: process.arch, + ffmpeg, + chrome: await browser.version(), + screenshotBytes: shot.length, + }; + } finally { + await browser.close(); + } +}; From c4889c0cced554e321de29cb386cca1093d2f136 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 22 Oct 2026 21:33:57 -0400 Subject: [PATCH 4/5] fix(aws-lambda): agree the move of the ffmpeg build workflow into the image workflow --- scripts/check-no-main-deletions.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index 5792c5ea4e3..dc48eb87484 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -1909,6 +1909,10 @@ export const ALLOWED_DELETIONS = new Map([ "packages/engine/src/services/frameCapture-releaseInjectedVideoMedia.browser.test.ts", "the revert of #5795 removes releaseInjectedVideoMedia, the only code this file tested", ], + [ + ".github/workflows/lambda-ffmpeg.yml", + "the ffmpeg build moves into lambda-arm64-image.yml, which also builds and smokes the image", + ], ]); export function parseBase(argv, fallback = "origin/main") { From 0a6af61fae60495fd0f72e885671dce49c5adeca Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 22 Oct 2026 22:15:48 -0400 Subject: [PATCH 5/5] fix(aws-lambda): build the Chrome pack URL from the pin and hand the checked pack to tar in memory --- .../aws-lambda/scripts/stage-arm64-image.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/aws-lambda/scripts/stage-arm64-image.ts b/packages/aws-lambda/scripts/stage-arm64-image.ts index 9e34f01a8fc..b7356aacc57 100644 --- a/packages/aws-lambda/scripts/stage-arm64-image.ts +++ b/packages/aws-lambda/scripts/stage-arm64-image.ts @@ -12,7 +12,6 @@ import { readFileSync, readSync, rmSync, - writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -36,8 +35,11 @@ async function fetchBytes(url: string): Promise { return new Uint8Array(await res.arrayBuffer()); } -function run(cmd: string, args: string[]): void { - const result = spawnSync(cmd, args, { stdio: "inherit" }); +function run(cmd: string, args: string[], input?: Uint8Array): void { + const result = spawnSync(cmd, args, { + stdio: [input ? "pipe" : "inherit", "inherit", "inherit"], + input, + }); if (result.status !== 0) { throw new Error(`[stage-arm64-image] ${cmd} ${args.join(" ")} exited ${result.status}`); } @@ -61,23 +63,23 @@ async function stageChromiumArm64(chromiumDir: string, opts: StageOptions) { const { version } = JSON.parse(readFileSync(join(chromiumDir, "package.json"), "utf-8")) as { version: string; }; - const pinned = (opts.packPins ?? CHROMIUM_ARM64_PACK_SHA256)[version]; - if (!pinned) { + // The URL is built from the pin table's own key, never from the version read off disk. + const [pinnedVersion, pinned] = + Object.entries(opts.packPins ?? CHROMIUM_ARM64_PACK_SHA256).find(([v]) => v === version) ?? []; + if (!pinnedVersion || !pinned) { throw new Error( `[stage-arm64-image] no arm64 Chrome pack pinned for @sparticuz/chromium ${version}; ` + "add its sha256 to CHROMIUM_ARM64_PACK_SHA256", ); } - const url = `https://github.com/Sparticuz/chromium/releases/download/v${version}/chromium-v${version}-pack.arm64.tar`; + const url = `https://github.com/Sparticuz/chromium/releases/download/v${pinnedVersion}/chromium-v${pinnedVersion}-pack.arm64.tar`; const pack = await (opts.fetchPack ?? fetchBytes)(url); const actual = createHash("sha256").update(pack).digest("hex"); if (actual !== pinned) { throw new Error(`[stage-arm64-image] ${url} has sha256 ${actual}, pinned ${pinned}`); } - const tar = join(chromiumDir, "pack.arm64.tar"); - writeFileSync(tar, pack); - run("tar", ["-xf", tar, "-C", join(chromiumDir, "bin")]); - rmSync(tar); + // Only bytes that matched the pin reach tar, over stdin; the pack is never written to a file. + run("tar", ["-xf", "-", "-C", join(chromiumDir, "bin")], pack); } export async function stageArm64Image(opts: StageOptions): Promise {