From 6a33418476827b83f09c7cecf40faeb46891bb54 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:02:10 +0000 Subject: [PATCH 1/4] feat(producer): add --mode=lambda-local to the regression harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third harness mode that drives the OSS @hyperframes/aws-lambda handler through the exact event sequence Step Functions produces in production: handler({Action: "plan"}) → planDir tarball on fake S3 handler({Action: "renderChunk"}) × N → chunk artifacts on fake S3 handler({Action: "assemble"}) → final mp4/mov/png-sequence The S3 client is a filesystem-backed fake (every s3:/// URI maps to /s3/), so the harness exercises the handler's event-parsing + tar/S3 conventions + dispatch logic on top of the underlying producer primitives. Regressions in event JSON shape, S3 key layout, or plan-hash boundary checks now surface in the same CI run as the in-process and distributed-simulated modes without paying for a real AWS round-trip. Deliberately NOT a Docker/RIE invocation — that would gate the producer test suite on Docker-in-Docker support which most CI runners lack. Real-ZIP-via-RIE tests live in packages/aws-lambda/scripts/ (probe:beginframe) and the maintainer-run smoke.sh. Wired up via: - HarnessMode union extended to include "lambda-local" - parseHarnessModeFlag accepts --mode=lambda-local - regression-harness.ts dispatches to runLambdaLocalRender for the new mode, sharing the distributed-support gate + pathology-floor threshold with distributed-simulated mode - package.json scripts: test:lambda-local + docker:test:lambda-local - producer.devDependencies += @hyperframes/aws-lambda (workspace) - producer/tsconfig.json gains path mappings to self so the type cycle through aws-lambda's source resolves at typecheck time without needing producer to be pre-built Tests: 3 new unit tests on parseHarnessModeFlag + resolveMinPsnrForMode cover the new mode. End-to-end PSNR contract still runs through Dockerfile.test (manual + CI). --- bun.lock | 1 + packages/producer/package.json | 3 + .../regression-harness-distributed.test.ts | 16 ++ .../src/regression-harness-distributed.ts | 21 +- .../src/regression-harness-lambda-local.ts | 235 ++++++++++++++++++ packages/producer/src/regression-harness.ts | 39 +-- packages/producer/tsconfig.json | 7 +- 7 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 packages/producer/src/regression-harness-lambda-local.ts diff --git a/bun.lock b/bun.lock index bc2ebca20..a5441b7d7 100644 --- a/bun.lock +++ b/bun.lock @@ -184,6 +184,7 @@ "@fontsource/poppins": "^5.2.7", "@fontsource/roboto": "^5.2.10", "@fontsource/source-code-pro": "^5.2.7", + "@hyperframes/aws-lambda": "workspace:*", "@types/node": "^25.0.10", "@webgpu/types": "^0.1.69", "esbuild": "^0.25.12", diff --git a/packages/producer/package.json b/packages/producer/package.json index 934756583..30ca3e7bf 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -50,6 +50,8 @@ "docker:test": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test", "docker:test:update": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --update", "docker:test:distributed": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --mode=distributed-simulated", + "test:lambda-local": "tsx src/regression-harness.ts --exclude-tags transparency --mode=lambda-local", + "docker:test:lambda-local": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --mode=lambda-local", "prepublishOnly": "echo skip" }, "dependencies": { @@ -81,6 +83,7 @@ "@fontsource/poppins": "^5.2.7", "@fontsource/roboto": "^5.2.10", "@fontsource/source-code-pro": "^5.2.7", + "@hyperframes/aws-lambda": "workspace:*", "@types/node": "^25.0.10", "@webgpu/types": "^0.1.69", "esbuild": "^0.25.12", diff --git a/packages/producer/src/regression-harness-distributed.test.ts b/packages/producer/src/regression-harness-distributed.test.ts index 27200f30c..a036aba36 100644 --- a/packages/producer/src/regression-harness-distributed.test.ts +++ b/packages/producer/src/regression-harness-distributed.test.ts @@ -18,6 +18,10 @@ describe("parseHarnessModeFlag()", () => { expect(parseHarnessModeFlag("--mode=distributed-simulated")).toBe("distributed-simulated"); }); + it("parses --mode=lambda-local", () => { + expect(parseHarnessModeFlag("--mode=lambda-local")).toBe("lambda-local"); + }); + it("returns null for tokens that aren't --mode", () => { expect(parseHarnessModeFlag("--update")).toBeNull(); expect(parseHarnessModeFlag("font-variant-numeric")).toBeNull(); @@ -28,6 +32,11 @@ describe("parseHarnessModeFlag()", () => { expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/--mode must be/); expect(() => parseHarnessModeFlag("--mode=")).toThrow(/--mode must be/); }); + + it("error message lists all three accepted modes", () => { + expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/lambda-local/); + expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/distributed-simulated/); + }); }); describe("checkDistributedSupport()", () => { @@ -113,6 +122,13 @@ describe("resolveMinPsnrForMode()", () => { ); }); + it("lambda-local mirrors distributed-simulated's pathology floor", () => { + // Both non-in-process modes run through the same producer primitives, + // so they share the same pathology threshold. + expect(resolveMinPsnrForMode("lambda-local", 30)).toBe(30); + expect(resolveMinPsnrForMode("lambda-local", 0)).toBe(DISTRIBUTED_SIMULATED_MIN_PSNR_DB); + }); + it("every committed fixture authors a minPsnr above the absolute floor", async () => { // The pathology floor only fires for a fixture whose authored minPsnr // is below it — by design that should be no committed fixture. If diff --git a/packages/producer/src/regression-harness-distributed.ts b/packages/producer/src/regression-harness-distributed.ts index d2d2b3c15..449bced29 100644 --- a/packages/producer/src/regression-harness-distributed.ts +++ b/packages/producer/src/regression-harness-distributed.ts @@ -36,8 +36,20 @@ import { join } from "node:path"; import type { Fps } from "@hyperframes/core"; import { assemble, plan, renderChunk } from "./distributed.js"; -/** Two-mode contract that backs `--mode=` on the regression harness CLI. */ -export type HarnessMode = "in-process" | "distributed-simulated"; +/** + * Three-mode contract that backs `--mode=` on the regression + * harness CLI: + * + * - `in-process` — `executeRenderJob`, the same path the CLI takes. + * - `distributed-simulated` — `plan` → `renderChunk` × N → `assemble` + * in-process. No adapter (no Temporal, no Lambda). + * - `lambda-local` — drives the OSS `@hyperframes/aws-lambda` handler + * dispatch through a filesystem-backed fake S3, so every event + * shape SFN sends in production also lands here. Catches regressions + * in event JSON / S3 path conventions without paying for a real AWS + * round-trip. + */ +export type HarnessMode = "in-process" | "distributed-simulated" | "lambda-local"; /** * Absolute pathology floor for `--mode=distributed-simulated` — catches @@ -207,6 +219,8 @@ export async function runDistributedSimulatedRender( */ export function resolveMinPsnrForMode(mode: HarnessMode, fixtureMinPsnr: number): number { if (mode === "in-process") return fixtureMinPsnr; + // `lambda-local` shares the distributed-simulated pathology floor — + // both modes go through the same plan/renderChunk/assemble primitives. return Math.max(fixtureMinPsnr, DISTRIBUTED_SIMULATED_MIN_PSNR_DB); } @@ -220,10 +234,11 @@ export function resolveMinPsnrForMode(mode: HarnessMode, fixtureMinPsnr: number) export function parseHarnessModeFlag(token: string): HarnessMode | null { if (token === "--mode=in-process") return "in-process"; if (token === "--mode=distributed-simulated") return "distributed-simulated"; + if (token === "--mode=lambda-local") return "lambda-local"; if (token.startsWith("--mode=")) { const value = token.slice("--mode=".length); throw new Error( - `regression-harness: --mode must be 'in-process' or 'distributed-simulated' (got ${JSON.stringify(value)})`, + `regression-harness: --mode must be 'in-process', 'distributed-simulated', or 'lambda-local' (got ${JSON.stringify(value)})`, ); } return null; diff --git a/packages/producer/src/regression-harness-lambda-local.ts b/packages/producer/src/regression-harness-lambda-local.ts new file mode 100644 index 000000000..e0f923a63 --- /dev/null +++ b/packages/producer/src/regression-harness-lambda-local.ts @@ -0,0 +1,235 @@ +/** + * Lambda-local render path for the regression harness. + * + * Drives the OSS `@hyperframes/aws-lambda` handler through the exact + * sequence Step Functions invokes in production: + * + * handler({ Action: "plan" }) → planDir tarball on S3 + * handler({ Action: "renderChunk" }) × N → chunk artifacts on S3 + * handler({ Action: "assemble" }) → final mp4 / mov / png-seq + * + * The S3 client is a filesystem-backed fake: every `s3://test-bucket/` + * URI maps to `/s3/`. This means the harness exercises + * the handler's event-parsing + tar / S3 layout + dispatch logic in + * addition to the underlying producer primitives, catching regressions + * (event JSON drift, S3 key conventions, plan-hash boundary checks) + * that `distributed-simulated` mode wouldn't. + * + * `lambda-local` is **deliberately** not a Docker / RIE invocation — + * that would gate the producer test suite on Docker-in-Docker support + * which most CI runners lack. Real-ZIP-via-RIE tests live in + * `packages/aws-lambda/scripts/` (`probe:beginframe`) and the + * maintainer-run `smoke.sh`. + */ + +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; +import type { Fps } from "@hyperframes/core"; +import { handler } from "@hyperframes/aws-lambda/handler"; +import type { + AssembleEvent, + AssembleLambdaResult, + HandlerDeps, + PlanEvent, + PlanLambdaResult, + RenderChunkEvent, + RenderChunkLambdaResult, + SerializableDistributedRenderConfig, +} from "@hyperframes/aws-lambda"; +import { downloadS3ObjectToFile, tarDirectory } from "@hyperframes/aws-lambda"; + +/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */ +export interface RunLambdaLocalInput { + projectDir: string; + tempRoot: string; + renderedOutputPath: string; + fps: 24 | 30 | 60; + format: "mp4" | "mov" | "png-sequence"; + codec?: "h264" | "h265"; + chunkSize?: number; + maxParallelChunks?: number; + variables?: Record; +} + +const FAKE_BUCKET = "harness-lambda-local"; + +/** S3 URI helpers — keep the URI shape identical to what SFN uses in production. */ +function uri(key: string): string { + return `s3://${FAKE_BUCKET}/${key}`; +} + +/** + * Run plan → renderChunk × N → assemble through the OSS handler with a + * filesystem-backed fake S3. Output lands at `input.renderedOutputPath`. + */ +export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise { + const s3Root = join(input.tempRoot, "s3"); + mkdirSync(s3Root, { recursive: true }); + + // STEP 0: stage the project as a tar.gz at the fake-S3 path the Plan + // event will reference, mirroring what `deploySite` does in prod. + const projectKey = `sites/harness/${Date.now()}/project.tar.gz`; + const projectS3Path = join(s3Root, projectKey); + mkdirSync(dirname(projectS3Path), { recursive: true }); + await tarDirectory(input.projectDir, projectS3Path); + + const fakeS3 = new FilesystemBackedFakeS3(s3Root); + const deps: HandlerDeps = { + s3: fakeS3 as unknown as HandlerDeps["s3"], + // The handler resolves a Chrome path via `@sparticuz/chromium` by + // default; that's the Lambda-specific binary. In Dockerfile.test + // we want the producer's already-configured Chrome instead. The + // skip flag tells the handler not to override PRODUCER_HEADLESS_SHELL_PATH. + skipChromeResolution: true, + tmpRoot: join(input.tempRoot, "lambda-tmp"), + }; + mkdirSync(deps.tmpRoot as string, { recursive: true }); + + const config: SerializableDistributedRenderConfig = { + fps: input.fps, + // Required-by-type but overridden by the composition's data-width / + // data-height attrs; any positive integer works (same trick as + // `runDistributedSimulatedRender`). + width: 1920, + height: 1080, + format: input.format, + ...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}), + chunkSize: input.chunkSize, + maxParallelChunks: input.maxParallelChunks, + hdrMode: "force-sdr", + }; + + // STEP A: plan + const planPrefix = `renders/harness/${Date.now()}/`; + const planEvent: PlanEvent = { + Action: "plan", + ProjectS3Uri: uri(projectKey), + PlanOutputS3Prefix: uri(planPrefix), + Config: config, + }; + const planResult = (await handler(planEvent, deps)) as PlanLambdaResult; + + // STEP B: render every chunk through the handler. + const chunkUris: string[] = []; + for (let i = 0; i < planResult.ChunkCount; i++) { + const chunkEvent: RenderChunkEvent = { + Action: "renderChunk", + PlanS3Uri: planResult.PlanS3Uri, + PlanHash: planResult.PlanHash, + ChunkIndex: i, + ChunkOutputS3Prefix: uri(planPrefix), + Format: input.format, + }; + const chunkResult = (await handler(chunkEvent, deps)) as RenderChunkLambdaResult; + chunkUris.push(chunkResult.ChunkS3Uri); + } + + // STEP C: assemble + const finalUri = uri( + `${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`, + ); + const assembleEvent: AssembleEvent = { + Action: "assemble", + PlanS3Uri: planResult.PlanS3Uri, + ChunkS3Uris: chunkUris, + AudioS3Uri: planResult.AudioS3Uri, + OutputS3Uri: finalUri, + Format: input.format, + }; + (await handler(assembleEvent, deps)) as AssembleLambdaResult; + + // Copy the final output from fake-S3 land back out to the path the + // harness expects. For png-sequence, untar into the dir. + const finalKey = finalUri.slice(`s3://${FAKE_BUCKET}/`.length); + if (input.format === "png-sequence") { + const tarPath = join(s3Root, finalKey); + mkdirSync(input.renderedOutputPath, { recursive: true }); + const { untarDirectory } = await import("@hyperframes/aws-lambda"); + await untarDirectory(tarPath, input.renderedOutputPath); + } else { + await downloadS3ObjectToFile( + fakeS3 as unknown as Parameters[0], + finalUri, + input.renderedOutputPath, + ); + } +} + +/** + * Minimum AWS-SDK-shaped fake S3 the handler's `send(GetObject)` and + * `send(PutObject)` calls land in. Stores blobs on the local filesystem + * under `root/` so the harness can pre-stage inputs (tarball'd + * project) and post-inspect outputs (per-chunk artifacts, final video) + * without going through a real S3 endpoint. + */ +class FilesystemBackedFakeS3 { + constructor(private readonly root: string) {} + + async send(command: unknown): Promise { + const cmdName = (command as { constructor: { name: string } }).constructor.name; + const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input; + const fsPath = join(this.root, input.Key); + + if (cmdName === "GetObjectCommand") { + if (!existsSync(fsPath)) { + const err = new Error( + `FakeS3: GetObject for missing key ${input.Bucket}/${input.Key}`, + ) as Error & { + $metadata: { httpStatusCode: number }; + }; + err.$metadata = { httpStatusCode: 404 }; + throw err; + } + const bytes = readFileSync(fsPath); + return { Body: Readable.from([bytes]) }; + } + if (cmdName === "PutObjectCommand") { + mkdirSync(dirname(fsPath), { recursive: true }); + const body = input.Body; + if (body instanceof Buffer) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(fsPath, body); + } else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") { + await pipeline(body as NodeJS.ReadableStream, createWriteStream(fsPath)); + } else if (typeof body === "string") { + const { writeFileSync } = await import("node:fs"); + writeFileSync(fsPath, body); + } else { + throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`); + } + return { ETag: `"fake-${statSync(fsPath).size}"` }; + } + if (cmdName === "HeadObjectCommand") { + if (!existsSync(fsPath)) { + const err = new Error( + `FakeS3: HeadObject for missing key ${input.Bucket}/${input.Key}`, + ) as Error & { + $metadata: { httpStatusCode: number }; + }; + err.$metadata = { httpStatusCode: 404 }; + throw err; + } + return { ContentLength: statSync(fsPath).size, LastModified: new Date() }; + } + throw new Error(`FakeS3: unexpected command ${cmdName}`); + } +} + +// Reference `createReadStream` so the dynamic node:fs import above isn't +// dropped by aggressive bundlers; the handler's tar / S3 transport +// internally relies on the real createReadStream when it's the one +// reading. +void createReadStream; + +// Re-export the Fps type so callers that pass through this module's +// boundary don't need a second @hyperframes/core dep declaration. +export type { Fps }; diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index fbbf4e2ba..3d42deef0 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -31,6 +31,7 @@ import { resolveMinPsnrForMode, runDistributedSimulatedRender, } from "./regression-harness-distributed.js"; +import { runLambdaLocalRender } from "./regression-harness-lambda-local.js"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -228,13 +229,13 @@ function parseArgs(argv: string[]): CliOptions { } } - if (update && mode === "distributed-simulated") { + if (update && (mode === "distributed-simulated" || mode === "lambda-local")) { // The in-process renderer is the source of truth for golden baselines — - // distributed-simulated's job is to verify the contract against the - // same baseline, not to author its own. Surfacing this at parse time - // saves a multi-minute render before the user notices. + // the other two modes verify the contract against the same baseline, + // not author their own. Surfacing this at parse time saves a multi- + // minute render before the user notices. throw new Error( - "regression-harness: --update is incompatible with --mode=distributed-simulated. " + + `regression-harness: --update is incompatible with --mode=${mode}. ` + "Generate baselines with the in-process renderer (the default mode), then re-run " + "without --update to verify both modes match.", ); @@ -889,13 +890,14 @@ async function runTestSuite( copyFixtureSupportFiles(suite, tempRoot); cpSync(suite.srcDir, tempSrcDir, { recursive: true }); - if (options.mode === "distributed-simulated") { + if (options.mode === "distributed-simulated" || options.mode === "lambda-local") { const support = checkDistributedSupport(suite.meta.renderConfig); if (!support.supported) { - // Skipping is a clean outcome — the distributed pipeline can't - // run this fixture, but in-process mode already covers it. Mark - // passed so the suite summary doesn't trip CI; the `skipped` - // field is what distinguishes a real pass from a skip. + // Skipping is a clean outcome — the distributed pipeline (which + // both modes go through) can't run this fixture, but in-process + // mode already covers it. Mark passed so the suite summary + // doesn't trip CI; the `skipped` field is what distinguishes a + // real pass from a skip. console.log( JSON.stringify({ event: "test_skipped", @@ -912,21 +914,26 @@ async function runTestSuite( // `checkDistributedSupport` already narrowed fps to {24,30,60} and // rejected webm; the cast surfaces that guarantee to TS. const fpsNum = suite.meta.renderConfig.fps.num as 24 | 30 | 60; - // `runDistributedSimulatedRender`'s `format` parameter accepts the - // distributed-supported set; the harness type allows `"webm"` too - // but `checkDistributedSupport` rejected that above. Narrow the cast - // accordingly. - await runDistributedSimulatedRender({ + const distributedInput = { projectDir: tempSrcDir, tempRoot, renderedOutputPath, fps: fpsNum, + // `runDistributedSimulatedRender` / `runLambdaLocalRender`'s + // `format` parameter accepts the distributed-supported set; + // the harness type allows `"webm"` too but + // `checkDistributedSupport` rejected that above. Narrow. format: outputFormat as "mp4" | "mov" | "png-sequence", codec: suite.meta.renderConfig.codec, chunkSize: suite.meta.renderConfig.chunkSize, maxParallelChunks: suite.meta.renderConfig.maxParallelChunks, variables: suite.meta.renderConfig.variables, - }); + }; + if (options.mode === "lambda-local") { + await runLambdaLocalRender(distributedInput); + } else { + await runDistributedSimulatedRender(distributedInput); + } } else { const job = createRenderJob({ fps: suite.meta.renderConfig.fps, diff --git a/packages/producer/tsconfig.json b/packages/producer/tsconfig.json index eedbe153f..cbe56306a 100644 --- a/packages/producer/tsconfig.json +++ b/packages/producer/tsconfig.json @@ -12,7 +12,12 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "types": ["@webgpu/types"] + "types": ["@webgpu/types"], + "baseUrl": ".", + "paths": { + "@hyperframes/producer": ["./src/index.ts"], + "@hyperframes/producer/distributed": ["./src/distributed.ts"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__test_utils__/**"] From 6c94687e275ede29843a1573402d20b459eb6c67 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:16:06 +0000 Subject: [PATCH 2/4] refactor(producer): /simplify pass on lambda-local harness imports Three small cleanups on top of the lambda-local harness: - Drop the unused createReadStream import + its `void` workaround comment. The aws-lambda handler's tar / S3 transport pulls createReadStream from its own imports; this file never references it directly. - Hoist the dynamic `await import("node:fs")` calls for writeFileSync out of FilesystemBackedFakeS3.send into the static import block. Repeated PutObject calls don't need to repay the dynamic-import cost. - Hoist the dynamic `await import("@hyperframes/aws-lambda")` call for untarDirectory similarly. Drops the now-redundant duplicate aws-lambda import statement. The PutObject body branch also collapses: `body instanceof Buffer` and `typeof body === "string"` both call writeFileSync identically, so they share one branch. No behavior changes. --- .../src/regression-harness-lambda-local.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/producer/src/regression-harness-lambda-local.ts b/packages/producer/src/regression-harness-lambda-local.ts index e0f923a63..1eda7a1e4 100644 --- a/packages/producer/src/regression-harness-lambda-local.ts +++ b/packages/producer/src/regression-harness-lambda-local.ts @@ -23,17 +23,18 @@ */ import { - createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync, + writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; import type { Fps } from "@hyperframes/core"; +import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperframes/aws-lambda"; import { handler } from "@hyperframes/aws-lambda/handler"; import type { AssembleEvent, @@ -45,7 +46,6 @@ import type { RenderChunkLambdaResult, SerializableDistributedRenderConfig, } from "@hyperframes/aws-lambda"; -import { downloadS3ObjectToFile, tarDirectory } from "@hyperframes/aws-lambda"; /** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */ export interface RunLambdaLocalInput { @@ -153,7 +153,6 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise< if (input.format === "png-sequence") { const tarPath = join(s3Root, finalKey); mkdirSync(input.renderedOutputPath, { recursive: true }); - const { untarDirectory } = await import("@hyperframes/aws-lambda"); await untarDirectory(tarPath, input.renderedOutputPath); } else { await downloadS3ObjectToFile( @@ -195,14 +194,10 @@ class FilesystemBackedFakeS3 { if (cmdName === "PutObjectCommand") { mkdirSync(dirname(fsPath), { recursive: true }); const body = input.Body; - if (body instanceof Buffer) { - const { writeFileSync } = await import("node:fs"); + if (body instanceof Buffer || typeof body === "string") { writeFileSync(fsPath, body); } else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") { await pipeline(body as NodeJS.ReadableStream, createWriteStream(fsPath)); - } else if (typeof body === "string") { - const { writeFileSync } = await import("node:fs"); - writeFileSync(fsPath, body); } else { throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`); } @@ -224,12 +219,6 @@ class FilesystemBackedFakeS3 { } } -// Reference `createReadStream` so the dynamic node:fs import above isn't -// dropped by aggressive bundlers; the handler's tar / S3 transport -// internally relies on the real createReadStream when it's the one -// reading. -void createReadStream; - // Re-export the Fps type so callers that pass through this module's // boundary don't need a second @hyperframes/core dep declaration. export type { Fps }; From 310332616df11fc9343a0badb601a779ddc3b12c Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:31:19 +0000 Subject: [PATCH 3/4] fix(producer): lazy-import lambda-local harness module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static import of regression-harness-lambda-local.ts pulled @hyperframes/aws-lambda (and its @aws-sdk/* + @sparticuz/chromium transitive deps) at module-load time. Dockerfile.test only copies the producer's own files into the container, so aws-lambda's src isn't present at runtime — and even `--mode=in-process` failed: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/packages/producer/node_modules/@hyperframes/aws-lambda/src/index.ts' imported from /app/packages/producer/src/regression-harness-lambda-local.ts Load the module on demand instead. `--mode=lambda-local` callers pay the import cost; the existing in-process and distributed- simulated modes don't. --- packages/producer/src/regression-harness.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 3d42deef0..b5c996fde 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -31,7 +31,18 @@ import { resolveMinPsnrForMode, runDistributedSimulatedRender, } from "./regression-harness-distributed.js"; -import { runLambdaLocalRender } from "./regression-harness-lambda-local.js"; + +// `regression-harness-lambda-local` statically imports +// `@hyperframes/aws-lambda`, which depends on @aws-sdk + @sparticuz/chromium. +// In Dockerfile.test the workspace copy of aws-lambda's src isn't present, +// so a static import here would fail at module-load time even when +// running `--mode=in-process`. Load it on demand instead. +async function loadLambdaLocalRender(): Promise< + typeof import("./regression-harness-lambda-local.js").runLambdaLocalRender +> { + const mod = await import("./regression-harness-lambda-local.js"); + return mod.runLambdaLocalRender; +} // ── Types ──────────────────────────────────────────────────────────────────── @@ -930,6 +941,7 @@ async function runTestSuite( variables: suite.meta.renderConfig.variables, }; if (options.mode === "lambda-local") { + const runLambdaLocalRender = await loadLambdaLocalRender(); await runLambdaLocalRender(distributedInput); } else { await runDistributedSimulatedRender(distributedInput); From 7aa2e417c3be71a31d4694e1e6fc8f627f344741 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:49:42 +0000 Subject: [PATCH 4/4] fix(producer): address PR review on lambda-local harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review items from Vai: - `Config.width`/`Config.height` are now plumbed through RunLambdaLocalInput rather than hardcoded inside runLambdaLocalRender. Lambda-local's whole point is to catch event-shape drift; if the handler ever starts honouring Config.width/height (e.g. for canvas sizing), having those values flow from the caller means the harness sees what the fixture authored. The interface change makes the eventual upgrade-to-real-fixture-resolution a one-line dispatch swap. - Drop the dead `export type { Fps }` and its unused import from @hyperframes/core. The module never re-exports it. - The dispatch site in regression-harness.ts now passes 1920×1080 explicitly with a comment marking it as a placeholder until the harness compiles the composition HTML up-front to surface the authored data-width/data-height. distributed-simulated mode uses the same placeholder internally, kept for parity. No behavior change in the existing modes; lambda-local now has a clear extension point for honouring fixture dimensions. --- .../src/regression-harness-lambda-local.ts | 23 +++++++++++-------- packages/producer/src/regression-harness.ts | 8 ++++++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/producer/src/regression-harness-lambda-local.ts b/packages/producer/src/regression-harness-lambda-local.ts index 1eda7a1e4..b731ac35d 100644 --- a/packages/producer/src/regression-harness-lambda-local.ts +++ b/packages/producer/src/regression-harness-lambda-local.ts @@ -33,7 +33,6 @@ import { import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; -import type { Fps } from "@hyperframes/core"; import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperframes/aws-lambda"; import { handler } from "@hyperframes/aws-lambda/handler"; import type { @@ -53,6 +52,17 @@ export interface RunLambdaLocalInput { tempRoot: string; renderedOutputPath: string; fps: 24 | 30 | 60; + /** + * Width/height from the fixture's renderConfig. Forwarded directly to + * the Lambda event so this mode catches drift if the handler ever + * starts honouring `Config.width/height` for canvas sizing rather + * than reading the composition's `data-width`/`data-height`. The + * `distributed-simulated` mode hardcodes 1920×1080 because it + * bypasses the event-serialization boundary; lambda-local goes + * through it, which is the whole point. + */ + width: number; + height: number; format: "mp4" | "mov" | "png-sequence"; codec?: "h264" | "h265"; chunkSize?: number; @@ -96,11 +106,8 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise< const config: SerializableDistributedRenderConfig = { fps: input.fps, - // Required-by-type but overridden by the composition's data-width / - // data-height attrs; any positive integer works (same trick as - // `runDistributedSimulatedRender`). - width: 1920, - height: 1080, + width: input.width, + height: input.height, format: input.format, ...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}), chunkSize: input.chunkSize, @@ -218,7 +225,3 @@ class FilesystemBackedFakeS3 { throw new Error(`FakeS3: unexpected command ${cmdName}`); } } - -// Re-export the Fps type so callers that pass through this module's -// boundary don't need a second @hyperframes/core dep declaration. -export type { Fps }; diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index b5c996fde..5b9d3c153 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -942,7 +942,13 @@ async function runTestSuite( }; if (options.mode === "lambda-local") { const runLambdaLocalRender = await loadLambdaLocalRender(); - await runLambdaLocalRender(distributedInput); + // The fixture's authored dimensions live in the composition's + // `data-width`/`data-height` attributes, not in `meta.json`'s + // renderConfig. Until the harness compiles the HTML up-front + // to surface them here, pass 1920×1080 — the same placeholder + // `runDistributedSimulatedRender` uses internally. The + // composition attrs override at plan time. + await runLambdaLocalRender({ ...distributedInput, width: 1920, height: 1080 }); } else { await runDistributedSimulatedRender(distributedInput); }