Files
hyperframes/packages/cli/src/commands/cloudrun.ts
T

932 lines
35 KiB
TypeScript

// fallow-ignore-file code-duplication
import { failCommand } from "../utils/commandResult.js";
/**
* `hyperframes cloudrun` — deploy + drive distributed renders on Google
* Cloud Run + Cloud Workflows.
*
* The GCP counterpart to `hyperframes lambda`. Thin glue: argument parsing
* + help here; the work lives in `@hyperframes/gcp-cloud-run/sdk`
* (`deploySite` / `renderToCloudRun` / `getRenderProgress`) plus `terraform`
* and `gcloud` for provisioning + the image build.
*
* Stack coordinates (bucket / service URL / workflow id) are captured by
* `deploy` into a small state file under `~/.hyperframes/` so `render` and
* `progress` don't need them re-passed every call.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { defineCommand } from "citty";
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 {
reportVariableIssues,
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../utils/variables.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { readAllowedCompositionFpsFromDir } from "../utils/compositionFps.js";
export const examples: Example[] = [
["Deploy the Cloud Run render stack", "hyperframes cloudrun deploy --project my-gcp-project"],
[
"Render a composition on the deployed stack",
"hyperframes cloudrun render ./my-project --width 1920 --height 1080 --wait",
],
[
"Render a personalised template with variables",
'hyperframes cloudrun render ./my-template --width 1920 --height 1080 --variables \'{"title":"Hello Alice"}\'',
],
[
"Supersample a 1080p composition to 4K",
"hyperframes cloudrun render ./my-project --width 1920 --height 1080 --output-resolution 4k --wait",
],
[
"Batch-render N personalised videos from a JSONL file",
"hyperframes cloudrun render-batch ./my-template --batch ./users.jsonl --width 1920 --height 1080 --max-concurrent 10",
],
["Check progress for a started render", "hyperframes cloudrun progress <executionName>"],
[
"Pre-upload a project so renders share the upload",
"hyperframes cloudrun sites create ./my-project",
],
["Tear the stack down", "hyperframes cloudrun destroy --project my-gcp-project"],
];
const HELP = `
${c.bold("hyperframes cloudrun")} ${c.dim("<subcommand> [args]")}
Deploy + drive distributed video renders on Google Cloud Run + Workflows.
${c.bold("SUBCOMMANDS:")}
${c.accent("deploy")} ${c.dim("Build the image + apply the Terraform module (Cloud Run + Workflows + GCS)")}
${c.accent("sites create")} ${c.dim("Tar + upload a project to GCS (reusable across renders)")}
${c.accent("render")} ${c.dim("Start a distributed render (returns an execution name)")}
${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")}
${c.bold("FIRST RUN:")}
${c.accent("hyperframes cloudrun deploy --project my-gcp-project")}
${c.accent("hyperframes cloudrun render ./my-project --width 1920 --height 1080 --wait")}
${c.bold("REQUIREMENTS:")}
• gcloud authenticated; the target project must have billing enabled
• terraform (>= 1.5) and docker / Cloud Build access on PATH
`;
interface StackState {
projectId: string;
region: string;
bucketName: string;
serviceUrl: string;
workflowId: string;
}
type CloudRunAdapter = typeof import("@hyperframes/gcp-cloud-run/sdk") &
typeof import("@hyperframes/gcp-cloud-run/terraform");
let cloudRunAdapterPromise: Promise<CloudRunAdapter> | undefined;
function loadCloudRunAdapter(): Promise<CloudRunAdapter> {
cloudRunAdapterPromise ??= Promise.all([
import("@hyperframes/gcp-cloud-run/sdk"),
import("@hyperframes/gcp-cloud-run/terraform"),
]).then(([sdk, terraform]) => ({ ...sdk, ...terraform }));
return cloudRunAdapterPromise;
}
export function isMissingCloudRunAdapterError(error: unknown): boolean {
return (
(error as NodeJS.ErrnoException)?.code === "ERR_MODULE_NOT_FOUND" &&
normalizeErrorMessage(error).includes("@hyperframes/gcp-cloud-run")
);
}
export function missingCloudRunAdapterMessage(subcommand: string): string {
return (
`${c.error("@hyperframes/gcp-cloud-run is not installed.")} The ${c.accent(`hyperframes cloudrun ${subcommand}`)} command needs it at runtime.\n` +
`Install it alongside the CLI:\n` +
` ${c.accent("npm install -g @hyperframes/gcp-cloud-run")}\n` +
`Or, for an opt-in project setup:\n` +
` ${c.accent("npm install @hyperframes/gcp-cloud-run")}`
);
}
export default defineCommand({
meta: { name: "cloudrun", description: "Deploy and drive renders on Google Cloud Run" },
args: {
subcommand: {
type: "positional",
required: false,
description: "deploy | sites | render | render-batch | progress | destroy",
},
target: {
type: "positional",
required: false,
description: "Subcommand positional (project dir, execution name, sites verb)",
},
extra: {
type: "positional",
required: false,
description: "Extra positional (e.g. `sites create <projectDir>`)",
},
// Stack identity
project: {
type: "string",
description: "GCP project id (required for deploy/destroy; cached after deploy)",
},
region: { type: "string", description: "GCP region (default: us-central1)" },
image: {
type: "string",
description:
"Container image for the render service (deploy). If unset, deploy builds it via Cloud Build.",
},
repo: {
type: "string",
description: "Artifact Registry repo for the built image (default: hyperframes)",
},
// Machine sizing / scaling (deploy). Omitted flags keep the Terraform
// module defaults (4 vCPU / 16Gi / 100 instances / 3600s).
cpu: { type: "string", description: "vCPU per Cloud Run instance: 1 | 2 | 4 | 8 (deploy)" },
memory: { type: "string", description: "Memory per instance, e.g. 16Gi | 32Gi (deploy)" },
"max-instances": {
type: "string",
description: "Max Cloud Run instances = render fan-out ceiling (deploy)",
},
timeout: {
type: "string",
description: "Per-request timeout in seconds, max 3600 (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" },
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" },
"max-parallel-chunks": { type: "string", description: "Max concurrent chunks" },
"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.",
},
"output-resolution": {
type: "string",
description:
"Output resolution preset that engages Chrome deviceScaleFactor supersampling (e.g. 4k, 1080p, landscape-4k). The composition's authored data-width/data-height is supersampled to the target without changing layout.",
},
variables: {
type: "string",
description:
'JSON object of composition variable values, e.g. --variables \'{"title":"Hi"}\'',
},
"variables-file": { type: "string", description: "Path to a JSON file of variable values" },
"strict-variables": {
type: "boolean",
description:
"Fail the render if any --variables key is undeclared or mistyped vs the composition's data-composition-variables. Without it, mismatches are warnings.",
default: false,
},
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 executions for `render-batch` (default: 50).",
},
"dry-run": {
type: "boolean",
description:
"For `render-batch`: parse the batch file and print the manifest without starting any execution.",
default: false,
},
"render-id": {
type: "string",
description: "Client render id / GCS prefix (default: hf-render-<uuid>)",
},
"output-key": {
type: "string",
description: "Final output GCS key (default: renders/<renderId>/output.<ext>)",
},
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)",
},
json: { type: "boolean", description: "Emit machine-readable JSON" },
},
// fallow-ignore-next-line complexity
async run({ args }) {
const subcommand = args.subcommand as string | undefined;
if (!subcommand) {
console.log(HELP);
return;
}
const verbsNeedingAdapter = new Set([
"deploy",
"sites",
"render",
"render-batch",
"progress",
"destroy",
]);
if (verbsNeedingAdapter.has(subcommand)) {
try {
await loadCloudRunAdapter();
} catch (error) {
if (isMissingCloudRunAdapterError(error)) {
console.error(missingCloudRunAdapterMessage(subcommand));
failCommand();
}
throw error;
}
}
switch (subcommand) {
case "deploy":
return runDeploy(args);
case "sites":
return runSites(args);
case "render":
return runRender(args);
case "render-batch":
return runRenderBatch(args);
case "progress":
return runProgress(args);
case "destroy":
return runDestroy(args);
default:
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
failCommand();
}
},
});
// ── State helpers ─────────────────────────────────────────────────────────
function stateDir(): string {
return join(homedir(), ".hyperframes");
}
function statePath(): string {
return join(stateDir(), "cloudrun-state.json");
}
function writeState(state: StackState): void {
mkdirSync(stateDir(), { recursive: true });
writeFileSync(statePath(), JSON.stringify(state, null, 2));
}
function readState(args: Record<string, unknown>): StackState {
const overrides = {
projectId: args.project as string | undefined,
region: args.region as string | undefined,
};
let base: Partial<StackState> = {};
if (existsSync(statePath())) {
try {
base = JSON.parse(readFileSync(statePath(), "utf8")) as StackState;
} catch {
// ignore a corrupt state file; flags must supply the values.
}
}
const merged: Partial<StackState> = { ...base, ...stripUndefined(overrides) };
const missing = (
["projectId", "region", "bucketName", "serviceUrl", "workflowId"] as const
).filter((k) => !merged[k]);
if (missing.length > 0) {
console.error(
`[cloudrun] missing stack coordinates: ${missing.join(", ")}. ` +
`Run \`hyperframes cloudrun deploy --project <id>\` first, or pass them as flags.`,
);
failCommand();
}
return merged as StackState;
}
function stripUndefined<T extends Record<string, unknown>>(o: T): Partial<T> {
return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)) as Partial<T>;
}
function run(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): void {
const res = spawnSync(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd });
if (res.status !== 0) {
throw new Error(`[cloudrun] \`${cmd} ${cmdArgs.join(" ")}\` exited with ${res.status}`);
}
}
function capture(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): string {
const res = spawnSync(cmd, cmdArgs, { encoding: "utf8", cwd: opts.cwd });
if (res.status !== 0) {
throw new Error(`[cloudrun] \`${cmd} ${cmdArgs.join(" ")}\` failed: ${res.stderr}`);
}
return res.stdout.trim();
}
// ── deploy ──────────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
async function runDeploy(args: Record<string, unknown>): Promise<void> {
const project = args.project as string | undefined;
if (!project) {
console.error("[cloudrun deploy] --project <gcp-project-id> is required.");
failCommand();
}
const region = (args.region as string | undefined) ?? "us-central1";
const repo = (args.repo as string | undefined) ?? "hyperframes";
const { getTerraformModuleDir } = await loadCloudRunAdapter();
const tfDir = getTerraformModuleDir();
const repoRoot = findRepoRoot(tfDir);
console.log(`→ Enabling required APIs on ${project}`);
run("gcloud", [
"services",
"enable",
"run.googleapis.com",
"workflows.googleapis.com",
"workflowexecutions.googleapis.com",
"artifactregistry.googleapis.com",
"cloudbuild.googleapis.com",
"monitoring.googleapis.com",
"--project",
project,
]);
let image = args.image as string | undefined;
if (!image) {
if (!repoRoot) {
console.error(
"[cloudrun deploy] --image is required when not running from a hyperframes checkout (no Dockerfile context found).",
);
failCommand();
}
// Ensure the Artifact Registry repo exists.
const exists =
spawnSync("gcloud", [
"artifacts",
"repositories",
"describe",
repo,
"--location",
region,
"--project",
project,
]).status === 0;
if (!exists) {
run("gcloud", [
"artifacts",
"repositories",
"create",
repo,
"--repository-format",
"docker",
"--location",
region,
"--project",
project,
]);
}
const tag = new Date()
.toISOString()
.replace(/[^0-9]/g, "")
.slice(0, 14);
image = `${region}-docker.pkg.dev/${project}/${repo}/hyperframes-render:${tag}`;
console.log(`→ Building + pushing ${image} via Cloud Build`);
run("gcloud", [
"builds",
"submit",
repoRoot,
"--project",
project,
"--timeout",
"3600s",
"--config",
writeCloudBuildConfig(image),
]);
}
console.log("→ terraform apply");
run("terraform", ["init", "-input=false"], { cwd: tfDir });
run(
"terraform",
["apply", "-input=false", "-auto-approve", ...machineVars(args, project, region, image)],
{ cwd: tfDir },
);
const state: StackState = {
projectId: project,
region,
bucketName: capture("terraform", ["output", "-raw", "render_bucket_name"], { cwd: tfDir }),
serviceUrl: capture("terraform", ["output", "-raw", "service_url"], { cwd: tfDir }),
workflowId: capture("terraform", ["output", "-raw", "workflow_name"], { cwd: tfDir }),
};
writeState(state);
console.log(`${c.accent("✓ deployed.")} bucket=${state.bucketName} workflow=${state.workflowId}`);
console.log(` service=${state.serviceUrl}`);
console.log(
` Next: ${c.accent("hyperframes cloudrun render ./my-project --width 1920 --height 1080 --wait")}`,
);
}
/**
* Build the `-var` list for `terraform apply`: always project/region/image,
* plus any machine-sizing / scaling flags the caller supplied. Omitted flags
* fall through to the Terraform module defaults (4 vCPU / 16Gi / 100 / 3600s).
*/
// fallow-ignore-next-line complexity
function machineVars(
args: Record<string, unknown>,
project: string,
region: string,
image: string,
): string[] {
const vars = [
"-var",
`project_id=${project}`,
"-var",
`region=${region}`,
"-var",
`image=${image}`,
];
const cpu = args.cpu as string | undefined;
const memory = args.memory as string | undefined;
const maxInstances = parsePositiveInt(args["max-instances"], "--max-instances");
const timeout = parsePositiveInt(args.timeout, "--timeout");
if (cpu) vars.push("-var", `cpu=${cpu}`);
if (memory) vars.push("-var", `memory=${memory}`);
if (maxInstances !== undefined) vars.push("-var", `max_instances=${maxInstances}`);
if (timeout !== undefined) vars.push("-var", `request_timeout_seconds=${timeout}`);
return vars;
}
/** Walk up from the terraform dir to find the repo root (the one with the Dockerfile context). */
function findRepoRoot(tfDir: string): string | null {
// tfDir is <root>/packages/gcp-cloud-run/terraform
const candidate = resolve(tfDir, "..", "..", "..");
if (existsSync(join(candidate, "packages", "gcp-cloud-run", "Dockerfile"))) return candidate;
return null;
}
function writeCloudBuildConfig(image: string): string {
const cfgPath = join(stateDir(), "cloudrun-cloudbuild.yaml");
mkdirSync(stateDir(), { recursive: true });
// The build context (passed to `gcloud builds submit` as the repo root) is
// referenced as "." inside the config; the Dockerfile path is relative to
// that context.
writeFileSync(
cfgPath,
[
"steps:",
"- name: gcr.io/cloud-builders/docker",
` args: ["build","-f","packages/gcp-cloud-run/Dockerfile","-t","${image}","."]`,
`images: ["${image}"]`,
"timeout: 3600s",
"",
].join("\n"),
);
return cfgPath;
}
// ── sites create ──────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
async function runSites(args: Record<string, unknown>): Promise<void> {
if (args.target !== "create") {
console.error(
`[cloudrun sites] unknown verb "${String(args.target)}". Only "create" is supported.`,
);
failCommand();
}
const projectDir = args.extra as string | undefined;
if (!projectDir) {
console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create <projectDir>");
failCommand();
}
const state = readState(args);
const { deploySite } = await loadCloudRunAdapter();
const handle = await deploySite({
projectDir: resolve(projectDir),
bucketName: state.bucketName,
siteId: args["site-id"] as string | undefined,
});
if (args.json) {
console.log(JSON.stringify(handle, null, 2));
} else {
console.log(
`${handle.uploaded ? c.accent("✓ uploaded") : c.dim("• already present")} ` +
`site=${handle.siteId} (${handle.bytes} bytes)\n ${handle.projectGcsUri}`,
);
}
}
// ── render ──────────────────────────────────────────────────────────────────
function resolveCloudRunFps(
args: Record<string, unknown>,
projectDir: string,
command: "render" | "render-batch",
): 24 | 30 | 60 {
const fps =
parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 30, 60]) ?? 30;
if (fps === 24 || fps === 30 || fps === 60) return fps;
console.error(`[cloudrun ${command}] --fps must be 24, 30, or 60; got ${fps}.`);
failCommand();
}
// fallow-ignore-next-line complexity
async function runRender(args: Record<string, unknown>): Promise<void> {
const projectDir = args.target as string | undefined;
if (!projectDir) {
console.error(
"[cloudrun render] usage: hyperframes cloudrun render <projectDir> --width <px> --height <px>",
);
failCommand();
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
if (width === undefined || height === undefined) {
console.error("[cloudrun render] --width and --height are required.");
failCommand();
}
const fps = resolveCloudRunFps(args, projectDir, "render");
const state = readState(args);
const variables = resolveAndValidateVariables(args, resolve(projectDir));
const config = buildRenderConfig(args, fps, width, height, variables);
const { renderToCloudRun, getRenderProgress } = await loadCloudRunAdapter();
const handle = await renderToCloudRun({
projectDir: resolve(projectDir),
config: config as Parameters<typeof renderToCloudRun>[0]["config"],
bucketName: state.bucketName,
projectId: state.projectId,
location: state.region,
workflowId: state.workflowId,
serviceUrl: state.serviceUrl,
renderId: args["render-id"] as string | undefined,
outputKey: args["output-key"] as string | undefined,
} as Parameters<typeof renderToCloudRun>[0]);
if (!args.wait) {
if (args.json) console.log(JSON.stringify(handle, null, 2));
else {
console.log(`${c.accent("✓ render started")} renderId=${handle.renderId}`);
console.log(` output → ${handle.outputGcsUri}`);
console.log(
` progress: ${c.accent(`hyperframes cloudrun progress ${handle.executionName}`)}`,
);
}
return;
}
const intervalMs = parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000;
let progress = await getRenderProgress({ executionName: handle.executionName });
while (progress.status === "running") {
await new Promise((r) => setTimeout(r, intervalMs));
progress = await getRenderProgress({ executionName: handle.executionName });
if (!args.json) process.stdout.write(`\r status=${progress.status} `);
}
if (!args.json) process.stdout.write("\n");
if (args.json) {
console.log(JSON.stringify(progress, null, 2));
} else if (progress.status === "succeeded") {
console.log(
`${c.accent("✓ done.")} ${progress.outputFile?.gcsUri} (${progress.costs.displayCost})`,
);
} else {
console.error(`${c.error("✗ render " + progress.status)}`);
for (const e of progress.errors) console.error(` ${e.state}: ${e.cause}`);
failCommand();
}
}
// ── progress ──────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
async function runProgress(args: Record<string, unknown>): Promise<void> {
const executionName = args.target as string | undefined;
if (!executionName) {
console.error("[cloudrun progress] usage: hyperframes cloudrun progress <executionName>");
failCommand();
}
const { getRenderProgress } = await loadCloudRunAdapter();
const progress = await getRenderProgress({ executionName });
if (args.json) {
console.log(JSON.stringify(progress, null, 2));
return;
}
console.log(`status=${progress.status} progress=${(progress.overallProgress * 100).toFixed(0)}%`);
if (progress.totalFrames)
console.log(`frames=${progress.framesRendered}/${progress.totalFrames}`);
if (progress.outputFile) console.log(`output=${progress.outputFile.gcsUri}`);
console.log(`cost=${progress.costs.displayCost}`);
for (const e of progress.errors) console.error(` error ${e.state}: ${e.cause}`);
}
// ── render-batch ────────────────────────────────────────────────────────────
interface BatchEntry {
outputKey: string;
variables?: Record<string, unknown>;
}
const DEFAULT_BATCH_MAX_CONCURRENT = 50;
/**
* Fan out N personalised renders of the same project from a JSONL batch file
* (one `{ outputKey, variables }` per line). Deploys the site once, then
* starts an execution per entry with a concurrency cap. `--dry-run` prints the
* resolved manifest without starting anything. Mirrors `hyperframes lambda
* render-batch`.
*/
// fallow-ignore-next-line complexity
async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
const projectDir = args.target as string | undefined;
const batchPath = args.batch as string | undefined;
if (!projectDir || !batchPath) {
console.error(
"[cloudrun render-batch] usage: hyperframes cloudrun render-batch <projectDir> --batch <file.jsonl> --width <px> --height <px>",
);
failCommand();
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
if (width === undefined || height === undefined) {
console.error("[cloudrun render-batch] --width and --height are required.");
failCommand();
}
const fps = resolveCloudRunFps(args, projectDir, "render-batch");
if (!existsSync(resolve(batchPath))) {
console.error(`[cloudrun render-batch] batch file not found: ${batchPath}`);
failCommand();
}
const entries = parseBatchFile(resolve(batchPath));
if (entries.length === 0) {
console.error("[cloudrun render-batch] batch file has no entries.");
failCommand();
}
const dryRun = Boolean(args["dry-run"]);
if (dryRun) {
const manifest = entries.map((e, i) => ({
line: i + 1,
outputKey: e.outputKey,
status: "would-start",
}));
console.log(JSON.stringify(manifest, null, 2));
return;
}
const state = readState(args);
const maxConcurrent =
parsePositiveInt(args["max-concurrent"], "--max-concurrent") ?? DEFAULT_BATCH_MAX_CONCURRENT;
const { deploySite, renderToCloudRun } = await loadCloudRunAdapter();
// Upload the project once; every entry reuses the same content-addressed
// site handle so the tar+upload cost is paid a single time.
const siteHandle = await deploySite({
projectDir: resolve(projectDir),
bucketName: state.bucketName,
siteId: args["site-id"] as string | undefined,
});
const results: Array<{ outputKey: string; executionName?: string; error?: string }> = [];
// Start executions in fixed-size waves so we never exceed `maxConcurrent`
// in-flight CreateExecution calls.
for (let i = 0; i < entries.length; i += maxConcurrent) {
const wave = entries.slice(i, i + maxConcurrent);
const settled = await Promise.all(
wave.map(async (entry) => {
try {
const config = buildRenderConfig(args, fps, width, height, entry.variables);
const handle = await renderToCloudRun({
siteHandle,
config: config as Parameters<typeof renderToCloudRun>[0]["config"],
bucketName: state.bucketName,
projectId: state.projectId,
location: state.region,
workflowId: state.workflowId,
serviceUrl: state.serviceUrl,
outputKey: entry.outputKey,
} as Parameters<typeof renderToCloudRun>[0]);
return { outputKey: entry.outputKey, executionName: handle.executionName };
} catch (err) {
return {
outputKey: entry.outputKey,
error: normalizeErrorMessage(err),
};
}
}),
);
results.push(...settled);
}
const failed = results.filter((r) => r.error);
if (args.json) {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(
`${c.accent("✓ started")} ${results.length - failed.length}/${results.length} renders`,
);
for (const r of failed) console.error(` ✗ ${r.outputKey}: ${r.error}`);
}
if (failed.length > 0) failCommand();
}
/** Parse a JSONL batch file into entries, exiting with a clear error on a bad line. */
function parseBatchFile(path: string): BatchEntry[] {
const lines = readFileSync(path, "utf8").split(/\r?\n/);
const entries: BatchEntry[] = [];
// fallow-ignore-next-line complexity
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) return;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
console.error(`[cloudrun render-batch] line ${idx + 1}: not valid JSON`);
failCommand();
}
if (
!parsed ||
typeof parsed !== "object" ||
typeof (parsed as BatchEntry).outputKey !== "string"
) {
console.error(
`[cloudrun render-batch] line ${idx + 1}: must be an object with a string "outputKey"`,
);
failCommand();
}
entries.push(parsed as BatchEntry);
});
return entries;
}
// ── destroy ──────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
async function runDestroy(args: Record<string, unknown>): Promise<void> {
const { getTerraformModuleDir } = await loadCloudRunAdapter();
const tfDir = getTerraformModuleDir();
const state = existsSync(statePath())
? (JSON.parse(readFileSync(statePath(), "utf8")) as Partial<StackState>)
: {};
const project = (args.project as string | undefined) ?? state.projectId;
const region = (args.region as string | undefined) ?? state.region ?? "us-central1";
const image = (args.image as string | undefined) ?? "unused:latest";
if (!project) {
console.error("[cloudrun destroy] --project is required (or deploy first to cache it).");
failCommand();
}
const vars = [
"-var",
`project_id=${project}`,
"-var",
`region=${region}`,
"-var",
`image=${image}`,
"-var",
"bucket_force_destroy=true",
];
console.log("→ terraform destroy");
run("terraform", ["init", "-input=false"], { cwd: tfDir });
// Apply `bucket_force_destroy=true` into state FIRST. Terraform reads a
// bucket's force_destroy from prior state when emptying it during destroy,
// so passing the var only at destroy time can't flip it — a destroy of a
// bucket that still holds render artifacts would fail with "bucket not
// empty". The quick apply updates the attribute, then destroy can sweep
// the (scratch) bucket. Best-effort: if there's nothing to apply this
// no-ops.
try {
run("terraform", ["apply", "-input=false", "-auto-approve", ...vars], { cwd: tfDir });
} catch {
// A failed pre-apply shouldn't block the destroy attempt below.
}
run("terraform", ["destroy", "-input=false", "-auto-approve", ...vars], { cwd: tfDir });
console.log(`${c.accent("✓ destroyed.")}`);
}
// ── config + variables helpers (shared by render + render-batch) ────────────
/**
* Build the serializable render config from CLI flags. `variables` is resolved
* separately (it differs per batch entry). Mirrors the local `hyperframes
* render` flag surface so the two stay consistent.
*/
/**
* Exported for unit-test coverage of the aspect-agnostic wire shape — the
* portrait-1080p sibling-surface regression that shipped in v0.7.60 landed
* here because this builder dropped the tier-alias signal on the floor.
*/
export function buildRenderConfig(
args: Record<string, unknown>,
fps: number,
width: number,
height: number,
variables: Record<string, unknown> | undefined,
): Record<string, unknown> {
const { outputResolution, outputResolutionAspectAgnostic } = parseOutputResolution(
args["output-resolution"],
);
return stripUndefined({
fps,
width,
height,
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"),
outputResolution,
// Set only when true so the wire shape stays sparse for the common
// canonical-preset path (matches how the flag flows through
// `SerializableDistributedRenderConfig` from every other emitter).
outputResolutionAspectAgnostic: outputResolutionAspectAgnostic ? true : undefined,
variables,
});
}
/**
* Resolve --variables / --variables-file via the shared CLI parser (the same
* one `hyperframes render` and `hyperframes lambda render` use), then validate
* against the composition's `data-composition-variables` when an `index.html`
* is on disk. `--strict-variables` turns mismatches into a hard failure.
*/
function resolveAndValidateVariables(
args: Record<string, unknown>,
projectDir: string,
): Record<string, unknown> | undefined {
const variables = resolveVariablesArg(
args.variables as string | undefined,
args["variables-file"] as string | undefined,
);
if (variables && Object.keys(variables).length > 0) {
const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) {
const issues = validateVariablesAgainstProject(indexPath, variables);
reportVariableIssues(issues, {
strict: Boolean(args["strict-variables"]),
quiet: Boolean(args.json),
});
}
}
return variables;
}
/**
* Cloud Run flavor of the shared {@link parseOutputResolutionFlag} — carries
* the aspect-agnostic signal through so `SerializableDistributedRenderConfig`
* can trigger the compile-stage remap. The runtime work lives in the shared
* util; wire-config-level coverage lives at `cloudrun.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: "[cloudrun render]" });
}
// ── parse helpers ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
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;
}
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(`[cloudrun] ${flagName} must be a positive integer; got ${n}`);
}
return n;
}
// fallow-ignore-next-line complexity
function parseEnum<T extends string>(
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;
const CODECS = ["h264", "h265"] as const;
const QUALITIES = ["draft", "standard", "high"] as const;
const parseFormat = (raw: unknown): (typeof FORMATS)[number] =>
parseEnum(raw, FORMATS, "[cloudrun render] --format", "mp4")!;
const parseCodec = (raw: unknown): (typeof CODECS)[number] | undefined =>
parseEnum(raw, CODECS, "[cloudrun render] --codec", undefined);
const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined =>
parseEnum(raw, QUALITIES, "[cloudrun render] --quality", undefined);