Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/deploy/aws-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The Lambda handler is a thin dispatch: parse the Step Functions event, download
| AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain the AWS SDK for JavaScript v3 would resolve. |
| AWS SAM CLI | `hyperframes lambda deploy/destroy` shells out to `sam deploy`/`sam delete`. | [Install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) |
| Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. Each release pins a prebuilt Lambda handler ZIP (a GitHub release asset); `lambda deploy` downloads it, checks its SHA-256 and deploys it with the adapter's SAM template. No repo checkout needed. | `npm install -g @hyperframes/aws-lambda` alongside the CLI, at the same version |
| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) |
| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. A checkout deploy on arm64 also needs the video image built from that checkout (`HYPERFRAMES_LAMBDA_VIDEO_IMAGE`), or `--video-arch x86_64`. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) |

## Three deployment paths

Expand Down Expand Up @@ -102,6 +102,10 @@ The `siteId` is content-addressed via a SHA-256 of the project tree. A site is a

The stack's bucket expires objects under `cas/sha256/` 30 days after they were last written (the `ExpireSharedContent` lifecycle rule). Each render or `sites create` that reuses a file at least 7 days old rewrites it in place, so files in use never expire. If a site's files did expire, `lambda render --site-id` re-uploads them from the project directory, if its copies still match. Otherwise, or when `renderToLambda` gets a `siteHandle` but no `projectDir`, the render stops with `SITE_FILES_MISSING` before starting; run `sites create` again to restore them.

#### Upgrading from a version that deployed x86 only

`lambda deploy` now runs the plan and the render chunks on an arm64 container-image function by default; audio and assemble stay on the x86 function. Redeploying an existing stack without `--video-arch` moves it to arm64 and adds the `<stack>-video` ECR repository. To keep a stack on x86, pass `--video-arch x86_64` on every deploy. `--chrome-source chrome-headless-shell` also needs `--video-arch x86_64`, because the arm64 image bundles sparticuz Chrome only.

#### Upgrading from a version before shared content storage

- **Redeploy the stack before upgrading the CLI or SDK that starts renders.** A stack from an earlier version cannot read `files.json` sites. The CLI and `renderToLambda` check the state machine first and stop with `STACK_TOO_OLD` before uploading anything; run `hyperframes lambda deploy` (or redeploy your SAM template or CDK construct from the same version) and render again.
Expand Down Expand Up @@ -231,6 +235,8 @@ If progress doesn't advance for >10 minutes, check the Step Functions execution

The render bucket is created with CloudFormation `Retain` on delete — `hyperframes lambda destroy` (or `sam delete`) tears the function + state machine down but the bucket survives. This is intentional: it protects final-rendered MP4s from being lost when you re-deploy. To fully reclaim storage, empty + delete the bucket via the AWS console / `aws s3 rb`.

A stack deployed on arm64 (the default) also has an ECR repository, `<stack>-video`, holding the video function's image. `hyperframes lambda destroy` deletes it after the stack.

### `STACK_TOO_OLD`

The stack was deployed by an earlier version that cannot read this version's sites. Redeploy it (`hyperframes lambda deploy`), then render again. See [Upgrading](#upgrading-from-a-version-before-shared-content-storage).
Expand Down
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2207,6 +2207,7 @@ deploy:
| `--concurrency` | none | Lambda reserved concurrency. Without it, a new stack reserves none and each render runs up to 90% of the account's unreserved concurrency; a redeploy keeps the stack's current reservation; `none` removes it |
| `--memory` | `10240` | Lambda memory in MB |
| `--chrome-source` | `sparticuz` | `sparticuz` or `chrome-headless-shell` |
| `--video-arch` | `arm64` | `arm64` runs the plan and the chunks on an arm64 container-image function, pushed to an ECR repository named `<stack>-video`; audio and assemble stay on x86. Needs `sparticuz`. `x86_64` runs everything on the x86 function |
| `--skip-build` | off | Reuse the existing `handler.zip` |

And these drive `render` and `render-batch`:
Expand Down
9 changes: 9 additions & 0 deletions packages/aws-lambda/src/sdk/costAccounting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ describe("computeRenderCost", () => {
expect(result.breakdown.stepFunctionsUsd).toBe(0);
});

it("prices arm64 invocations at the arm64 rate", () => {
// 10 GiB × 6 s = 60 GB-s × $0.0000133334 = $0.0008.
const result = computeRenderCost(
[{ billedDurationMs: 6_000, memorySizeMb: 10_240, estimated: false, architecture: "arm64" }],
0,
);
expect(result.breakdown.lambdaUsd).toBeCloseTo(0.0008, 4);
});

it("sums multiple Lambda invocations", () => {
const invs: BilledLambdaInvocation[] = [
{ billedDurationMs: 1_000, memorySizeMb: 1_024, estimated: false }, // 1 GB-s
Expand Down
9 changes: 6 additions & 3 deletions packages/aws-lambda/src/sdk/costAccounting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
* raw number.
*/

/** On-demand Lambda price, us-east-1, x86_64, on-demand: USD per GB-second. */
const LAMBDA_USD_PER_GB_SECOND = 0.0000166667;
/** On-demand Lambda price, us-east-1: USD per GB-second, by function architecture. */
const LAMBDA_USD_PER_GB_SECOND = { x86_64: 0.0000166667, arm64: 0.0000133334 } as const;
export type LambdaArchitecture = keyof typeof LAMBDA_USD_PER_GB_SECOND;
/** Step Functions Standard Workflows, us-east-1: USD per state transition. */
const SFN_USD_PER_TRANSITION = 0.000025;

Expand All @@ -30,6 +31,8 @@ export interface BilledLambdaInvocation {
memorySizeMb: number;
/** `true` if the event payload did NOT carry a billed duration and we fell back to `Duration` or a constant. */
estimated: boolean;
/** Architecture of the invoked function; x86_64 when omitted. */
architecture?: LambdaArchitecture;
}

/** Result of {@link computeRenderCost}. */
Expand Down Expand Up @@ -63,7 +66,7 @@ export function computeRenderCost(
let anyEstimated = false;
for (const inv of lambdaInvocations) {
const gbSeconds = (inv.memorySizeMb / 1024) * (inv.billedDurationMs / 1000);
lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND;
lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND[inv.architecture ?? "x86_64"];
if (inv.estimated) anyEstimated = true;
}
const stepFunctionsUsd = stateTransitions * SFN_USD_PER_TRANSITION;
Expand Down
67 changes: 67 additions & 0 deletions packages/aws-lambda/src/sdk/getRenderProgress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,36 @@ describe("getRenderProgress", () => {
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.001, 4);
});

it("prices the plan and chunk invocations at the video function's architecture", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
taskSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 100_000 }),
stateEntered("RenderChunk"),
taskSucceeded({ Action: "renderChunk", FramesEncoded: 30, DurationMs: 4_000 }),
stateEntered("Assemble"),
taskSucceeded({ Action: "assemble", DurationMs: 2_000 }),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
defaultMemorySizeMb: 10_240,
videoArchitecture: "arm64",
sfn: sfn as unknown as SFNClient,
});
const billed = (ms: number) => ({
billedDurationMs: ms,
memorySizeMb: 10_240,
estimated: false,
});
const arm64 = (ms: number) => ({ ...billed(ms), architecture: "arm64" as const });
const expected = [arm64(100_000), arm64(4_000), billed(2_000)];
expect(progress.costs.breakdown.lambdaUsd).toBe(
computeRenderCost(expected, 0).breakdown.lambdaUsd,
);
});

it("captures Lambda failures with the enclosing state name", async () => {
const sfn = new FakeSFN();
const failed: HistoryEvent = {
Expand Down Expand Up @@ -451,6 +481,43 @@ describe("getRenderProgress", () => {
expect(p.costs.breakdown.lambdaUsd).toBe(computeRenderCost(billed, 0).breakdown.lambdaUsd);
});

it("prices the plan and chunks at the arm64 rate when an arm64 video function runs them", async () => {
const sfn = new FakeSFN();
const chunks = Array.from({ length: CHUNKS }, (_, i) => ({
ChunkIndex: i,
FramesEncoded: 16,
DurationMs: 4000,
}));
sfn.historyPages = [
[
...parentUntilMapRun(3600),
{
type: "MapStateExited",
id: 1,
timestamp: new Date(),
stateExitedEventDetails: {
name: "RenderChunksV2",
output: JSON.stringify({ Chunks: chunks }),
},
} as HistoryEvent,
],
];
const p = await getRenderProgress({
executionArn: "arn:x",
videoArchitecture: "arm64",
sfn: sfn as unknown as SFNClient,
});
const plan = { billedDurationMs: 1000, memorySizeMb: 10240, estimated: false };
const chunk = { billedDurationMs: 4000, memorySizeMb: 10240, estimated: false };
const onArm = (inv: typeof plan) => ({ ...inv, architecture: "arm64" as const });
const arm64 = [onArm(plan), ...chunks.map(() => onArm(chunk))];
const allX86 = [plan, ...chunks.map(() => chunk)];
expect(p.costs.breakdown.lambdaUsd).toBe(computeRenderCost(arm64, 0).breakdown.lambdaUsd);
expect(p.costs.breakdown.lambdaUsd).toBeLessThan(
computeRenderCost(allX86, 0).breakdown.lambdaUsd,
);
});

it("surfaces a failed chunk's own error, reading only failed children", async () => {
const statuses = Array.from({ length: CHUNKS }, (_, i) =>
i === 7 ? "FAILED" : i < 100 ? "SUCCEEDED" : "ABORTED",
Expand Down
77 changes: 55 additions & 22 deletions packages/aws-lambda/src/sdk/getRenderProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import {
type BilledLambdaInvocation,
computeRenderCost,
type LambdaArchitecture,
type RenderCost,
} from "./costAccounting.js";

Expand All @@ -45,6 +46,8 @@ export interface GetRenderProgressOptions {
* `LambdaMemoryMb` parameter the stack was deployed with.
*/
defaultMemorySizeMb?: number;
/** Architecture of the stack's video function, which runs the plan and the chunks: `arm64` when it has one. */
videoArchitecture?: LambdaArchitecture;
region?: string;
/** Test injection seam. */
sfn?: SFNClient;
Expand Down Expand Up @@ -113,10 +116,10 @@ export async function getRenderProgress(opts: GetRenderProgressOptions): Promise
const endedAt = describe.stopDate?.toISOString() ?? null;

const history = await loadFullHistory(sfn, opts.executionArn);
const summary = summarizeHistory(history, memoryMb);
const summary = summarizeHistory(history, memoryMb, opts.videoArchitecture);
const mapRun = findChunkMapRun(history);
const warnings: string[] = [];
if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb);
if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb, opts.videoArchitecture);
else if (mapRun) {
await addRunningMapRun(sfn, mapRun.arn, summary, memoryMb).catch((err: unknown) => {
if ((err as { name?: unknown } | null)?.name !== "AccessDeniedException") throw err;
Expand Down Expand Up @@ -194,17 +197,17 @@ function findChunkMapRun(events: HistoryEvent[]): ChunkMapRun | null {
}

/** Exact chunk frames and cost from the chunk results the Map's output carries. */
function addChunkResults(summary: HistorySummary, mapOutput: string | undefined, memoryMb: number) {
function addChunkResults(
summary: HistorySummary,
mapOutput: string | undefined,
memoryMb: number,
architecture: LambdaArchitecture | undefined,
) {
const chunks = (parseJson(mapOutput) as { Chunks?: unknown } | null)?.Chunks;
for (const chunk of Array.isArray(chunks) ? chunks : []) {
summary.lambdasInvoked++;
summary.stateTransitions++;
const billedDurationMs = inferBilledMs(chunk);
summary.lambdaInvocations.push({
billedDurationMs,
memorySizeMb: memoryMb,
estimated: billedDurationMs === 0,
});
summary.lambdaInvocations.push(billedInvocation(chunk, memoryMb, architecture));
applyPayloadFrameCounts(chunk, "RenderChunk", (delta) => {
summary.framesRendered += delta;
});
Expand Down Expand Up @@ -267,7 +270,11 @@ interface HistorySummary {
* invocations = `LambdaFunctionScheduled` count. Frame totals come from
* the success-payload of each Lambda invocation.
*/
function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySummary {
function summarizeHistory(
events: HistoryEvent[],
memoryMb: number,
videoArchitecture?: LambdaArchitecture,
): HistorySummary {
let framesRendered = 0;
let totalFrames: number | null = null;
let lambdasInvoked = 0;
Expand Down Expand Up @@ -314,12 +321,13 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
if (ev.taskSucceededEventDetails?.resourceType !== "lambda") break;
const wrapped = parseJson(ev.taskSucceededEventDetails?.output);
const payload = unwrapLambdaPayload(wrapped);
const billedDurationMs = inferBilledMs(payload);
lambdaInvocations.push({
billedDurationMs,
memorySizeMb: memoryMb,
estimated: billedDurationMs === 0,
});
lambdaInvocations.push(
billedInvocation(
payload,
memoryMb,
stateArchitecture(currentLambdaState, videoArchitecture),
),
);
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
framesRendered += delta;
});
Expand All @@ -331,12 +339,13 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
}
case "LambdaFunctionSucceeded": {
const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
const billedDurationMs = inferBilledMs(payload);
lambdaInvocations.push({
billedDurationMs,
memorySizeMb: memoryMb,
estimated: billedDurationMs === 0,
});
lambdaInvocations.push(
billedInvocation(
payload,
memoryMb,
stateArchitecture(currentLambdaState, videoArchitecture),
),
);
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
framesRendered += delta;
});
Expand Down Expand Up @@ -477,6 +486,30 @@ function isAssembleState(name: string | null | undefined): boolean {
* on `LambdaFunctionSucceeded` (the dedicated `BilledDuration` field
* is in CloudWatch Metrics, not the SFN history payload).
*/
function billedInvocation(
payload: unknown,
memoryMb: number,
architecture: LambdaArchitecture | undefined,
): BilledLambdaInvocation {
const billedDurationMs = inferBilledMs(payload);
return {
billedDurationMs,
memorySizeMb: memoryMb,
estimated: billedDurationMs === 0,
architecture,
};
}

/** The video function runs the plan and the chunks; audio and assemble run on the render function. */
function stateArchitecture(
state: string | null,
videoArchitecture: LambdaArchitecture | undefined,
): LambdaArchitecture | undefined {
return state === "Plan" || state === "PlanV2" || isRenderChunkState(state)
? videoArchitecture
: undefined;
}

function inferBilledMs(payload: unknown): number {
if (!payload || typeof payload !== "object") return 0;
const obj = payload as Record<string, unknown>;
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
import { type CanvasResolution, DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import type { VideoArch } from "./lambda/videoImage.js";
import { c } from "../ui/colors.js";
import { defaultCloudFps } from "../utils/compositionFps.js";
import { isMissingPackageError, missingPackageMessage } from "../utils/missingPackage.js";
Expand Down Expand Up @@ -120,6 +121,11 @@ export default defineCommand({
description: "sparticuz | chrome-headless-shell (default: sparticuz)",
},
memory: { type: "string", description: "Lambda memory MB (default: 10240)" },
"video-arch": {
type: "string",
description:
"arm64 | x86_64: where the plan and chunks run; arm64 adds a container-image function (default: arm64)",
},
"skip-build": { type: "boolean", description: "Reuse existing handler.zip (deploy)" },

// sites / render
Expand Down Expand Up @@ -258,6 +264,7 @@ export default defineCommand({
reservedConcurrency: parseReservedConcurrency(args.concurrency),
chromeSource: parseChromeSource(args["chrome-source"]),
lambdaMemoryMb: parsePositiveInt(args.memory, "--memory"),
videoArch: parseVideoArch(args["video-arch"]),
skipBuild: Boolean(args["skip-build"]),
});
return;
Expand Down Expand Up @@ -483,6 +490,9 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined =>
parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined);
const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] =>
parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!;
const VIDEO_ARCHS = ["x86_64", "arm64"] as const satisfies readonly VideoArch[];
const parseVideoArch = (raw: unknown): (typeof VIDEO_ARCHS)[number] | undefined =>
parseEnum(raw, VIDEO_ARCHS, "[lambda deploy] --video-arch", undefined);

/**
* Lambda flavor of the shared {@link parseOutputResolutionFlag} — same wire
Expand Down
Loading
Loading