// 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 "], [ "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(" [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 | undefined; function loadCloudRunAdapter(): Promise { 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 `)", }, // 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-)", }, "output-key": { type: "string", description: "Final output GCS key (default: renders//output.)", }, 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): StackState { const overrides = { projectId: args.project as string | undefined, region: args.region as string | undefined, }; let base: Partial = {}; 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 = { ...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 \` first, or pass them as flags.`, ); failCommand(); } return merged as StackState; } function stripUndefined>(o: T): Partial { return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)) as Partial; } 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): Promise { const project = args.project as string | undefined; if (!project) { console.error("[cloudrun deploy] --project 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, 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 /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): Promise { 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 "); 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, 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): Promise { const projectDir = args.target as string | undefined; if (!projectDir) { console.error( "[cloudrun render] usage: hyperframes cloudrun render --width --height ", ); 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[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[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): Promise { const executionName = args.target as string | undefined; if (!executionName) { console.error("[cloudrun progress] usage: hyperframes cloudrun progress "); 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; } 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): Promise { 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 --batch --width --height ", ); 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[0]["config"], bucketName: state.bucketName, projectId: state.projectId, location: state.region, workflowId: state.workflowId, serviceUrl: state.serviceUrl, outputKey: entry.outputKey, } as Parameters[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): Promise { const { getTerraformModuleDir } = await loadCloudRunAdapter(); const tfDir = getTerraformModuleDir(); const state = existsSync(statePath()) ? (JSON.parse(readFileSync(statePath(), "utf8")) as Partial) : {}; 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, fps: number, width: number, height: number, variables: Record | undefined, ): Record { 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, projectDir: string, ): Record | 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( 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);