import { failCommand } from "../utils/commandResult.js"; /** * `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands. * * Each subverb lives in `./lambda/.ts` and exports a single * `runXxx(args)` async function. The subcommand surface is intentionally * thin glue: argument parsing + help text here; the actual work * (`renderToLambda` / `getRenderProgress` / `deploySite` / SAM driver) * lives in `@hyperframes/aws-lambda/sdk`. */ import { defineCommand } from "citty"; import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk"; import { type CanvasResolution } from "@hyperframes/core"; import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { readAllowedCompositionFpsFromDir } from "../utils/compositionFps.js"; export const examples: Example[] = [ ["Deploy the Lambda render stack to AWS", "hyperframes lambda deploy"], [ "Render a composition on the deployed stack", "hyperframes lambda render ./my-project --width 1920 --height 1080", ], [ "Render and stream progress until done", "hyperframes lambda render ./my-project --width 1920 --height 1080 --wait", ], [ "Supersample a 1080p composition to 4K via Chrome deviceScaleFactor", "hyperframes lambda render ./my-project --width 1920 --height 1080 --output-resolution 4k --wait", ], [ "Render with composition variables (personalised template)", 'hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables \'{"title":"Hello Alice","accent":"#ff0000"}\'', ], [ "Render with variables from a JSON file", "hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables-file ./alice.json", ], [ "Batch-render N personalised videos from a JSONL file (deploys the site once)", "hyperframes lambda render-batch ./my-template --batch ./users.jsonl --width 1920 --height 1080 --max-concurrent 10", ], ["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"], [ "Pre-upload a project so multiple renders share the upload", "hyperframes lambda sites create ./my-project", ], ["Tear the stack down", "hyperframes lambda destroy"], ["Print the IAM policy the CLI needs", "hyperframes lambda policies user"], [ "Validate a checked-in IAM policy still covers the CLI", "hyperframes lambda policies validate ./infra/iam/hyperframes.json", ], ]; const HELP = ` ${c.bold("hyperframes lambda")} ${c.dim(" [args]")} Deploy + drive distributed video renders on AWS Lambda. ${c.bold("SUBCOMMANDS:")} ${c.accent("deploy")} ${c.dim("Provision the Lambda + Step Functions + S3 stack via SAM")} ${c.accent("sites create")} ${c.dim("Tar + upload a project to S3 (reusable across renders)")} ${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")} ${c.accent("render-batch")} ${c.dim("Fan out N personalised renders from a JSONL batch file")} ${c.accent("progress")} ${c.dim("Print progress + cost for an in-flight or finished render")} ${c.accent("destroy")} ${c.dim("Tear the stack down (S3 bucket is retained)")} ${c.accent("policies")} ${c.dim("Print or validate the IAM permissions the CLI needs")} ${c.bold("FIRST RUN:")} ${c.accent("hyperframes lambda deploy")} ${c.accent("hyperframes lambda render ./my-project --width 1920 --height 1080 --wait")} ${c.bold("REQUIREMENTS:")} • AWS CLI configured (env vars, ~/.aws/credentials, or SSO) • AWS SAM CLI installed (https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) • bun on PATH (used to build the handler ZIP) `; export default defineCommand({ meta: { name: "lambda", description: "Deploy and drive renders on AWS Lambda" }, args: { subcommand: { type: "positional", required: false, description: "deploy | sites | render | progress | destroy | policies", }, target: { type: "positional", required: false, description: "Subcommand-specific positional (project dir, render id, policies verb, etc.)", }, extra: { type: "positional", required: false, description: "Extra positional (e.g. `sites create ` or `policies validate `)", }, // Stack identity "stack-name": { type: "string", description: "CloudFormation stack name (default: hyperframes-default)", }, region: { type: "string", description: "AWS region (default: AWS_REGION env or us-east-1)" }, profile: { type: "string", description: "AWS profile name (default: AWS_PROFILE env)" }, // deploy concurrency: { type: "string", description: "Lambda reserved concurrency (default: 8)" }, "chrome-source": { type: "string", description: "sparticuz | chrome-headless-shell (default: sparticuz)", }, memory: { type: "string", description: "Lambda memory MB (default: 10240)" }, "skip-build": { type: "boolean", description: "Reuse existing handler.zip (deploy)" }, // sites / render "site-id": { type: "string", description: "Explicit site id (overrides content hash)" }, width: { type: "string", description: "Render width in pixels" }, height: { type: "string", description: "Render height in pixels" }, "output-resolution": { type: "string", description: "Output resolution preset that engages Chrome deviceScaleFactor supersampling. Accepts canonical names (landscape, landscape-4k, portrait, portrait-4k, square, square-4k) and aliases (1080p, 4k, uhd, hd). When set, the composition's authored data-width/data-height is supersampled to the target preset without changing the layout.", }, fps: { type: "string", description: "Render fps (24 | 30 | 60)" }, format: { type: "string", description: "mp4 | mov | png-sequence | webm (default: mp4)" }, codec: { type: "string", description: "h264 | h265 (mp4 only)" }, quality: { type: "string", description: "draft | standard | high" }, "chunk-size": { type: "string", description: "Frames per chunk (default: 240)" }, "max-parallel-chunks": { type: "string", description: "Max concurrent chunks (default: 16)" }, "target-chunk-frames": { type: "string", description: "Cap per-chunk frames; auto-adds chunks (up to --max-parallel-chunks) to keep each under this. Ignored if --chunk-size is set.", }, "execution-name": { type: "string", description: "Step Functions execution name (default: hf-render-)", }, "output-key": { type: "string", description: "Final output S3 key (default: renders//output.)", }, // Variables — mirrors the local `hyperframes render` UX. Inline JSON or // file path, plus --strict-variables for type-checked validation against // the composition's `data-composition-variables` declaration. variables: { type: "string", description: 'JSON object of variable values for the composition. Example: --variables \'{"title":"Hello"}\'. Values flow into window.__hfVariables on the Lambda chunk workers.', }, "variables-file": { type: "string", description: "Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.", }, "strict-variables": { type: "boolean", description: "Fail the render command if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.", default: false, }, // render-batch batch: { type: "string", description: 'Path to a JSONL batch file for `render-batch`. Each line: {"outputKey":"...","variables":{...}}', }, "max-concurrent": { type: "string", description: "Max in-flight Step Functions executions for `render-batch` (default: 50). Distinct from --max-parallel-chunks (which caps chunks per render).", }, "dry-run": { type: "boolean", description: "For `render-batch`: parse the batch file and print the manifest without invoking AWS. Every entry's status becomes `would-invoke`.", default: false, }, wait: { type: "boolean", description: "Block until the render finishes" }, "wait-interval-ms": { type: "string", description: "Poll cadence in ms when --wait is set (default: 5000)", }, // shared json: { type: "boolean", description: "Emit machine-readable JSON" }, }, async run({ args }) { const subcommand = args.subcommand; if (!subcommand) { console.log(HELP); return; } const stackName = (args["stack-name"] as string | undefined) ?? // Lazy-imported so the dispatcher doesn't pull state.ts (and its // node:fs deps) on every CLI invocation — only on lambda runs. (await import("./lambda/state.js")).DEFAULT_STACK_NAME; // Apply --profile globally before any AWS-SDK / `aws` / `sam` call runs. // The AWS SDK + the SAM CLI both read AWS_PROFILE from the environment, // so setting it here threads the value through render / progress / sites // (which don't take an explicit awsProfile arg) without each subverb // having to know about it. Region gets the same treatment so the SDK // clients constructed inside the SDK pick it up too. const profileFlag = args.profile as string | undefined; if (profileFlag) process.env.AWS_PROFILE = profileFlag; const regionFlag = args.region as string | undefined; if (regionFlag) process.env.AWS_REGION = regionFlag; // The lambda subverbs dynamic-import `@hyperframes/aws-lambda` at call // time. We keep aws-lambda as a workspace devDependency (not a runtime // dep) so the published CLI install stays small for users who don't // deploy to Lambda. Subverbs other than `policies` need aws-lambda; // catch the missing-module error here and turn it into a friendly hint. const verbsNeedingSDK = new Set([ "deploy", "sites", "render", "render-batch", "progress", "destroy", ]); if (verbsNeedingSDK.has(subcommand)) { try { await import("@hyperframes/aws-lambda/sdk"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND") { console.error( `${c.error("@hyperframes/aws-lambda is not installed.")} The ${c.accent(`hyperframes lambda ${subcommand}`)} command needs it at runtime.\n` + `Install it alongside the CLI:\n` + ` ${c.accent("npm install -g @hyperframes/aws-lambda")}\n` + `Or, for an opt-in dev setup:\n` + ` ${c.accent("npm install @hyperframes/aws-lambda")}`, ); failCommand(); } throw err; } } switch (subcommand) { case "deploy": { const { runDeploy } = await import("./lambda/deploy.js"); await runDeploy({ stackName, region: args.region as string | undefined, awsProfile: args.profile as string | undefined, reservedConcurrency: parsePositiveInt(args.concurrency, "--concurrency"), chromeSource: parseChromeSource(args["chrome-source"]), lambdaMemoryMb: parsePositiveInt(args.memory, "--memory"), skipBuild: Boolean(args["skip-build"]), }); return; } case "sites": { if (args.target !== "create") { console.error( `[lambda sites] unknown verb "${String(args.target)}". Only "create" is supported.`, ); failCommand(); } const projectDir = args.extra as string | undefined; if (!projectDir) { console.error( "[lambda sites create] usage: hyperframes lambda sites create ", ); failCommand(); } const { runSitesCreate } = await import("./lambda/sites.js"); await runSitesCreate({ projectDir, stackName, siteId: args["site-id"] as string | undefined, json: Boolean(args.json), }); return; } case "render": { const projectDir = args.target as string | undefined; if (!projectDir) { console.error( "[lambda render] usage: hyperframes lambda render --width --height ", ); failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[lambda render] --width and --height are required."); failCommand(); } const fpsRaw = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 30, 60]) ?? 30; if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) { console.error(`[lambda render] --fps must be 24, 30, or 60; got ${fpsRaw}.`); failCommand(); } const { runRender } = await import("./lambda/render.js"); const renderResolution = parseOutputResolution(args["output-resolution"]); await runRender({ projectDir, stackName, siteId: args["site-id"] as string | undefined, fps: fpsRaw, width, height, outputResolution: renderResolution.outputResolution, outputResolutionAspectAgnostic: renderResolution.outputResolutionAspectAgnostic, format: parseFormat(args.format), codec: parseCodec(args.codec), quality: parseQuality(args.quality), chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"), maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"), executionName: args["execution-name"] as string | undefined, outputKey: args["output-key"] as string | undefined, variables: args.variables as string | undefined, variablesFile: args["variables-file"] as string | undefined, strictVariables: Boolean(args["strict-variables"]), json: Boolean(args.json), wait: Boolean(args.wait), waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000, }); return; } case "render-batch": { const projectDir = args.target as string | undefined; if (!projectDir) { console.error( "[lambda render-batch] usage: hyperframes lambda render-batch --batch --width --height ", ); failCommand(); } const batch = args.batch as string | undefined; if (!batch) { console.error( "[lambda render-batch] --batch is required. Each line is a JSON object with at least { outputKey: '...' }.", ); failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[lambda render-batch] --width and --height are required."); failCommand(); } const fpsRaw = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 30, 60]) ?? 30; if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) { console.error(`[lambda render-batch] --fps must be 24, 30, or 60; got ${fpsRaw}.`); failCommand(); } const { runRenderBatch } = await import("./lambda/render-batch.js"); const batchResolution = parseOutputResolution(args["output-resolution"]); await runRenderBatch({ projectDir, stackName, batch, siteId: args["site-id"] as string | undefined, fps: fpsRaw, width, height, outputResolution: batchResolution.outputResolution, outputResolutionAspectAgnostic: batchResolution.outputResolutionAspectAgnostic, format: parseFormat(args.format), codec: parseCodec(args.codec), quality: parseQuality(args.quality), chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"), maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"), maxConcurrent: parsePositiveInt(args["max-concurrent"], "--max-concurrent"), strictVariables: Boolean(args["strict-variables"]), dryRun: Boolean(args["dry-run"]), json: Boolean(args.json), }); return; } case "progress": { const target = args.target as string | undefined; if (!target) { console.error( "[lambda progress] usage: hyperframes lambda progress ", ); failCommand(); } const { runProgress } = await import("./lambda/progress.js"); await runProgress({ target, stackName, json: Boolean(args.json) }); return; } case "destroy": { const { runDestroy } = await import("./lambda/destroy.js"); await runDestroy({ stackName, awsProfile: args.profile as string | undefined }); return; } case "policies": { const verb = args.target as string | undefined; if (verb !== "role" && verb !== "user" && verb !== "validate") { console.error( `[lambda policies] usage: hyperframes lambda policies [args]`, ); failCommand(); } const { runPolicies } = await import("./lambda/policies.js"); await runPolicies({ verb, inputPath: args.extra as string | undefined, json: Boolean(args.json), }); return; } default: console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); failCommand(); } }, }); function parseIntFlag(raw: unknown): number | undefined { if (raw === undefined || raw === null || raw === "") return undefined; const n = Number.parseInt(String(raw), 10); return Number.isFinite(n) ? n : undefined; } /** * Parse a flag that must be a positive integer (>= 1) when supplied. * Negative values or non-integers fail loudly instead of flowing into * the SDK and producing opaque AWS validation errors mid-render. */ function parsePositiveInt(raw: unknown, flagName: string): number | undefined { const n = parseIntFlag(raw); if (n === undefined) return undefined; if (!Number.isInteger(n) || n < 1) { throw new Error(`[lambda] ${flagName} must be a positive integer; got ${n}`); } return n; } /** * Parse a string-union flag against a closed set of allowed values. * Returns `defaultValue` (which may be `undefined`) when the input is * empty; throws with a flag-specific message when the value is set * but unrecognised. */ function parseEnum( raw: unknown, allowed: readonly T[], errorPrefix: string, defaultValue: T | undefined, ): T | undefined { if (raw === undefined || raw === null || raw === "") return defaultValue; const s = String(raw); if ((allowed as readonly string[]).includes(s)) return s as T; throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`); } const FORMATS = [ "mp4", "mov", "png-sequence", "webm", ] as const satisfies readonly DistributedFormat[]; const CODECS = ["h264", "h265"] as const; const QUALITIES = ["draft", "standard", "high"] as const; const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const; const parseFormat = (raw: unknown): (typeof FORMATS)[number] => parseEnum(raw, FORMATS, "[lambda render] --format", "mp4")!; const parseCodec = (raw: unknown): (typeof CODECS)[number] | undefined => parseEnum(raw, CODECS, "[lambda render] --codec", undefined); 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")!; /** * Lambda flavor of the shared {@link parseOutputResolutionFlag} — same wire * contract as the Cloud Run counterpart. Runtime work lives in the shared * util; wire-config-level coverage lives at `./lambda/render.test.ts` / * `./lambda/render-batch.test.ts`, and full input-space coverage at * `../utils/parseOutputResolution.test.ts`. */ function parseOutputResolution(raw: unknown): { outputResolution: CanvasResolution | undefined; outputResolutionAspectAgnostic: boolean; } { return parseOutputResolutionFlag(raw, { surfaceLabel: "[lambda render]", // The Lambda `--output-resolution` help text advertises the full alias // list (tier-only + orientation-suffixed) — keep the error message // faithful to that surface's docs. aliasHint: "1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square", }); }