import { setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda render ` — start a distributed render * against the deployed stack. Wraps {@link renderToLambda}. Does NOT * poll — use `hyperframes lambda progress` for that. */ import { existsSync } from "node:fs"; import { join, resolve as resolvePath } from "node:path"; import type { DistributedFormat, SerializableDistributedRenderConfig, } from "@hyperframes/aws-lambda/sdk"; import type { CanvasResolution } from "@hyperframes/core"; import { c } from "../../ui/colors.js"; import { reportVariableIssues, resolveVariablesArg, validateVariablesAgainstProject, } from "../../utils/variables.js"; import { warnOnDimensionMismatch } from "./_dimensions.js"; import { requireStack, stateFilePath } from "./state.js"; // Dynamic-import the SDK so tsup keeps it out of the static-import head of // the CLI bundle. See sites.ts loadSDK() for the full rationale. async function loadSDK(): Promise { return import("@hyperframes/aws-lambda/sdk"); } export interface RenderArgs { projectDir: string; stackName: string; siteId?: string; /** Composition config — fps/width/height/format required, rest optional. */ fps: 24 | 30 | 60; width: number; height: number; /** * Optional output resolution preset that engages Chrome `deviceScaleFactor` * supersampling. When set, the composition is laid out at the canvas size * declared by `data-width`/`data-height` (or `--width`/`--height`) and * supersampled to the preset's dimensions — `landscape-4k` (3840×2160) * from a 1920×1080 layout uses DPR 2, etc. */ outputResolution?: CanvasResolution; /** * True when {@link outputResolution} was normalized from an aspect-agnostic * alias (`1080p` / `hd` / `4k` / `uhd`). Threaded through * `SerializableDistributedRenderConfig` so the Lambda worker's compile stage * remaps `landscape` → `portrait` / `square` when the composition's * orientation demands it. Dropping this field is what shipped the * portrait-1080p regression at the sibling entrypoints (see PR #2529). */ outputResolutionAspectAgnostic?: boolean; format: DistributedFormat; codec?: "h264" | "h265"; quality?: "draft" | "standard" | "high"; chunkSize?: number; maxParallelChunks?: number; targetChunkFrames?: number; executionName?: string; outputKey?: string; /** Inline JSON for `--variables '{...}'`. Mutually exclusive with `variablesFile`. */ variables?: string; /** Path to a JSON file for `--variables-file ./vars.json`. */ variablesFile?: string; /** * Fail the 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 (matches the local `hyperframes render` * behavior). */ strictVariables?: boolean; /** Print machine-readable JSON instead of the human-friendly summary. */ json: boolean; /** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */ wait: boolean; /** Poll cadence in ms when `--wait` is set. */ waitIntervalMs: number; } // fallow-ignore-next-line complexity export async function runRender(args: RenderArgs): Promise { const stack = requireStack(args.stackName); const projectDir = resolvePath(args.projectDir); warnOnDimensionMismatch({ projectDir, cliWidth: args.width, cliHeight: args.height, outputResolution: args.outputResolution, quiet: args.json, }); // Resolve --variables / --variables-file using the same parser the local // `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly // errorBox on parse errors so callers don't have to. const variables = resolveVariablesArg(args.variables, args.variablesFile); // Validate against the composition's `data-composition-variables` // declaration when present. The local CLI silently treats unreadable // index.html as "no declarations" — mirror that. Skip validation // entirely when the project dir is missing on disk (e.g. `--site-id` // pointing at a pre-uploaded site that was packaged on another machine). if (variables && Object.keys(variables).length > 0) { const indexPath = join(projectDir, "index.html"); if (existsSync(indexPath)) { const issues = validateVariablesAgainstProject(indexPath, variables); // Suppress the warning block when --json is set; stdout is reserved // for the manifest. The strict-mode errorBox still prints to stderr // and exits, so machine consumers still get a non-zero exit. reportVariableIssues(issues, { strict: args.strictVariables ?? false, quiet: args.json }); } else if (args.strictVariables && !args.json) { // --strict-variables asks for typed checking but there's no // index.html to check against (typical with --site-id pointing at a // pre-uploaded site). Make that silent skip visible so the flag // doesn't quietly become a no-op. console.warn( c.warn( `--strict-variables: no ${indexPath} on disk — schema validation skipped. ` + "Variables flow through unchecked. To enable strict checking, run from a project dir that contains the composition.", ), ); } } const config: SerializableDistributedRenderConfig = buildLambdaRenderConfig(args, variables); // When the caller passes only --site-id, synthesise the minimum-shape // SiteHandle pointing at the deterministic content-addressed key. The // `bytes` / `uploadedAt` fields are intentionally placeholders — the // SDK reads only `siteId` + `projectS3Uri` when `uploaded: false`. const siteHandle = args.siteId ? { siteId: args.siteId, bucketName: stack.bucketName, projectS3Uri: `s3://${stack.bucketName}/sites/${args.siteId}/project.tar.gz`, bytes: 0, uploadedAt: "", uploaded: false, } : undefined; const { renderToLambda } = await loadSDK(); const handle = await renderToLambda({ projectDir: siteHandle ? undefined : projectDir, siteHandle, bucketName: stack.bucketName, stateMachineArn: stack.stateMachineArn, region: stack.region, config, executionName: args.executionName, outputKey: args.outputKey, }); if (args.json) { // --wait + --json should emit a single parseable JSON document: the // final progress snapshot. Without --wait, emit the handle (the // caller will poll progress separately). Previously this printed // both, producing two concatenated JSON blobs that `jq -r` would // misparse. if (args.wait) { await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json); } else { console.log(JSON.stringify(handle, null, 2)); } return; } console.log(c.success("Render started.")); console.log(` ${c.dim("Render ID:")} ${handle.renderId}`); console.log(` ${c.dim("Execution ARN:")} ${handle.executionArn}`); console.log(` ${c.dim("Output S3 URI:")} ${handle.outputS3Uri}`); console.log(` ${c.dim("Project S3:")} ${handle.projectS3Uri}`); console.log(` ${c.dim("Stack state:")} ${stateFilePath(args.stackName)}`); console.log(); if (args.wait) { await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json); return; } console.log(c.dim(`Poll with: hyperframes lambda progress ${handle.renderId}`)); } async function waitForCompletion( executionArn: string, stack: { region: string; functionName: string; lambdaMemoryMb: number }, intervalMs: number, json: boolean, ): Promise { // Lazy import to avoid pulling SFN client when only `render --no-wait` is used. const { getRenderProgress } = await loadSDK(); let lastRendered = -1; while (true) { const progress = await getRenderProgress({ executionArn, region: stack.region, defaultMemorySizeMb: stack.lambdaMemoryMb, }); if (!json && progress.framesRendered !== lastRendered) { lastRendered = progress.framesRendered; const total = progress.totalFrames ?? "?"; const pct = Math.round(progress.overallProgress * 100); console.log( ` ${c.dim(`[${progress.status}]`)} ${pct}% • ${progress.framesRendered}/${total} frames • ${progress.costs.displayCost}`, ); } if (progress.status !== "RUNNING") { if (json) { console.log(JSON.stringify(progress, null, 2)); } else if (progress.status === "SUCCEEDED" && progress.outputFile) { console.log(); console.log(c.success("Render complete.")); console.log(` ${c.dim("Output:")} ${progress.outputFile.s3Uri}`); console.log(` ${c.dim("Size:")} ${progress.outputFile.bytes ?? "?"} bytes`); console.log(` ${c.dim("Total cost:")} ${progress.costs.displayCost}`); } else { console.log(); console.log(c.error(`Render ended with status ${progress.status}.`)); for (const err of progress.errors) { console.log(` ${c.dim(err.state)}: ${err.error} — ${err.cause}`); } setCommandExitCode(1); } return; } await sleep(intervalMs); } } function sleep(ms: number): Promise { return new Promise((res) => setTimeout(res, ms)); } /** * Build the wire {@link SerializableDistributedRenderConfig} for * `hyperframes lambda render`. Extracted for boundary-test coverage of the * aspect-agnostic flag threading — the field this helper preserves is what * lets the Lambda worker's compile stage remap `landscape` → `portrait` on * an aspect-agnostic alias (`1080p` / `hd` / `4k` / `uhd`). */ export function buildLambdaRenderConfig( args: RenderArgs, variables: Record | undefined, ): SerializableDistributedRenderConfig { const config: SerializableDistributedRenderConfig = { fps: args.fps, width: args.width, height: args.height, outputResolution: args.outputResolution, format: args.format, codec: args.codec, quality: args.quality, chunkSize: args.chunkSize, maxParallelChunks: args.maxParallelChunks, targetChunkFrames: args.targetChunkFrames, runtimeCap: "lambda", variables, }; // Keep the wire shape sparse when the alias flag wasn't set — matches how // `SerializableDistributedRenderConfig` is emitted at every other adapter. if (args.outputResolutionAspectAgnostic) { config.outputResolutionAspectAgnostic = true; } return config; }