chore: import upstream snapshot with attribution
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SAM CLI state — written by `sam deploy --guided`, contains user choices.
samconfig.toml
.aws-sam/
+227
View File
@@ -0,0 +1,227 @@
# AWS Lambda + Step Functions deployment
Reference SAM template for deploying HyperFrames distributed rendering on
AWS. One Lambda function, three roles (Plan / RenderChunk / Assemble),
choreographed by a Step Functions standard workflow with a Map state for
parallel chunk rendering.
See [`packages/aws-lambda/README.md`](../../packages/aws-lambda/README.md)
for the Lambda handler architecture.
## Prerequisites
- AWS account with IAM permissions to deploy CloudFormation stacks
containing Lambda, Step Functions, S3, IAM, and CloudWatch resources.
- [`sam` CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
installed (≥ 1.100).
- [`bun`](https://bun.sh) installed (≥ 1.3) to build the handler ZIP.
## One-shot deploy
```bash
# 1. Build the handler ZIP that `template.yaml`'s CodeUri points at.
bun install # at repo root
bun run --cwd packages/aws-lambda build:zip
# 2. Deploy. First time: `--guided` to set stack name + region.
cd examples/aws-lambda
sam deploy --guided --resolve-s3
```
`--resolve-s3` lets SAM pick (or create) a per-account bucket to host the
uploaded ZIP. After the first deploy, subsequent updates can omit
`--guided` and `--resolve-s3` — SAM remembers your choices in
`samconfig.toml`.
## What gets created
| Resource | Purpose |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `Render Lambda` | Single function, handler `handler.handler`. Dispatches on `event.Action`. |
| `Render State Machine` | Step Functions standard workflow. Plan → Map(N) RenderChunk → Assemble. |
| `Render Bucket` | S3 bucket for plan tarballs, chunk outputs, and final mp4. `renders/` prefix expires after 7 days. |
| IAM role for the state machine | Invokes the Lambda; writes CloudWatch logs; X-Ray traces. |
| IAM role for the Lambda (managed by SAM) | S3 CRUD on the render bucket; CloudWatch logs. |
| Runaway-invocation alarm | Fires if RenderChunk runs more than `ChunkInvocationAlarmThreshold` times in an hour. |
## Running a render
Upload your project as a zip to the render bucket, then start a Step
Functions execution:
```bash
STACK_NAME=hyperframes-render # whatever you picked at deploy
RENDER_BUCKET=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query 'Stacks[0].Outputs[?OutputKey==`RenderBucketName`].OutputValue' \
--output text)
STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query 'Stacks[0].Outputs[?OutputKey==`RenderStateMachineArn`].OutputValue' \
--output text)
# Tar + upload the project directory. The handler uses `tar` (not
# `unzip`, which Lambda's base image doesn't ship), so the on-the-wire
# archive format is `.tar.gz`.
tar -czf my-project.tar.gz -C ./my-project .
aws s3 cp my-project.tar.gz "s3://${RENDER_BUCKET}/projects/my-project.tar.gz"
# Start the execution. The input JSON tells the state machine where to
# read inputs and write outputs.
aws stepfunctions start-execution \
--state-machine-arn "$STATE_MACHINE_ARN" \
--input "$(cat <<EOF
{
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
"Config": {
"fps": 30,
"width": 1920,
"height": 1080,
"format": "mp4",
"chunkSize": 240,
"maxParallelChunks": 8,
"runtimeCap": "lambda"
}
}
EOF
)"
```
The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
## Local invocation
You can test the Lambda handler without deploying anything via SAM
local:
```bash
# Build the ZIP first.
bun run --cwd packages/aws-lambda build:zip
# Launch a local Lambda runtime emulator and run a sample plan event.
cd examples/aws-lambda
sam validate
sam local invoke RenderFunction --event sample-events/plan.json
```
The `sample-events/` directory ships small JSON payloads for each of the
three actions. They reference fake S3 URIs — useful for sanity-checking
the handler's dispatch logic; not for full end-to-end testing (real S3
calls require credentials and a project zip to actually exist).
## End-to-end smoke + benchmark
For full end-to-end validation against real AWS — the gate that proves
the architecture works on a deployed Lambda — use the local smoke
script:
```bash
# All defaults (mp4-h264-sdr fixture, chunk counts 2/4/8, PSNR >= 40 dB).
./scripts/smoke.sh
# Customised:
./scripts/smoke.sh \
--fixture mp4-h264-sdr \
--chunk-counts 2,4,8,16 \
--psnr-threshold 40 \
--reserved-concurrency 8
# Keep the stack alive for inspection afterward:
./scripts/smoke.sh --keep-stack
# Show all flags including cost notes:
./scripts/smoke.sh --help
```
The script builds the handler ZIP, deploys this template under a
per-run stack name, renders the fixture at each chunk count via the
Step Functions state machine, PSNR-compares against the in-process
baseline (which is git-LFS tracked under
`packages/producer/tests/distributed/<fixture>/output/`), captures
per-execution Step Functions history, and tears the stack down.
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
local-vs-Lambda "speedup" column. The local timing includes `bun` +
`tsx` + harness scaffolding (not just renderer-internal time); the
Lambda timing measures Step Functions execution only. This biases the
speedup against Lambda on tiny fixtures and in favour of Lambda on
larger ones. Treat the number as "end-to-end CLI experience," not as a
renderer-vs-renderer benchmark. Cold-start variance is ±5-10s per
chunk; run with `--iterations 3+` to report medians.
**Cost per pass.** Each `eval.sh` invocation runs `SAM deploy` (~$0.01
in CFN operations) plus N fixtures × ITERATIONS × CHUNK_COUNT Lambda
invocations at `MemorySize` (default 10 GiB) × per-chunk wall clock.
With defaults (4 fixtures, 1 iteration, chunk-count 4) the Lambda
spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
`--reserved-concurrency` for cost-conscious accounts; higher
`--iterations` improves median stability at proportional cost.
Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
- `results.json``chunkCount × wallClockMs × psnrAvgDb`
- `renders/N<N>-output.mp4` — each rendered chunk count
- `renders/N<N>-history.json` — full Step Functions execution history
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
`jq`, `zip`. AWS credentials come from the standard resolution chain
(env vars → `~/.aws/credentials` → SSO → IMDS). Pin a specific profile
with `--profile <name>` or `AWS_PROFILE=<name>`.
## Parameters
| Parameter | Default | Notes |
| ------------------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `ProjectName` | `hyperframes` | Prefix for created resource names. |
| `LambdaMemoryMb` | `10240` | Lambda memory; Lambda allocates CPU proportionally. 10 GB recommended for 1080p. |
| `LambdaTimeoutSec` | `900` | Per-invocation timeout. 15 min is Lambda's hard ceiling. |
| `ReservedConcurrency` | `-1` | Hard cap on simultaneous Lambda invocations. `-1` = unreserved. Set to e.g. `50` to bound cost. |
| `ChromeSource` | `sparticuz` | Must match the `--source=` flag passed to `build-zip.ts`. |
| `ChunkInvocationAlarmThreshold` | `1000` | CloudWatch alarm threshold (RenderChunk invocations per hour). |
## Cleanup
```bash
sam delete --stack-name hyperframes-render
```
S3 buckets are `Retain`ed on delete to protect rendered artifacts.
Empty + delete the bucket manually after `sam delete` if you want to
fully tear down.
## Cost model
| Service | Driver | Approximate cost |
| ----------------------- | --------------------------------------- | -------------------------------------------------------------- |
| Lambda | Per-invocation billed duration × memory | ≈ $0.0000167/GB-s; a 10 GB function running 5 min costs ~$0.50 |
| Step Functions Standard | Per state transition | $0.025/1k transitions |
| S3 | Storage + GET/PUT | Dominated by mp4 storage; plan tarballs expire in 7 days |
| CloudWatch Logs | Ingestion + storage | Logs are not throttled; set retention manually if cost matters |
A 60-second 1080p30 composition at default chunkSize=240 (8 chunks)
typically costs ~$0.04 in Lambda time + ~$0.001 in Step Functions.
The eval script under `scripts/eval.sh` produces real per-fixture cost
numbers when you run it against your own AWS account.
## Troubleshooting
- **"Chrome failed to launch"** — the ZIP was likely built with the wrong
`--source`. Match `ChromeSource` to the build flag.
- **"PLAN_HASH_MISMATCH"** — non-retryable. The plan tarball was written
by a different version of the producer than the chunk worker is
running. Re-plan from scratch.
- **"BROWSER_GPU_NOT_SOFTWARE"** — Chromium fell back to a hardware GL
backend. Should not happen in Lambda (no GPU); file an issue.
- **CloudWatch alarm firing on `runaway-chunk-invocations`** — check
the state machine execution history for an unintended Map fan-out, or
raise the threshold if your workload genuinely exceeds it.
## What's NOT in this directory
- CDK construct shipping the same topology programmatically — follow-up.
- `hyperframes lambda deploy / render / progress / destroy` CLI — follow-up.
- Migration guide — follow-up.
- Lambda RIE local smoke harness mode — follow-up.
@@ -0,0 +1,13 @@
{
"Action": "assemble",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
"ChunkS3Uris": [
"s3://example-bucket/renders/sample/chunks/0000.mp4",
"s3://example-bucket/renders/sample/chunks/0001.mp4",
"s3://example-bucket/renders/sample/chunks/0002.mp4",
"s3://example-bucket/renders/sample/chunks/0003.mp4"
],
"AudioS3Uri": null,
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
"Format": "mp4"
}
@@ -0,0 +1,14 @@
{
"Action": "plan",
"ProjectS3Uri": "s3://example-bucket/projects/sample-composition.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": {
"fps": 30,
"width": 1920,
"height": 1080,
"format": "mp4",
"chunkSize": 240,
"maxParallelChunks": 8,
"runtimeCap": "lambda"
}
}
@@ -0,0 +1,8 @@
{
"Action": "renderChunk",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Format": "mp4"
}
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env bash
# Eval: local in-process renderer vs Lambda distributed across a set of
# fixtures. For each fixture:
#
# 1. Render in-process locally (regression harness) → wall-clock
# 2. Render via Lambda Step Functions at the configured chunk count → wall-clock + output mp4
# 3. ffmpeg-psnr (Lambda output, in-process baseline) → visual equivalence
#
# This is a maintainer-run benchmark, not a CI gate. It deploys a real
# Lambda stack (same template as smoke.sh) and tears it down at the end.
#
# Wall-clock methodology caveat:
# The "local" timing includes `bun` + `tsx` + harness startup
# scaffolding, not just renderer-internal time. Lambda timing measures
# pure Step-Functions execution. The "speedup" column therefore biases
# AGAINST Lambda on tiny fixtures (where harness boot dominates) and
# IN FAVOUR of Lambda on larger ones. Treat the speedup as a rough
# "what does the end-to-end CLI experience feel like" number, not as
# "renderer-vs-renderer." Use --iterations N to get medians instead of
# single-sample readings — cold-start variance is ±5-10s.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.."
FIXTURES="${FIXTURES:-mp4-h264-sdr,many-cuts,gsap-letters-render-compat,heygen-promo-preview-assets}"
CHUNK_COUNT="${CHUNK_COUNT:-4}"
# Pin chunkSize across all fixtures so wall-clock comparisons are
# meaningful — without a fixed value, each fixture's plan() picks
# `min(default 240, frameCount)` and short compositions render in 1
# chunk regardless of CHUNK_COUNT. 60 frames keeps every fixture in
# the table chunked.
CHUNK_SIZE="${CHUNK_SIZE:-60}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-eval-$(date +%s)}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
KEEP_STACK="false"
SKIP_BUILD="false"
SKIP_LOCAL="false"
# Lambda Map-state concurrency cap. 16 is aggressive; lower for cheaper
# runs, raise as far as your account's regional quota allows.
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
# Number of Lambda renders per fixture. Cold-start variance is ±5-10s
# per chunk; a single sample is noisy. With --iterations 3+ we report
# the median Lambda wall-clock and use it for the speedup calculation.
ITERATIONS="${ITERATIONS:-1}"
ARTIFACT_DIR="$REPO_ROOT/lambda-eval-artifacts"
usage() {
cat <<'EOF'
Usage: eval.sh [flags]
Maintainer-run benchmark comparing local in-process rendering to Lambda
distributed rendering across a set of fixtures. Deploys a real Lambda
stack, renders each fixture twice (locally + via Step Functions), and
tears the stack down.
Flags:
--fixtures <comma-sep> fixture names (default: mp4-h264-sdr,many-cuts,gsap-letters-render-compat,heygen-promo-preview-assets)
--chunk-count <N> chunk fan-out per Lambda render (default: 4)
--chunk-size <frames> frames per chunk; pinned across fixtures (default: 60)
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40)
--iterations <N> Lambda renders per fixture; report median (default: 1)
--stack-name <name> SAM stack name (default: hyperframes-lambda-eval-<timestamp>)
--region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end
--skip-build reuse existing dist/handler.zip
--skip-local skip the in-process local render (Lambda-only)
-h, --help show this help and exit
Cost notes:
Each pass: SAM deploy (~$0.01) + N fixtures × ITERATIONS × CHUNK_COUNT
Lambda invocations at MemorySize (default 10240 MB) × per-chunk wall
clock. With defaults (4 fixtures, 1 iteration, chunk-count 4) the
Lambda spend is roughly $0.10-$0.20 per pass before S3 PUT/GET. Drop
--reserved-concurrency for cost-conscious accounts; bump --iterations
for stable median timing at proportional cost.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--fixtures) FIXTURES="$2"; shift 2 ;;
--chunk-count) CHUNK_COUNT="$2"; shift 2 ;;
--chunk-size) CHUNK_SIZE="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--iterations) ITERATIONS="$2"; shift 2 ;;
--stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;;
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;;
--skip-local) SKIP_LOCAL="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
done
# Validate ITERATIONS is a positive integer (used as awk numeric input
# in the median computation; a non-numeric value would silently produce
# garbage timings).
case "$ITERATIONS" in
''|*[!0-9]*) echo "ERROR: --iterations must be a positive integer (got '$ITERATIONS')" >&2; exit 1 ;;
esac
if [ "$ITERATIONS" -lt 1 ]; then
echo "ERROR: --iterations must be >= 1 (got $ITERATIONS)" >&2
exit 1
fi
# AWS_DEFAULT_REGION is required for SAM (it doesn't honour AWS_REGION
# alone). Export both so any sub-tool resolves the same region.
export AWS_REGION
export AWS_DEFAULT_REGION="$AWS_REGION"
if [ -n "$AWS_PROFILE" ]; then
export AWS_PROFILE
fi
BUCKET=""
cleanup_and_exit() {
local code="${1:-0}"
# The EXIT trap re-enters cleanup_and_exit on the way out; disarm it
# so we don't recurse infinitely if `aws s3 rm` itself trips set -e.
trap - EXIT
if [ "$KEEP_STACK" = "true" ]; then
echo "→ Keeping stack (--keep-stack); stack=$STACK_NAME"
else
echo "→ Tearing down stack $STACK_NAME"
if [ -n "$BUCKET" ]; then
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true
fi
(cd "$SAM_DIR" && sam delete --stack-name "$STACK_NAME" --no-prompts) >/dev/null 2>&1 || true
fi
exit "$code"
}
# Trap unexpected failures (set -e trips, SIGINT, etc.) so we don't leak
# the deployed stack + S3 bucket on a non-routed error. Explicit
# cleanup_and_exit calls disarm the trap first so the teardown runs
# exactly once.
trap 'cleanup_and_exit $?' EXIT
# ── Pre-flight ───────────────────────────────────────────────────────────
for cmd in aws sam bun ffmpeg jq zip; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: '$cmd' not found on PATH." >&2
exit 1
fi
done
echo "→ Verifying AWS credentials"
aws sts get-caller-identity --output text >/dev/null
mkdir -p "$ARTIFACT_DIR/lambda" "$ARTIFACT_DIR/local"
RESULTS_CSV="$ARTIFACT_DIR/results.csv"
echo "fixture,localMs,lambdaMs,speedup,psnrLambdaVsBaselineDb,audioStatus,audioResidualRmsDb" > "$RESULTS_CSV"
# ── 1. Build + deploy once ───────────────────────────────────────────────
if [ "$SKIP_BUILD" = "false" ]; then
echo "→ Building handler ZIP"
bun run --cwd "$REPO_ROOT/packages/aws-lambda" build:zip
fi
echo "→ SAM deploy (stack=$STACK_NAME)"
(cd "$SAM_DIR" && sam deploy \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--resolve-s3 \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides \
ChromeSource=sparticuz \
"ReservedConcurrency=$RESERVED_CONCURRENCY") || cleanup_and_exit 3
BUCKET=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='RenderBucketName'].OutputValue" \
--output text)
STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
--output text)
echo "→ Stack ready: bucket=$BUCKET"
# ── 2. Per-fixture eval ──────────────────────────────────────────────────
IFS=',' read -ra FIXTURE_LIST <<< "$FIXTURES"
for FIXTURE in "${FIXTURE_LIST[@]}"; do
echo
echo "================== Fixture: $FIXTURE =================="
FIXTURE_DIR=""
for cand in "$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE" "$REPO_ROOT/packages/producer/tests/$FIXTURE"; do
if [ -f "$cand/meta.json" ] && [ -f "$cand/src/index.html" ]; then
FIXTURE_DIR="$cand"
break
fi
done
if [ -z "$FIXTURE_DIR" ]; then
echo "WARN: fixture $FIXTURE not found, skipping" >&2
continue
fi
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
LAMBDA_MP4="$ARTIFACT_DIR/lambda/$FIXTURE.mp4"
# ── 2a. Local in-process timing via the regression harness ────────────
# The harness renders the fixture in-process locally and compares it to
# the committed Docker-built baseline. We don't keep the rendered mp4
# (the harness discards its tempdir); we only need its wall-clock here
# because the PSNR comparisons below all run against the committed
# `output/output.mp4` baseline — the same artifact the harness produced
# when the fixture was first authored.
LOCAL_MS=""
if [ "$SKIP_LOCAL" = "false" ]; then
echo "→ Local in-process render via regression harness"
LOCAL_START=$(date +%s%3N)
if bun run --cwd "$REPO_ROOT/packages/producer" --silent test -- "$FIXTURE" \
>"$ARTIFACT_DIR/local/$FIXTURE.harness.log" 2>&1; then
LOCAL_END=$(date +%s%3N)
LOCAL_MS=$((LOCAL_END - LOCAL_START))
echo " local wall=${LOCAL_MS}ms"
else
echo "WARN: regression harness failed for $FIXTURE (see $ARTIFACT_DIR/local/$FIXTURE.harness.log)" >&2
LOCAL_MS=""
fi
else
echo "→ Skipping local render (--skip-local)"
LOCAL_MS="0"
fi
# ── 2b. Lambda render ───────────────────────────────────────────────────
echo "→ Lambda render (N=$CHUNK_COUNT, iterations=$ITERATIONS)"
FIXTURE_META="$FIXTURE_DIR/meta.json"
# Some fixtures store fps as a number (e.g. 30), others as {num,den}.
# Pick the integer fps the Lambda config wants out of either shape.
BASE_FPS=$(jq -r '
.renderConfig.fps
| if type == "object" then .num // 30 else . end
// 30
' "$FIXTURE_META")
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
# Pack + upload once per fixture (the project tarball is content-
# addressable; iterations reuse the same S3 object).
TMP=$(mktemp -d)
tar -czf "$TMP/project.tar.gz" -C "$FIXTURE_DIR/src" .
aws s3 cp "$TMP/project.tar.gz" "s3://$BUCKET/projects/$FIXTURE.tar.gz" >/dev/null
rm -rf "$TMP"
ITER_TIMINGS=()
ITER_FAILED=0
for ITER in $(seq 1 "$ITERATIONS"); do
if [ "$ITERATIONS" -gt 1 ]; then
echo " iter $ITER/$ITERATIONS"
fi
EXEC_NAME="eval-$FIXTURE-$(date +%s)-${ITER}"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
INPUT_JSON=$(jq -n \
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
--argjson n "$CHUNK_COUNT" \
--argjson cs "$CHUNK_SIZE" \
--argjson fps "$BASE_FPS" \
--argjson w "$BASE_W" \
--argjson h "$BASE_H" \
'{ProjectS3Uri:$project,PlanOutputS3Prefix:$prefix,OutputS3Uri:$output,Config:{fps:$fps,width:$w,height:$h,format:"mp4",chunkSize:$cs,maxParallelChunks:$n,runtimeCap:"lambda"}}')
LAMBDA_START=$(date +%s%3N)
EXEC_ARN=$(aws stepfunctions start-execution \
--state-machine-arn "$STATE_MACHINE_ARN" \
--name "$EXEC_NAME" \
--input "$INPUT_JSON" \
--query executionArn --output text)
STATUS="RUNNING"
for _ in $(seq 1 360); do
sleep 5
STATUS=$(aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" --query status --output text)
if [ "$STATUS" != "RUNNING" ]; then break; fi
done
LAMBDA_END=$(date +%s%3N)
ITER_MS=$((LAMBDA_END - LAMBDA_START))
if [ "$STATUS" != "SUCCEEDED" ]; then
echo "WARN: Lambda render of $FIXTURE (iter $ITER) failed ($STATUS); saving execution history" >&2
aws stepfunctions describe-execution --execution-arn "$EXEC_ARN" \
> "$ARTIFACT_DIR/lambda/$FIXTURE.iter${ITER}.execution.json" 2>/dev/null || true
aws stepfunctions get-execution-history --execution-arn "$EXEC_ARN" --max-results 1000 --output json \
> "$ARTIFACT_DIR/lambda/$FIXTURE.iter${ITER}.history.json" 2>/dev/null || true
cause=$(jq -r '.events[] | select(.type=="ExecutionFailed" or .type=="TaskFailed") | (.executionFailedEventDetails // .taskFailedEventDetails) | .cause // .error' "$ARTIFACT_DIR/lambda/$FIXTURE.iter${ITER}.history.json" 2>/dev/null | head -1)
[ -n "$cause" ] && echo " cause: $(echo "$cause" | head -c 300)" >&2
ITER_FAILED=1
break
fi
# Keep the last successful iteration's mp4 as the PSNR/audio input.
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$LAMBDA_MP4" >/dev/null
ITER_TIMINGS+=("$ITER_MS")
if [ "$ITERATIONS" -gt 1 ]; then
echo " iter $ITER wall=${ITER_MS}ms"
fi
done
if [ "$ITER_FAILED" -eq 1 ] || [ ${#ITER_TIMINGS[@]} -eq 0 ]; then
continue
fi
# Median of the iteration wall-clocks. Awk handles both odd (middle
# element) and even (mean of middle two) sample counts without
# bash-side branching. For ITERATIONS=1 the median is just the sample.
LAMBDA_MS=$(printf '%s\n' "${ITER_TIMINGS[@]}" \
| sort -n \
| awk '
{ a[NR] = $1 }
END {
n = NR
if (n % 2 == 1) print a[int((n + 1) / 2)]
else printf("%.0f\n", (a[n/2] + a[n/2 + 1]) / 2)
}
')
if [ "$ITERATIONS" -gt 1 ]; then
echo " lambda median wall=${LAMBDA_MS}ms (samples: ${ITER_TIMINGS[*]})"
else
echo " lambda wall=${LAMBDA_MS}ms"
fi
# ── 2c. PSNR comparisons ───────────────────────────────────────────────
psnr_of() {
local a="$1" b="$2"
local log
log=$(mktemp)
ffmpeg -nostdin -v error -i "$a" -i "$b" -lavfi "psnr=stats_file=$log" -f null - 2>/dev/null || true
awk '/psnr_avg:/ { for(i=1;i<=NF;i++) if($i ~ /^psnr_avg:/){split($i,kv,":"); sum+=kv[2]; c++} } END { if(c>0) printf("%.2f", sum/c); else print "0" }' "$log"
rm -f "$log"
}
# The "local" mp4 IS the baseline (we don't keep a fresh in-process
# render — the harness above discards its tempdir, and the baseline
# IS the canonical in-process output). So `psnr(lambda, baseline)` is
# the only PSNR we report. A future revision that retains a fresh
# local render could split this back into three comparisons.
PSNR_LAMBDA_BASE=$(psnr_of "$LAMBDA_MP4" "$BASELINE_MP4")
# ── 2d. Audio equivalence (residual RMS) ──────────────────────────────
# Subtract baseline audio from Lambda audio; measure residual RMS in
# dBFS. A perfectly-equivalent track produces residual silence
# (≤ -90 dBFS in practice for AAC-vs-AAC); we treat ≤ -50 dBFS as
# "effectively identical." For fixtures with no audio stream on either
# side, we emit `n/a` rather than a number.
audio_residual_rms_db() {
local a="$1" b="$2"
local has_a has_b
has_a=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$a" 2>/dev/null | head -1)
has_b=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$b" 2>/dev/null | head -1)
if [ -z "$has_a" ] && [ -z "$has_b" ]; then
printf "no-audio-on-either"
return
fi
if [ -z "$has_a" ] || [ -z "$has_b" ]; then
printf "audio-stream-mismatch"
return
fi
# ffmpeg emits astats summary at log level `info`; -v error would
# suppress it. Use -v info and parse from the combined stderr.
#
# `amix normalize=0` is load-bearing: the default normalize=true
# scales each input by 1/N before summing, so a 2-input subtract
# reports the residual at -6 dB versus the true difference, making
# the -50 dBFS gate effectively -44 dBFS. Disabling normalization
# gives the actual sample-cancellation reading.
local out
out=$(ffmpeg -nostdin -v info -i "$a" -i "$b" \
-filter_complex "[0:a]aresample=48000,pan=stereo|c0=c0|c1=c1,asetpts=N/SR/TB[a0];[1:a]aresample=48000,pan=stereo|c0=c0|c1=c1,asetpts=N/SR/TB,volume=-1[a1];[a0][a1]amix=inputs=2:duration=shortest:dropout_transition=0:normalize=0,astats=metadata=1:reset=1[out]" \
-map "[out]" -f null - 2>&1)
# Match the Overall-RMS line (variant forms across ffmpeg versions).
local rms
rms=$(printf '%s\n' "$out" | grep -oE "Overall RMS level(\s*dB)?\s*:\s*(-?inf|[-0-9.]+)" | head -1 | sed -E 's/.*:\s*//')
if [ -z "$rms" ]; then
# Fallback 1: per-channel "RMS level dB:" lines, which most modern
# ffmpeg builds emit. Picks the first (most pessimistic).
rms=$(printf '%s\n' "$out" | grep -oE "RMS level\s*dB\s*:\s*(-?inf|[-0-9.]+)" | head -1 | sed -E 's/.*:\s*//')
fi
if [ -z "$rms" ]; then
# Fallback 2: very old ffmpeg builds emit `RMS level:` with no `dB`
# suffix and the unit trailing the value (e.g. `RMS level: -42.3 dB`).
# Use word boundaries to avoid eating `RMS peak level` lines.
rms=$(printf '%s\n' "$out" | grep -oE "\bRMS level\b\s*:\s*(-?inf|[-0-9.]+)" | head -1 | sed -E 's/.*:\s*//')
fi
if [ -z "$rms" ]; then
rms="0"
fi
# Normalize ffmpeg's "-inf" / "inf" sentinels to a sortable number well
# below any sensible threshold so downstream awk comparisons don't trip
# on the literal string. ("-inf" = perfect cancellation; -200 dBFS is
# far below the -50 dBFS gate.) Done in an if/then/fi rather than
# `[ A ] || [ B ] && C` — that compound form is parsed as `(A||B)&&C`
# and silently returns nonzero when both LHS checks fail, which trips
# `set -e` callers.
if [ "$rms" = "-inf" ] || [ "$rms" = "inf" ]; then
rms="-200"
fi
printf "%s" "$rms"
}
AUDIO_RMS=$(audio_residual_rms_db "$LAMBDA_MP4" "$BASELINE_MP4")
if [[ "$AUDIO_RMS" =~ ^-?[0-9.]+$ ]]; then
if awk -v r="$AUDIO_RMS" 'BEGIN{exit !(r<=-50)}'; then
AUDIO_STATUS="OK"
else
AUDIO_STATUS="DRIFT"
fi
else
AUDIO_STATUS="$AUDIO_RMS"
AUDIO_RMS="n/a"
fi
if [ -n "$LOCAL_MS" ] && [ "$LOCAL_MS" != "0" ] && [ "$LAMBDA_MS" != "0" ]; then
SPEEDUP=$(awk -v a="$LOCAL_MS" -v b="$LAMBDA_MS" 'BEGIN { printf("%.2f", a/b) }')
else
SPEEDUP="n/a"
fi
LOCAL_MS_FOR_CSV="${LOCAL_MS:-n/a}"
echo " psnr(lambda,baseline)=${PSNR_LAMBDA_BASE}dB"
echo " audio(lambda,baseline)=${AUDIO_STATUS} (residual RMS=${AUDIO_RMS} dBFS) speedup=${SPEEDUP}x"
echo "$FIXTURE,$LOCAL_MS_FOR_CSV,$LAMBDA_MS,$SPEEDUP,$PSNR_LAMBDA_BASE,$AUDIO_STATUS,$AUDIO_RMS" >> "$RESULTS_CSV"
done
# ── 3. Summary ───────────────────────────────────────────────────────────
echo
echo "================ RESULTS ================"
column -t -s, < "$RESULTS_CSV"
echo
echo "Artifacts: $ARTIFACT_DIR"
# Gate on lambda-vs-baseline PSNR (visual equivalence) AND audio status.
# Pass states: "OK" (residual ≤ -50 dBFS, audio matches), "no-audio-on-either"
# (fixture intentionally silent on both sides). Everything else
# ("DRIFT", "audio-stream-mismatch", "n/a", future statuses) fails.
#
# Use process substitution `done < <(tail ...)` rather than the pipeline
# form `tail | while`. The pipeline form runs the while loop in a
# subshell where FAILED=1 mutations are discarded when the subshell
# exits, so the parent's FAILED stays 0 forever — the gate would
# silently pass even on real failures.
FAILED=0
while IFS=, read -r fixture localMs lambdaMs speedup psnr audioStatus audioRms; do
if awk -v p="$psnr" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
echo "FAIL: $fixture lambda-vs-baseline PSNR=$psnr dB below threshold $PSNR_THRESHOLD" >&2
FAILED=1
fi
case "$audioStatus" in
OK|no-audio-on-either) ;;
*)
echo "FAIL: $fixture audio status=$audioStatus (residual RMS=$audioRms dBFS)" >&2
FAILED=1
;;
esac
done < <(tail -n +2 "$RESULTS_CSV")
cleanup_and_exit "$FAILED"
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env bash
# Real-AWS smoke + benchmark for the HyperFrames Lambda adapter.
#
# Run this from a workstation with `aws` CLI credentials. Builds the
# handler ZIP, deploys the SAM template at examples/aws-lambda/ to your
# AWS account, renders a fixture composition through the Step Functions
# state machine at several chunk counts, PSNR-compares each output
# against the in-process baseline, and tears the stack down.
#
# Usage:
# ./smoke.sh # all defaults
# ./smoke.sh --chunk-counts 2,4,8
# ./smoke.sh --fixture mp4-h264-sdr --keep-stack
# AWS_PROFILE=<your-profile> ./smoke.sh
#
# Required tools on PATH:
# - aws (v2)
# - sam (AWS SAM CLI, >= 1.100)
# - bun (>= 1.3, to build the handler ZIP)
# - ffmpeg (system or built-in; PSNR computation)
# - jq
# - zip
#
# Inputs (flags or env vars):
# --fixture <name> (default: mp4-h264-sdr)
# --chunk-counts <list> (default: 2,4,8)
# --psnr-threshold <db> (default: 40)
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>)
# --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
# Outputs:
# ./lambda-smoke-artifacts/results.json (chunkCount x wallClockMs x psnrAvgDb)
# ./lambda-smoke-artifacts/renders/N<N>-output.mp4
# ./lambda-smoke-artifacts/renders/N<N>-history.json
#
# Exit codes:
# 0 all good
# 1 argument / pre-flight error
# 2 ZIP build failed
# 3 SAM deploy failed
# 4 one or more renders failed
# 5 PSNR below threshold
set -euo pipefail
# ── Resolve script directory + repo root ──────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.."
# ── Defaults ──────────────────────────────────────────────────────────────
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_COUNTS="${CHUNK_COUNTS:-2,4,8}"
# The producer regression harness uses 50 dB as its PSNR floor for
# distributed-vs-in-process renders within the SAME runtime — both
# modes execute inside the same Dockerfile.test image, so pixel drift
# is minimal. Real Lambda runs against a different ffmpeg build
# (`ffmpeg-static`) and a different Chromium build (`@sparticuz/chromium`)
# than the in-process baseline (Debian-bookworm-slim's apt ffmpeg +
# Puppeteer-managed chrome-headless-shell). Expected drift across those
# environments is ~3 dB on simple fixtures, more on font-heavy ones.
# The gate defaults to 40 dB to absorb that drift; tighten it via
# --psnr-threshold for a stricter check.
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
KEEP_STACK="false"
SKIP_BUILD="false"
# Lambda Map-state concurrency cap. 16 fans out the chunks aggressively
# at the cost of a higher peak Lambda bill. Drop to 2-4 for cheaper runs;
# raise as far as your AWS account's regional concurrency quota allows.
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
usage() {
cat <<'EOF'
Usage: smoke.sh [flags]
Real-AWS smoke + benchmark for the HyperFrames Lambda adapter. Builds the
handler ZIP, deploys the SAM stack to your AWS account, renders a fixture
through Step Functions at several chunk counts, PSNR-compares each
output against the in-process baseline, and tears the stack down.
Flags:
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>)
--region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later)
--skip-build reuse existing dist/handler.zip
-h, --help show this help and exit
Cost notes:
Each run: build (free) + SAM deploy (~$0.01 in CFN ops) + per-chunk
Lambda invocations × MemorySize (default 10240 MB) × wall-clock seconds.
At 10 GB Lambda + ~30s per chunk × 8 chunks × 3 chunk-counts ≈ $0.04
per run before S3 PUT/GET. Set --reserved-concurrency lower for
cost-conscious accounts.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3), ffmpeg, jq, zip.
EOF
}
# ── Arg parsing ───────────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--fixture) FIXTURE="$2"; shift 2 ;;
--chunk-counts) CHUNK_COUNTS="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;;
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
done
# Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the
# standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's
# default resolution (env → ~/.aws/config → IMDS) take over without us
# having to pass `--profile` flags everywhere.
#
# AWS_DEFAULT_REGION is also set because SAM CLI honours it as a higher-
# priority signal than AWS_REGION; without it, sam will read the region
# from the active profile's samconfig.toml or ~/.aws/config and ignore
# whatever AWS_REGION points at.
export AWS_REGION
export AWS_DEFAULT_REGION="$AWS_REGION"
if [ -n "$AWS_PROFILE" ]; then
export AWS_PROFILE
fi
# ── Cleanup helper (defined early so the failure paths below can call it) ─
BUCKET=""
cleanup_and_exit() {
local exit_code="${1:-0}"
# The EXIT trap re-enters cleanup_and_exit on the way out; disarm it
# so we don't recurse if a teardown step trips set -e.
trap - EXIT
if [ "$KEEP_STACK" = "true" ]; then
echo "→ Keeping stack (--keep-stack); inspect at:"
echo " aws cloudformation describe-stacks --stack-name $STACK_NAME"
if [ -n "$BUCKET" ]; then
echo " aws s3 ls s3://$BUCKET/"
fi
else
echo "→ Tearing down stack $STACK_NAME"
if [ -n "$BUCKET" ]; then
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true
fi
(cd "$SAM_DIR" && sam delete \
--stack-name "$STACK_NAME" \
--no-prompts) >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
# Trap unexpected failures (set -e trips, SIGINT, etc.) so we don't leak
# the deployed stack + bucket on a non-routed error path. Explicit
# cleanup_and_exit calls disarm the trap first so teardown runs once.
trap 'cleanup_and_exit $?' EXIT
# ── Pre-flight checks ─────────────────────────────────────────────────────
for cmd in aws sam bun ffmpeg jq zip; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: '$cmd' not found on PATH." >&2
exit 1
fi
done
FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE"
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
if [ ! -d "$FIXTURE_DIR" ] || [ ! -f "$FIXTURE_DIR/src/index.html" ]; then
echo "ERROR: fixture not found or malformed: $FIXTURE_DIR" >&2
exit 1
fi
if [ ! -f "$BASELINE_MP4" ]; then
echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2
echo " (this is git-LFS tracked; run 'git lfs pull' to fetch it)" >&2
exit 1
fi
# Verify AWS credentials before building anything heavy. We don't print
# the profile name in error text — operators are expected to know which
# credentials they configured.
echo "→ Pre-flight: verifying AWS credentials (region=$AWS_REGION${AWS_PROFILE:+, profile=$AWS_PROFILE})"
if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
echo "ERROR: aws sts get-caller-identity failed." >&2
echo " Configure AWS credentials (env vars, ~/.aws/credentials, SSO, IMDS) or set AWS_PROFILE." >&2
exit 1
fi
mkdir -p "$ARTIFACT_DIR/renders"
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
if [ "$SKIP_BUILD" = "false" ]; then
echo "→ Building handler ZIP"
if ! bun run --cwd "$REPO_ROOT/packages/aws-lambda" build:zip; then
echo "ERROR: handler ZIP build failed." >&2
exit 2
fi
bun run --cwd "$REPO_ROOT/packages/aws-lambda" verify:zip-size
else
echo "→ Skipping ZIP build (--skip-build)"
fi
ls -lh "$REPO_ROOT/packages/aws-lambda/dist/handler.zip"
# ── 2. SAM validate + deploy ──────────────────────────────────────────────
echo "→ SAM validate"
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
# ProjectName is intentionally NOT set to $STACK_NAME — the template
# uses ProjectName only for the function/state-machine human-facing
# names, and forcing it long here doesn't help. The BucketName is
# auto-generated by CloudFormation per stack so concurrent smoke runs
# don't collide. Pass --region explicitly here even though
# AWS_DEFAULT_REGION is set, so a stray samconfig.toml in the working
# directory can't override the script's choice.
if ! (cd "$SAM_DIR" && sam deploy \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--resolve-s3 \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides \
ChromeSource=sparticuz \
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then
echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2
cleanup_and_exit 3
fi
# ── 3. Read stack outputs ─────────────────────────────────────────────────
BUCKET=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='RenderBucketName'].OutputValue" \
--output text)
STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
--output text)
echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN"
# ── 4. Upload fixture as a project tarball ────────────────────────────────
# tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not
# `unzip` in /usr/bin. See packages/aws-lambda/src/handler.ts for the
# matching untar call on the Lambda side.
echo "→ Uploading fixture to s3://$BUCKET/projects/$FIXTURE.tar.gz"
TMP_ARCHIVE=$(mktemp -d)
tar -czf "$TMP_ARCHIVE/project.tar.gz" -C "$FIXTURE_DIR/src" .
aws s3 cp "$TMP_ARCHIVE/project.tar.gz" "s3://$BUCKET/projects/$FIXTURE.tar.gz"
rm -rf "$TMP_ARCHIVE"
# ── 5. Render at each chunk count ─────────────────────────────────────────
FIXTURE_META="$FIXTURE_DIR/meta.json"
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
RESULTS_JSON="$ARTIFACT_DIR/results.json"
echo "[]" > "$RESULTS_JSON"
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
INPUT_JSON=$(jq -n \
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
--argjson n "$N" \
--argjson fps "$BASE_FPS" \
--argjson w "$BASE_W" \
--argjson h "$BASE_H" \
'{
ProjectS3Uri: $project,
PlanOutputS3Prefix: $prefix,
OutputS3Uri: $output,
Config: {
fps: $fps,
width: $w,
height: $h,
format: "mp4",
maxParallelChunks: $n,
runtimeCap: "lambda"
}
}')
echo
echo "================== N=$N =================="
echo "$INPUT_JSON" | jq .
START_MS=$(date +%s%3N)
EXEC_ARN=$(aws stepfunctions start-execution \
--state-machine-arn "$STATE_MACHINE_ARN" \
--name "$EXEC_NAME" \
--input "$INPUT_JSON" \
--query executionArn --output text)
echo "Started: $EXEC_ARN"
STATUS="RUNNING"
for _ in $(seq 1 300); do
sleep 5
STATUS=$(aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" --query status --output text)
if [ "$STATUS" != "RUNNING" ]; then break; fi
done
END_MS=$(date +%s%3N)
WALL_MS=$((END_MS - START_MS))
if [ "$STATUS" != "SUCCEEDED" ]; then
echo "ERROR: N=$N execution did not succeed ($STATUS)." >&2
aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" \
> "$ARTIFACT_DIR/renders/N$N-execution.json"
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 200 \
> "$ARTIFACT_DIR/renders/N$N-history.json" || true
cleanup_and_exit 4
fi
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 1000 --output json \
> "$ARTIFACT_DIR/renders/N$N-history.json"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/N$N-output.mp4"
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL"
PSNR_LOG=$(mktemp)
# ffmpeg's psnr filter prints per-frame stats `psnr_avg:X.XX` to its
# stats_file. We average those across frames to get the rendering's
# overall PSNR vs the baseline. The filter also prints a final summary
# line `PSNR ... average:X.XX ...` to stderr; we'd rather compute from
# per-frame data because the summary line is missing on some ffmpeg
# builds when the stream is too short.
ffmpeg -nostdin -v error \
-i "$OUTPUT_LOCAL" -i "$BASELINE_MP4" \
-lavfi "psnr=stats_file=$PSNR_LOG" -f null - 2>/dev/null || true
PSNR_AVG=$(awk '
/psnr_avg:/ {
for (i = 1; i <= NF; i++) {
if ($i ~ /^psnr_avg:/) {
split($i, kv, ":")
sum += kv[2]; count++
}
}
}
END { if (count > 0) printf("%.2f", sum / count); else print "0" }
' "$PSNR_LOG")
rm -f "$PSNR_LOG"
echo "N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
jq --argjson n "$N" \
--argjson wall "$WALL_MS" \
--arg psnr "$PSNR_AVG" \
'. += [{chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/N\($n)-output.mp4", history: "renders/N\($n)-history.json"}]' \
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
done
# ── 6. Gate on PSNR threshold ─────────────────────────────────────────────
FAILED=0
while read -r row; do
N=$(echo "$row" | jq -r .chunkCount)
P=$(echo "$row" | jq -r .psnrAvgDb)
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
echo "FAIL: N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
FAILED=$((FAILED + 1))
fi
done < <(jq -c '.[]' "$RESULTS_JSON")
# ── 7. Summary ────────────────────────────────────────────────────────────
echo
echo "================ RESULTS ================"
printf '%-10s %-12s %-10s\n' "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-12s %-10s\n", $1, $2, $3}'
echo
echo "Artifacts: $ARTIFACT_DIR"
if [ "$FAILED" -gt 0 ]; then
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
cleanup_and_exit 5
fi
echo "PASS"
cleanup_and_exit 0
+503
View File
@@ -0,0 +1,503 @@
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >-
HyperFrames distributed rendering — Step Functions standard workflow with
one Lambda function handling Plan, RenderChunk (fan-out via Map state),
and Assemble. One S3 bucket, alarms for runaway concurrency, Lambda
errors, and Step Functions execution failures.
Built from the handler ZIP at packages/aws-lambda/dist/handler.zip.
See:
- packages/aws-lambda/README.md (handler architecture)
- examples/aws-lambda/README.md (this directory's deploy guide)
Parameters:
ProjectName:
Type: String
Default: hyperframes
Description: Name prefix applied to all created resources.
LambdaMemoryMb:
Type: Number
Default: 10240
AllowedValues: [2048, 3072, 4096, 5120, 6144, 7168, 8192, 9216, 10240]
Description: >-
Lambda memory in MB. Render workloads are CPU-bound; bumping memory
proportionally bumps the CPU share Lambda gives the function. 10 GB
(the max) is recommended for 1080p renders.
LambdaTimeoutSec:
Type: Number
Default: 900
MinValue: 60
MaxValue: 900
Description: >-
Per-invocation Lambda timeout. Render chunks at the default
chunkSize=240 frames complete in seconds; 15 minutes is the Lambda
hard ceiling and the default here to absorb cold-start variance.
ReservedConcurrency:
Type: Number
Default: -1
Description: >-
Lambda reserved concurrency cap. Set to a positive integer to bound
simultaneous chunk renders (e.g. 50 to limit cost). -1 means
unreserved (account default).
ChromeSource:
Type: String
Default: sparticuz
AllowedValues: [sparticuz, chrome-headless-shell]
Description: >-
Which Chrome runtime the bundled ZIP was built with. Must match the
`--source=` flag passed to `build-zip.ts`. The handler reads this
via the HYPERFRAMES_LAMBDA_CHROME_SOURCE env var at boot.
ChunkInvocationAlarmThreshold:
Type: Number
Default: 1000
Description: >-
CloudWatch alarm threshold for total RenderChunk invocations per
hour. The runaway-Map state pathology would fan out far more
chunks than expected; an alarm at 10× the typical workload
protects against billing surprises.
Conditions:
HasReservedConcurrency: !Not [!Equals [!Ref ReservedConcurrency, -1]]
Globals:
Function:
Runtime: nodejs22.x
MemorySize: !Ref LambdaMemoryMb
Timeout: !Ref LambdaTimeoutSec
# x86_64 is required for @sparticuz/chromium — its prebuilt
# Chromium ships x86_64-only. Adopters who switch to a custom
# ARM-built chrome-headless-shell can change this to `arm64`, but
# the default ZIP build will fail to launch on Graviton.
Architectures: [x86_64]
# Lambda function-level X-Ray tracing. The state machine already
# has Tracing.Enabled: true; without this, X-Ray traces would
# terminate at the Step Functions → Lambda boundary instead of
# following into per-function spans.
Tracing: Active
# Cost-allocation tags. Setting these at the Globals level applies
# to every AWS::Serverless::Function in the template — there's
# only one today, but the contract is portable to multi-function
# variants. Bucket + state-machine carry the same tags resource-
# locally because Globals only covers functions.
Tags:
Project: !Ref ProjectName
HyperFramesComponent: lambda-renderer
Environment:
Variables:
NODE_OPTIONS: "--enable-source-maps"
HYPERFRAMES_LAMBDA_CHROME_SOURCE: !Ref ChromeSource
Resources:
# ── S3 bucket for plan tarballs, chunk outputs, and final renders ───────
RenderBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
# BucketName omitted — CloudFormation generates a unique name like
# "<stack-name>-renderbucket-<random>". S3 bucket names are capped at
# 63 chars; a static !Sub expression including ProjectName +
# AWS::AccountId + AWS::Region trips that limit when ProjectName
# carries a timestamp (e.g. the smoke script's per-run stack name).
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
VersioningConfiguration:
# `Suspended` keeps storage costs flat — versions are not
# retained on overwrites. Tradeoff: if an adopter writes their
# final rendered mp4 to this bucket and a re-render overwrites
# the same key, the prior version is gone. Adopters who treat
# the final mp4 as user-keepable should set this to `Enabled`
# (intermediates under `renders/` still expire via the
# lifecycle rule below regardless).
Status: Suspended
LifecycleConfiguration:
Rules:
- Id: ExpireIntermediates
Status: Enabled
Prefix: renders/
# Plan tarballs and chunk outputs are intermediate artifacts.
# Users keep the final mp4 (different key prefix); the rest
# can age out after a week to keep storage costs flat.
ExpirationInDays: 7
Tags:
- Key: Project
Value: !Ref ProjectName
- Key: HyperFramesComponent
Value: lambda-renderer
# ── Single Lambda function handling all three roles ──────────────────────
RenderFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub "${ProjectName}-render"
Description: >-
HyperFrames distributed render handler. Dispatches on event.Action.
Handler: handler.handler
# Local path is resolved by `sam build` + `sam deploy --resolve-s3`
# (or `--s3-bucket`); the resulting CodeUri rewrites to s3://.
CodeUri: ../../packages/aws-lambda/dist/handler.zip
PackageType: Zip
ReservedConcurrentExecutions: !If
- HasReservedConcurrency
- !Ref ReservedConcurrency
- !Ref AWS::NoValue
EphemeralStorage:
Size: 10240
Environment:
Variables:
# Lambda's Node 22 runtime sets these by default; explicit for
# clarity + so users can override during local SAM invoke.
TMPDIR: /tmp
Policies:
- S3CrudPolicy:
BucketName: !Ref RenderBucket
# CloudWatch Logs perms are covered by SAM's default
# AWSLambdaBasicExecutionRole — explicit `CloudWatchLogsFullAccess`
# would be overscope (`logs:*` on `*`, including DeleteLogGroup +
# CreateExportTask). Reference templates shouldn't leak overbroad
# IAM into adopters' accounts.
# ── CloudWatch log group for the state machine ──────────────────────────
# SAM doesn't auto-create one when `LoggingConfiguration` is set, so we
# define it explicitly — that way the IAM grant on the state-machine
# role has a destination to write to.
RenderStateMachineLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/states/${ProjectName}-render"
RetentionInDays: 30
# ── Step Functions state machine: Plan → Map(N) RenderChunk → Assemble ──
RenderStateMachine:
Type: AWS::Serverless::StateMachine
Properties:
Name: !Sub "${ProjectName}-render"
Type: STANDARD
Tracing:
Enabled: true
Logging:
# Without this, the `WriteCloudwatchLogs` grant on the state
# machine role would be unused — operators would see zero
# execution history outside the Step Functions console.
# `Level: ERROR` keeps log volume low; bump to `ALL` for
# heavy debugging.
Level: ERROR
IncludeExecutionData: false
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt RenderStateMachineLogGroup.Arn
Definition:
Comment: >-
HyperFrames distributed render orchestration: Plan → Map(N)
RenderChunk → Assemble.
# Defensive 1-hour ceiling on the whole choreography. The
# individual states already have retries + per-task timeouts;
# this catches pathological runaways (Plan-retry storm,
# stuck-state-machine bugs) at the top before per-task budgets
# compound into a multi-hour execution. The longest legitimate
# render observed in PR 880's eval was ~3 minutes.
TimeoutSeconds: 3600
StartAt: Plan
States:
Plan:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: plan
ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanS3Uri.$: "$.Payload.PlanS3Uri"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
Format.$: "$.Payload.Format"
HasAudio.$: "$.Payload.HasAudio"
AudioS3Uri.$: "$.Payload.AudioS3Uri"
ResultPath: $.Plan
Retry:
- ErrorEquals:
# These error names are thrown by the producer's plan
# stage when retrying can never help — version skew,
# determinism violations, GPU misconfiguration, font
# fetch failures, plan-size cap, unsupported format.
# Fail fast rather than burning ~120s of retry budget.
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
Next: BuildChunkList
BuildChunkList:
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
# Map state below has something to iterate. Range is the
# idiomatic Step Functions intrinsic for this; no Lambda call
# required.
Type: Pass
Parameters:
ChunkIndexes.$: "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)"
ResultPath: $.Iterator
Next: AssertChunkCount
AssertChunkCount:
# Defensive gate: `resolveChunkPlan` guarantees ChunkCount ≥ 1,
# but if some future regression let a zero-chunk plan through,
# `RenderChunks` (Map state) would iterate zero times and
# `Assemble` would receive an empty `ChunkS3Uris` array — silently
# producing an empty output. Fail fast instead.
Type: Choice
Choices:
- Variable: $.Plan.ChunkCount
NumericGreaterThan: 0
Next: RenderChunks
Default: PlanProducedZeroChunks
PlanProducedZeroChunks:
Type: Fail
Error: PLAN_TOO_LARGE
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
RenderChunks:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
ItemSelector:
ChunkIndex.$: "$$.Map.Item.Value"
PlanS3Uri.$: "$.Plan.PlanS3Uri"
PlanHash.$: "$.Plan.PlanHash"
ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Format.$: "$.Plan.Format"
# Map fan-out cap derives from the Plan's chunkCount so
# caller-supplied `Config.maxParallelChunks` (which
# `plan()` honours when sizing the chunk list) is the
# single source of truth. A hardcoded value here would
# silently throttle adopters who scale up the chunk count
# in their event payload.
MaxConcurrencyPath: $.Plan.ChunkCount
ResultPath: $.Chunks
ItemProcessor:
ProcessorConfig:
Mode: INLINE
StartAt: RenderChunk
States:
RenderChunk:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: renderChunk
ChunkIndex.$: "$.ChunkIndex"
PlanS3Uri.$: "$.PlanS3Uri"
PlanHash.$: "$.PlanHash"
ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
Format.$: "$.Format"
ResultSelector:
ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
ChunkIndex.$: "$.Payload.ChunkIndex"
Sha256.$: "$.Payload.Sha256"
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- BROWSER_GPU_NOT_SOFTWARE
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
Next: Assemble
Assemble:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: assemble
PlanS3Uri.$: "$.Plan.PlanS3Uri"
ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
AudioS3Uri.$: "$.Plan.AudioS3Uri"
OutputS3Uri.$: "$.OutputS3Uri"
Format.$: "$.Plan.Format"
ResultSelector:
OutputS3Uri.$: "$.Payload.OutputS3Uri"
FramesEncoded.$: "$.Payload.FramesEncoded"
FileSize.$: "$.Payload.FileSize"
ResultPath: $.Output
Retry:
- ErrorEquals:
# Same non-retryable error names as the Plan state's
# gate — these surface at assemble time too because
# ffmpeg-driven concat picks up version drift and we
# re-verify plan hash + format at assemble. Skip the
# retry storm; fail fast.
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
Role: !GetAtt RenderStateMachineRole.Arn
RenderStateMachineRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: states.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeRenderFunction
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt RenderFunction.Arn
- PolicyName: WriteCloudwatchLogs
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogDelivery
- logs:GetLogDelivery
- logs:UpdateLogDelivery
- logs:DeleteLogDelivery
- logs:ListLogDeliveries
- logs:PutResourcePolicy
- logs:DescribeResourcePolicies
- logs:DescribeLogGroups
Resource: "*"
- PolicyName: XRayTracing
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- xray:PutTraceSegments
- xray:PutTelemetryRecords
Resource: "*"
# ── CloudWatch alarm: runaway chunk invocations ─────────────────────────
RenderChunkInvocationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ProjectName}-runaway-chunk-invocations"
AlarmDescription: >-
Fires if RenderChunk Lambda invocations exceed the configured
threshold in a 1-hour window. The Map state's MaxConcurrency cap
protects against simultaneous fan-out, but a runaway state
machine that triggers many sequential renders would still rack
up cost; this alarm catches that pattern.
Namespace: AWS/Lambda
MetricName: Invocations
Dimensions:
- Name: FunctionName
Value: !Ref RenderFunction
Statistic: Sum
Period: 3600
EvaluationPeriods: 1
Threshold: !Ref ChunkInvocationAlarmThreshold
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
# ── CloudWatch alarm: Lambda function errors ────────────────────────────
# Fires on any non-zero error rate. The invocation alarm above catches
# *too many calls*; this catches *calls that failed*. Without it,
# silent per-chunk failures (a non-retryable error inside the
# producer) would only surface by reading Step Functions execution
# history.
RenderFunctionErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ProjectName}-render-function-errors"
AlarmDescription: >-
Fires if the render Lambda reports any errors in a 5-minute
window. Set EvaluationPeriods=1 so a single failure pages.
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref RenderFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
# ── CloudWatch alarm: Step Functions execution failures ─────────────────
# Fires when a state-machine execution reaches a terminal failure
# state (typed non-retryable, retry-exhausted, or top-level timeout).
# Complementary to the Lambda Errors alarm: SFN failures include
# Choice-state Fail branches (PlanProducedZeroChunks) that bypass
# Lambda entirely, plus retry-exhaustion of transient errors that
# individual Lambda invocations counted as successful "retries".
RenderStateMachineFailedAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ProjectName}-render-state-machine-failed"
AlarmDescription: >-
Fires when the render state machine reports a failed
execution. Catches retry-exhaustion + typed non-retryable +
TimeoutSeconds cases that the Lambda Errors metric misses.
Namespace: AWS/States
MetricName: ExecutionsFailed
Dimensions:
- Name: StateMachineArn
Value: !Ref RenderStateMachine
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
Outputs:
RenderBucketName:
Description: S3 bucket for plan tarballs, chunk outputs, and final renders.
Value: !Ref RenderBucket
Export:
Name: !Sub "${AWS::StackName}-RenderBucket"
RenderFunctionArn:
Description: ARN of the Lambda function. Pass to `aws lambda invoke` for local testing.
Value: !GetAtt RenderFunction.Arn
Export:
Name: !Sub "${AWS::StackName}-RenderFunctionArn"
RenderStateMachineArn:
Description: ARN of the Step Functions state machine. Pass to `aws stepfunctions start-execution`.
Value: !Ref RenderStateMachine
Export:
Name: !Sub "${AWS::StackName}-RenderStateMachineArn"
+51
View File
@@ -0,0 +1,51 @@
# Google Cloud Run example
End-to-end deployment + smoke for [`@hyperframes/gcp-cloud-run`](../../packages/gcp-cloud-run) — the Cloud Run + Cloud Workflows adapter for HyperFrames distributed rendering.
## Layout
```
scripts/smoke.sh Real-GCP smoke: build → deploy → render → PSNR → destroy
sample-events/ Example request bodies for the Cloud Run handler
(plan.json, render-chunk.json, assemble.json)
```
The Terraform module and the Cloud Workflows definition that the smoke deploys live with the package, at `packages/gcp-cloud-run/terraform/` (including `workflow.yaml`).
## Prerequisites
- `gcloud` authenticated, with a project that has **billing enabled**
- `terraform` (≥ 1.5), `docker`, `ffmpeg`, `jq` on PATH
## Run the smoke
```bash
# Renders the mp4-h264-sdr fixture through the workflow and PSNR-compares it
# against the in-process baseline, then tears the stack down.
./scripts/smoke.sh --project YOUR_GCP_PROJECT --region us-central1
# Keep the stack up to poke at it:
./scripts/smoke.sh --project YOUR_GCP_PROJECT --keep-stack
# Render at several chunk sizes to see the fan-out scaling:
./scripts/smoke.sh --project YOUR_GCP_PROJECT --chunk-sizes 30,15,10
```
Outputs land in `scripts/gcp-smoke-artifacts/`: `results.json`
(`chunkSize × wallClockMs × psnrAvgDb`), the rendered MP4s, and each
workflow execution's describe output.
## Test the handler locally
The sample events exercise the same body shape Cloud Workflows sends. With the
container running locally (`PORT=8080`) and credentials that can reach a GCS
bucket, you can drive a single action:
```bash
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan.json | jq .
```
Replace the `PROJECT` placeholder bucket names and `REPLACE_WITH_PLAN_HASH`
with real values from a prior `plan` response.
@@ -0,0 +1,11 @@
{
"Action": "assemble",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4"
],
"AudioGcsUri": null,
"OutputGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/output.mp4",
"Format": "mp4"
}
@@ -0,0 +1,6 @@
{
"Action": "plan",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
}
@@ -0,0 +1,8 @@
{
"Action": "renderChunk",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0,
"ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Format": "mp4"
}
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# Real-GCP smoke + benchmark for the HyperFrames Cloud Run adapter.
#
# Run from a workstation with `gcloud` credentials. Builds the render
# container, pushes it to Artifact Registry, applies the Terraform module at
# packages/gcp-cloud-run/terraform to your project, renders a fixture
# composition through the Cloud Workflows definition, PSNR-compares the
# output against the in-process baseline, and tears the stack down.
#
# Usage:
# ./smoke.sh --project <gcp-project>
# ./smoke.sh --project p --fixture mp4-h264-sdr --chunk-sizes 15,30
# ./smoke.sh --project p --keep-stack
#
# Required tools on PATH:
# - gcloud (authenticated; the target project must have billing enabled)
# - terraform (>= 1.5)
# - docker
# - ffmpeg (PSNR computation)
# - jq
#
# Inputs (flags or env vars):
# --project <id> (required; or $GCP_PROJECT)
# --region <region> (default: us-central1)
# --fixture <name> (default: mp4-h264-sdr — under packages/producer/tests/distributed/)
# --chunk-sizes <list> (default: from the fixture meta; CSV of chunkSize overrides)
# --psnr-threshold <db> (default: 35)
# --repo <ar-repo> (Artifact Registry repo name, default: hyperframes)
# --keep-stack (skip `terraform destroy` at the end)
# --skip-build (reuse the last-pushed image tag in ./gcp-smoke-artifacts/image.txt)
#
# Outputs:
# ./gcp-smoke-artifacts/results.json (chunkSize x wallClockMs x psnrAvgDb)
# ./gcp-smoke-artifacts/renders/c<N>-output.mp4
# ./gcp-smoke-artifacts/renders/c<N>-execution.json
#
# Exit codes:
# 0 all good 1 arg/pre-flight 2 build/push 3 terraform apply
# 4 a render failed 5 PSNR below threshold
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TF_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform"
# ── Defaults ──────────────────────────────────────────────────────────────
PROJECT="${GCP_PROJECT:-}"
REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
AR_REPO="${AR_REPO:-hyperframes}"
KEEP_STACK=0
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
--fixture) FIXTURE="$2"; shift 2 ;;
--chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--repo) AR_REPO="$2"; shift 2 ;;
--keep-stack) KEEP_STACK=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
[ -n "$PROJECT" ] || { echo "ERROR: --project (or \$GCP_PROJECT) is required" >&2; exit 1; }
for tool in gcloud terraform docker ffmpeg jq; do
command -v "$tool" >/dev/null || { echo "ERROR: $tool not on PATH" >&2; exit 1; }
done
FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE"
FIXTURE_META="$FIXTURE_DIR/meta.json"
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
[ -d "$FIXTURE_DIR/src" ] || { echo "ERROR: fixture src missing: $FIXTURE_DIR/src" >&2; exit 1; }
[ -f "$BASELINE_MP4" ] || { echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2; exit 1; }
ARTIFACT_DIR="$SCRIPT_DIR/gcp-smoke-artifacts"
mkdir -p "$ARTIFACT_DIR/renders"
echo "→ Project: $PROJECT Region: $REGION Fixture: $FIXTURE"
# ── 1. Enable APIs ──────────────────────────────────────────────────────────
echo "→ Enabling required APIs (idempotent)"
gcloud services enable \
run.googleapis.com workflows.googleapis.com workflowexecutions.googleapis.com \
artifactregistry.googleapis.com cloudbuild.googleapis.com monitoring.googleapis.com \
--project "$PROJECT" >/dev/null
# ── 2. Build + push the render image ────────────────────────────────────────
IMAGE_TXT="$ARTIFACT_DIR/image.txt"
if [ "$SKIP_BUILD" -eq 1 ] && [ -f "$IMAGE_TXT" ]; then
IMAGE="$(cat "$IMAGE_TXT")"
echo "→ Reusing image $IMAGE"
else
gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 || \
gcloud artifacts repositories create "$AR_REPO" --repository-format docker \
--location "$REGION" --project "$PROJECT" >/dev/null
TAG="$(date +%Y%m%d-%H%M%S)"
IMAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/hyperframes-render:$TAG"
echo "→ Building + pushing $IMAGE via Cloud Build"
# The Dockerfile lives at packages/gcp-cloud-run/Dockerfile, not the repo
# root, so we drive the build with an inline cloudbuild config rather than
# `--tag` (which assumes a root Dockerfile).
CB_CONFIG="$ARTIFACT_DIR/cloudbuild.yaml"
cat > "$CB_CONFIG" <<EOF
steps:
- name: gcr.io/cloud-builders/docker
args: ["build","-f","packages/gcp-cloud-run/Dockerfile","-t","$IMAGE","."]
images: ["$IMAGE"]
timeout: 3600s
options:
machineType: E2_HIGHCPU_8
EOF
gcloud builds submit "$REPO_ROOT" --project "$PROJECT" --config "$CB_CONFIG" \
|| { echo "ERROR: image build/push failed" >&2; exit 2; }
echo "$IMAGE" > "$IMAGE_TXT"
fi
# ── 3. terraform apply ──────────────────────────────────────────────────────
# The google provider authenticates via Application Default Credentials. If
# ADC isn't configured (common on a box set up with only `gcloud auth login`),
# fall back to a short-lived access token from the active gcloud account.
if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then
echo "→ ADC not configured; using a gcloud access token for Terraform"
export GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)"
export GOOGLE_PROJECT="$PROJECT"
fi
echo "→ terraform apply"
terraform -chdir="$TF_DIR" init -input=false >/dev/null
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
|| { echo "ERROR: terraform apply failed" >&2; exit 3; }
BUCKET="$(terraform -chdir="$TF_DIR" output -raw render_bucket_name)"
SERVICE_URL="$(terraform -chdir="$TF_DIR" output -raw service_url)"
WORKFLOW="$(terraform -chdir="$TF_DIR" output -raw workflow_name)"
echo " bucket=$BUCKET service=$SERVICE_URL workflow=$WORKFLOW"
cleanup() {
if [ "$KEEP_STACK" -eq 0 ]; then
echo "→ terraform destroy"
# Apply force_destroy=true into state FIRST. Terraform reads the bucket's
# force_destroy from prior state during the destroy step, so a destroy
# alone can't flip it; a quick apply updates the attribute, then destroy
# can empty + remove the (scratch) bucket.
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" >/dev/null 2>&1 || true
terraform -chdir="$TF_DIR" destroy -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" || true
else
echo "→ --keep-stack set; leaving the stack up. Destroy with:"
echo " terraform -chdir=$TF_DIR destroy -var project_id=$PROJECT -var region=$REGION -var image=$IMAGE -var bucket_force_destroy=true"
fi
}
trap cleanup EXIT
# ── 4. Upload the fixture as a project tarball ──────────────────────────────
SITE_TAR="$ARTIFACT_DIR/project.tar.gz"
tar -czf "$SITE_TAR" -C "$FIXTURE_DIR/src" .
PROJECT_GCS="gs://$BUCKET/sites/$FIXTURE/project.tar.gz"
gcloud storage cp "$SITE_TAR" "$PROJECT_GCS" --project "$PROJECT" >/dev/null
echo "→ Uploaded fixture to $PROJECT_GCS"
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
META_CHUNK=$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META")
[ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}"
echo "[]" > "$ARTIFACT_DIR/results.json"
OVERALL_RC=0
IFS=',' read -ra SIZES <<< "$CHUNK_SIZES"
for CS in "${SIZES[@]}"; do
RENDER_ID="hf-smoke-c${CS}-$(date +%s)"
OUT_GCS="gs://$BUCKET/renders/$RENDER_ID/output.mp4"
ARG=$(jq -n \
--arg svc "$SERVICE_URL" \
--arg proj "$PROJECT_GCS" \
--arg prefix "gs://$BUCKET/renders/$RENDER_ID/" \
--arg out "$OUT_GCS" \
--argjson fps "$BASE_FPS" \
--argjson cs "$CS" \
'{ServiceUrl:$svc, ProjectGcsUri:$proj, PlanOutputGcsPrefix:$prefix, OutputGcsUri:$out,
Config:{fps:$fps, width:640, height:360, format:"mp4", chunkSize:$cs}}')
echo "→ Render chunkSize=$CS (renderId=$RENDER_ID)"
START_MS=$(date +%s%3N)
EXEC=$(gcloud workflows execute "$WORKFLOW" --location "$REGION" --project "$PROJECT" \
--data "$ARG" --format='value(name)')
# Poll until terminal.
STATE="ACTIVE"
while [ "$STATE" = "ACTIVE" ] || [ "$STATE" = "QUEUED" ]; do
sleep 5
STATE=$(gcloud workflows executions describe "$EXEC" --location "$REGION" \
--project "$PROJECT" --format='value(state)')
done
END_MS=$(date +%s%3N)
WALL=$((END_MS - START_MS))
gcloud workflows executions describe "$EXEC" --location "$REGION" --project "$PROJECT" \
--format=json > "$ARTIFACT_DIR/renders/c$CS-execution.json"
if [ "$STATE" != "SUCCEEDED" ]; then
echo " ✗ execution state=$STATE"
jq -r '.error.payload // empty' "$ARTIFACT_DIR/renders/c$CS-execution.json" | head -c 800
OVERALL_RC=4
continue
fi
OUT_LOCAL="$ARTIFACT_DIR/renders/c$CS-output.mp4"
gcloud storage cp "$OUT_GCS" "$OUT_LOCAL" --project "$PROJECT" >/dev/null
# PSNR vs the in-process baseline.
PSNR_LOG="$ARTIFACT_DIR/renders/c$CS-psnr.log"
ffmpeg -y -i "$OUT_LOCAL" -i "$BASELINE_MP4" \
-lavfi "psnr=stats_file=$PSNR_LOG" -f null - 2>/dev/null || true
PSNR_AVG=$(awk -F'psnr_avg:' '/psnr_avg:/{split($2,a," "); s+=a[1]; n++} END{if(n>0) printf "%.2f", s/n; else print "0"}' "$PSNR_LOG" 2>/dev/null || echo "0")
echo " ✓ state=SUCCEEDED wall=${WALL}ms psnr_avg=${PSNR_AVG}dB"
jq --argjson cs "$CS" --argjson wall "$WALL" --arg psnr "$PSNR_AVG" \
'. += [{chunkSize:$cs, wallClockMs:$wall, psnrAvgDb:($psnr|tonumber)}]' \
"$ARTIFACT_DIR/results.json" > "$ARTIFACT_DIR/results.json.tmp" && \
mv "$ARTIFACT_DIR/results.json.tmp" "$ARTIFACT_DIR/results.json"
if awk "BEGIN{exit !($PSNR_AVG < $PSNR_THRESHOLD)}"; then
echo " ✗ PSNR ${PSNR_AVG}dB below threshold ${PSNR_THRESHOLD}dB"
OVERALL_RC=5
fi
done
echo "→ Results:"; cat "$ARTIFACT_DIR/results.json" | jq .
exit $OVERALL_RC
+123
View File
@@ -0,0 +1,123 @@
# HyperFrames distributed renderer — reference Dockerfile for non-Lambda runtimes.
#
# This image bakes Node 22, chrome-headless-shell, ffmpeg-static, and the
# `@hyperframes/producer/distributed` primitives. One image runs the
# Plan / RenderChunk / Assemble activities for any non-Lambda orchestrator:
#
# - Kubernetes Jobs (one Job per chunk, Argo Workflows on top)
# - AWS ECS Fargate (one task per chunk)
# - Google Cloud Run Jobs
# - Azure Container Apps Jobs
# - Plain `docker run` on a beefy VM
#
# We deliberately do NOT publish this image to a registry. The OSS contract
# is that adopters build it themselves — that way the Chrome / ffmpeg /
# producer versions are pinned to the source checkout they audited, not a
# floating tag we'd have to keep in sync with every release.
#
# Build from the repo root:
#
# docker build -t hyperframes-chunk-runner:local -f examples/k8s-jobs/Dockerfile.example .
#
# Run a chunk worker (an orchestrator script wraps this entry point):
#
# docker run --rm \
# -e PRODUCER_HEADLESS_SHELL_PATH=/opt/chrome/chrome-headless-shell \
# -v /tmp/hyperframes:/tmp/hyperframes \
# hyperframes-chunk-runner:local \
# node -e 'import("@hyperframes/producer/distributed").then(({renderChunk})=>renderChunk(...))'
#
# Lambda adopters use `packages/aws-lambda/dist/handler.zip` instead;
# this Dockerfile is the K8s / Cloud Run / ECS path.
# ── Base ─────────────────────────────────────────────────────────────────────
# Debian bookworm-slim because the chrome-headless-shell dynamic-library set
# matches what we use in CI. Amazon Linux 2023 also works (Lambda's base
# image) but is harder to debug locally.
FROM node:22-bookworm-slim AS base
# ── System deps ──────────────────────────────────────────────────────────────
# - ffmpeg: the producer's encode + audio mix
# - libfontconfig / libfreetype / fonts-liberation: Chrome text shaping
# - chromium-style ABI deps (the minimum set chrome-headless-shell needs):
# libnss3, libatk-bridge2.0, libdrm2, libgbm1, libxshmfence1, libxkbcommon0,
# libxcomposite1, libxdamage1, libxfixes3, libxrandr2, libasound2,
# libpangocairo-1.0-0
# - tini: clean PID 1 for container teardown signals (Cloud Run / Fargate
# send SIGTERM at the 10-min grace boundary).
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libfontconfig1 \
libfreetype6 \
libgbm1 \
libglib2.0-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxshmfence1 \
tini \
tzdata \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ── Chrome ───────────────────────────────────────────────────────────────────
# Use `@puppeteer/browsers` to fetch the same chrome-headless-shell version
# the producer pins. Keep the bun + chrome install in the build context so
# the runtime image is reproducible.
ENV CHROME_HEADLESS_SHELL_VERSION=131.0.6778.139
ENV CHROME_DIR=/opt/chrome
RUN mkdir -p "$CHROME_DIR" && \
npm install --global @puppeteer/browsers@2.13.0 && \
npx @puppeteer/browsers install "chrome-headless-shell@${CHROME_HEADLESS_SHELL_VERSION}" \
--path "$CHROME_DIR" && \
npm uninstall --global @puppeteer/browsers && \
rm -rf /root/.npm
ENV PRODUCER_HEADLESS_SHELL_PATH=${CHROME_DIR}/chrome-headless-shell/linux-${CHROME_HEADLESS_SHELL_VERSION}/chrome-headless-shell-linux64/chrome-headless-shell
# ── HyperFrames ──────────────────────────────────────────────────────────────
# Copy the workspace bun-locked package set. We use bun in the build
# (matches the rest of the repo) but the runtime is plain Node — no bun
# is needed at run time.
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/aws-lambda/package.json packages/aws-lambda/
COPY packages/core/package.json packages/core/
COPY packages/engine/package.json packages/engine/
COPY packages/producer/package.json packages/producer/
RUN npm install --global bun && \
bun install --frozen-lockfile && \
npm uninstall --global bun
# Bring in the source. We're not building the producer's dist/ here — bun's
# workspace + tsx + esm resolution can run the producer straight from
# `packages/producer/src/**`. Adopters who want a built distribution can
# `bun run --cwd packages/producer build` against this image.
COPY packages/core ./packages/core
COPY packages/engine ./packages/engine
COPY packages/producer ./packages/producer
# ── Runtime ──────────────────────────────────────────────────────────────────
ENTRYPOINT ["/usr/bin/tini", "--"]
# Default CMD prints the producer version + Chrome path; orchestrators
# typically override CMD with their per-chunk activity invocation.
CMD ["node", "-e", "console.log(JSON.stringify({producerVersion: require('./packages/producer/package.json').version, chromePath: process.env.PRODUCER_HEADLESS_SHELL_PATH}))"]
+44
View File
@@ -0,0 +1,44 @@
# K8s / Cloud Run / ECS reference Dockerfile
This directory ships a reference `Dockerfile.example` for adopters who want to run HyperFrames distributed renders **outside AWS Lambda**. The image bakes Node 22 + `chrome-headless-shell` + `ffmpeg` + the producer source, and works on Kubernetes Jobs, Argo Workflows, Cloud Run Jobs, ECS Fargate, or plain `docker run`.
We do **not** publish this image to a registry — the OSS contract is that adopters build it themselves so Chrome / ffmpeg / producer versions stay pinned to the source checkout they audited, not a floating tag we'd have to keep in sync with every release.
## Build
From the repo root:
```bash
docker build -t hyperframes-chunk-runner:local -f examples/k8s-jobs/Dockerfile.example .
```
The build pulls `chrome-headless-shell` via `@puppeteer/browsers` and installs Debian system packages for the Chromium ABI deps. Expect a ~1.2 GB compressed image; ~3 GB unpacked.
## Use
The producer's distributed primitives are pure functions over local paths. Wire them into your orchestrator however you like:
```ts
import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed";
// Controller-side: produce a self-contained planDir + content-addressed planHash.
const planResult = await plan(projectDir, config, planDir);
// Worker-side: render one chunk (byte-identical on retry for the same input).
const chunk = await renderChunk(planDir, chunkIndex, outputChunkPath);
// Controller-side: stitch chunks into the final deliverable.
await assemble(planDir, chunkPaths, audioPath, outputPath);
```
A typical Kubernetes Jobs orchestration:
1. **Controller** runs a one-shot Job that mounts the project directory + calls `plan()`. Uploads the resulting `planDir/` to your shared storage (S3, GCS, PVC, …).
2. **Per-chunk** Jobs (one per chunk index) download the planDir, call `renderChunk(planDir, i, output)`, upload the output. Argo Workflows' `withSequence` is a natural fit.
3. **Assembler** Job downloads the planDir + every chunk output, calls `assemble(...)`, uploads the final mp4 / mov.
The AWS Lambda implementation in `packages/aws-lambda/src/handler.ts` is one concrete adapter — read it as a reference for the per-activity event shape.
## Lambda?
If you want AWS Lambda specifically, use `hyperframes lambda deploy` instead — it ships a turnkey deployment. See [docs/deploy/aws-lambda.mdx](../../docs/deploy/aws-lambda.mdx).