mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import { setCommandExitCode } from "../../utils/commandResult.js";
|
||
/**
|
||
* `hyperframes lambda render <projectDir>` — 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<typeof import("@hyperframes/aws-lambda/sdk")> {
|
||
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<void> {
|
||
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<void> {
|
||
// 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<void> {
|
||
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<string, unknown> | 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;
|
||
}
|