From 4da567df22ad2857a8773bf61e425548447967e6 Mon Sep 17 00:00:00 2001 From: James Russo Date: Sun, 7 Jun 2026 14:43:38 -0700 Subject: [PATCH] feat(gcp-cloud-run): Google Cloud Run + Workflows distributed render adapter (#1253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gcp-cloud-run): add Google Cloud Run + Workflows distributed render adapter Adds @hyperframes/gcp-cloud-run, the GCP counterpart to @hyperframes/aws-lambda (issue #932). The OSS distributed primitives (plan, renderChunk x N, assemble) are unchanged; this package is the storage/compute/orchestration glue. Package: Cloud Run handler (one image, three actions), runs under bun; GCS transport; in-image chrome-headless-shell resolver; client SDK (renderToCloudRun, getRenderProgress, deploySite, computeRenderCost); Dockerfile; Cloud Workflows definition; Terraform module; CLI cloudrun deploy|sites|render|render-batch|progress|destroy with --output-resolution and --strict-variables; 62 unit tests + docs + live smoke script. Shared extraction (removes ~640 lines of adapter duplication): move the cloud-agnostic config validator + content-hash into producer/distributed; both adapters import them. Validated end-to-end on GCP at 37.4 dB PSNR vs baseline. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): resolve @hyperframes/gcp-cloud-run in the CLI build + root build The CLI bundle (esbuild) couldn't resolve `@hyperframes/gcp-cloud-run/sdk`, failing Build/Typecheck/CLI-smoke (and the perf/windows/regression jobs that build first). Mirror the aws-lambda handling: mark the gcp adapter + its /sdk subpath external in tsup.config.ts with a source alias, and add gcp-cloud-run to the root `build` filter so its dist exists for publish + runtime. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ci): copy gcp-cloud-run manifest in Dockerfile.test for frozen install The regression test image runs `bun install --frozen-lockfile` after copying each workspace package.json individually. The CLI now depends on @hyperframes/gcp-cloud-run (workspace:*), so the frozen install fails to resolve it unless its manifest is present. Add the COPY line. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): add machine-sizing flags to `cloudrun deploy` Closes the parity gap with `lambda deploy` (which exposes --memory etc.). `cloudrun deploy` now threads --cpu, --memory, --max-instances, and --timeout into the Terraform apply; omitted flags keep the module defaults (4 vCPU / 16Gi / 100 instances / 3600s). For finer control, apply the module directly. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(gcp-cloud-run): address PR review (security, waste, limits, alerts) - server.ts: bucket-allowlist guard no longer fails open silently. Unset env logs a one-time WARNING; "*" is an explicit opt-out; otherwise it enforces. - server.ts: stop double-shipping audio.aac. It already rides in the plan tarball every consumer downloads, so drop the redundant standalone upload (plan) + re-download/overwrite (assemble); assemble reads it from the untar, falling back to a supplied AudioGcsUri for compat. - server.ts: chunk extension via path.extname() instead of slice(lastIndexOf). - workflow.yaml: clamp parallel concurrency_limit to math.min(chunkCount, 20) — Cloud Workflows hard-caps concurrent iterations at 20. - Dockerfile: pin bun (bun-v1.3.9) so an interop change can't silently break the image rebuild. - terraform: add min_instances var (default 0); add a workflow-failure alert (finished_execution_count status=FAILED) alongside the request-count one. - costAccounting: document that displayCost excludes GCS storage/egress. Verified against the actual APIs: @google-cloud/workflows@4.4.0 ICreateExecutionRequest has no executionId (so the idempotency-token suggestion isn't available in this client); Workflows concurrency cap is 20; failure metric is workflows.googleapis.com/finished_execution_count (status label). 174 adapter tests pass, fallow/oxlint/oxfmt/terraform clean. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(gcp-cloud-run): address round-2 review — error code + CFR forwarding - workflow.yaml: rename the zero-chunk failure code PLAN_TOO_LARGE → PLAN_PRODUCED_ZERO_CHUNKS. The old code implied a size-ceiling breach (the opposite cause), misleading anyone triaging the alert. - workflow.yaml: forward Config.cfr to the assemble step (`Cfr: ${("cfr" in config) and config.cfr}`). It was read by the handler but never sent, so exact-CFR was silently off for every Cloud Run render. Uses the same `in`-operator guard already proven in the retryable predicate. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(release): include gcp-cloud-run in set-version PACKAGES list set-version.ts (driven by release:prepare) bumps an explicit package list to the shared version on each release. gcp-cloud-run was wired into the build + publish.yml but missing here, so a release would leave it at a stale version and publish.yml would push the wrong version. Add it so the new package version-bumps + publishes in lockstep with the others. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .fallowrc.jsonc | 1 + .github/workflows/publish.yml | 1 + .gitignore | 4 + .prettierignore | 3 + Dockerfile.test | 1 + bun.lock | 187 +++- docs/deploy/gcp-cloud-run.mdx | 99 ++ docs/docs.json | 1 + docs/packages/cli.mdx | 49 + examples/gcp-cloud-run/README.md | 51 ++ .../gcp-cloud-run/sample-events/assemble.json | 11 + .../gcp-cloud-run/sample-events/plan.json | 6 + .../sample-events/render-chunk.json | 8 + examples/gcp-cloud-run/scripts/smoke.sh | 239 +++++ package.json | 2 +- packages/aws-lambda/src/events.ts | 18 +- packages/aws-lambda/src/sdk/deploySite.ts | 42 +- packages/aws-lambda/src/sdk/validateConfig.ts | 324 +------ packages/cli/package.json | 1 + packages/cli/src/cli.ts | 1 + packages/cli/src/commands/cloudrun.ts | 862 ++++++++++++++++++ packages/cli/src/help.ts | 1 + packages/cli/tsup.config.ts | 8 + packages/gcp-cloud-run/Dockerfile | 118 +++ packages/gcp-cloud-run/README.md | 128 +++ packages/gcp-cloud-run/build.mjs | 58 ++ packages/gcp-cloud-run/package.json | 62 ++ .../gcp-cloud-run/src/__fixtures__/fakeGcs.ts | 116 +++ packages/gcp-cloud-run/src/chromium.ts | 95 ++ packages/gcp-cloud-run/src/events.ts | 146 +++ packages/gcp-cloud-run/src/formatExtension.ts | 27 + .../gcp-cloud-run/src/gcsTransport.test.ts | 114 +++ packages/gcp-cloud-run/src/gcsTransport.ts | 130 +++ packages/gcp-cloud-run/src/index.ts | 64 ++ .../src/sdk/costAccounting.test.ts | 35 + .../gcp-cloud-run/src/sdk/costAccounting.ts | 113 +++ .../gcp-cloud-run/src/sdk/deploySite.test.ts | 86 ++ packages/gcp-cloud-run/src/sdk/deploySite.ts | 130 +++ .../src/sdk/getRenderProgress.test.ts | 108 +++ .../src/sdk/getRenderProgress.ts | 268 ++++++ packages/gcp-cloud-run/src/sdk/index.ts | 40 + .../src/sdk/renderToCloudRun.test.ts | 127 +++ .../gcp-cloud-run/src/sdk/renderToCloudRun.ts | 188 ++++ .../src/sdk/validateConfig.test.ts | 119 +++ .../gcp-cloud-run/src/sdk/validateConfig.ts | 77 ++ packages/gcp-cloud-run/src/server.test.ts | 332 +++++++ packages/gcp-cloud-run/src/server.ts | 647 +++++++++++++ packages/gcp-cloud-run/terraform/.gitignore | 7 + packages/gcp-cloud-run/terraform/main.tf | 197 ++++ packages/gcp-cloud-run/terraform/outputs.tf | 34 + packages/gcp-cloud-run/terraform/providers.tf | 12 + packages/gcp-cloud-run/terraform/variables.tf | 75 ++ packages/gcp-cloud-run/terraform/versions.tf | 9 + .../gcp-cloud-run/terraform/workflow.yaml | 179 ++++ packages/gcp-cloud-run/tsconfig.build.json | 12 + packages/gcp-cloud-run/tsconfig.json | 19 + packages/producer/src/distributed.ts | 12 + .../src/services/distributed/projectHash.ts | 52 ++ .../distributed/renderConfigValidation.ts | 287 ++++++ scripts/set-version.ts | 1 + 60 files changed, 5782 insertions(+), 362 deletions(-) create mode 100644 docs/deploy/gcp-cloud-run.mdx create mode 100644 examples/gcp-cloud-run/README.md create mode 100644 examples/gcp-cloud-run/sample-events/assemble.json create mode 100644 examples/gcp-cloud-run/sample-events/plan.json create mode 100644 examples/gcp-cloud-run/sample-events/render-chunk.json create mode 100755 examples/gcp-cloud-run/scripts/smoke.sh create mode 100644 packages/cli/src/commands/cloudrun.ts create mode 100644 packages/gcp-cloud-run/Dockerfile create mode 100644 packages/gcp-cloud-run/README.md create mode 100644 packages/gcp-cloud-run/build.mjs create mode 100644 packages/gcp-cloud-run/package.json create mode 100644 packages/gcp-cloud-run/src/__fixtures__/fakeGcs.ts create mode 100644 packages/gcp-cloud-run/src/chromium.ts create mode 100644 packages/gcp-cloud-run/src/events.ts create mode 100644 packages/gcp-cloud-run/src/formatExtension.ts create mode 100644 packages/gcp-cloud-run/src/gcsTransport.test.ts create mode 100644 packages/gcp-cloud-run/src/gcsTransport.ts create mode 100644 packages/gcp-cloud-run/src/index.ts create mode 100644 packages/gcp-cloud-run/src/sdk/costAccounting.test.ts create mode 100644 packages/gcp-cloud-run/src/sdk/costAccounting.ts create mode 100644 packages/gcp-cloud-run/src/sdk/deploySite.test.ts create mode 100644 packages/gcp-cloud-run/src/sdk/deploySite.ts create mode 100644 packages/gcp-cloud-run/src/sdk/getRenderProgress.test.ts create mode 100644 packages/gcp-cloud-run/src/sdk/getRenderProgress.ts create mode 100644 packages/gcp-cloud-run/src/sdk/index.ts create mode 100644 packages/gcp-cloud-run/src/sdk/renderToCloudRun.test.ts create mode 100644 packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts create mode 100644 packages/gcp-cloud-run/src/sdk/validateConfig.test.ts create mode 100644 packages/gcp-cloud-run/src/sdk/validateConfig.ts create mode 100644 packages/gcp-cloud-run/src/server.test.ts create mode 100644 packages/gcp-cloud-run/src/server.ts create mode 100644 packages/gcp-cloud-run/terraform/.gitignore create mode 100644 packages/gcp-cloud-run/terraform/main.tf create mode 100644 packages/gcp-cloud-run/terraform/outputs.tf create mode 100644 packages/gcp-cloud-run/terraform/providers.tf create mode 100644 packages/gcp-cloud-run/terraform/variables.tf create mode 100644 packages/gcp-cloud-run/terraform/versions.tf create mode 100644 packages/gcp-cloud-run/terraform/workflow.yaml create mode 100644 packages/gcp-cloud-run/tsconfig.build.json create mode 100644 packages/gcp-cloud-run/tsconfig.json create mode 100644 packages/producer/src/services/distributed/projectHash.ts create mode 100644 packages/producer/src/services/distributed/renderConfigValidation.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 54f9fb333..4703fa525 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -3,6 +3,7 @@ "entry": [ "packages/producer/src/**/*.test.ts", "packages/aws-lambda/src/**/*.test.ts", + "packages/gcp-cloud-run/src/**/*.test.ts", "packages/producer/src/regression-harness.ts", "packages/producer/src/regression-harness-distributed.test.ts", "packages/producer/src/regression-harness-lambda-local.ts", diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dc17b8d01..459d8c276 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -131,6 +131,7 @@ jobs: publish_pkg "@hyperframes/shader-transitions" "@hyperframes/shader-transitions" publish_pkg "@hyperframes/studio" "@hyperframes/studio" publish_pkg "@hyperframes/aws-lambda" "@hyperframes/aws-lambda" + publish_pkg "@hyperframes/gcp-cloud-run" "@hyperframes/gcp-cloud-run" # CLI is @hyperframes/cli in the monorepo but published as unscoped "hyperframes" on npm. # Rewrite the name in package.json before publishing, then use npm publish directly diff --git a/.gitignore b/.gitignore index 037b814d3..ba86bfbdf 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,10 @@ examples/* !examples/aws-lambda/** !examples/k8s-jobs !examples/k8s-jobs/** +!examples/gcp-cloud-run +!examples/gcp-cloud-run/** +# …but never the local smoke run's build/render artifacts. +examples/gcp-cloud-run/scripts/gcp-smoke-artifacts/ packages/studio/data/ .desloppify/ diff --git a/.prettierignore b/.prettierignore index d70887435..9fba17dc3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ docs/ DOCS_GUIDELINES.md packages/producer/tests/ *.generated.ts + +# Cloud Workflows GCL — uses ${...} expressions that are not standard YAML. +packages/gcp-cloud-run/terraform/workflow.yaml diff --git a/Dockerfile.test b/Dockerfile.test index d6e83f9c2..45b7d89e3 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -79,6 +79,7 @@ COPY packages/cli/package.json packages/cli/package.json COPY packages/studio/package.json packages/studio/package.json COPY packages/shader-transitions/package.json packages/shader-transitions/package.json COPY packages/aws-lambda/package.json packages/aws-lambda/package.json +COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json RUN bun install --frozen-lockfile # Copy source diff --git a/bun.lock b/bun.lock index 67a6dd8e6..add7eec86 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.69", + "version": "0.6.79", "bin": { "hyperframes": "./dist/cli.js", }, @@ -81,6 +81,7 @@ "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", + "@hyperframes/gcp-cloud-run": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", @@ -100,7 +101,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", @@ -129,7 +130,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -145,9 +146,29 @@ "vitest": "^3.2.4", }, }, + "packages/gcp-cloud-run": { + "name": "@hyperframes/gcp-cloud-run", + "version": "0.6.79", + "dependencies": { + "@google-cloud/storage": "^7.14.0", + "@google-cloud/workflows": "^4.2.0", + "@hono/node-server": "^1.13.0", + "@hyperframes/producer": "workspace:^", + "hono": "^4.6.0", + "puppeteer-core": "^24.39.1", + "tar": "^7.4.3", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "@types/tar": "^6.1.13", + "esbuild": "^0.25.12", + "tsx": "^4.21.0", + "typescript": "^5.7.2", + }, + }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.69", + "version": "0.6.79", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -159,7 +180,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -199,7 +220,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "html2canvas": "^1.4.1", }, @@ -211,7 +232,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.69", + "version": "0.6.79", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -576,8 +597,22 @@ "@fontsource/space-mono": ["@fontsource/space-mono@5.2.9", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="], + "@google-cloud/paginator": ["@google-cloud/paginator@5.0.2", "", { "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" } }, "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg=="], + + "@google-cloud/projectify": ["@google-cloud/projectify@4.0.0", "", {}, "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA=="], + + "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], + + "@google-cloud/storage": ["@google-cloud/storage@7.19.0", "", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0", "uuid": "^8.0.0" } }, "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ=="], + + "@google-cloud/workflows": ["@google-cloud/workflows@4.4.0", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-euazZS+KeByU1ECWpZhAn8FKBKg56jEm70mQkWq1sZwyxYNO9Ke952h8yh19W2bODuJRQrBIhOwj5YjY5L0Tdw=="], + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@hyperframes/aws-lambda": ["@hyperframes/aws-lambda@workspace:packages/aws-lambda"], @@ -588,6 +623,8 @@ "@hyperframes/engine": ["@hyperframes/engine@workspace:packages/engine"], + "@hyperframes/gcp-cloud-run": ["@hyperframes/gcp-cloud-run@workspace:packages/gcp-cloud-run"], + "@hyperframes/player": ["@hyperframes/player@workspace:packages/player"], "@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"], @@ -662,6 +699,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], "@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="], @@ -948,6 +987,8 @@ "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], + "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -966,6 +1007,8 @@ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -988,6 +1031,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="], @@ -1020,6 +1065,8 @@ "@webgpu/types": ["@webgpu/types@0.1.70", "", {}, "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], @@ -1042,6 +1089,8 @@ "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], + "arrify": ["arrify@2.0.1", "", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], @@ -1050,6 +1099,10 @@ "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], "aws-cdk-lib": ["aws-cdk-lib@2.254.0", "", { "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", "@aws-cdk/cloud-assembly-api": "^2.2.3", "@aws-cdk/cloud-assembly-schema": "^53.21.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", "ignore": "^5.3.2", "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^10.2.3", "punycode": "^2.3.1", "semver": "^7.7.4", "table": "^6.9.0", "yaml": "1.10.3" }, "peerDependencies": { "constructs": "^10.5.0" } }, "sha512-O7fn6fu1FXb0BgoO/+WeiBXZ16H/q6z4fOndjdG62c9bPU7Z/UeW5gz7qBiwLm4ERvTObobLCqv7wjd8bp+v3A=="], @@ -1110,6 +1163,8 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], @@ -1142,6 +1197,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], @@ -1212,6 +1269,8 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="], @@ -1232,6 +1291,10 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -1254,6 +1317,10 @@ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -1272,6 +1339,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], @@ -1322,6 +1391,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -1334,7 +1405,7 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], @@ -1342,6 +1413,10 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], @@ -1364,6 +1439,8 @@ "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + "google-gax": ["google-gax@5.0.7", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "duplexify": "^4.1.3", "google-auth-library": "10.5.0", "google-logging-utils": "1.1.3", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "3.0.4", "protobufjs": "^7.5.4", "retry-request": "^8.0.2", "rimraf": "^5.0.1" } }, "sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA=="], + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1372,18 +1449,26 @@ "gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], @@ -1432,6 +1517,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1508,6 +1595,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -1524,6 +1613,8 @@ "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mediabunny": ["mediabunny@1.45.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-GUCPYjR+5olLM7DRmupCXCmZkkSrKHVl1gyW2RztpObqLfrix19kWGf/9WgWzDW0g49DvfGXpl+zfArCx5HDMQ=="], @@ -1534,6 +1625,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1560,7 +1653,7 @@ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], @@ -1590,6 +1683,8 @@ "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], @@ -1650,6 +1745,8 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "proto3-json-serializer": ["proto3-json-serializer@3.0.4", "", { "dependencies": { "protobufjs": "^7.4.0" } }, "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw=="], + "protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], @@ -1694,8 +1791,12 @@ "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1748,6 +1849,10 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], + + "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1766,6 +1871,8 @@ "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], @@ -1786,6 +1893,8 @@ "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], + "teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="], + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], @@ -1864,6 +1973,8 @@ "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1918,6 +2029,8 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], @@ -1938,6 +2051,8 @@ "@codemirror/lint/@codemirror/view": ["@codemirror/view@6.42.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1968,12 +2083,24 @@ "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "gcp-metadata/gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "google-auth-library/gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "google-gax/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-gax/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "google-gax/retry-request": ["retry-request@8.0.3", "", { "dependencies": { "extend": "^3.0.2", "teeny-request": "^10.0.0" } }, "sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], @@ -1990,6 +2117,8 @@ "minizlib/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "pac-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2018,6 +2147,10 @@ "tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + + "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "tsup/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -2030,6 +2163,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@google-cloud/storage/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -2038,8 +2173,26 @@ "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "gcp-metadata/gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "google-auth-library/gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "google-gax/google-auth-library/gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "google-gax/google-auth-library/gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "google-gax/retry-request/teeny-request": ["teeny-request@10.1.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug=="], + + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -2148,8 +2301,22 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + + "gcp-metadata/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "google-gax/google-auth-library/gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "google-gax/retry-request/teeny-request/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "google-gax/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "google-gax/retry-request/teeny-request/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], } } diff --git a/docs/deploy/gcp-cloud-run.mdx b/docs/deploy/gcp-cloud-run.mdx new file mode 100644 index 000000000..41c5c0072 --- /dev/null +++ b/docs/deploy/gcp-cloud-run.mdx @@ -0,0 +1,99 @@ +--- +title: Google Cloud Run +description: "Deploy distributed HyperFrames rendering to Google Cloud Run + Cloud Workflows, and drive renders from a laptop or CI." +--- + +HyperFrames ships a Google Cloud deployment that mirrors the [AWS Lambda](/deploy/aws-lambda) one: a single Cloud Run service fronts a Cloud Workflows definition that fans renders out across many parallel chunk workers, with intermediate artifacts in Google Cloud Storage. The render primitives are identical — only the storage, compute, and orchestration adapters differ. + +It's the right choice for teams already running their backend and storage on Google Cloud who want distributed HyperFrames rendering without adding AWS infrastructure. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Cloud Workflows definition │ +│ Plan → parallel(for chunk) RenderChunk → Assemble │ +└──────────────────────────────────────────────────────────────────┘ + │ OIDC-authenticated http.post per step + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ One Cloud Run service (packages/gcp-cloud-run/Dockerfile) │ +│ dist/server.js │ +│ ├─ Action="plan" → @hyperframes/producer/distributed │ +│ ├─ Action="renderChunk" → @hyperframes/producer/distributed │ +│ └─ Action="assemble" → @hyperframes/producer/distributed │ +└──────────────────────────────────────────────────────────────────┘ + │ GCS download / upload + ▼ + Google Cloud Storage bucket +``` + +Each workflow step `POST`s to the same Cloud Run URL with a different `Action`. The handler downloads its inputs from GCS into the container's filesystem, runs the matching OSS primitive, uploads the output back to GCS, and returns a small JSON result. The workflow accumulates every step's result and returns `{ Plan, Chunks, Assemble }`. + +## Why Cloud Run is simpler than Lambda here + +Cloud Run runs a container image, so the Chrome story collapses to a `Dockerfile` line. There's no 250 MB ZIP ceiling, no `@sparticuz/chromium` runtime decompression, and no packaging probe — the image installs the same pinned `chrome-headless-shell` build the production renderer uses. Cloud Run gen2 also gives more headroom than Lambda: up to a 60-minute request timeout and 32 GiB of memory. + +## Deploy + +The Terraform module at `packages/gcp-cloud-run/terraform` provisions the GCS bucket, the Cloud Run service, the Cloud Workflows definition, two least-privilege service accounts, and a runaway-request alert. + +```bash +# 1. Build + push the render image. +gcloud builds submit . \ + --tag us-central1-docker.pkg.dev/PROJECT/hyperframes/hyperframes-render:v1 + +# 2. Apply the module. +cd node_modules/@hyperframes/gcp-cloud-run/terraform +terraform init +terraform apply \ + -var project_id=PROJECT \ + -var region=us-central1 \ + -var image=us-central1-docker.pkg.dev/PROJECT/hyperframes/hyperframes-render:v1 +``` + +Terraform outputs `render_bucket_name`, `service_url`, `workflow_name`, and `region`. Pass those into the SDK. + + +The target GCP project must have **billing enabled** — Cloud Run, Cloud Workflows, Artifact Registry, and Cloud Build are all billed services. + + +## Render + +```ts +import { + renderToCloudRun, + getRenderProgress, +} from "@hyperframes/gcp-cloud-run/sdk"; + +const handle = await renderToCloudRun({ + projectDir: "./my-composition", + config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, + bucketName: "hyperframes-render-my-project", + projectId: "my-project", + location: "us-central1", + workflowId: "hyperframes-render", + serviceUrl: "https://hyperframes-render-abc.us-central1.run.app", +}); + +let progress = await getRenderProgress({ executionName: handle.executionName }); +while (progress.status === "running") { + await new Promise((r) => setTimeout(r, 5000)); + progress = await getRenderProgress({ executionName: handle.executionName }); +} +console.log(progress.status, progress.outputFile, progress.costs.displayCost); +``` + +Templates with [variables](/concepts/variables) work the same way — declare `data-composition-variables` on the composition and pass `config.variables`. The Cloud Workflows execution argument is capped at 512 KiB, so pass media as URL references the composition resolves at render time rather than inlining base64. + +## End-to-end smoke + +`examples/gcp-cloud-run/scripts/smoke.sh` builds the image, applies the Terraform module, renders a fixture composition through the workflow at one or more chunk sizes, PSNR-compares each output against the in-process baseline, and tears the stack down. + +```bash +examples/gcp-cloud-run/scripts/smoke.sh --project my-project --region us-central1 +``` + +## Supported formats + +Same as the distributed pipeline everywhere: `mp4` (H.264 / H.265), `mov` (ProRes), `webm` (VP9), and `png-sequence`. HDR mp4 is not supported in distributed mode. diff --git a/docs/docs.json b/docs/docs.json index 0de7e282e..3982833c1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -101,6 +101,7 @@ "pages": [ "guides/deploy", "deploy/aws-lambda", + "deploy/gcp-cloud-run", "deploy/templates-on-lambda", "deploy/migrating-to-hyperframes-lambda" ] diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 8607ada9d..e2d3bc023 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1207,6 +1207,55 @@ The actions list is deliberately broad (`Resource: "*"`) because CloudFormation `hyperframes lambda` keeps per-stack metadata under `/.hyperframes/lambda-stack-.json` so the verbs don't need to call `describe-stacks` every time. Commit the file to a repo or `.gitignore` it depending on your workflow — it contains the bucket name, state-machine ARN, and region, none of which are secrets but all of which are AWS-account-identifying. +## hyperframes cloudrun + +The Google Cloud counterpart to `hyperframes lambda`. Deploys HyperFrames distributed rendering to Cloud Run + Cloud Workflows and drives renders from your laptop or CI. Wraps the `@hyperframes/gcp-cloud-run` SDK plus `terraform` (the module shipped with the package) and `gcloud` / Cloud Build for the image. + +```bash +hyperframes cloudrun deploy --project my-gcp-project +hyperframes cloudrun render ./my-project --width 1920 --height 1080 --wait +hyperframes cloudrun destroy --project my-gcp-project # when you're done +``` + +#### `cloudrun deploy` + +Enables the required APIs, builds + pushes the render image via Cloud Build (unless you pass `--image`), then `terraform apply`s the module that provisions the GCS bucket, Cloud Run service, Cloud Workflows definition, two service accounts, and a runaway-request alert. Caches the resulting bucket / service URL / workflow id so later verbs don't need them re-passed. + +```bash +hyperframes cloudrun deploy --project my-gcp-project --region us-central1 +hyperframes cloudrun deploy --project my-gcp-project --image us-central1-docker.pkg.dev/my-gcp-project/hyperframes/hyperframes-render:v1 +``` + +Flags: `--project` (required), `--region` (default `us-central1`), `--image` (skip the build), `--repo` (Artifact Registry repo, default `hyperframes`). Machine sizing / scaling: `--cpu` (1/2/4/8, default 4), `--memory` (e.g. `32Gi`, default `16Gi`), `--max-instances` (render fan-out ceiling, default 100), `--timeout` (per-request seconds, max 3600). Omitted sizing flags keep the module defaults; for anything finer, apply the Terraform module directly. + +#### `cloudrun sites create ` + +Tar + upload a project to GCS once and reuse it across renders. `--site-id` overrides the content hash. Prints the `gs://` URI. + +#### `cloudrun render ` + +Start a distributed render. `--width` / `--height` are required; `--fps` (24/30/60), `--format`, `--codec`, `--quality`, `--chunk-size`, `--max-parallel-chunks`, and `--output-resolution` (deviceScaleFactor supersampling, e.g. `4k`) mirror the local render flags. Pass composition variables with `--variables '{"title":"Hi"}'` or `--variables-file alice.json`; add `--strict-variables` to fail on a key that's undeclared or mistyped vs the composition's `data-composition-variables`. `--wait` polls until the render finishes and prints the output URI + cost; without it the command returns an execution name. + +#### `cloudrun render-batch ` + +Fan out N personalised renders from a JSONL batch file (`--batch users.jsonl`, one `{"outputKey":"...","variables":{...}}` per line). Deploys the site once and starts an execution per entry, capped at `--max-concurrent` (default 50). `--dry-run` prints the resolved manifest without starting anything. Shares the render flags above. + +#### `cloudrun progress ` + +Print progress + cost for an in-flight or finished render. Coarse `running` progress; exact frame counts + cost on success. + +#### `cloudrun destroy` + +`terraform destroy` the stack (force-destroys the render bucket). Reads the cached project/region, or pass `--project` / `--region`. + +### When to pick `cloudrun` vs `lambda` + +Same trade-off as `lambda`, on Google Cloud instead of AWS. Pick `cloudrun` when your backend + storage already live on GCP. The render primitives are identical; only the storage (GCS), compute (Cloud Run), and orchestration (Cloud Workflows) adapters differ. + +### State file + +`hyperframes cloudrun` caches the deployed stack's coordinates under `~/.hyperframes/cloudrun-state.json` (project id, region, bucket, service URL, workflow id) so `render` / `progress` / `destroy` don't need them re-passed. None are secrets, but all are GCP-project-identifying. + ## hyperframes.json `hyperframes init` writes a `hyperframes.json` file at the root of every new project. `hyperframes add` reads it to know which registry to pull items from and where to drop them. Edit the file (or delete it to fall back to defaults) to reshape your project layout or point at a custom registry. diff --git a/examples/gcp-cloud-run/README.md b/examples/gcp-cloud-run/README.md new file mode 100644 index 000000000..f7be4eda9 --- /dev/null +++ b/examples/gcp-cloud-run/README.md @@ -0,0 +1,51 @@ +# Google Cloud Run example + +End-to-end deployment + smoke for [`@hyperframes/gcp-cloud-run`](../../packages/gcp-cloud-run) — the Cloud Run + Cloud Workflows adapter for HyperFrames distributed rendering. + +## Layout + +``` +scripts/smoke.sh Real-GCP smoke: build → deploy → render → PSNR → destroy +sample-events/ Example request bodies for the Cloud Run handler + (plan.json, render-chunk.json, assemble.json) +``` + +The Terraform module and the Cloud Workflows definition that the smoke deploys live with the package, at `packages/gcp-cloud-run/terraform/` (including `workflow.yaml`). + +## Prerequisites + +- `gcloud` authenticated, with a project that has **billing enabled** +- `terraform` (≥ 1.5), `docker`, `ffmpeg`, `jq` on PATH + +## Run the smoke + +```bash +# Renders the mp4-h264-sdr fixture through the workflow and PSNR-compares it +# against the in-process baseline, then tears the stack down. +./scripts/smoke.sh --project YOUR_GCP_PROJECT --region us-central1 + +# Keep the stack up to poke at it: +./scripts/smoke.sh --project YOUR_GCP_PROJECT --keep-stack + +# Render at several chunk sizes to see the fan-out scaling: +./scripts/smoke.sh --project YOUR_GCP_PROJECT --chunk-sizes 30,15,10 +``` + +Outputs land in `scripts/gcp-smoke-artifacts/`: `results.json` +(`chunkSize × wallClockMs × psnrAvgDb`), the rendered MP4s, and each +workflow execution's describe output. + +## Test the handler locally + +The sample events exercise the same body shape Cloud Workflows sends. With the +container running locally (`PORT=8080`) and credentials that can reach a GCS +bucket, you can drive a single action: + +```bash +curl -sX POST localhost:8080/ \ + -H 'content-type: application/json' \ + --data @sample-events/plan.json | jq . +``` + +Replace the `PROJECT` placeholder bucket names and `REPLACE_WITH_PLAN_HASH` +with real values from a prior `plan` response. diff --git a/examples/gcp-cloud-run/sample-events/assemble.json b/examples/gcp-cloud-run/sample-events/assemble.json new file mode 100644 index 000000000..115a538bd --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/assemble.json @@ -0,0 +1,11 @@ +{ + "Action": "assemble", + "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "ChunkGcsUris": [ + "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4", + "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4" + ], + "AudioGcsUri": null, + "OutputGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/output.mp4", + "Format": "mp4" +} diff --git a/examples/gcp-cloud-run/sample-events/plan.json b/examples/gcp-cloud-run/sample-events/plan.json new file mode 100644 index 000000000..667988b41 --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/plan.json @@ -0,0 +1,6 @@ +{ + "Action": "plan", + "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz", + "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", + "Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" } +} diff --git a/examples/gcp-cloud-run/sample-events/render-chunk.json b/examples/gcp-cloud-run/sample-events/render-chunk.json new file mode 100644 index 000000000..b98ad63ad --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/render-chunk.json @@ -0,0 +1,8 @@ +{ + "Action": "renderChunk", + "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "PlanHash": "REPLACE_WITH_PLAN_HASH", + "ChunkIndex": 0, + "ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", + "Format": "mp4" +} diff --git a/examples/gcp-cloud-run/scripts/smoke.sh b/examples/gcp-cloud-run/scripts/smoke.sh new file mode 100755 index 000000000..4cd4dbf1d --- /dev/null +++ b/examples/gcp-cloud-run/scripts/smoke.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# Real-GCP smoke + benchmark for the HyperFrames Cloud Run adapter. +# +# Run from a workstation with `gcloud` credentials. Builds the render +# container, pushes it to Artifact Registry, applies the Terraform module at +# packages/gcp-cloud-run/terraform to your project, renders a fixture +# composition through the Cloud Workflows definition, PSNR-compares the +# output against the in-process baseline, and tears the stack down. +# +# Usage: +# ./smoke.sh --project +# ./smoke.sh --project p --fixture mp4-h264-sdr --chunk-sizes 15,30 +# ./smoke.sh --project p --keep-stack +# +# Required tools on PATH: +# - gcloud (authenticated; the target project must have billing enabled) +# - terraform (>= 1.5) +# - docker +# - ffmpeg (PSNR computation) +# - jq +# +# Inputs (flags or env vars): +# --project (required; or $GCP_PROJECT) +# --region (default: us-central1) +# --fixture (default: mp4-h264-sdr — under packages/producer/tests/distributed/) +# --chunk-sizes (default: from the fixture meta; CSV of chunkSize overrides) +# --psnr-threshold (default: 35) +# --repo (Artifact Registry repo name, default: hyperframes) +# --keep-stack (skip `terraform destroy` at the end) +# --skip-build (reuse the last-pushed image tag in ./gcp-smoke-artifacts/image.txt) +# +# Outputs: +# ./gcp-smoke-artifacts/results.json (chunkSize x wallClockMs x psnrAvgDb) +# ./gcp-smoke-artifacts/renders/c-output.mp4 +# ./gcp-smoke-artifacts/renders/c-execution.json +# +# Exit codes: +# 0 all good 1 arg/pre-flight 2 build/push 3 terraform apply +# 4 a render failed 5 PSNR below threshold + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +TF_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform" + +# ── Defaults ────────────────────────────────────────────────────────────── +PROJECT="${GCP_PROJECT:-}" +REGION="${GCP_REGION:-us-central1}" +FIXTURE="${FIXTURE:-mp4-h264-sdr}" +CHUNK_SIZES="${CHUNK_SIZES:-}" +PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}" +AR_REPO="${AR_REPO:-hyperframes}" +KEEP_STACK=0 +SKIP_BUILD=0 + +while [ $# -gt 0 ]; do + case "$1" in + --project) PROJECT="$2"; shift 2 ;; + --region) REGION="$2"; shift 2 ;; + --fixture) FIXTURE="$2"; shift 2 ;; + --chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;; + --psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;; + --repo) AR_REPO="$2"; shift 2 ;; + --keep-stack) KEEP_STACK=1; shift ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +[ -n "$PROJECT" ] || { echo "ERROR: --project (or \$GCP_PROJECT) is required" >&2; exit 1; } +for tool in gcloud terraform docker ffmpeg jq; do + command -v "$tool" >/dev/null || { echo "ERROR: $tool not on PATH" >&2; exit 1; } +done + +FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE" +FIXTURE_META="$FIXTURE_DIR/meta.json" +BASELINE_MP4="$FIXTURE_DIR/output/output.mp4" +[ -d "$FIXTURE_DIR/src" ] || { echo "ERROR: fixture src missing: $FIXTURE_DIR/src" >&2; exit 1; } +[ -f "$BASELINE_MP4" ] || { echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2; exit 1; } + +ARTIFACT_DIR="$SCRIPT_DIR/gcp-smoke-artifacts" +mkdir -p "$ARTIFACT_DIR/renders" + +echo "→ Project: $PROJECT Region: $REGION Fixture: $FIXTURE" + +# ── 1. Enable APIs ────────────────────────────────────────────────────────── +echo "→ Enabling required APIs (idempotent)" +gcloud services enable \ + run.googleapis.com workflows.googleapis.com workflowexecutions.googleapis.com \ + artifactregistry.googleapis.com cloudbuild.googleapis.com monitoring.googleapis.com \ + --project "$PROJECT" >/dev/null + +# ── 2. Build + push the render image ──────────────────────────────────────── +IMAGE_TXT="$ARTIFACT_DIR/image.txt" +if [ "$SKIP_BUILD" -eq 1 ] && [ -f "$IMAGE_TXT" ]; then + IMAGE="$(cat "$IMAGE_TXT")" + echo "→ Reusing image $IMAGE" +else + gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 || \ + gcloud artifacts repositories create "$AR_REPO" --repository-format docker \ + --location "$REGION" --project "$PROJECT" >/dev/null + TAG="$(date +%Y%m%d-%H%M%S)" + IMAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/hyperframes-render:$TAG" + echo "→ Building + pushing $IMAGE via Cloud Build" + # The Dockerfile lives at packages/gcp-cloud-run/Dockerfile, not the repo + # root, so we drive the build with an inline cloudbuild config rather than + # `--tag` (which assumes a root Dockerfile). + CB_CONFIG="$ARTIFACT_DIR/cloudbuild.yaml" + cat > "$CB_CONFIG" <&2; exit 2; } + echo "$IMAGE" > "$IMAGE_TXT" +fi + +# ── 3. terraform apply ────────────────────────────────────────────────────── +# The google provider authenticates via Application Default Credentials. If +# ADC isn't configured (common on a box set up with only `gcloud auth login`), +# fall back to a short-lived access token from the active gcloud account. +if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then + echo "→ ADC not configured; using a gcloud access token for Terraform" + export GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)" + export GOOGLE_PROJECT="$PROJECT" +fi +echo "→ terraform apply" +terraform -chdir="$TF_DIR" init -input=false >/dev/null +terraform -chdir="$TF_DIR" apply -input=false -auto-approve \ + -var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ + || { echo "ERROR: terraform apply failed" >&2; exit 3; } + +BUCKET="$(terraform -chdir="$TF_DIR" output -raw render_bucket_name)" +SERVICE_URL="$(terraform -chdir="$TF_DIR" output -raw service_url)" +WORKFLOW="$(terraform -chdir="$TF_DIR" output -raw workflow_name)" +echo " bucket=$BUCKET service=$SERVICE_URL workflow=$WORKFLOW" + +cleanup() { + if [ "$KEEP_STACK" -eq 0 ]; then + echo "→ terraform destroy" + # Apply force_destroy=true into state FIRST. Terraform reads the bucket's + # force_destroy from prior state during the destroy step, so a destroy + # alone can't flip it; a quick apply updates the attribute, then destroy + # can empty + remove the (scratch) bucket. + terraform -chdir="$TF_DIR" apply -input=false -auto-approve \ + -var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ + -var "bucket_force_destroy=true" >/dev/null 2>&1 || true + terraform -chdir="$TF_DIR" destroy -input=false -auto-approve \ + -var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ + -var "bucket_force_destroy=true" || true + else + echo "→ --keep-stack set; leaving the stack up. Destroy with:" + echo " terraform -chdir=$TF_DIR destroy -var project_id=$PROJECT -var region=$REGION -var image=$IMAGE -var bucket_force_destroy=true" + fi +} +trap cleanup EXIT + +# ── 4. Upload the fixture as a project tarball ────────────────────────────── +SITE_TAR="$ARTIFACT_DIR/project.tar.gz" +tar -czf "$SITE_TAR" -C "$FIXTURE_DIR/src" . +PROJECT_GCS="gs://$BUCKET/sites/$FIXTURE/project.tar.gz" +gcloud storage cp "$SITE_TAR" "$PROJECT_GCS" --project "$PROJECT" >/dev/null +echo "→ Uploaded fixture to $PROJECT_GCS" + +BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META") +META_CHUNK=$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META") +[ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}" + +echo "[]" > "$ARTIFACT_DIR/results.json" +OVERALL_RC=0 + +IFS=',' read -ra SIZES <<< "$CHUNK_SIZES" +for CS in "${SIZES[@]}"; do + RENDER_ID="hf-smoke-c${CS}-$(date +%s)" + OUT_GCS="gs://$BUCKET/renders/$RENDER_ID/output.mp4" + ARG=$(jq -n \ + --arg svc "$SERVICE_URL" \ + --arg proj "$PROJECT_GCS" \ + --arg prefix "gs://$BUCKET/renders/$RENDER_ID/" \ + --arg out "$OUT_GCS" \ + --argjson fps "$BASE_FPS" \ + --argjson cs "$CS" \ + '{ServiceUrl:$svc, ProjectGcsUri:$proj, PlanOutputGcsPrefix:$prefix, OutputGcsUri:$out, + Config:{fps:$fps, width:640, height:360, format:"mp4", chunkSize:$cs}}') + + echo "→ Render chunkSize=$CS (renderId=$RENDER_ID)" + START_MS=$(date +%s%3N) + EXEC=$(gcloud workflows execute "$WORKFLOW" --location "$REGION" --project "$PROJECT" \ + --data "$ARG" --format='value(name)') + # Poll until terminal. + STATE="ACTIVE" + while [ "$STATE" = "ACTIVE" ] || [ "$STATE" = "QUEUED" ]; do + sleep 5 + STATE=$(gcloud workflows executions describe "$EXEC" --location "$REGION" \ + --project "$PROJECT" --format='value(state)') + done + END_MS=$(date +%s%3N) + WALL=$((END_MS - START_MS)) + + gcloud workflows executions describe "$EXEC" --location "$REGION" --project "$PROJECT" \ + --format=json > "$ARTIFACT_DIR/renders/c$CS-execution.json" + + if [ "$STATE" != "SUCCEEDED" ]; then + echo " ✗ execution state=$STATE" + jq -r '.error.payload // empty' "$ARTIFACT_DIR/renders/c$CS-execution.json" | head -c 800 + OVERALL_RC=4 + continue + fi + + OUT_LOCAL="$ARTIFACT_DIR/renders/c$CS-output.mp4" + gcloud storage cp "$OUT_GCS" "$OUT_LOCAL" --project "$PROJECT" >/dev/null + + # PSNR vs the in-process baseline. + PSNR_LOG="$ARTIFACT_DIR/renders/c$CS-psnr.log" + ffmpeg -y -i "$OUT_LOCAL" -i "$BASELINE_MP4" \ + -lavfi "psnr=stats_file=$PSNR_LOG" -f null - 2>/dev/null || true + PSNR_AVG=$(awk -F'psnr_avg:' '/psnr_avg:/{split($2,a," "); s+=a[1]; n++} END{if(n>0) printf "%.2f", s/n; else print "0"}' "$PSNR_LOG" 2>/dev/null || echo "0") + + echo " ✓ state=SUCCEEDED wall=${WALL}ms psnr_avg=${PSNR_AVG}dB" + jq --argjson cs "$CS" --argjson wall "$WALL" --arg psnr "$PSNR_AVG" \ + '. += [{chunkSize:$cs, wallClockMs:$wall, psnrAvgDb:($psnr|tonumber)}]' \ + "$ARTIFACT_DIR/results.json" > "$ARTIFACT_DIR/results.json.tmp" && \ + mv "$ARTIFACT_DIR/results.json.tmp" "$ARTIFACT_DIR/results.json" + + if awk "BEGIN{exit !($PSNR_AVG < $PSNR_THRESHOLD)}"; then + echo " ✗ PSNR ${PSNR_AVG}dB below threshold ${PSNR_THRESHOLD}dB" + OVERALL_RC=5 + fi +done + +echo "→ Results:"; cat "$ARTIFACT_DIR/results.json" | jq . +exit $OVERALL_RC diff --git a/package.json b/package.json index 60e032a88..62383845a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts index f4c5f0b47..738ef0188 100644 --- a/packages/aws-lambda/src/events.ts +++ b/packages/aws-lambda/src/events.ts @@ -16,7 +16,12 @@ * results per §2.4). */ -import type { DistributedFormat, DistributedRenderConfig } from "@hyperframes/producer/distributed"; +import type { + DistributedFormat, + SerializableDistributedRenderConfig, +} from "@hyperframes/producer/distributed"; + +export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/distributed"; /** Discriminator for the three roles the one Lambda image fulfills. */ export type LambdaAction = "plan" | "renderChunk" | "assemble"; @@ -93,17 +98,6 @@ export interface AssembleEvent { Cfr?: boolean; } -/** - * `DistributedRenderConfig` minus the runtime-only fields (`logger`, - * `abortSignal`, `producerConfig`). The Step Functions event JSON cannot - * carry function references; the handler reconstitutes the runtime fields - * from Lambda environment + the AbortController it owns. - */ -export type SerializableDistributedRenderConfig = Omit< - DistributedRenderConfig, - "logger" | "abortSignal" | "producerConfig" ->; - // ── Result types — kept small to fit Step Functions history budgets ───────── /** Result of a `plan` invocation. Carries enough to size the Map(N) state. */ diff --git a/packages/aws-lambda/src/sdk/deploySite.ts b/packages/aws-lambda/src/sdk/deploySite.ts index 7cc5af09d..c325a0b6a 100644 --- a/packages/aws-lambda/src/sdk/deploySite.ts +++ b/packages/aws-lambda/src/sdk/deploySite.ts @@ -12,12 +12,11 @@ * produce the same `siteId` and `HeadObject`-short-circuit the upload. */ -import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; -import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { join } from "node:path"; import { HeadObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "@hyperframes/producer/distributed"; +import { hashProjectDir } from "@hyperframes/producer/distributed"; import { formatS3Uri, tarDirectory, uploadFileToS3 } from "../s3Transport.js"; /** Options for {@link deploySite}. */ @@ -110,41 +109,6 @@ export async function deploySite(opts: DeploySiteOptions): Promise { } } -/** - * SHA-256 over every regular file under `projectDir` (sorted by relative - * path) → 16-character hex prefix. The prefix is the `siteId`. - * - * The hash includes the relative path plus every byte of each file, so a - * same-bytes rename still yields a fresh id. We trim to 16 chars because - * the full 64 isn't useful in an S3 key for legibility. - * - * Reads are synchronous: project trees are typically tens of MB at most - * (HTML/CSS/JS plus a few composition assets), so the simpler shape wins - * over a streaming pipeline. - */ -function hashProjectDir(projectDir: string): string { - const hash = createHash("sha256"); - const files: string[] = []; - function walk(dir: string, isRoot: boolean): void { - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name < b.name ? -1 : a.name > b.name ? 1 : 0, - )) { - if (isRoot && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(entry.name)) continue; - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full, false); - else if (entry.isFile()) files.push(full); - } - } - walk(projectDir, true); - for (const file of files) { - const rel = relative(projectDir, file).replaceAll("\\", "/"); - hash.update(rel); - hash.update("\0"); - hash.update(readFileSync(file)); - } - return hash.digest("hex").slice(0, 16); -} - async function headObject( s3: S3Client, bucket: string, diff --git a/packages/aws-lambda/src/sdk/validateConfig.ts b/packages/aws-lambda/src/sdk/validateConfig.ts index 59dd0d9a9..934e2a470 100644 --- a/packages/aws-lambda/src/sdk/validateConfig.ts +++ b/packages/aws-lambda/src/sdk/validateConfig.ts @@ -1,194 +1,32 @@ /** - * Client-side validation of `SerializableDistributedRenderConfig` so the - * SDK fails on shape errors with a typed `InvalidConfigError` *before* a - * Step Functions execution starts. + * Client-side validation for the AWS Lambda adapter. * - * The producer's `plan` stage validates the same fields server-side, but a - * caller staring at "ExecutionFailed: BROWSER_GPU_NOT_SOFTWARE" five - * minutes after StartExecution has to dig through Step Functions history - * to learn that the renderToLambda call passed an unsupported format. - * Catching the obvious mistakes locally turns that wait into a synchronous - * throw. - * - * The check is deliberately narrow — it covers the *shape* errors any - * caller could have surfaced with `tsc` if they passed a literal, plus - * the `force-hdr` rejection (HDR mp4 isn't supported in distributed - * mode). webm was previously rejected here too; v0.7+ supports it via - * closed-GOP concat-copy. Anything deeper (font availability, plan - * size cap, GPU mode at runtime) needs the actual planner. + * The cloud-agnostic config-shape validation (`validateDistributedRenderConfig`, + * `validateVariablesPayload`, `InvalidConfigError`) lives in + * `@hyperframes/producer/distributed` and is shared with the other adapters. + * This module re-exports those and adds the one piece specific to Step + * Functions: the 256 KiB Standard-workflow execution-input size cap. */ -import type { DistributedFormat } from "../formatExtension.js"; -import type { SerializableDistributedRenderConfig } from "../events.js"; +import { InvalidConfigError } from "@hyperframes/producer/distributed"; -/** Thrown for any client-side `SerializableDistributedRenderConfig` violation. */ -export class InvalidConfigError extends Error { - // Read via Error.prototype.toString; fallow can't see it. - // fallow-ignore-next-line unused-class-member - override readonly name = "InvalidConfigError"; - /** Dotted JSON-pointer-ish path to the offending field, e.g. `config.fps`. */ - readonly field: string; - constructor(field: string, message: string) { - super(`[validateConfig] ${field}: ${message}`); - this.field = field; - } -} - -const ALLOWED_FPS = [24, 30, 60] as const; -const ALLOWED_FORMATS = [ - "mp4", - "mov", - "png-sequence", - "webm", -] as const satisfies readonly DistributedFormat[]; -const ALLOWED_CODECS = ["h264", "h265"] as const; -const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const; -const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const; -const ALLOWED_HDR_MODES = ["auto", "force-sdr"] as const; - -const MAX_DIMENSION = 7680; -const MIN_DIMENSION = 16; -const MAX_CHUNK_SIZE = 3600; -const MAX_PARALLEL_CHUNKS_CEILING = 256; +export { + InvalidConfigError, + validateDistributedRenderConfig, + validateVariablesPayload, +} from "@hyperframes/producer/distributed"; /** - * Throw an `InvalidConfigError` if `config` is not a valid - * `SerializableDistributedRenderConfig`. Returns the same reference on - * success so the call site reads: - * - * const validated = validateDistributedRenderConfig(input); - */ -export function validateDistributedRenderConfig( - config: SerializableDistributedRenderConfig, -): SerializableDistributedRenderConfig { - if (config === null || typeof config !== "object") { - throw new InvalidConfigError("config", "must be an object"); - } - - if (!ALLOWED_FPS.includes(config.fps as 24 | 30 | 60)) { - throw new InvalidConfigError( - "config.fps", - `must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`, - ); - } - - validateIntDimension("config.width", config.width); - validateIntDimension("config.height", config.height); - - if (!ALLOWED_FORMATS.includes(config.format)) { - throw new InvalidConfigError( - "config.format", - `must be one of ${ALLOWED_FORMATS.join(", ")}; got ${String(config.format)}`, - ); - } - - if (config.codec !== undefined) { - if (config.format !== "mp4") { - throw new InvalidConfigError( - "config.codec", - `is only valid with format="mp4"; got format=${String(config.format)}`, - ); - } - if (!ALLOWED_CODECS.includes(config.codec)) { - throw new InvalidConfigError( - "config.codec", - `must be one of ${ALLOWED_CODECS.join(", ")}; got ${String(config.codec)}`, - ); - } - } - - if (config.quality !== undefined && !ALLOWED_QUALITIES.includes(config.quality)) { - throw new InvalidConfigError( - "config.quality", - `must be one of ${ALLOWED_QUALITIES.join(", ")}; got ${String(config.quality)}`, - ); - } - - if (config.crf !== undefined && config.bitrate !== undefined) { - throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate"); - } - if ( - config.crf !== undefined && - (!Number.isInteger(config.crf) || config.crf < 0 || config.crf > 51) - ) { - throw new InvalidConfigError("config.crf", `must be an integer in [0, 51]; got ${config.crf}`); - } - if (config.bitrate !== undefined && !/^\d+(\.\d+)?[kKmM]?$/.test(config.bitrate)) { - throw new InvalidConfigError( - "config.bitrate", - `must look like "10M" or "5000k"; got ${JSON.stringify(config.bitrate)}`, - ); - } - - if (config.chunkSize !== undefined) { - if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) { - throw new InvalidConfigError( - "config.chunkSize", - `must be a positive integer; got ${config.chunkSize}`, - ); - } - if (config.chunkSize > MAX_CHUNK_SIZE) { - throw new InvalidConfigError( - "config.chunkSize", - // Lambda 15-min cap leaves no useful headroom past ~3600 frames - // at 4 fps capture-equivalent throughput; rejecting up front - // avoids a 14-minute Plan-state retry storm. - `must be ≤ ${MAX_CHUNK_SIZE} (Lambda 15-min cap); got ${config.chunkSize}`, - ); - } - } - - if (config.maxParallelChunks !== undefined) { - if (!Number.isInteger(config.maxParallelChunks) || config.maxParallelChunks < 1) { - throw new InvalidConfigError( - "config.maxParallelChunks", - `must be a positive integer; got ${config.maxParallelChunks}`, - ); - } - if (config.maxParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) { - throw new InvalidConfigError( - "config.maxParallelChunks", - `must be ≤ ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.maxParallelChunks}`, - ); - } - } - - if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) { - throw new InvalidConfigError( - "config.runtimeCap", - `must be one of ${ALLOWED_RUNTIME_CAPS.join(", ")}; got ${String(config.runtimeCap)}`, - ); - } - - if (config.hdrMode !== undefined && !ALLOWED_HDR_MODES.includes(config.hdrMode)) { - // `force-hdr` is rejected here on top of the producer's plan-stage - // rejection — it makes the typical typo (`"force-hdr"` from a copy- - // paste of in-process config) surface synchronously instead of as a - // typed Step Functions failure two minutes in. - throw new InvalidConfigError( - "config.hdrMode", - `distributed mode supports only ${ALLOWED_HDR_MODES.join(", ")}; got ${String(config.hdrMode)}`, - ); - } - - if (config.variables !== undefined) { - validateVariablesPayload(config.variables); - } - - return config; -} - -/** - * Hard cap on Step Functions Standard workflow execution input — 256 KiB - * per the AWS limits page. Express workflows cap at 32 KiB; the render - * stack runs Standard for execution-history visibility, so the larger - * limit applies. The cap is on the entire serialized input, not just the - * variables, because users hit it at the wire boundary regardless of - * which field caused the bloat. + * Hard cap on Step Functions Standard workflow execution input — 256 KiB per + * the AWS limits page. Express workflows cap at 32 KiB; the render stack runs + * Standard for execution-history visibility, so the larger limit applies. The + * cap is on the entire serialized input, not just the variables, because + * users hit it at the wire boundary regardless of which field caused the + * bloat. * * Specific to Step Functions Standard. Other workflow runtimes (Temporal, - * Express SFN, raw Lambda invoke) have different caps; this constant - * shouldn't be reused for those without confirming the limit. + * Express SFN, Cloud Workflows, raw Lambda invoke) have different caps; don't + * reuse this constant for those without confirming the limit. */ export const MAX_STEP_FUNCTIONS_INPUT_BYTES = 256 * 1024; @@ -197,10 +35,10 @@ const LARGE_VARIABLES_DOCS_URL = "https://hyperframes.heygen.com/deploy/templates-on-lambda#working-with-large-variables"; /** - * Validate that the serialized Step Functions execution input fits inside - * the 256 KiB Standard-workflow cap. Measured in UTF-8 bytes (the format - * Step Functions uses on the wire) — JS strings count UTF-16 code units, - * which under-reports for any multi-byte character. + * Validate that the serialized Step Functions execution input fits inside the + * 256 KiB Standard-workflow cap. Measured in UTF-8 bytes (the format Step + * Functions uses on the wire) — JS strings count UTF-16 code units, which + * under-reports for any multi-byte character. * * Throws {@link InvalidConfigError} with a clear message naming the actual * byte count, the cap, and a pointer to the "working with large variables" @@ -214,17 +52,12 @@ export function validateStepFunctionsInputSize(input: unknown): void { try { serialized = JSON.stringify(input); } catch (err) { - // JSON.stringify throws on circular refs and BigInt. The variables - // walker catches both inside `config.variables`, but a non-variables - // field could hit the same case in a future field addition. throw new InvalidConfigError( "config", `Step Functions execution input is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`, ); } if (serialized === undefined) { - // JSON.stringify returns undefined for non-serializable roots - // (functions, Symbols at the top level). throw new InvalidConfigError( "config", "Step Functions execution input is not JSON-serializable (JSON.stringify returned undefined). " + @@ -244,112 +77,3 @@ export function validateStepFunctionsInputSize(input: unknown): void { ); } } - -/** - * Validate that `variables` is a plain JSON-safe object — no functions, - * Symbols, `undefined` leaves, BigInts, non-finite numbers, or non-plain - * objects (Dates, Maps, Sets, class instances). Rejected values would - * either round-trip incorrectly through Step Functions (`undefined` is - * silently dropped by `JSON.stringify`) or throw at the wire boundary - * (`bigint`), so we surface the offending path synchronously. - * - * The check is purely structural — semantic constraints (e.g. "is this - * variable declared in `data-composition-variables`?") belong to the CLI - * layer where the project's HTML is on disk. - */ -export function validateVariablesPayload(value: unknown): void { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InvalidConfigError( - "config.variables", - `must be a plain JSON object (got ${describeValue(value)})`, - ); - } - walkVariables(value, "config.variables", new WeakSet()); -} - -/** Per-typeof rejection messages for JSON-unsafe leaves. */ -const LEAF_REJECTIONS: Partial> = { - // `JSON.stringify` silently drops `undefined` leaves — caller would never - // notice their value isn't actually being sent. - undefined: - "undefined leaves are silently dropped by JSON.stringify — use null if you mean an absent value", - function: "functions are not JSON-serializable", - symbol: "Symbols are not JSON-serializable", - bigint: "BigInt values throw at JSON.stringify — encode as a string if you need 64-bit integers", -}; - -// fallow-ignore-next-line complexity -function walkVariables(value: unknown, path: string, seen: WeakSet): void { - const t = typeof value; - if (value === null || t === "string" || t === "boolean") return; - if (t === "number") { - if (!Number.isFinite(value as number)) { - throw new InvalidConfigError( - path, - `non-finite numbers (NaN / Infinity) are not JSON-serializable; got ${String(value)}`, - ); - } - return; - } - const leafReject = LEAF_REJECTIONS[t]; - if (leafReject !== undefined) { - throw new InvalidConfigError(path, leafReject); - } - // t === "object" from here on. Reject circular refs up front — recursing - // through a back-edge would stack-overflow with no actionable error. - if (seen.has(value as object)) { - throw new InvalidConfigError( - path, - "circular reference detected — JSON.stringify cannot serialize cycles", - ); - } - seen.add(value as object); - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - walkVariables(value[i], `${path}[${i}]`, seen); - } - return; - } - // Reject non-plain objects (Date, Map, Set, class instances) up front. - // Date's `toJSON` does round-trip as a string, but the composition gets a - // string, not a Date — explicit reject is clearer than silent type-loss. - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - throw new InvalidConfigError( - path, - `non-plain objects are not supported (got ${describeValue(value)}); use a plain {…} object`, - ); - } - for (const key of Object.keys(value as Record)) { - walkVariables((value as Record)[key], `${path}.${key}`, seen); - } -} - -// fallow-ignore-next-line complexity -function describeValue(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - if (typeof value !== "object") return typeof value; - // Class instances expose their constructor name; plain objects fall through - // to the generic "object" label. `Object.create(null)` has no constructor — - // treat its absent name the same as "Object" for reporting. - const ctorName = (value as { constructor?: { name?: string } }).constructor?.name ?? "Object"; - return ctorName === "Object" ? "object" : ctorName; -} - -function validateIntDimension(field: string, value: unknown): void { - if (typeof value !== "number" || !Number.isInteger(value)) { - throw new InvalidConfigError(field, `must be an integer; got ${String(value)}`); - } - if (value < MIN_DIMENSION || value > MAX_DIMENSION) { - throw new InvalidConfigError( - field, - `must be in [${MIN_DIMENSION}, ${MAX_DIMENSION}]; got ${value}`, - ); - } - if (value % 2 !== 0) { - // libx264 / libx265 yuv420p require even dimensions; rejecting now - // beats a Plan-stage ffmpeg crash on dimension parity. - throw new InvalidConfigError(field, `must be even (yuv420p constraint); got ${value}`); - } -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b7e855c3..cdd337a1d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", + "@hyperframes/gcp-cloud-run": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3f8aace1c..298ef88fa 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -146,6 +146,7 @@ const subCommands = { snapshot: () => import("./commands/snapshot.js").then((m) => m.default), capture: () => import("./commands/capture.js").then((m) => m.default), lambda: () => import("./commands/lambda.js").then((m) => m.default), + cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default), cloud: () => import("./commands/cloud.js").then((m) => m.default), auth: () => import("./commands/auth.js").then((m) => m.default), }; diff --git a/packages/cli/src/commands/cloudrun.ts b/packages/cli/src/commands/cloudrun.ts new file mode 100644 index 000000000..01219ed87 --- /dev/null +++ b/packages/cli/src/commands/cloudrun.ts @@ -0,0 +1,862 @@ +/** + * `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 { createRequire } from "node:module"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { defineCommand } from "citty"; +import { + type CanvasResolution, + normalizeResolutionFlag, + VALID_CANVAS_RESOLUTIONS, +} from "@hyperframes/core"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../utils/variables.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; +} + +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" }, + "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; + } + 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}`); + process.exit(1); + } + }, +}); + +// ── 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.`, + ); + process.exit(1); + } + return merged as StackState; +} +function stripUndefined>(o: T): Partial { + return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)) as Partial; +} + +/** Resolve the Terraform module dir shipped with @hyperframes/gcp-cloud-run. */ +function terraformDir(): string { + const require = createRequire(import.meta.url); + const pkgJson = require.resolve("@hyperframes/gcp-cloud-run/package.json"); + return join(dirname(pkgJson), "terraform"); +} + +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 +function runDeploy(args: Record): void { + const project = args.project as string | undefined; + if (!project) { + console.error("[cloudrun deploy] --project is required."); + process.exit(1); + } + const region = (args.region as string | undefined) ?? "us-central1"; + const repo = (args.repo as string | undefined) ?? "hyperframes"; + const tfDir = terraformDir(); + 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).", + ); + process.exit(1); + } + // 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.`, + ); + process.exit(1); + } + const projectDir = args.extra as string | undefined; + if (!projectDir) { + console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create "); + process.exit(1); + } + const state = readState(args); + const { deploySite } = await import("@hyperframes/gcp-cloud-run/sdk"); + 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 ────────────────────────────────────────────────────────────────── + +// 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 ", + ); + process.exit(1); + } + 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."); + process.exit(1); + } + const fps = parseIntFlag(args.fps) ?? 30; + if (fps !== 24 && fps !== 30 && fps !== 60) { + console.error(`[cloudrun render] --fps must be 24, 30, or 60; got ${fps}.`); + process.exit(1); + } + const state = readState(args); + const variables = resolveAndValidateVariables(args, resolve(projectDir)); + const config = buildRenderConfig(args, fps, width, height, variables); + + const { renderToCloudRun, getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk"); + 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}`); + process.exit(1); + } +} + +// ── 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 "); + process.exit(1); + } + const { getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk"); + 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 ", + ); + process.exit(1); + } + 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."); + process.exit(1); + } + const fps = parseIntFlag(args.fps) ?? 30; + if (fps !== 24 && fps !== 30 && fps !== 60) { + console.error(`[cloudrun render-batch] --fps must be 24, 30, or 60; got ${fps}.`); + process.exit(1); + } + if (!existsSync(resolve(batchPath))) { + console.error(`[cloudrun render-batch] batch file not found: ${batchPath}`); + process.exit(1); + } + const entries = parseBatchFile(resolve(batchPath)); + if (entries.length === 0) { + console.error("[cloudrun render-batch] batch file has no entries."); + process.exit(1); + } + + 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 import("@hyperframes/gcp-cloud-run/sdk"); + + // 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: err instanceof Error ? err.message : String(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) process.exit(1); +} + +/** 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`); + process.exit(1); + } + 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"`, + ); + process.exit(1); + } + entries.push(parsed as BatchEntry); + }); + return entries; +} + +// ── destroy ────────────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +function runDestroy(args: Record): void { + const tfDir = terraformDir(); + 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)."); + process.exit(1); + } + 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. + */ +function buildRenderConfig( + args: Record, + fps: number, + width: number, + height: number, + variables: Record | undefined, +): Record { + 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"), + outputResolution: parseOutputResolution(args["output-resolution"]), + 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; +} + +function parseOutputResolution(raw: unknown): CanvasResolution | undefined { + if (raw == null || raw === "") return undefined; + const normalized = normalizeResolutionFlag(String(raw)); + if (normalized) return normalized; + throw new Error( + `[cloudrun render] --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` + + `(or an alias: 1080p, 4k, uhd, hd, …); got ${String(raw)}`, + ); +} + +// ── 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); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 4a0330031..44acb3737 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -56,6 +56,7 @@ const GROUPS: Group[] = [ commands: [ ["cloud", "Render compositions on HeyGen's cloud (no local Chrome/ffmpeg)"], ["lambda", "Deploy and drive distributed renders on AWS Lambda"], + ["cloudrun", "Deploy and drive distributed renders on Google Cloud Run"], ], }, { diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 1399b1f72..e220eacbd 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -59,6 +59,12 @@ var __dirname = __hf_dirname(__filename);`, // @hyperframes/aws-lambda being a `dependencies` entry in package.json. "@hyperframes/aws-lambda", "@hyperframes/aws-lambda/sdk", + // Same treatment for the GCP adapter: the cloudrun subverb files + // dynamic-import `@hyperframes/gcp-cloud-run/sdk` only when the user runs + // `hyperframes cloudrun *`. Keep it external; runtime resolution comes + // from the `dependencies`/workspace entry, not the bundled CLI. + "@hyperframes/gcp-cloud-run", + "@hyperframes/gcp-cloud-run/sdk", ], noExternal: [ "@hyperframes/core", @@ -88,6 +94,8 @@ var __dirname = __hf_dirname(__filename);`, // which would resolve to `../aws-lambda/src/index.ts/sdk` without // an explicit subpath alias. The SDK subpath has its own barrel. "@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"), + // Same for the GCP adapter's SDK subpath barrel. + "@hyperframes/gcp-cloud-run/sdk": resolve(__dirname, "../gcp-cloud-run/src/sdk/index.ts"), // hf#732 lever-4: alias for the PNG decode+blit worker's import. // `alphaBlit.ts` is import-free (only zlib) so the worker survives // the worker_thread loader boundary directly via this TS source. diff --git a/packages/gcp-cloud-run/Dockerfile b/packages/gcp-cloud-run/Dockerfile new file mode 100644 index 000000000..6a7aa5d19 --- /dev/null +++ b/packages/gcp-cloud-run/Dockerfile @@ -0,0 +1,118 @@ +# HyperFrames distributed render — Cloud Run image. +# +# One container image, three roles (plan / renderChunk / assemble). Cloud +# Workflows POSTs a JSON body with an `Action` field; `dist/server.js` +# dispatches to the matching `@hyperframes/producer/distributed` primitive. +# +# Build context is the REPOSITORY ROOT (not this package dir) because the +# package depends on the `@hyperframes/producer` workspace and renders with +# the same chrome-headless-shell + fonts + ffmpeg the production renderer +# uses. The example smoke script and `hyperframes cloudrun deploy` both +# build with the repo root as context: +# +# docker build -f packages/gcp-cloud-run/Dockerfile -t . +# +# NOTE: this Dockerfile only builds from a full hyperframes monorepo checkout +# — it COPYs sibling workspace packages (core/engine/producer). It is NOT +# buildable from the published npm tarball alone; install the repo (or use +# `hyperframes cloudrun deploy`, which builds from your checkout via Cloud +# Build) to produce the image. +# +# Unlike the AWS Lambda adapter there is no ZIP-size ceiling and no runtime +# Chrome decompression step — the binary lives in the image at a fixed path. + +FROM node:22-bookworm-slim + +# ── System dependencies (identical set to the producer's render image) ─────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + ffmpeg \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libdrm2 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libcups2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libxshmfence1 \ + libgtk-3-0 \ + fonts-liberation \ + fonts-noto-color-emoji \ + fonts-noto-cjk \ + fonts-noto-core \ + fonts-noto-extra \ + fonts-noto-ui-core \ + fonts-freefont-ttf \ + fonts-dejavu-core \ + fontconfig \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean \ + && fc-cache -fv + +# ── chrome-headless-shell (deterministic BeginFrame capture) ───────────────── +# Pinned to the SAME build the producer regression baselines were generated +# against so distributed renders are pixel-comparable. Bump together with the +# producer's Dockerfile.test pin and a baseline regen. +RUN npx --yes @puppeteer/browsers install chrome-headless-shell@148.0.7778.167 \ + --path /opt/puppeteer \ + && CHS="$(find /opt/puppeteer/chrome-headless-shell -name chrome-headless-shell -type f | head -n1)" \ + && mkdir -p /opt/chrome \ + && ln -s "$CHS" /opt/chrome/chrome-headless-shell \ + && /opt/chrome/chrome-headless-shell --version + +# The chromium.ts resolver reads this first; pointing the engine straight at +# the symlinked binary avoids the runtime cache scan. +ENV HYPERFRAMES_CHROME_PATH=/opt/chrome/chrome-headless-shell +ENV PRODUCER_HEADLESS_SHELL_PATH=/opt/chrome/chrome-headless-shell +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV CONTAINER=true + +WORKDIR /app + +# Install bun (the repo's package manager / build driver). Pin the version: +# the container runs `bun dist/server.js` and relies on bun's ESM `require` +# interop to load the producer bundle, so a future bun release changing that +# behaviour shouldn't silently break the next image rebuild. Bump deliberately. +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.9" +ENV PATH="/root/.bun/bin:$PATH" + +# Install workspace dependencies. Copy manifests first for layer caching. +COPY package.json bun.lock ./ +COPY packages/core/package.json packages/core/package.json +COPY packages/engine/package.json packages/engine/package.json +COPY packages/player/package.json packages/player/package.json +COPY packages/producer/package.json packages/producer/package.json +COPY packages/cli/package.json packages/cli/package.json +COPY packages/studio/package.json packages/studio/package.json +COPY packages/shader-transitions/package.json packages/shader-transitions/package.json +COPY packages/aws-lambda/package.json packages/aws-lambda/package.json +COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json +RUN bun install --frozen-lockfile + +# Copy source for the packages the render path needs. +COPY packages/core/ packages/core/ +COPY packages/engine/ packages/engine/ +COPY packages/producer/ packages/producer/ +COPY packages/gcp-cloud-run/ packages/gcp-cloud-run/ + +# Build core runtime artifacts (needed by the renderer) + producer, then the +# adapter. Generate embedded font data so glyph layout matches production. +RUN bun run --filter @hyperframes/core build:hyperframes-runtime:modular \ + && (cd packages/producer && bunx tsx scripts/generate-font-data.ts) \ + && bun run --cwd packages/producer build \ + && bun run --cwd packages/gcp-cloud-run build + +# Cloud Run injects PORT (default 8080). The server reads it. +ENV PORT=8080 +EXPOSE 8080 + +WORKDIR /app/packages/gcp-cloud-run +# Run under bun, not node. The repo is bun-native and `@hyperframes/producer`'s +# bundled `distributed.js` relies on a `require` being available at runtime +# (its esbuild interop shim); bun provides one in ESM, bare `node` does not. +CMD ["bun", "dist/server.js"] diff --git a/packages/gcp-cloud-run/README.md b/packages/gcp-cloud-run/README.md new file mode 100644 index 000000000..ec6faf985 --- /dev/null +++ b/packages/gcp-cloud-run/README.md @@ -0,0 +1,128 @@ +# @hyperframes/gcp-cloud-run + +Google Cloud Run + Cloud Workflows adapter for HyperFrames distributed +rendering. The OSS render primitives (`plan` → `renderChunk` × N → +`assemble`) are pure functions over local file paths; this package is the +deployment, orchestration, and storage glue that runs them on Google Cloud — +the GCP counterpart to [`@hyperframes/aws-lambda`](../aws-lambda). + +Two surfaces, one package: + +- **Server-side handler** (`./server`) — a Cloud Run HTTP service that + dispatches `plan` / `renderChunk` / `assemble` on the request body's + `Action` field, bridging GCS ↔ the container's filesystem around each OSS + primitive. This is what the bundled `Dockerfile` runs. +- **Client-side SDK** (`./sdk`) — `renderToCloudRun`, `getRenderProgress`, + `deploySite`, `validateDistributedRenderConfig`, and `computeRenderCost`. + Call these from a Node process (CI, CLI, app backend) to drive a deployed + stack without writing GCS / Workflows boilerplate. + +The package is **not** a dependency of `@hyperframes/producer`; install it +separately. + +## Architecture + +``` +GCS bucket ←→ Cloud Run service (plan / renderChunk / assemble) + ▲ + │ OIDC-authenticated http.post, one per step + │ + Cloud Workflows (Plan → parallel RenderChunk → Assemble) +``` + +- **Plan** downloads the project tarball, runs `plan()`, uploads the planDir + tarball (+ audio) to GCS, and returns the chunk count. +- **RenderChunk** runs in a parallel `for` loop in the workflow, fanned out + up to the plan's chunk count. Each invocation renders one chunk and uploads + it. +- **Assemble** downloads every chunk + audio, stitches the final + deliverable, and uploads it. + +Every step is a `POST` to the same Cloud Run URL with a different `Action`. +The workflow accumulates each step's small result body and returns +`{ Plan, Chunks, Assemble }` so `getRenderProgress` can read frame totals and +per-step durations on success. + +## Chrome runtime + +Unlike the Lambda adapter — which fights a 250 MB ZIP ceiling and +decompresses `@sparticuz/chromium` into `/tmp` at runtime — Cloud Run runs a +container image. The `Dockerfile` installs the same pinned +`chrome-headless-shell` build and font set the production renderer uses, at a +fixed path, and exports `HYPERFRAMES_CHROME_PATH`. CDP-level `BeginFrame` +works because the command lives in the protocol, not the binary. There is no +runtime decompression step and no packaging ceiling. + +## Deploying + +The `terraform/` module provisions everything: the GCS render bucket, the +Cloud Run service, the Cloud Workflows definition, two least-privilege +service accounts (the service reads/writes the bucket; the workflow invokes +the service), and a runaway-request alert. + +```bash +# 1. Build + push the image (Cloud Build or local docker). +gcloud builds submit . \ + --tag REGION-docker.pkg.dev/PROJECT/REPO/hyperframes-render:TAG + +# 2. Apply the module. +terraform -chdir=node_modules/@hyperframes/gcp-cloud-run/terraform init +terraform -chdir=node_modules/@hyperframes/gcp-cloud-run/terraform apply \ + -var project_id=PROJECT \ + -var region=us-central1 \ + -var image=REGION-docker.pkg.dev/PROJECT/REPO/hyperframes-render:TAG +``` + +Terraform outputs `render_bucket_name`, `service_url`, `workflow_name`, and +`region` — pass them straight into the SDK. + +## Using the SDK + +```ts +import { renderToCloudRun, getRenderProgress } from "@hyperframes/gcp-cloud-run/sdk"; + +const handle = await renderToCloudRun({ + projectDir: "./my-composition", + config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, + bucketName: "hyperframes-render-my-project", // from terraform output + projectId: "my-project", + location: "us-central1", + workflowId: "hyperframes-render", + serviceUrl: "https://hyperframes-render-abc.us-central1.run.app", +}); + +// Poll until done. +let progress = await getRenderProgress({ executionName: handle.executionName }); +while (progress.status === "running") { + await new Promise((r) => setTimeout(r, 5000)); + progress = await getRenderProgress({ executionName: handle.executionName }); +} +console.log(progress.status, progress.outputFile, progress.costs.displayCost); +``` + +`deploySite` is called implicitly when you pass `projectDir`; call it +yourself to pre-upload once and reuse the `siteHandle` across many renders +(e.g. personalised template batches). + +## Running tests + +```bash +bun test # unit tests over an in-memory GCS double — no network +bun run typecheck +``` + +The live end-to-end smoke (build image → terraform apply → render a fixture +through the workflow → PSNR-compare → destroy) lives at +`examples/gcp-cloud-run/scripts/smoke.sh` and needs a GCP project with +billing enabled. + +## What's still ahead + +- **Mid-flight per-chunk progress.** `getRenderProgress` reports coarse + `running` progress and exact numbers on success. Reading the Cloud + Workflows step-entries API would give per-chunk progress while the render + is in flight; tracked as a follow-up. +- **Cloud Run Jobs / Firebase Functions variants.** This first version + targets Cloud Run services + Workflows (the closest analog to Lambda + + Step Functions). The same handler runs unchanged under Cloud Run Jobs; + only the orchestration trigger differs. diff --git a/packages/gcp-cloud-run/build.mjs b/packages/gcp-cloud-run/build.mjs new file mode 100644 index 000000000..d9a8b0b3a --- /dev/null +++ b/packages/gcp-cloud-run/build.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Build script for @hyperframes/gcp-cloud-run (public OSS package). + * + * Bundles each subpath barrel via esbuild → dist/, then emits .d.ts via tsc. + * + * Subpaths (each gets its own dist entry so adopters that import one path + * don't load the others' transitive graphs at module-load time): + * + * . (the umbrella barrel: server + sdk types re-exported) + * ./server (the Cloud Run runtime entry — what the Dockerfile runs) + * ./sdk (client-side helpers — GCS + Workflows clients only, no + * chromium/puppeteer) + * + * All production deps are kept external so consumers (and the container + * image) resolve them via their own node_modules. + */ + +import { build } from "esbuild"; +import { execSync } from "node:child_process"; +import { mkdirSync, rmSync } from "node:fs"; + +rmSync("dist", { recursive: true, force: true }); +mkdirSync("dist", { recursive: true }); + +const sharedOpts = { + bundle: true, + platform: "node", + target: "node22", + format: "esm", + minify: false, + sourcemap: true, + external: [ + "@google-cloud/storage", + "@google-cloud/workflows", + "@hono/node-server", + "@hyperframes/producer", + "@hyperframes/producer/distributed", + "hono", + "puppeteer-core", + "tar", + ], +}; + +await Promise.all([ + build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }), + build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/server.js" }), + build({ ...sharedOpts, entryPoints: ["src/sdk/index.ts"], outfile: "dist/sdk/index.js" }), +]); + +// esbuild doesn't emit .d.ts. tsc does, with a build-only tsconfig that +// drops the workspace `paths` overrides so `@hyperframes/producer` resolves +// through node_modules to the sibling package's already-built `dist/` +// types instead of pulling its full source tree into emit (which would +// violate rootDir). +execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" }); + +console.log("[Build] Complete: dist/{index,server,sdk/index}.js + .d.ts"); diff --git a/packages/gcp-cloud-run/package.json b/packages/gcp-cloud-run/package.json new file mode 100644 index 000000000..5b638588d --- /dev/null +++ b/packages/gcp-cloud-run/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hyperframes/gcp-cloud-run", + "version": "0.6.79", + "description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.", + "repository": { + "type": "git", + "url": "https://github.com/heygen-com/hyperframes", + "directory": "packages/gcp-cloud-run" + }, + "files": [ + "dist/", + "terraform/", + "Dockerfile", + "README.md" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./server": { + "import": "./dist/server.js", + "types": "./dist/server.d.ts" + }, + "./sdk": { + "import": "./dist/sdk/index.js", + "types": "./dist/sdk/index.d.ts" + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "node build.mjs", + "start": "bun dist/server.js", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@google-cloud/storage": "^7.14.0", + "@google-cloud/workflows": "^4.2.0", + "@hono/node-server": "^1.13.0", + "@hyperframes/producer": "workspace:^", + "hono": "^4.6.0", + "puppeteer-core": "^24.39.1", + "tar": "^7.4.3" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "@types/tar": "^6.1.13", + "esbuild": "^0.25.12", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/gcp-cloud-run/src/__fixtures__/fakeGcs.ts b/packages/gcp-cloud-run/src/__fixtures__/fakeGcs.ts new file mode 100644 index 000000000..f49dc31b2 --- /dev/null +++ b/packages/gcp-cloud-run/src/__fixtures__/fakeGcs.ts @@ -0,0 +1,116 @@ +/** + * In-memory `@google-cloud/storage` stand-in for the adapter's unit tests. + * + * Mimics just the surface the transport + deploySite use: + * storage.bucket(name).file(key) → { createReadStream, exists, getMetadata } + * storage.bucket(name).upload(localPath, { destination, contentType }) + * + * Objects live in a Map keyed `bucket/key`. `upload` reads the real local + * file from disk (the handler writes real tarballs), so round-trips through + * tar/untar exercise the actual code path. Every op is recorded for + * sequence assertions. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import type { Storage } from "@google-cloud/storage"; + +export interface FakeGcsOp { + kind: "download" | "upload" | "exists" | "getMetadata"; + uri: string; + bytes?: number; +} + +export class FakeGcs { + ops: FakeGcsOp[] = []; + objects = new Map(); + + // Accessed only through the `Storage` cast in tests, so fallow's static + // analysis can't see the reference. + // fallow-ignore-next-line unused-class-member + bucket(bucketName: string): FakeBucket { + return new FakeBucket(this, bucketName); + } + + /** Seed an object directly (e.g. a pre-built project tarball). */ + seed(uri: string, bytes: Buffer): void { + this.objects.set(uri, bytes); + } + + /** Seed from a local file on disk. */ + seedFromFile(uri: string, localPath: string): void { + this.objects.set(uri, readFileSync(localPath)); + } +} + +class FakeBucket { + constructor( + private readonly gcs: FakeGcs, + private readonly bucketName: string, + ) {} + + get name(): string { + return this.bucketName; + } + + file(key: string): FakeFile { + return new FakeFile(this.gcs, this.bucketName, key); + } + + async upload( + localPath: string, + opts: { destination: string; contentType?: string }, + ): Promise { + const uri = `gs://${this.bucketName}/${opts.destination}`; + const bytes = readFileSync(localPath); + this.gcs.objects.set(uri, bytes); + this.gcs.ops.push({ kind: "upload", uri, bytes: bytes.length }); + return [{}]; + } +} + +class FakeFile { + private readonly uri: string; + constructor( + private readonly gcs: FakeGcs, + bucketName: string, + key: string, + ) { + this.uri = `gs://${bucketName}/${key}`; + } + + createReadStream(): Readable { + const bytes = this.gcs.objects.get(this.uri); + if (!bytes) { + const r = new Readable({ read() {} }); + r.destroy(new Error(`FakeGcs: object not found: ${this.uri}`)); + return r; + } + this.gcs.ops.push({ kind: "download", uri: this.uri, bytes: bytes.length }); + return Readable.from([bytes]); + } + + async exists(): Promise<[boolean]> { + const has = this.gcs.objects.has(this.uri); + this.gcs.ops.push({ kind: "exists", uri: this.uri }); + return [has]; + } + + async getMetadata(): Promise<[{ size?: string | number; updated?: string }]> { + const bytes = this.gcs.objects.get(this.uri); + this.gcs.ops.push({ kind: "getMetadata", uri: this.uri }); + return [{ size: bytes?.length ?? 0, updated: "2026-06-06T00:00:00.000Z" }]; + } + + /** Helper for tests that want to materialize an object to disk. */ + writeToDisk(destPath: string): void { + const bytes = this.gcs.objects.get(this.uri); + if (!bytes) throw new Error(`FakeGcs: object not found: ${this.uri}`); + writeFileSync(destPath, bytes); + } +} + +/** Cast so `deploySite({ storage: asStorage(fake) })` reads cleanly. */ +export function asStorage(fake: FakeGcs): Storage { + return fake as unknown as Storage; +} diff --git a/packages/gcp-cloud-run/src/chromium.ts b/packages/gcp-cloud-run/src/chromium.ts new file mode 100644 index 000000000..5e18b6859 --- /dev/null +++ b/packages/gcp-cloud-run/src/chromium.ts @@ -0,0 +1,95 @@ +/** + * Cloud Run Chrome resolver. + * + * `renderChunk()` (the only primitive that needs a browser) launches Chrome + * via the engine's `BrowserManager`. Because Cloud Run runs a container + * image rather than a size-capped ZIP, the Chrome story is far simpler than + * the Lambda adapter's: the `Dockerfile` installs `chrome-headless-shell` + * (the same BeginFrame-capable build the K8s deploy uses) into the image at + * a known path and exports `HYPERFRAMES_CHROME_PATH`. There is no runtime + * decompression-into-/tmp step and no 250 MB packaging ceiling to fight. + * + * Resolution order: + * 1. `PRODUCER_HEADLESS_SHELL_PATH` — the engine's own override. If a + * caller (or the Docker image) already set it, honour it untouched. + * 2. `HYPERFRAMES_CHROME_PATH` — set by the Dockerfile to the installed + * `chrome-headless-shell` binary. + * 3. A small list of conventional install paths, as a last resort for + * images built outside our Dockerfile. + * + * Throws {@link ChromeBinaryUnavailableError} when nothing resolves, so a + * misconfigured image fails loudly at the first chunk rather than emitting + * a confusing puppeteer-core "executablePath must be specified" assertion. + */ + +import { existsSync } from "node:fs"; + +/** + * Thrown when the Chrome binary resolver can't produce a usable path. The + * class name is the workflow's non-retryable error discriminator. + */ +export class ChromeBinaryUnavailableError extends Error { + // Read indirectly via the error envelope / Error.prototype.toString. + // fallow-ignore-next-line unused-class-member + override readonly name = "ChromeBinaryUnavailableError"; + readonly resolvedPath: string | null; + constructor(resolvedPath: string | null, hint: string) { + super(`[chromium] Chrome binary unavailable: ${hint}`); + this.resolvedPath = resolvedPath; + } +} + +/** + * Conventional locations a `chrome-headless-shell` (or full Chrome) binary + * may live at in a Debian/Ubuntu-based container. Checked only after the + * two env-var overrides miss. + */ +const FALLBACK_CHROME_PATHS = [ + "/opt/chrome/chrome-headless-shell", + "/usr/bin/chrome-headless-shell", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +]; + +/** + * Resolve the absolute path to a Chrome binary suitable for BeginFrame. + * Pure (no env mutation) so callers decide whether to export the result + * into `PRODUCER_HEADLESS_SHELL_PATH`. + */ +// fallow-ignore-next-line complexity +export function resolveChromeExecutablePath(): string { + const fromEngineOverride = process.env.PRODUCER_HEADLESS_SHELL_PATH?.trim(); + if (fromEngineOverride) { + if (!existsSync(fromEngineOverride)) { + throw new ChromeBinaryUnavailableError( + fromEngineOverride, + `PRODUCER_HEADLESS_SHELL_PATH=${JSON.stringify(fromEngineOverride)} does not exist on disk.`, + ); + } + return fromEngineOverride; + } + + const fromImage = process.env.HYPERFRAMES_CHROME_PATH?.trim(); + if (fromImage) { + if (!existsSync(fromImage)) { + throw new ChromeBinaryUnavailableError( + fromImage, + `HYPERFRAMES_CHROME_PATH=${JSON.stringify(fromImage)} does not exist on disk.`, + ); + } + return fromImage; + } + + for (const candidate of FALLBACK_CHROME_PATHS) { + if (existsSync(candidate)) return candidate; + } + + throw new ChromeBinaryUnavailableError( + null, + "no Chrome binary found. Set HYPERFRAMES_CHROME_PATH (the Dockerfile does this) or " + + "PRODUCER_HEADLESS_SHELL_PATH to the absolute path of a chrome-headless-shell binary. " + + `Searched: ${FALLBACK_CHROME_PATHS.join(", ")}.`, + ); +} diff --git a/packages/gcp-cloud-run/src/events.ts b/packages/gcp-cloud-run/src/events.ts new file mode 100644 index 000000000..bafde86d3 --- /dev/null +++ b/packages/gcp-cloud-run/src/events.ts @@ -0,0 +1,146 @@ +/** + * Request + result types for the HyperFrames distributed render handler + * running on Cloud Run. + * + * The Cloud Workflows definition in `packages/gcp-cloud-run/terraform/workflow.yaml` + * dispatches on the `Action` field of the JSON request body. Each action + * maps 1:1 onto one of the three OSS distributed primitives: + * + * "plan" → `plan(projectDir, config, planDir)` (Activity A) + * "renderChunk" → `renderChunk(planDir, chunkIndex, output)` (Activity B) + * "assemble" → `assemble(planDir, chunkPaths, audio, out)` (Activity C) + * + * All file I/O is mediated by GCS — the handler downloads inputs into a + * per-request workdir under the container's writable `/tmp`, invokes the + * primitive, uploads outputs back to GCS, and returns a small JSON payload + * that fits inside a Cloud Workflows step variable (Workflows caps a single + * step's memory; chunk results stay well under 1 KB so the orchestration + * can hold one per Map iteration). + * + * These shapes are intentionally identical to `@hyperframes/aws-lambda`'s + * `events.ts` apart from the URI scheme (`gs://` vs `s3://`): the wire + * contract is the adapter's, the primitives underneath are shared. + */ + +import type { + DistributedFormat, + SerializableDistributedRenderConfig, +} from "@hyperframes/producer/distributed"; + +export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/distributed"; + +/** Discriminator for the three roles the one Cloud Run image fulfills. */ +export type CloudRunAction = "plan" | "renderChunk" | "assemble"; + +/** + * Top-level shape of any request body the handler may receive. + * + * Cloud Workflows passes the step's `body` through verbatim, but a caller + * driving the service directly (or a Workflows definition that wraps the + * payload) may nest it under `Payload` / `Input`; the handler unwraps both + * before dispatching, matching the Lambda adapter's envelope tolerance. + */ +export type CloudRunEvent = + | PlanEvent + | RenderChunkEvent + | AssembleEvent + | { Payload: CloudRunEvent } + | { Input: CloudRunEvent }; + +/** Activity A: produce a planDir, upload to GCS. */ +export interface PlanEvent { + Action: "plan"; + /** GCS URI pointing at a `tar -czf`-archived project directory (`gs://bucket/key.tar.gz`). */ + ProjectGcsUri: string; + /** GCS URI prefix where the planDir tar should be uploaded (`gs://bucket/{prefix}/`). */ + PlanOutputGcsPrefix: string; + /** `DistributedRenderConfig` minus runtime-only fields (logger, abortSignal). */ + Config: SerializableDistributedRenderConfig; +} + +/** Activity B: fetch planDir, render one chunk, upload result. */ +export interface RenderChunkEvent { + Action: "renderChunk"; + /** GCS URI of the plan tar produced by a PlanEvent invocation. */ + PlanGcsUri: string; + /** + * `PlanResult.planHash` from the Plan invocation. The handler verifies + * this against the untarred planDir's `plan.json` before invoking the + * producer, throwing a typed `PLAN_HASH_MISMATCH` on divergence so the + * workflow routes it as non-retryable. Defense-in-depth — the producer + * also re-checks internally. + */ + PlanHash: string; + /** 0-based chunk index this invocation should render. */ + ChunkIndex: number; + /** GCS URI prefix where the chunk output should be uploaded (`gs://bucket/{prefix}/`). */ + ChunkOutputGcsPrefix: string; + /** Output container format from the plan's encoder.json; drives file vs frame-dir handling. */ + Format: DistributedFormat; +} + +/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */ +export interface AssembleEvent { + Action: "assemble"; + /** GCS URI of the plan tar produced by a PlanEvent invocation. */ + PlanGcsUri: string; + /** GCS URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */ + ChunkGcsUris: string[]; + /** GCS URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */ + AudioGcsUri: string | null; + /** Final output GCS URI (`gs://bucket/key.mp4`). */ + OutputGcsUri: string; + /** Output container format; drives file vs frame-dir handling. */ + Format: DistributedFormat; + /** + * Optional exact-CFR re-encode at assemble time. When `true`, the final + * assembled video is re-encoded with `-fps_mode cfr -r ` so the + * stream's `avg_frame_rate` matches the container's `r_frame_rate` + * exactly (and the file's duration is exact, not PTS-derived). Trade-off + * is ~2-5x the assemble wall-clock. mp4 only — webm / mov stream-copy + * paths already produce exact avg_frame_rate. Default `false` / + * unset preserves current `-c copy` behavior. + */ + Cfr?: boolean; +} + +// ── Result types — kept small to fit Cloud Workflows step budgets ──────────── + +/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */ +export interface PlanResultBody { + Action: "plan"; + PlanGcsUri: string; + PlanHash: string; + ChunkCount: number; + TotalFrames: number; + Fps: 24 | 30 | 60; + Width: number; + Height: number; + Format: DistributedFormat; + HasAudio: boolean; + AudioGcsUri: string | null; + FfmpegVersion: string; + ProducerVersion: string; + DurationMs: number; +} + +/** Result of a `renderChunk` invocation. Sized ≤200 bytes. */ +export interface RenderChunkResultBody { + Action: "renderChunk"; + ChunkGcsUri: string; + ChunkIndex: number; + Sha256: string; + FramesEncoded: number; + DurationMs: number; +} + +/** Result of an `assemble` invocation. */ +export interface AssembleResultBody { + Action: "assemble"; + OutputGcsUri: string; + FramesEncoded: number; + FileSize: number; + DurationMs: number; +} + +export type CloudRunResult = PlanResultBody | RenderChunkResultBody | AssembleResultBody; diff --git a/packages/gcp-cloud-run/src/formatExtension.ts b/packages/gcp-cloud-run/src/formatExtension.ts new file mode 100644 index 000000000..31ff3b124 --- /dev/null +++ b/packages/gcp-cloud-run/src/formatExtension.ts @@ -0,0 +1,27 @@ +/** + * Map a distributed `format` to the file extension the assembled output + * should carry on disk + in GCS. Shared by `src/server.ts` (chunk + + * assemble output paths) and `src/sdk/renderToCloudRun.ts` (final + * output key construction) so the two sides agree on what an mp4 + * looks like vs a png-sequence. + */ + +import type { DistributedFormat } from "@hyperframes/producer/distributed"; + +export type { DistributedFormat } from "@hyperframes/producer/distributed"; + +// Closed-enum lookup table. TS enforces exhaustiveness via the +// `Record` annotation — adding a format to +// `DistributedFormat` without adding the matching key here fails to +// typecheck, which is the same exhaustiveness guarantee a switch + +// `_exhaustive: never` arm provides but at lower complexity. +const FORMAT_EXTENSIONS: Record = { + mp4: ".mp4", + mov: ".mov", + webm: ".webm", + "png-sequence": "", +}; + +export function formatExtension(format: DistributedFormat): string { + return FORMAT_EXTENSIONS[format]; +} diff --git a/packages/gcp-cloud-run/src/gcsTransport.test.ts b/packages/gcp-cloud-run/src/gcsTransport.test.ts new file mode 100644 index 000000000..8b12de075 --- /dev/null +++ b/packages/gcp-cloud-run/src/gcsTransport.test.ts @@ -0,0 +1,114 @@ +/** + * GCS transport unit tests — URI parsing, tar round-trip, and the + * download/upload bridge over the `FakeGcs` double. + */ + +import { afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { asStorage, FakeGcs } from "./__fixtures__/fakeGcs.js"; +import { + downloadGcsObjectToFile, + formatGcsUri, + parseGcsUri, + tarDirectory, + untarDirectory, + uploadFileToGcs, +} from "./gcsTransport.js"; + +const tmpDirs: string[] = []; +function mkTmp(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("parseGcsUri", () => { + it("splits bucket and key", () => { + expect(parseGcsUri("gs://my-bucket/path/to/object.tar.gz")).toEqual({ + bucket: "my-bucket", + key: "path/to/object.tar.gz", + }); + }); + + it("rejects non-gs URIs", () => { + expect(() => parseGcsUri("s3://b/k")).toThrow(/expected gs:\/\//); + }); + + it("rejects a bucket with no key", () => { + expect(() => parseGcsUri("gs://just-a-bucket")).toThrow(/missing key/); + }); + + it("rejects an empty bucket", () => { + expect(() => parseGcsUri("gs:///key")).toThrow(/empty bucket or key/); + }); + + it("round-trips through formatGcsUri", () => { + const uri = "gs://b/some/key"; + expect(formatGcsUri(parseGcsUri(uri))).toBe(uri); + }); +}); + +describe("tarDirectory / untarDirectory", () => { + it("round-trips a directory tree", async () => { + const src = mkTmp("hf-tar-src-"); + mkdirSync(join(src, "nested"), { recursive: true }); + writeFileSync(join(src, "index.html"), "hi"); + writeFileSync(join(src, "nested", "data.json"), '{"a":1}'); + + const work = mkTmp("hf-tar-work-"); + const tarball = join(work, "out.tar.gz"); + await tarDirectory(src, tarball); + expect(existsSync(tarball)).toBe(true); + + const dest = join(work, "extracted"); + await untarDirectory(tarball, dest); + expect(readFileSync(join(dest, "index.html"), "utf8")).toBe("hi"); + expect(readFileSync(join(dest, "nested", "data.json"), "utf8")).toBe('{"a":1}'); + }); + + it("untar wipes a stale destination first", async () => { + const src = mkTmp("hf-tar-src2-"); + writeFileSync(join(src, "keep.txt"), "new"); + const work = mkTmp("hf-tar-work2-"); + const tarball = join(work, "out.tar.gz"); + await tarDirectory(src, tarball); + + const dest = join(work, "extracted"); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(dest, "stale.txt"), "should be gone"); + + await untarDirectory(tarball, dest); + expect(existsSync(join(dest, "stale.txt"))).toBe(false); + expect(readFileSync(join(dest, "keep.txt"), "utf8")).toBe("new"); + }); +}); + +describe("download/upload bridge", () => { + it("uploads a local file then downloads identical bytes", async () => { + const gcs = new FakeGcs(); + const work = mkTmp("hf-dl-"); + const srcFile = join(work, "src.bin"); + writeFileSync(srcFile, Buffer.from("hello gcs")); + + const uri = "gs://bucket/obj.bin"; + await uploadFileToGcs(asStorage(gcs), srcFile, uri, "application/octet-stream"); + + const dest = join(work, "dl.bin"); + await downloadGcsObjectToFile(asStorage(gcs), uri, dest); + expect(readFileSync(dest, "utf8")).toBe("hello gcs"); + + expect(gcs.ops.map((o) => o.kind)).toEqual(["upload", "download"]); + }); + + it("upload throws when the source file is missing", async () => { + const gcs = new FakeGcs(); + await expect(uploadFileToGcs(asStorage(gcs), "/no/such/file", "gs://b/k")).rejects.toThrow( + /upload source missing/, + ); + }); +}); diff --git a/packages/gcp-cloud-run/src/gcsTransport.ts b/packages/gcp-cloud-run/src/gcsTransport.ts new file mode 100644 index 000000000..a3a0913c4 --- /dev/null +++ b/packages/gcp-cloud-run/src/gcsTransport.ts @@ -0,0 +1,130 @@ +/** + * Thin GCS transport for the Cloud Run handler. + * + * The OSS distributed primitives are pure functions over local file paths; + * the handler bridges GCS ↔ the container's writable `/tmp` filesystem on + * each request. Functions here are intentionally narrow: parse a URI, + * download an object to a local path, upload a path, tar-pack a planDir, + * tar-extract a planDir back out. + * + * Tar (not zip) for planDir transit: + * - planDirs contain symlinks (the extract stage materializes them but + * the compiled/ subtree may include linked assets); tar preserves them, + * zip does not. + * - We use the `tar` npm package (pure JS over `node:zlib`) so the + * archive format doesn't depend on a system `tar`/`unzip` being present + * in the container image. + * + * Apart from the `gs://` scheme and the `@google-cloud/storage` client this + * is the same shape as `@hyperframes/aws-lambda`'s `s3Transport.ts`. + */ + +import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import type { Storage } from "@google-cloud/storage"; +import * as tar from "tar"; + +/** Parsed `gs://bucket/key` URI. */ +export interface GcsLocation { + bucket: string; + key: string; +} + +/** Parse `gs://bucket/key/path` → `{ bucket, key }`. Throws on malformed input. */ +// fallow-ignore-next-line complexity +export function parseGcsUri(uri: string): GcsLocation { + if (!uri.startsWith("gs://")) { + throw new Error(`[gcsTransport] expected gs:// URI, got: ${JSON.stringify(uri)}`); + } + const rest = uri.slice("gs://".length); + const slash = rest.indexOf("/"); + if (slash === -1) { + throw new Error(`[gcsTransport] missing key in gs URI: ${JSON.stringify(uri)}`); + } + const bucket = rest.slice(0, slash); + const key = rest.slice(slash + 1); + if (!bucket || !key) { + throw new Error(`[gcsTransport] empty bucket or key in gs URI: ${JSON.stringify(uri)}`); + } + return { bucket, key }; +} + +/** Build `gs://bucket/key` from a location. */ +export function formatGcsUri(loc: GcsLocation): string { + return `gs://${loc.bucket}/${loc.key}`; +} + +/** Stream a GCS object to a local file path. */ +export async function downloadGcsObjectToFile( + storage: Storage, + uri: string, + destPath: string, +): Promise { + const { bucket, key } = parseGcsUri(uri); + mkdirSync(dirname(destPath), { recursive: true }); + const file = storage.bucket(bucket).file(key); + // `createReadStream` streams the object body; piping into a write stream + // keeps memory flat for large plan tarballs / chunk files rather than + // buffering the whole object the way `file.download()` would. + await pipeline(file.createReadStream(), createWriteStream(destPath)); +} + +/** + * Upload a local file's contents to a GCS URI using a resumable upload. + * GCS objects have no practical size ceiling for the artifacts this adapter + * handles (plan tarballs ≤ 2 GB, chunks ≤ a few hundred MB), so a single + * upload call works for every case. + */ +export async function uploadFileToGcs( + storage: Storage, + localPath: string, + uri: string, + contentType?: string, +): Promise { + if (!existsSync(localPath)) { + throw new Error(`[gcsTransport] upload source missing: ${localPath}`); + } + const { bucket, key } = parseGcsUri(uri); + await storage.bucket(bucket).upload(localPath, { + destination: key, + // `resumable: false` (simple upload) is faster for the small-to-medium + // objects this adapter moves and avoids the extra round-trip a resumable + // session start costs; GCS recommends resumable only past ~8 MB but our + // chunks are reliably above that, so let the client pick by default. + contentType, + }); +} + +/** + * Pack a directory into a `.tar.gz` at `destTarball`. Uses the `tar` npm + * package (pure JS over `node:zlib`) rather than spawning a system tar + * binary so the archive format is independent of the container's userland. + */ +export async function tarDirectory(sourceDir: string, destTarball: string): Promise { + if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) { + throw new Error(`[gcsTransport] tar source must be an existing directory: ${sourceDir}`); + } + mkdirSync(dirname(destTarball), { recursive: true }); + await tar.create({ gzip: true, file: destTarball, cwd: sourceDir }, ["."]); +} + +/** + * Extract a `.tar.gz` produced by {@link tarDirectory} into `destDir`. + * The directory is created (or cleared) before extraction so a retried + * request doesn't observe stale files from a prior run on the same warm + * container instance. + */ +export async function untarDirectory(tarballPath: string, destDir: string): Promise { + if (!existsSync(tarballPath)) { + throw new Error(`[gcsTransport] tarball missing: ${tarballPath}`); + } + // Wipe target so a warm container instance's prior planDir doesn't bleed + // into the new request. Cloud Run re-uses the instance filesystem across + // requests served by the same instance. + if (existsSync(destDir)) { + rmSync(destDir, { recursive: true, force: true }); + } + mkdirSync(destDir, { recursive: true }); + await tar.extract({ file: tarballPath, cwd: destDir }); +} diff --git a/packages/gcp-cloud-run/src/index.ts b/packages/gcp-cloud-run/src/index.ts new file mode 100644 index 000000000..3ecf8df6d --- /dev/null +++ b/packages/gcp-cloud-run/src/index.ts @@ -0,0 +1,64 @@ +/** + * `@hyperframes/gcp-cloud-run` — Google Cloud Run + Workflows adapter for + * the HyperFrames distributed render pipeline. + * + * Two surfaces, one package: + * + * - **Server-side handler.** `dispatch`, `createApp`, the request/result + * types, Chrome resolution, and GCS transport. These power the Cloud Run + * service built from the package `Dockerfile`. + * - **Client-side SDK.** `renderToCloudRun`, `getRenderProgress`, + * `deploySite`, `validateDistributedRenderConfig`, and `computeRenderCost`. + * Adopters call these from their Node process (CI scripts, CLIs) to drive + * a deployed stack without writing GCS / Workflows boilerplate. + * + * The Terraform module that provisions the bucket + service + workflow lives + * under `terraform/` in the published package; see the README. The package + * is NOT a dependency of `@hyperframes/producer`; consumers install it + * separately. + */ + +export { createApp, dispatch, type HandlerDeps, startServer, unwrapEvent } from "./server.js"; +export { + type AssembleEvent, + type AssembleResultBody, + type CloudRunAction, + type CloudRunEvent, + type CloudRunResult, + type PlanEvent, + type PlanResultBody, + type RenderChunkEvent, + type RenderChunkResultBody, + type SerializableDistributedRenderConfig, +} from "./events.js"; +export { ChromeBinaryUnavailableError, resolveChromeExecutablePath } from "./chromium.js"; +export { + downloadGcsObjectToFile, + formatGcsUri, + type GcsLocation, + parseGcsUri, + tarDirectory, + untarDirectory, + uploadFileToGcs, +} from "./gcsTransport.js"; + +// ── Client-side SDK ───────────────────────────────────────────────────────── +export { deploySite, type DeploySiteOptions, type SiteHandle } from "./sdk/deploySite.js"; +export { + renderToCloudRun, + type RenderHandle, + type RenderToCloudRunOptions, +} from "./sdk/renderToCloudRun.js"; +export { + getRenderProgress, + type GetRenderProgressOptions, + type RenderError, + type RenderProgress, + type RenderStatus, +} from "./sdk/getRenderProgress.js"; +export { + type BilledCloudRunInvocation, + computeRenderCost, + type RenderCost, +} from "./sdk/costAccounting.js"; +export { InvalidConfigError, validateDistributedRenderConfig } from "./sdk/validateConfig.js"; diff --git a/packages/gcp-cloud-run/src/sdk/costAccounting.test.ts b/packages/gcp-cloud-run/src/sdk/costAccounting.test.ts new file mode 100644 index 000000000..a9810fd50 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/costAccounting.test.ts @@ -0,0 +1,35 @@ +/** + * `computeRenderCost` unit tests — Cloud Run vCPU/GiB-second + Workflows + * step math. + */ + +import { describe, expect, it } from "bun:test"; +import { type BilledCloudRunInvocation, computeRenderCost } from "./costAccounting.js"; + +describe("computeRenderCost", () => { + it("returns zero for no invocations", () => { + const cost = computeRenderCost([], 0); + expect(cost.accruedSoFarUsd).toBe(0); + expect(cost.displayCost).toBe("$0.0000"); + }); + + it("sums vCPU + memory seconds plus per-request and step charges", () => { + const invs: BilledCloudRunInvocation[] = [ + { durationMs: 10_000, vcpu: 4, memoryGib: 16, estimated: false }, + { durationMs: 10_000, vcpu: 4, memoryGib: 16, estimated: false }, + ]; + const cost = computeRenderCost(invs, 6); + // 2 × 10s: vCPU 80 vcpu-s × 0.000024 = 0.00192; mem 320 GiB-s × 0.0000025 = 0.0008; + // requests 2 × 4e-7 ≈ 0 → raw 0.0027208, rounded to 4 dp = 0.0027. + // workflows 6 × 1e-5 = 0.00006 → rounds up to 0.0001 at 4 dp. + expect(cost.breakdown.cloudRunUsd).toBeCloseTo(0.0027, 4); + expect(cost.breakdown.workflowsUsd).toBeCloseTo(0.0001, 4); + expect(cost.accruedSoFarUsd).toBeGreaterThan(0); + expect(cost.breakdown.gcsEstimate).toBe("not-included"); + }); + + it("flags estimated when any invocation was estimated", () => { + const cost = computeRenderCost([{ durationMs: 0, vcpu: 4, memoryGib: 16, estimated: true }], 4); + expect(cost.breakdown.estimated).toBe(true); + }); +}); diff --git a/packages/gcp-cloud-run/src/sdk/costAccounting.ts b/packages/gcp-cloud-run/src/sdk/costAccounting.ts new file mode 100644 index 000000000..909833b31 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/costAccounting.ts @@ -0,0 +1,113 @@ +/** + * Per-render cost accounting for {@link getRenderProgress}. + * + * Google bills the render service two ways: + * + * - **Cloud Run** by **vCPU-seconds** and **GiB-seconds** of request + * processing time, plus a flat per-request charge. Each handler + * invocation returns its own `DurationMs` in the result body, so the + * progress reader can recover billed time per step without a separate + * Cloud Monitoring query — multiply by the service's configured vCPU / + * memory to get the resource-seconds. + * - **Cloud Workflows** by **steps executed**. The orchestration is a + * fixed shape (Plan + N×RenderChunk + Assemble + a handful of control + * steps), so the step count scales with chunk count. + * + * The math is documented inline so the constants stay close to the pricing + * source they came from. Cost is **best-effort**: GCP pricing varies by + * region + committed-use discounts; we use on-demand `us-central1` (Tier 1) + * rates as of 2026-06 and label the result `displayCost` so callers see the + * dollar value but downstream automation can also read the raw number. + */ + +/** Cloud Run request-based billing, us-central1 Tier 1: USD per vCPU-second. */ +const CLOUD_RUN_USD_PER_VCPU_SECOND = 0.000024; +/** Cloud Run request-based billing, us-central1 Tier 1: USD per GiB-second. */ +const CLOUD_RUN_USD_PER_GIB_SECOND = 0.0000025; +/** Cloud Run: USD per request ($0.40 per million). */ +const CLOUD_RUN_USD_PER_REQUEST = 0.0000004; +/** Cloud Workflows: USD per internal step ($0.01 per 1,000, after a free tier). */ +const WORKFLOWS_USD_PER_STEP = 0.00001; + +/** Per-invocation billed slice the cost calc cares about. */ +export interface BilledCloudRunInvocation { + /** Wall-clock the handler reported via `DurationMs` in its result body. */ + durationMs: number; + /** vCPU the Cloud Run service was configured with at invocation time. */ + vcpu: number; + /** Memory in GiB the Cloud Run service was configured with. */ + memoryGib: number; + /** `true` if the duration was inferred (step result missing) rather than read from the handler payload. */ + estimated: boolean; +} + +/** + * Result of {@link computeRenderCost}. + * + * NOTE: `displayCost` / `accruedSoFarUsd` cover Cloud Run compute + Cloud + * Workflows steps only. They EXCLUDE GCS storage + network egress for the + * plan tarball (which can be ~100 MB), chunk artifacts, and the final output + * — see `breakdown.gcsEstimate`. Treat the figure as a compute-cost floor, + * not the authoritative total bill. + */ +export interface RenderCost { + /** USD accrued to date (Cloud Run + Workflows only; excludes GCS — see note above). */ + accruedSoFarUsd: number; + /** Human-readable USD string, e.g. `"$0.0214"`. Excludes GCS storage/egress. */ + displayCost: string; + breakdown: { + cloudRunUsd: number; + workflowsUsd: number; + /** GCS transfer + storage cost varies by tier; we don't try to compute it here. */ + gcsEstimate: "not-included"; + /** `true` if any invocation fell back to estimated billing. */ + estimated: boolean; + }; +} + +/** + * Sum Cloud Run vCPU-seconds + GiB-seconds + per-request charges and Cloud + * Workflows steps into an aggregate USD figure. + * + * `workflowSteps` is the count of Workflows steps executed so far — Plan + * (1) + RenderChunk (chunkCount) + Assemble (1) + the control steps + * (BuildChunkList, AssertChunkCount, …). Pass the count the progress reader + * derived from the execution; a rough constant overhead is fine since the + * step charge is a rounding error next to Cloud Run compute. + */ +export function computeRenderCost( + invocations: BilledCloudRunInvocation[], + workflowSteps: number, +): RenderCost { + let cloudRunUsd = 0; + let anyEstimated = false; + for (const inv of invocations) { + const seconds = inv.durationMs / 1000; + cloudRunUsd += seconds * inv.vcpu * CLOUD_RUN_USD_PER_VCPU_SECOND; + cloudRunUsd += seconds * inv.memoryGib * CLOUD_RUN_USD_PER_GIB_SECOND; + cloudRunUsd += CLOUD_RUN_USD_PER_REQUEST; + if (inv.estimated) anyEstimated = true; + } + const workflowsUsd = workflowSteps * WORKFLOWS_USD_PER_STEP; + const accruedSoFarUsd = roundUsd(cloudRunUsd + workflowsUsd); + return { + accruedSoFarUsd, + displayCost: formatUsd(accruedSoFarUsd), + breakdown: { + cloudRunUsd: roundUsd(cloudRunUsd), + workflowsUsd: roundUsd(workflowsUsd), + gcsEstimate: "not-included", + estimated: anyEstimated, + }, + }; +} + +function roundUsd(usd: number): number { + // Four decimal places — enough resolution for per-chunk granularity. + // Anything finer is noise vs GCP's own rounding. + return Math.round(usd * 10_000) / 10_000; +} + +function formatUsd(usd: number): string { + return `$${usd.toFixed(4)}`; +} diff --git a/packages/gcp-cloud-run/src/sdk/deploySite.test.ts b/packages/gcp-cloud-run/src/sdk/deploySite.test.ts new file mode 100644 index 000000000..6c631ef6c --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/deploySite.test.ts @@ -0,0 +1,86 @@ +/** + * `deploySite` unit tests — content-addressed siteId, existence + * short-circuit, and the upload path over `FakeGcs`. + */ + +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { asStorage, FakeGcs } from "../__fixtures__/fakeGcs.js"; +import { deploySite } from "./deploySite.js"; + +const tmpDirs: string[] = []; +function mkProject(content: string): string { + const dir = mkdtempSync(join(tmpdir(), "hf-site-")); + tmpDirs.push(dir); + writeFileSync(join(dir, "index.html"), content); + return dir; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("deploySite", () => { + it("uploads and returns a content-addressed handle", async () => { + const gcs = new FakeGcs(); + const dir = mkProject("v1"); + const handle = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) }); + expect(handle.uploaded).toBe(true); + expect(handle.projectGcsUri).toBe(`gs://b/sites/${handle.siteId}/project.tar.gz`); + expect(gcs.objects.has(handle.projectGcsUri)).toBe(true); + }); + + it("produces a stable siteId for identical content", async () => { + const a = await deploySite({ + projectDir: mkProject("same"), + bucketName: "b", + storage: asStorage(new FakeGcs()), + }); + const b = await deploySite({ + projectDir: mkProject("same"), + bucketName: "b", + storage: asStorage(new FakeGcs()), + }); + expect(a.siteId).toBe(b.siteId); + }); + + it("produces different siteIds for different content", async () => { + const a = await deploySite({ + projectDir: mkProject("one"), + bucketName: "b", + storage: asStorage(new FakeGcs()), + }); + const b = await deploySite({ + projectDir: mkProject("two"), + bucketName: "b", + storage: asStorage(new FakeGcs()), + }); + expect(a.siteId).not.toBe(b.siteId); + }); + + it("short-circuits the upload when the object already exists", async () => { + const gcs = new FakeGcs(); + const dir = mkProject("cache"); + const first = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) }); + expect(first.uploaded).toBe(true); + + const second = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) }); + expect(second.uploaded).toBe(false); + expect(second.siteId).toBe(first.siteId); + // Only one upload op total. + expect(gcs.ops.filter((o) => o.kind === "upload").length).toBe(1); + }); + + it("honours an explicit siteId override", async () => { + const gcs = new FakeGcs(); + const handle = await deploySite({ + projectDir: mkProject(""), + bucketName: "b", + siteId: "my-git-sha", + storage: asStorage(gcs), + }); + expect(handle.siteId).toBe("my-git-sha"); + expect(handle.projectGcsUri).toBe("gs://b/sites/my-git-sha/project.tar.gz"); + }); +}); diff --git a/packages/gcp-cloud-run/src/sdk/deploySite.ts b/packages/gcp-cloud-run/src/sdk/deploySite.ts new file mode 100644 index 000000000..dfd9f63ab --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/deploySite.ts @@ -0,0 +1,130 @@ +/** + * `deploySite` — upload a project directory to GCS once per content hash + * and return a reusable handle. + * + * `renderToCloudRun` calls this implicitly when no `siteHandle` is passed, + * but exposing it as a standalone verb lets adopters bundle a project ahead + * of time and reuse the handle across many renders without re-tarring the + * project tree on every call. + * + * The handle is **content-addressed**: `siteId` is derived from a SHA-256 + * over the project files. Two `deploySite` calls on an unchanged tree + * produce the same `siteId` and short-circuit the upload after a single + * existence check. + */ + +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Storage } from "@google-cloud/storage"; +import { hashProjectDir } from "@hyperframes/producer/distributed"; +import { formatGcsUri, tarDirectory, uploadFileToGcs } from "../gcsTransport.js"; + +/** Options for {@link deploySite}. */ +export interface DeploySiteOptions { + /** Local project directory containing `index.html` (and any composition assets). */ + projectDir: string; + /** GCS bucket the Terraform module provisioned. */ + bucketName: string; + /** + * Override the content-addressed site id. Useful when the caller has a + * stable external identifier they want to use (e.g. a git SHA); if unset, + * the hash of the project tree picks it. + */ + siteId?: string; + /** Injection seam for tests. Production callers leave unset. */ + storage?: Storage; +} + +/** Stable handle returned by {@link deploySite}. Pass back to {@link renderToCloudRun}. */ +export interface SiteHandle { + /** Content-addressed (or caller-supplied) identifier; stable across re-uploads of the same tree. */ + siteId: string; + /** Bucket the site landed in. Surfaced separately so callers don't have to re-parse `projectGcsUri`. */ + bucketName: string; + /** Full `gs://bucket/sites//project.tar.gz` URI; pass through to `renderToCloudRun`. */ + projectGcsUri: string; + /** Tarball size in bytes; useful for "did we actually skip the upload?" assertions. */ + bytes: number; + /** ISO timestamp of the most recent upload OR the existing object the short-circuit found. */ + uploadedAt: string; + /** `false` if the object already existed and we skipped the upload. */ + uploaded: boolean; +} + +/** + * Upload `projectDir` to `gs://bucketName/sites//project.tar.gz`. + * + * Short-circuits when an object with the same key already exists in the + * bucket — `siteId` derives from the project's content hash, so the same + * bytes produce the same key, and re-uploading would be redundant. + */ +// fallow-ignore-next-line complexity +export async function deploySite(opts: DeploySiteOptions): Promise { + if (!statSync(opts.projectDir).isDirectory()) { + throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`); + } + + const siteId = opts.siteId ?? hashProjectDir(opts.projectDir); + const key = `sites/${siteId}/project.tar.gz`; + const projectGcsUri = formatGcsUri({ bucket: opts.bucketName, key }); + const storage = opts.storage ?? new Storage(); + const file = storage.bucket(opts.bucketName).file(key); + + // Existence short-circuit. Adopters re-rendering the same project on a + // tight inner loop (CI smoke, demo flows) save the tar+gzip+upload pass + // on every iteration. + const existing = await headObject(file); + if (existing) { + return { + siteId, + bucketName: opts.bucketName, + projectGcsUri, + bytes: existing.bytes, + uploadedAt: existing.lastModified, + uploaded: false, + }; + } + + const workdir = mkdtempSync(join(tmpdir(), "hf-deploy-site-")); + try { + const tarball = join(workdir, "project.tar.gz"); + await tarDirectory(opts.projectDir, tarball); + const size = statSync(tarball).size; + await uploadFileToGcs(storage, tarball, projectGcsUri, "application/gzip"); + return { + siteId, + bucketName: opts.bucketName, + projectGcsUri, + bytes: size, + uploadedAt: new Date().toISOString(), + uploaded: true, + }; + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +} + +/** + * Narrow surface of the `@google-cloud/storage` `File` this module uses — + * lets the test double implement just `exists()` + `getMetadata()` without + * pulling the full client type. + */ +interface FileLike { + exists(): Promise<[boolean, ...unknown[]]>; + getMetadata(): Promise<[{ size?: string | number; updated?: string }, ...unknown[]]>; +} + +// fallow-ignore-next-line complexity +async function headObject(file: FileLike): Promise<{ bytes: number; lastModified: string } | null> { + const [exists] = await file.exists(); + if (!exists) return null; + const [meta] = await file.getMetadata(); + const sizeRaw = meta.size; + const bytes = + typeof sizeRaw === "string" ? Number(sizeRaw) : typeof sizeRaw === "number" ? sizeRaw : 0; + return { + bytes: Number.isFinite(bytes) ? bytes : 0, + lastModified: meta.updated ?? new Date().toISOString(), + }; +} diff --git a/packages/gcp-cloud-run/src/sdk/getRenderProgress.test.ts b/packages/gcp-cloud-run/src/sdk/getRenderProgress.test.ts new file mode 100644 index 000000000..bcc992e60 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/getRenderProgress.test.ts @@ -0,0 +1,108 @@ +/** + * `getRenderProgress` unit tests — state mapping + parsing the accumulated + * workflow result into frame totals, output file, and cost. + */ + +import { describe, expect, it } from "bun:test"; +import { + type ExecutionRecord, + type ExecutionsGetClientLike, + getRenderProgress, +} from "./getRenderProgress.js"; + +function fakeExecutions(record: ExecutionRecord): ExecutionsGetClientLike { + return { + async getExecution(_req: { name: string }) { + return [record] as [ExecutionRecord]; + }, + }; +} + +const accumulated = JSON.stringify({ + Plan: { TotalFrames: 90, DurationMs: 4000 }, + Chunks: [ + { FramesEncoded: 30, DurationMs: 8000 }, + { FramesEncoded: 30, DurationMs: 8000 }, + { FramesEncoded: 30, DurationMs: 8000 }, + ], + Assemble: { OutputGcsUri: "gs://b/renders/r1/output.mp4", FileSize: 123456, DurationMs: 3000 }, +}); + +describe("getRenderProgress", () => { + it("reports running with no frame data while ACTIVE", async () => { + const p = await getRenderProgress({ + executionName: "x", + executions: fakeExecutions({ state: "ACTIVE", startTime: { seconds: 1700000000 } }), + }); + expect(p.status).toBe("running"); + expect(p.overallProgress).toBe(0); + expect(p.totalFrames).toBeNull(); + expect(p.fatalErrorEncountered).toBe(false); + }); + + it("reports succeeded with parsed frames + cost", async () => { + const p = await getRenderProgress({ + executionName: "x", + vcpu: 4, + memoryGib: 16, + executions: fakeExecutions({ + state: "SUCCEEDED", + result: accumulated, + startTime: { seconds: 1700000000 }, + endTime: { seconds: 1700000031 }, + }), + }); + expect(p.status).toBe("succeeded"); + expect(p.overallProgress).toBe(1); + expect(p.totalFrames).toBe(90); + expect(p.framesRendered).toBe(90); + expect(p.invocationsObserved).toBe(5); // plan + 3 chunks + assemble + expect(p.outputFile).toEqual({ gcsUri: "gs://b/renders/r1/output.mp4", bytes: 123456 }); + expect(p.costs.accruedSoFarUsd).toBeGreaterThan(0); + expect(p.costs.breakdown.estimated).toBe(false); + }); + + it("maps FAILED to a fatal error and surfaces the error payload", async () => { + const p = await getRenderProgress({ + executionName: "x", + executions: fakeExecutions({ + state: "FAILED", + error: { payload: "boom", context: "renderChunk" }, + }), + }); + expect(p.status).toBe("failed"); + expect(p.fatalErrorEncountered).toBe(true); + expect(p.errors[0]?.cause).toBe("boom"); + expect(p.errors[0]?.state).toBe("renderChunk"); + }); + + it("extracts the handler error name from a wrapped http failure payload", async () => { + // Workflows wraps an http step failure as { code, message, body }, where + // body is the handler's JSON { error, message }. + const payload = JSON.stringify({ + code: 400, + message: "HTTP server responded with error code 400", + body: JSON.stringify({ error: "PLAN_HASH_MISMATCH", message: "mismatch" }), + }); + const p = await getRenderProgress({ + executionName: "x", + executions: fakeExecutions({ state: "FAILED", error: { payload, context: "renderChunk" } }), + }); + expect(p.errors[0]?.error).toBe("PLAN_HASH_MISMATCH"); + }); + + it("maps CANCELLED", async () => { + const p = await getRenderProgress({ + executionName: "x", + executions: fakeExecutions({ state: "CANCELLED" }), + }); + expect(p.status).toBe("cancelled"); + expect(p.fatalErrorEncountered).toBe(true); + }); + + it("requires an executionName", async () => { + await expect( + getRenderProgress({ executionName: "", executions: fakeExecutions({}) }), + ).rejects.toThrow(/executionName is required/); + }); +}); diff --git a/packages/gcp-cloud-run/src/sdk/getRenderProgress.ts b/packages/gcp-cloud-run/src/sdk/getRenderProgress.ts new file mode 100644 index 000000000..2ecada506 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/getRenderProgress.ts @@ -0,0 +1,268 @@ +/** + * `getRenderProgress` — read-only progress + cost snapshot for a single + * render started by {@link renderToCloudRun}. + * + * Pulls one `GetExecution` per call. Cloud Workflows does not surface + * per-step payloads through the basic Executions API the way Step Functions + * exposes its history, so this reader takes a different tack than the AWS + * adapter: the workflow definition **accumulates** each step's result body + * (Plan + every RenderChunk + Assemble) and returns them as one structured + * object. On success we parse that object for frame totals, the output + * file, and per-step `DurationMs` (which the handler stamps into every + * result), then compute cost against the service's configured vCPU/memory. + * + * Progress is therefore coarse while the execution is ACTIVE (we report + * `running` with `overallProgress = 0`) and exact once it SUCCEEDS + * (`overallProgress = 1`, real frame + cost numbers). Mid-flight per-chunk + * progress would require the Workflows step-entries API; that's a tracked + * follow-up, not part of the first version. + */ + +import { + type BilledCloudRunInvocation, + computeRenderCost, + type RenderCost, +} from "./costAccounting.js"; + +/** Normalised render status. Maps from Cloud Workflows execution states. */ +export type RenderStatus = "running" | "succeeded" | "failed" | "cancelled" | "unknown"; + +/** One error surfaced by the execution. */ +export interface RenderError { + /** Step the failure surfaced in, when recoverable from the error context; else ``. */ + state: string; + /** Error class / type. */ + error: string; + /** Cause string (often a stringified JSON payload from the handler). */ + cause: string; +} + +/** Snapshot of a single render's progress + cost + errors at one point in time. */ +export interface RenderProgress { + status: RenderStatus; + /** `[0, 1]`; coarse while running, exact on success. */ + overallProgress: number; + framesRendered: number; + /** `null` until the execution succeeds and the accumulated plan result is read. */ + totalFrames: number | null; + /** Cloud Run invocations the workflow scheduled (Plan + chunks + Assemble), when known. */ + invocationsObserved: number; + costs: RenderCost; + /** Final output object if Assemble succeeded; `null` otherwise. */ + outputFile: { gcsUri: string; bytes: number | null } | null; + errors: RenderError[]; + /** `true` once the execution has terminated in a non-success state. */ + fatalErrorEncountered: boolean; + startedAt: string; + endedAt: string | null; +} + +/** Protobuf Timestamp shape the gapic client returns for start/end times. */ +interface ProtoTimestamp { + seconds?: number | string | null; + nanos?: number | null; +} + +/** Subset of a Cloud Workflows Execution this reader consumes. */ +export interface ExecutionRecord { + name?: string | null; + state?: string | null; + result?: string | null; + error?: { payload?: string | null; context?: string | null } | null; + startTime?: ProtoTimestamp | string | null; + endTime?: ProtoTimestamp | string | null; +} + +/** Minimal surface of `@google-cloud/workflows`' `ExecutionsClient` for reads. */ +export interface ExecutionsGetClientLike { + getExecution(req: { name: string }): Promise<[ExecutionRecord, ...unknown[]]>; +} + +/** Options for {@link getRenderProgress}. */ +export interface GetRenderProgressOptions { + /** Server-assigned execution resource name from a {@link renderToCloudRun} call. */ + executionName: string; + /** vCPU the Cloud Run service is configured with (for cost). Default 4. */ + vcpu?: number; + /** Memory in GiB the Cloud Run service is configured with (for cost). Default 16. */ + memoryGib?: number; + /** Test injection seam — production callers leave unset. */ + executions?: ExecutionsGetClientLike; +} + +const DEFAULT_VCPU = 4; +const DEFAULT_MEMORY_GIB = 16; + +/** Result body the handler returns for each action; the workflow accumulates these. */ +interface AccumulatedResult { + Plan?: { TotalFrames?: number; DurationMs?: number } | null; + Chunks?: Array<{ FramesEncoded?: number; DurationMs?: number } | null> | null; + Assemble?: { + OutputGcsUri?: string; + FileSize?: number; + FramesEncoded?: number; + DurationMs?: number; + } | null; +} + +/** Pull a current progress snapshot for one render. */ +// fallow-ignore-next-line complexity +export async function getRenderProgress(opts: GetRenderProgressOptions): Promise { + if (!opts.executionName) { + throw new Error("[getRenderProgress] executionName is required"); + } + const executions = opts.executions ?? (await defaultExecutionsClient()); + const vcpu = opts.vcpu ?? DEFAULT_VCPU; + const memoryGib = opts.memoryGib ?? DEFAULT_MEMORY_GIB; + + const [execution] = await executions.getExecution({ name: opts.executionName }); + const status = mapState(execution.state); + const startedAt = toIso(execution.startTime) ?? new Date(0).toISOString(); + const endedAt = toIso(execution.endTime); + + const errors: RenderError[] = []; + if (execution.error) { + errors.push({ + state: execution.error.context ?? "", + error: extractErrorName(execution.error.payload) ?? "ExecutionError", + cause: execution.error.payload ?? "", + }); + } + + // Default snapshot: running / unknown — no frame or cost data until the + // accumulated result is available on success. + if (status !== "succeeded") { + return { + status, + overallProgress: 0, + framesRendered: 0, + totalFrames: null, + invocationsObserved: 0, + costs: computeRenderCost([], 0), + outputFile: null, + errors, + fatalErrorEncountered: status === "failed" || status === "cancelled", + startedAt, + endedAt, + }; + } + + const acc = parseAccumulated(execution.result); + const chunks = acc.Chunks?.filter((c): c is NonNullable => c != null) ?? []; + const framesRendered = chunks.reduce((sum, c) => sum + (c.FramesEncoded ?? 0), 0); + const totalFrames = typeof acc.Plan?.TotalFrames === "number" ? acc.Plan.TotalFrames : null; + + const invocations: BilledCloudRunInvocation[] = []; + const pushInv = (durationMs: number | undefined): void => { + invocations.push({ + durationMs: typeof durationMs === "number" ? durationMs : 0, + vcpu, + memoryGib, + estimated: typeof durationMs !== "number", + }); + }; + if (acc.Plan) pushInv(acc.Plan.DurationMs); + for (const c of chunks) pushInv(c.DurationMs); + if (acc.Assemble) pushInv(acc.Assemble.DurationMs); + + // Workflow step count: Plan + N chunks + Assemble + a small constant of + // control steps (BuildChunkList, AssertChunkCount, the map scaffold). + const workflowSteps = invocations.length + 4; + const costs = computeRenderCost(invocations, workflowSteps); + + const outputGcsUri = acc.Assemble?.OutputGcsUri; + const outputFile = outputGcsUri + ? { + gcsUri: outputGcsUri, + bytes: typeof acc.Assemble?.FileSize === "number" ? acc.Assemble.FileSize : null, + } + : null; + + return { + status, + overallProgress: 1, + framesRendered, + totalFrames, + invocationsObserved: invocations.length, + costs, + outputFile, + errors, + fatalErrorEncountered: false, + startedAt, + endedAt, + }; +} + +// fallow-ignore-next-line complexity +function mapState(state: string | null | undefined): RenderStatus { + switch (state) { + case "ACTIVE": + case "QUEUED": + return "running"; + case "SUCCEEDED": + return "succeeded"; + case "FAILED": + case "UNAVAILABLE": + return "failed"; + case "CANCELLED": + return "cancelled"; + default: + return "unknown"; + } +} + +// fallow-ignore-next-line complexity +function parseAccumulated(result: string | null | undefined): AccumulatedResult { + if (!result) return {}; + try { + const parsed = JSON.parse(result) as unknown; + if (parsed && typeof parsed === "object") return parsed as AccumulatedResult; + } catch { + // Non-JSON result — treat as empty so cost/frames degrade to zero + // rather than throwing on a snapshot read. + } + return {}; +} + +/** + * Best-effort pull of the handler's error name out of a Workflows failure + * payload. On an http step failure, Workflows wraps the response as + * `{ code, message, body, ... }` where `body` is the handler's JSON + * `{ error, message }`. We dig out `error` (the typed name like + * `PLAN_HASH_MISMATCH`) so triage sees the real cause, not a generic label. + * Returns undefined for any shape we don't recognise — never throws. + */ +// fallow-ignore-next-line complexity +function extractErrorName(payload: string | null | undefined): string | undefined { + if (!payload) return undefined; + try { + const outer = JSON.parse(payload) as { error?: unknown; body?: unknown }; + if (typeof outer.error === "string") return outer.error; + if (typeof outer.body === "string") { + const inner = JSON.parse(outer.body) as { error?: unknown }; + if (typeof inner.error === "string") return inner.error; + } else if (outer.body && typeof outer.body === "object") { + const inner = outer.body as { error?: unknown }; + if (typeof inner.error === "string") return inner.error; + } + } catch { + // Non-JSON / unexpected shape — fall through to the generic label. + } + return undefined; +} + +// fallow-ignore-next-line complexity +function toIso(ts: ProtoTimestamp | string | null | undefined): string | null { + if (ts == null) return null; + if (typeof ts === "string") return ts; + const seconds = ts.seconds == null ? null : Number(ts.seconds); + if (seconds == null || !Number.isFinite(seconds)) return null; + const ms = seconds * 1000 + (ts.nanos ?? 0) / 1e6; + return new Date(ms).toISOString(); +} + +async function defaultExecutionsClient(): Promise { + const mod = await import("@google-cloud/workflows"); + const client = new mod.ExecutionsClient(); + return client as unknown as ExecutionsGetClientLike; +} diff --git a/packages/gcp-cloud-run/src/sdk/index.ts b/packages/gcp-cloud-run/src/sdk/index.ts new file mode 100644 index 000000000..63fbd35fe --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/index.ts @@ -0,0 +1,40 @@ +/** + * SDK subpath export — `@hyperframes/gcp-cloud-run/sdk`. + * + * Pulled into its own subpath so consumers that only drive renders (CLI, CI + * scripts, adopter tooling) don't pay the cost of importing `./server.js`, + * which transitively pulls `puppeteer-core` into the module graph. The SDK + * files here are GCS + Workflows clients only — safe to load in any Node + * environment. + */ + +export { deploySite, type DeploySiteOptions, type SiteHandle } from "./deploySite.js"; +export { + type ExecutionsClientLike, + renderToCloudRun, + type RenderHandle, + type RenderToCloudRunOptions, +} from "./renderToCloudRun.js"; +export { + type ExecutionRecord, + type ExecutionsGetClientLike, + getRenderProgress, + type GetRenderProgressOptions, + type RenderError, + type RenderProgress, + type RenderStatus, +} from "./getRenderProgress.js"; +export { + type BilledCloudRunInvocation, + computeRenderCost, + type RenderCost, +} from "./costAccounting.js"; +export { + InvalidConfigError, + MAX_WORKFLOWS_INPUT_BYTES, + validateDistributedRenderConfig, + validateVariablesPayload, + validateWorkflowsInputSize, +} from "./validateConfig.js"; +export type { SerializableDistributedRenderConfig } from "../events.js"; +export type { DistributedFormat } from "../formatExtension.js"; diff --git a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.test.ts b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.test.ts new file mode 100644 index 000000000..8a41d5c12 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.test.ts @@ -0,0 +1,127 @@ +/** + * `renderToCloudRun` unit tests — argument assembly, required-field + * validation, and the CreateExecution call over a fake ExecutionsClient. + */ + +import { describe, expect, it } from "bun:test"; +import type { SerializableDistributedRenderConfig } from "../events.js"; +import { type ExecutionsClientLike, renderToCloudRun } from "./renderToCloudRun.js"; +import type { SiteHandle } from "./deploySite.js"; + +const config = { + fps: 30, + width: 1920, + height: 1080, + format: "mp4", +} as SerializableDistributedRenderConfig; + +const site: SiteHandle = { + siteId: "abc", + bucketName: "b", + projectGcsUri: "gs://b/sites/abc/project.tar.gz", + bytes: 100, + uploadedAt: "2026-06-06T00:00:00Z", + uploaded: true, +}; + +class FakeExecutions implements ExecutionsClientLike { + lastArgument: string | null = null; + lastParent: string | null = null; + + workflowPath(project: string, location: string, workflow: string): string { + return `projects/${project}/locations/${location}/workflows/${workflow}`; + } + + async createExecution(req: { + parent: string; + execution: { argument: string }; + }): Promise<[{ name?: string | null; state?: string | null }]> { + this.lastParent = req.parent; + this.lastArgument = req.execution.argument; + return [{ name: `${req.parent}/executions/exec-123`, state: "ACTIVE" }]; + } +} + +function opts(executions: ExecutionsClientLike) { + return { + siteHandle: site, + config, + bucketName: "b", + projectId: "proj", + location: "us-central1", + workflowId: "hyperframes-render", + serviceUrl: "https://render-abc.run.app", + renderId: "hf-render-fixed", + executions, + }; +} + +describe("renderToCloudRun", () => { + it("starts an execution and returns a handle", async () => { + const fake = new FakeExecutions(); + const handle = await renderToCloudRun(opts(fake)); + expect(handle.renderId).toBe("hf-render-fixed"); + expect(handle.executionName).toBe( + "projects/proj/locations/us-central1/workflows/hyperframes-render/executions/exec-123", + ); + expect(handle.outputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.mp4"); + expect(handle.projectGcsUri).toBe("gs://b/sites/abc/project.tar.gz"); + }); + + it("builds the workflow argument the YAML expects", async () => { + const fake = new FakeExecutions(); + await renderToCloudRun(opts(fake)); + const arg = JSON.parse(fake.lastArgument ?? "{}"); + expect(arg.RenderId).toBe("hf-render-fixed"); + expect(arg.ProjectGcsUri).toBe("gs://b/sites/abc/project.tar.gz"); + expect(arg.PlanOutputGcsPrefix).toBe("gs://b/renders/hf-render-fixed/"); + expect(arg.OutputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.mp4"); + expect(arg.ServiceUrl).toBe("https://render-abc.run.app"); + expect(arg.Config.format).toBe("mp4"); + expect(fake.lastParent).toBe( + "projects/proj/locations/us-central1/workflows/hyperframes-render", + ); + }); + + it("derives the output extension from the format", async () => { + const fake = new FakeExecutions(); + const handle = await renderToCloudRun({ + ...opts(fake), + config: { ...config, format: "webm" } as SerializableDistributedRenderConfig, + }); + expect(handle.outputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.webm"); + }); + + it("requires serviceUrl", async () => { + const fake = new FakeExecutions(); + await expect(renderToCloudRun({ ...opts(fake), serviceUrl: "" })).rejects.toThrow( + /serviceUrl is required/, + ); + }); + + it("requires a siteHandle or projectDir", async () => { + const fake = new FakeExecutions(); + const { siteHandle, ...rest } = opts(fake); + void siteHandle; + await expect(renderToCloudRun(rest)).rejects.toThrow(/siteHandle or projectDir/); + }); + + it("validates the config before any GCP call", async () => { + const fake = new FakeExecutions(); + await expect( + renderToCloudRun({ ...opts(fake), config: { ...config, fps: 25 } as never }), + ).rejects.toThrow(/config\.fps/); + expect(fake.lastArgument).toBeNull(); + }); + + it("rejects a renderId that could escape the GCS key prefix", async () => { + const fake = new FakeExecutions(); + await expect(renderToCloudRun({ ...opts(fake), renderId: "../escape" })).rejects.toThrow( + /renderId must match/, + ); + await expect(renderToCloudRun({ ...opts(fake), renderId: "has/slash" })).rejects.toThrow( + /renderId must match/, + ); + expect(fake.lastArgument).toBeNull(); + }); +}); diff --git a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts new file mode 100644 index 000000000..116d29071 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts @@ -0,0 +1,188 @@ +/** + * `renderToCloudRun` — start a distributed render against an already-deployed + * Cloud Run service + Cloud Workflows definition and return a handle the + * caller can poll with {@link getRenderProgress}. + * + * The function does *not* wait for the render to finish. Cloud Workflows + * executions can run for hours; blocking the caller's process on the + * execution is the wrong default. The returned `RenderHandle` carries + * everything the progress / cost / download paths need. + * + * Wire order: + * 1. Validate config (typed throw before any GCP call). + * 2. `deploySite` if no `siteHandle` was provided. + * 3. `CreateExecution` against the workflow with the argument shape the + * `packages/gcp-cloud-run/terraform/workflow.yaml` definition expects. + * 4. Return handle. The GCS `outputKey` is deterministic from the + * client-generated `renderId` so the caller can predict the final + * object URL before the (server-assigned) execution id exists. + * + * Unlike Step Functions, Cloud Workflows assigns the execution id + * server-side, so we cannot use it as the GCS prefix. We mint a `renderId` + * (uuid) client-side, use it for every GCS path, and pass it into the + * workflow argument; the server-assigned execution resource name is tracked + * separately for polling. + */ + +import { randomUUID } from "node:crypto"; +import type { Storage } from "@google-cloud/storage"; +import type { SerializableDistributedRenderConfig } from "../events.js"; +import { formatExtension } from "../formatExtension.js"; +import { formatGcsUri } from "../gcsTransport.js"; +import { deploySite, type SiteHandle } from "./deploySite.js"; +import { validateDistributedRenderConfig, validateWorkflowsInputSize } from "./validateConfig.js"; + +/** + * Minimal surface of `@google-cloud/workflows`' `ExecutionsClient` that + * this module needs. The real client satisfies this; tests inject a double. + */ +export interface ExecutionsClientLike { + workflowPath(project: string, location: string, workflow: string): string; + createExecution(req: { + parent: string; + execution: { argument: string }; + }): Promise<[{ name?: string | null; state?: string | null }, ...unknown[]]>; +} + +/** Options for {@link renderToCloudRun}. */ +export interface RenderToCloudRunOptions { + /** Local project directory. Required when `siteHandle` is not supplied. */ + projectDir?: string; + /** Re-use an existing `deploySite` upload (skips tar+GCS upload). */ + siteHandle?: SiteHandle; + /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ + config: SerializableDistributedRenderConfig; + /** GCS bucket from the Terraform output (`render_bucket_name`). */ + bucketName: string; + /** GCP project id hosting the workflow. */ + projectId: string; + /** Workflow location, e.g. `us-central1`. */ + location: string; + /** Workflow id from the Terraform output (`workflow_name`). */ + workflowId: string; + /** + * HTTPS URL of the deployed Cloud Run render service (Terraform output + * `service_url`). The workflow POSTs every step (plan / renderChunk / + * assemble) to this URL; passed as an execution argument so the workflow + * definition stays free of hard-coded URLs. + */ + serviceUrl: string; + /** + * Final output GCS key. Defaults to `renders//output.` + * where `` is derived from `config.format`. + */ + outputKey?: string; + /** + * Client-generated render id. Defaults to `hf-render-`. Used as the + * GCS key prefix and echoed into the workflow argument; not the same as + * the server-assigned execution id. + */ + renderId?: string; + /** Test injection seam — production callers leave unset. */ + executions?: ExecutionsClientLike; + /** Test injection seam — propagated to `deploySite` when applicable. */ + storage?: Storage; +} + +/** Stable identifier + every URL/name the caller needs to follow the render. */ +export interface RenderHandle { + /** Client-generated render id; the GCS prefix everything lands under. */ + renderId: string; + /** Server-assigned execution resource name; pass to {@link getRenderProgress}. */ + executionName: string; + bucketName: string; + workflowId: string; + outputGcsUri: string; + projectGcsUri: string; + startedAt: string; +} + +// fallow-ignore-next-line complexity +export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise { + validateDistributedRenderConfig(opts.config); + + if (!opts.bucketName) throw new Error("[renderToCloudRun] bucketName is required"); + if (!opts.projectId) throw new Error("[renderToCloudRun] projectId is required"); + if (!opts.location) throw new Error("[renderToCloudRun] location is required"); + if (!opts.workflowId) throw new Error("[renderToCloudRun] workflowId is required"); + if (!opts.serviceUrl) throw new Error("[renderToCloudRun] serviceUrl is required"); + if (!opts.siteHandle && !opts.projectDir) { + throw new Error("[renderToCloudRun] either siteHandle or projectDir must be supplied"); + } + + const renderId = opts.renderId ?? `hf-render-${randomUUID()}`; + // `renderId` is interpolated directly into GCS object keys + // (`renders//…`). Reject anything that could escape that prefix + // or build a malformed key — `..`, slashes, or other path metacharacters — + // so a caller-supplied id can't collide with or overwrite another render's + // artifacts elsewhere in the bucket. + if (!/^[A-Za-z0-9._-]+$/.test(renderId) || renderId.includes("..")) { + throw new Error( + `[renderToCloudRun] renderId must match [A-Za-z0-9._-]+ and not contain "..": ${JSON.stringify(renderId)}`, + ); + } + const ext = formatExtension(opts.config.format); + const outputKey = opts.outputKey ?? `renders/${renderId}/output${ext}`; + const planOutputGcsPrefix = formatGcsUri({ + bucket: opts.bucketName, + key: `renders/${renderId}/`, + }); + const outputGcsUri = formatGcsUri({ bucket: opts.bucketName, key: outputKey }); + + const site = + opts.siteHandle ?? + (await deploySite({ + projectDir: opts.projectDir as string, + bucketName: opts.bucketName, + storage: opts.storage, + })); + + const argument = { + RenderId: renderId, + ProjectGcsUri: site.projectGcsUri, + PlanOutputGcsPrefix: planOutputGcsPrefix, + OutputGcsUri: outputGcsUri, + ServiceUrl: opts.serviceUrl, + Config: opts.config, + }; + + // Reject oversize input client-side. Cloud Workflows caps the execution + // argument at 512 KiB; without this check, input bloat (typically from + // `config.variables` containing inlined media) surfaces as an opaque + // server-side error after the execution starts, far from the caller's + // stack frame. + validateWorkflowsInputSize(argument); + + const executions = opts.executions ?? (await defaultExecutionsClient()); + const parent = executions.workflowPath(opts.projectId, opts.location, opts.workflowId); + const startedAt = new Date().toISOString(); + const [execution] = await executions.createExecution({ + parent, + execution: { argument: JSON.stringify(argument) }, + }); + + if (!execution.name) { + throw new Error("[renderToCloudRun] CreateExecution returned no execution name"); + } + + return { + renderId, + executionName: execution.name, + bucketName: opts.bucketName, + workflowId: opts.workflowId, + outputGcsUri, + projectGcsUri: site.projectGcsUri, + startedAt, + }; +} + +/** + * Lazily import the real `@google-cloud/workflows` ExecutionsClient. Dynamic + * so SDK consumers that only call `validateDistributedRenderConfig` (or + * inject their own client) don't pay the import cost. + */ +async function defaultExecutionsClient(): Promise { + const mod = await import("@google-cloud/workflows"); + const client = new mod.ExecutionsClient(); + return client as unknown as ExecutionsClientLike; +} diff --git a/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts b/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts new file mode 100644 index 000000000..b7ee430f8 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts @@ -0,0 +1,119 @@ +/** + * `validateDistributedRenderConfig` + `validateWorkflowsInputSize` unit + * tests. Pins the shape rejections the SDK surfaces synchronously before a + * Cloud Workflows execution starts. + */ + +import { describe, expect, it } from "bun:test"; +import type { SerializableDistributedRenderConfig } from "../events.js"; +import { + InvalidConfigError, + MAX_WORKFLOWS_INPUT_BYTES, + validateDistributedRenderConfig, + validateVariablesPayload, + validateWorkflowsInputSize, +} from "./validateConfig.js"; + +function base(): SerializableDistributedRenderConfig { + return { + fps: 30, + width: 1920, + height: 1080, + format: "mp4", + } as SerializableDistributedRenderConfig; +} + +describe("validateDistributedRenderConfig", () => { + it("accepts a minimal valid config", () => { + expect(validateDistributedRenderConfig(base())).toBeDefined(); + }); + + it("rejects a bad fps", () => { + expect(() => validateDistributedRenderConfig({ ...base(), fps: 25 } as never)).toThrow( + /config\.fps/, + ); + }); + + it("rejects odd dimensions (yuv420p)", () => { + expect(() => validateDistributedRenderConfig({ ...base(), width: 1921 })).toThrow(/even/); + }); + + it("rejects an out-of-range dimension", () => { + expect(() => validateDistributedRenderConfig({ ...base(), height: 8 })).toThrow(/\[16, 7680\]/); + }); + + it("rejects an unknown format", () => { + expect(() => validateDistributedRenderConfig({ ...base(), format: "gif" as never })).toThrow( + /config\.format/, + ); + }); + + it("rejects codec with a non-mp4 format", () => { + expect(() => + validateDistributedRenderConfig({ ...base(), format: "webm", codec: "h264" } as never), + ).toThrow(/only valid with format="mp4"/); + }); + + it("rejects crf + bitrate together", () => { + expect(() => + validateDistributedRenderConfig({ ...base(), crf: 20, bitrate: "10M" } as never), + ).toThrow(/mutually exclusive/); + }); + + it("rejects force-hdr", () => { + expect(() => + validateDistributedRenderConfig({ ...base(), hdrMode: "force-hdr" as never }), + ).toThrow(/force-sdr/); + }); + + it("rejects an over-cap chunkSize", () => { + expect(() => validateDistributedRenderConfig({ ...base(), chunkSize: 5000 } as never)).toThrow( + /<= 3600/, + ); + }); + + it("throws InvalidConfigError with a field pointer", () => { + try { + validateDistributedRenderConfig({ ...base(), fps: 1 } as never); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(InvalidConfigError); + expect((err as InvalidConfigError).field).toBe("config.fps"); + } + }); +}); + +describe("validateVariablesPayload", () => { + it("accepts a plain JSON object", () => { + expect(() => + validateVariablesPayload({ title: "Hi", count: 3, nested: { ok: true } }), + ).not.toThrow(); + }); + + it("rejects undefined leaves", () => { + expect(() => validateVariablesPayload({ a: undefined })).toThrow(/undefined leaves/); + }); + + it("rejects a top-level array", () => { + expect(() => validateVariablesPayload([1, 2])).toThrow(/plain JSON object/); + }); + + it("rejects NaN", () => { + expect(() => validateVariablesPayload({ x: NaN })).toThrow(/non-finite/); + }); + + it("rejects a Date (non-plain object)", () => { + expect(() => validateVariablesPayload({ when: new Date(0) })).toThrow(/non-plain objects/); + }); +}); + +describe("validateWorkflowsInputSize", () => { + it("accepts a small payload", () => { + expect(() => validateWorkflowsInputSize({ a: "b" })).not.toThrow(); + }); + + it("rejects a payload over the 512 KiB cap", () => { + const big = { blob: "x".repeat(MAX_WORKFLOWS_INPUT_BYTES + 1) }; + expect(() => validateWorkflowsInputSize(big)).toThrow(/512 KiB/); + }); +}); diff --git a/packages/gcp-cloud-run/src/sdk/validateConfig.ts b/packages/gcp-cloud-run/src/sdk/validateConfig.ts new file mode 100644 index 000000000..982278811 --- /dev/null +++ b/packages/gcp-cloud-run/src/sdk/validateConfig.ts @@ -0,0 +1,77 @@ +/** + * Client-side validation for the Cloud Run adapter. + * + * The cloud-agnostic config-shape validation (`validateDistributedRenderConfig`, + * `validateVariablesPayload`, `InvalidConfigError`) lives in + * `@hyperframes/producer/distributed` and is shared with the other adapters. + * This module re-exports those and adds the one piece that is specific to + * Cloud Workflows: the 512 KiB execution-argument size cap. + */ + +import { InvalidConfigError } from "@hyperframes/producer/distributed"; + +export { + InvalidConfigError, + validateDistributedRenderConfig, + validateVariablesPayload, +} from "@hyperframes/producer/distributed"; + +/** + * Hard cap on Cloud Workflows execution arguments — 512 KiB per the Workflows + * quotas page (maximum size of arguments passed when an execution starts). + * The cap is on the entire serialized argument, not just the variables, + * because users hit it at the wire boundary regardless of which field caused + * the bloat. + * + * Specific to Cloud Workflows. Other runtimes (Lambda + Step Functions, + * Temporal) have different caps; don't reuse this constant for those without + * confirming the limit. + */ +export const MAX_WORKFLOWS_INPUT_BYTES = 512 * 1024; + +/** Pointer to the docs section that explains the URL-your-assets convention. */ +const LARGE_VARIABLES_DOCS_URL = + "https://hyperframes.heygen.com/deploy/templates-on-lambda#working-with-large-variables"; + +/** + * Validate that the serialized Cloud Workflows execution argument fits inside + * the 512 KiB cap. Measured in UTF-8 bytes (the format the API uses on the + * wire) — JS strings count UTF-16 code units, which under-reports for any + * multi-byte character. + * + * Throws {@link InvalidConfigError} with a clear message naming the actual + * byte count, the cap, and a pointer to the "working with large variables" + * docs section, so users hit the limit at the SDK boundary with actionable + * guidance instead of as an opaque argument-too-large error after the + * execution starts. + */ +// fallow-ignore-next-line complexity +export function validateWorkflowsInputSize(input: unknown): void { + let serialized: string | undefined; + try { + serialized = JSON.stringify(input); + } catch (err) { + throw new InvalidConfigError( + "config", + `Cloud Workflows execution argument is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (serialized === undefined) { + throw new InvalidConfigError( + "config", + "Cloud Workflows execution argument is not JSON-serializable (JSON.stringify returned undefined). " + + "Check that all fields, including config.variables, are plain JSON values.", + ); + } + const byteLength = Buffer.byteLength(serialized, "utf8"); + if (byteLength > MAX_WORKFLOWS_INPUT_BYTES) { + throw new InvalidConfigError( + "config", + `Cloud Workflows execution argument is ${byteLength} bytes, which exceeds the ` + + `${MAX_WORKFLOWS_INPUT_BYTES}-byte (512 KiB) limit. Variables are for typed data ` + + `(strings, numbers, structured records); media assets (images, audio, video) should ` + + `be passed as URL references the composition resolves at render time, not inlined as ` + + `base64. See ${LARGE_VARIABLES_DOCS_URL} for the URL-your-assets convention.`, + ); + } +} diff --git a/packages/gcp-cloud-run/src/server.test.ts b/packages/gcp-cloud-run/src/server.test.ts new file mode 100644 index 000000000..29888f470 --- /dev/null +++ b/packages/gcp-cloud-run/src/server.test.ts @@ -0,0 +1,332 @@ +/** + * Handler dispatch + HTTP-shell unit tests. + * + * Asserts that: + * - `dispatch` routes Action="plan"/"renderChunk"/"assemble" to the + * matching OSS primitive and plumbs GCS download/upload around it. + * - It unwraps `{ Payload }` / `{ Input }` envelopes and rejects unknown + * actions. + * - The handler-boundary guards fire: plan-hash mismatch + bucket + * allowlist throw the typed, non-retryable errors. + * - `createApp` maps non-retryable errors → 400 and retryable → 500. + * + * The real OSS primitives are NOT exercised — they have their own coverage + * in `packages/producer`. This file pins the adapter glue's contract. + */ + +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed"; +import { asStorage, FakeGcs } from "./__fixtures__/fakeGcs.js"; +import type { AssembleEvent, CloudRunEvent, PlanEvent, RenderChunkEvent } from "./events.js"; +import { createApp, dispatch, type HandlerDeps, unwrapEvent } from "./server.js"; +import { tarDirectory } from "./gcsTransport.js"; + +const tmpDirs: string[] = []; +function mkTmp(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const PLAN_HASH = "abc123planhash"; + +/** Build a real project tarball, seed it into the fake, return its URI. */ +async function seedProjectTar(gcs: FakeGcs, uri: string): Promise { + const src = mkTmp("hf-proj-"); + writeFileSync(join(src, "index.html"), ""); + const tarPath = join(mkTmp("hf-proj-tar-"), "project.tar.gz"); + await tarDirectory(src, tarPath); + gcs.seedFromFile(uri, tarPath); +} + +/** Build a real plan tarball containing plan.json, seed it, return its URI. */ +async function seedPlanTar(gcs: FakeGcs, uri: string, planHash: string): Promise { + const planDir = mkTmp("hf-plan-"); + writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash })); + const tarPath = join(mkTmp("hf-plan-tar-"), "plan.tar.gz"); + await tarDirectory(planDir, tarPath); + gcs.seedFromFile(uri, tarPath); +} + +const planResult: PlanResult = { + planDir: "(set at call time)", + planHash: PLAN_HASH, + chunkCount: 3, + totalFrames: 90, + fps: 30, + width: 1920, + height: 1080, + format: "mp4", + ffmpegVersion: "ffmpeg version 6.1.1", + producerVersion: "0.6.79", +}; + +function depsWith( + gcs: FakeGcs, + overrides: Partial> = {}, +): HandlerDeps { + const plan = async (_projectDir: string, _config: unknown, planDir: string) => { + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: PLAN_HASH })); + return planResult; + }; + const renderChunk = async (_planDir: string, chunkIndex: number, outputBase: string) => { + writeFileSync(outputBase, Buffer.from(`chunk-${chunkIndex}`)); + return { + outputPath: outputBase, + outputKind: "file", + framesEncoded: 30, + sha256: `sha-${chunkIndex}`, + } satisfies ChunkResult; + }; + const assemble = async ( + _planDir: string, + _chunkPaths: string[], + _audio: string | null, + finalOutput: string, + ) => { + writeFileSync(finalOutput, Buffer.from("final-output")); + return { framesEncoded: 90, fileSize: 12 } satisfies AssembleResult; + }; + return { + storage: asStorage(gcs), + skipChromeResolution: true, + primitives: { plan, renderChunk, assemble, ...overrides } as NonNullable< + HandlerDeps["primitives"] + >, + }; +} + +describe("unwrapEvent", () => { + const plan: PlanEvent = { + Action: "plan", + ProjectGcsUri: "gs://b/p.tar.gz", + PlanOutputGcsPrefix: "gs://b/out/", + Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"], + }; + + it("returns a bare event unchanged", () => { + expect(unwrapEvent(plan).Action).toBe("plan"); + }); + + it("unwraps { Payload }", () => { + expect(unwrapEvent({ Payload: plan } as CloudRunEvent).Action).toBe("plan"); + }); + + it("unwraps nested { Input: { Payload } }", () => { + expect(unwrapEvent({ Input: { Payload: plan } } as CloudRunEvent).Action).toBe("plan"); + }); + + it("throws when no Action is found", () => { + expect(() => unwrapEvent({ foo: "bar" } as unknown as CloudRunEvent)).toThrow( + /no recognised Action/, + ); + }); +}); + +describe("dispatch", () => { + it("routes plan, uploads the plan tarball", async () => { + const gcs = new FakeGcs(); + await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz"); + const event: PlanEvent = { + Action: "plan", + ProjectGcsUri: "gs://b/sites/x/project.tar.gz", + PlanOutputGcsPrefix: "gs://b/renders/r1/", + Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"], + }; + const res = await dispatch(event, depsWith(gcs)); + expect(res.Action).toBe("plan"); + if (res.Action !== "plan") throw new Error("unreachable"); + expect(res.PlanHash).toBe(PLAN_HASH); + expect(res.ChunkCount).toBe(3); + expect(res.PlanGcsUri).toBe("gs://b/renders/r1/plan.tar.gz"); + expect(gcs.objects.has("gs://b/renders/r1/plan.tar.gz")).toBe(true); + }); + + it("routes renderChunk and uploads the chunk", async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); + const event: RenderChunkEvent = { + Action: "renderChunk", + PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", + PlanHash: PLAN_HASH, + ChunkIndex: 2, + ChunkOutputGcsPrefix: "gs://b/renders/r1/", + Format: "mp4", + }; + const res = await dispatch(event, depsWith(gcs)); + if (res.Action !== "renderChunk") throw new Error("unreachable"); + expect(res.ChunkIndex).toBe(2); + expect(res.ChunkGcsUri).toBe("gs://b/renders/r1/chunks/0002.mp4"); + expect(gcs.objects.has("gs://b/renders/r1/chunks/0002.mp4")).toBe(true); + }); + + it("throws PLAN_HASH_MISMATCH when the event hash disagrees", async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); + const event: RenderChunkEvent = { + Action: "renderChunk", + PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", + PlanHash: "WRONG_HASH", + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://b/renders/r1/", + Format: "mp4", + }; + await expect(dispatch(event, depsWith(gcs))).rejects.toThrow(/PLAN_HASH_MISMATCH/); + }); + + it("routes assemble and uploads the final output", async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); + gcs.seed("gs://b/renders/r1/chunks/0000.mp4", Buffer.from("c0")); + gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1")); + const event: AssembleEvent = { + Action: "assemble", + PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", + ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"], + AudioGcsUri: null, + OutputGcsUri: "gs://b/renders/r1/output.mp4", + Format: "mp4", + }; + const res = await dispatch(event, depsWith(gcs)); + if (res.Action !== "assemble") throw new Error("unreachable"); + expect(res.FramesEncoded).toBe(90); + expect(gcs.objects.has("gs://b/renders/r1/output.mp4")).toBe(true); + }); + + it("rejects an unknown action", async () => { + const gcs = new FakeGcs(); + await expect( + dispatch({ Action: "nope" } as unknown as CloudRunEvent, depsWith(gcs)), + ).rejects.toThrow(/no recognised Action/); + }); +}); + +describe("bucket allowlist guard", () => { + it("throws GCS_URI_NOT_ALLOWED for an off-bucket URI", async () => { + const gcs = new FakeGcs(); + const prev = process.env.HYPERFRAMES_RENDER_BUCKET; + process.env.HYPERFRAMES_RENDER_BUCKET = "allowed-bucket"; + try { + const event: RenderChunkEvent = { + Action: "renderChunk", + PlanGcsUri: "gs://evil-bucket/plan.tar.gz", + PlanHash: PLAN_HASH, + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://allowed-bucket/renders/r1/", + Format: "mp4", + }; + await expect(dispatch(event, depsWith(gcs))).rejects.toThrow(/GCS_URI_NOT_ALLOWED/); + } finally { + if (prev === undefined) delete process.env.HYPERFRAMES_RENDER_BUCKET; + else process.env.HYPERFRAMES_RENDER_BUCKET = prev; + } + }); + + it('treats HYPERFRAMES_RENDER_BUCKET="*" as an explicit opt-out (off-bucket allowed)', async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://any-bucket/renders/r1/plan.tar.gz", PLAN_HASH); + const prev = process.env.HYPERFRAMES_RENDER_BUCKET; + process.env.HYPERFRAMES_RENDER_BUCKET = "*"; + try { + const event: RenderChunkEvent = { + Action: "renderChunk", + PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz", + PlanHash: PLAN_HASH, + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://any-bucket/renders/r1/", + Format: "mp4", + }; + const res = await dispatch(event, depsWith(gcs)); + expect(res.Action).toBe("renderChunk"); + } finally { + if (prev === undefined) delete process.env.HYPERFRAMES_RENDER_BUCKET; + else process.env.HYPERFRAMES_RENDER_BUCKET = prev; + } + }); +}); + +describe("createApp HTTP mapping", () => { + it("returns 200 with the result body on success", async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); + const app = createApp(depsWith(gcs)); + const res = await app.request("/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + Action: "renderChunk", + PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", + PlanHash: PLAN_HASH, + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://b/renders/r1/", + Format: "mp4", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { Action: string }; + expect(body.Action).toBe("renderChunk"); + }); + + it("returns 400 for a non-retryable error (plan-hash mismatch)", async () => { + const gcs = new FakeGcs(); + await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); + const app = createApp(depsWith(gcs)); + const res = await app.request("/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + Action: "renderChunk", + PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", + PlanHash: "WRONG", + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://b/renders/r1/", + Format: "mp4", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("PLAN_HASH_MISMATCH"); + }); + + it("returns 500 for a retryable/unknown error", async () => { + const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable) + const app = createApp(depsWith(gcs)); + const res = await app.request("/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + Action: "renderChunk", + PlanGcsUri: "gs://b/renders/r1/missing.tar.gz", + PlanHash: PLAN_HASH, + ChunkIndex: 0, + ChunkOutputGcsPrefix: "gs://b/renders/r1/", + Format: "mp4", + }), + }); + expect(res.status).toBe(500); + }); + + it("returns 400 when the body is not JSON", async () => { + const gcs = new FakeGcs(); + const app = createApp(depsWith(gcs)); + const res = await app.request("/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json{", + }); + expect(res.status).toBe(400); + }); + + it("healthz returns ok", async () => { + const app = createApp(depsWith(new FakeGcs())); + const res = await app.request("/healthz"); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/gcp-cloud-run/src/server.ts b/packages/gcp-cloud-run/src/server.ts new file mode 100644 index 000000000..078d2c78f --- /dev/null +++ b/packages/gcp-cloud-run/src/server.ts @@ -0,0 +1,647 @@ +/** + * Cloud Run request handler for HyperFrames distributed rendering. + * + * One container image, three roles. Cloud Workflows POSTs a JSON body with + * an `Action` field; the handler unwraps any `Payload`/`Input` envelope, + * primes the runtime (Chrome path), and forwards to the matching OSS + * primitive from `@hyperframes/producer/distributed`. + * + * Everything heavy — capture, encode, audio mix — happens inside the OSS + * primitives. The handler is thin glue: parse body → GCS download → call + * primitive → GCS upload → return small JSON result. + * + * `dispatch()` is the testable core (inject `storage` + `primitives`); the + * Hono app at the bottom is the HTTP shell the Dockerfile runs. The shape + * deliberately tracks `@hyperframes/aws-lambda`'s `handler.ts` so the two + * adapters stay easy to diff. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { serve } from "@hono/node-server"; +import { Storage } from "@google-cloud/storage"; +import { Hono } from "hono"; +import { + assemble, + type AssembleResult, + type ChunkResult, + type DistributedRenderConfig, + plan, + type PlanResult, + renderChunk, +} from "@hyperframes/producer/distributed"; +import { resolveChromeExecutablePath } from "./chromium.js"; +import type { + AssembleEvent, + AssembleResultBody, + CloudRunAction, + CloudRunEvent, + CloudRunResult, + PlanEvent, + PlanResultBody, + RenderChunkEvent, + RenderChunkResultBody, +} from "./events.js"; +import { type DistributedFormat, formatExtension } from "./formatExtension.js"; +import { + downloadGcsObjectToFile, + parseGcsUri, + tarDirectory, + untarDirectory, + uploadFileToGcs, +} from "./gcsTransport.js"; + +/** + * Lazily-constructed Storage client. Cached at module scope so warm + * container instances reuse the underlying HTTP keep-alive pool across + * requests. + */ +let cachedStorage: Storage | null = null; +function getStorage(): Storage { + if (cachedStorage) return cachedStorage; + cachedStorage = new Storage(); + return cachedStorage; +} + +/** + * Optional injection points used by the handler's unit tests. Production + * callers leave these unset; the real OSS primitives are used. Tests inject + * `storage` and `primitives` directly rather than mutating module state. + */ +export interface HandlerDeps { + storage?: Storage; + primitives?: { + plan: typeof plan; + renderChunk: typeof renderChunk; + assemble: typeof assemble; + }; + /** Override the per-request workdir root (defaults to the OS tmpdir). */ + tmpRoot?: string; + /** Skip Chrome resolution (used by dispatch tests that mock renderChunk). */ + skipChromeResolution?: boolean; +} + +/** + * Dispatch a single render request. Cloud Workflows (or a direct caller) + * sometimes wraps the body in `{ Payload: ... }` or `{ Input: ... }`; unwrap + * until we hit a discriminated event. + */ +// fallow-ignore-next-line complexity +export async function dispatch(event: CloudRunEvent, deps?: HandlerDeps): Promise { + const unwrapped = unwrapEvent(event); + validateEventGcsUris(unwrapped); + logEvent({ event: "handler_start", action: unwrapped.Action, input: summarizeEvent(unwrapped) }); + try { + switch (unwrapped.Action) { + case "plan": + return await handlePlan(unwrapped, deps); + case "renderChunk": + return await handleRenderChunk(unwrapped, deps); + case "assemble": + return await handleAssemble(unwrapped, deps); + default: { + // Compile-time exhaustiveness: a new CloudRunAction member trips + // the `never` assignment before the runtime error is reachable. + const _exhaustive: never = unwrapped; + throw new Error( + `[handler] unknown Action: ${JSON.stringify( + (_exhaustive as { Action?: string }).Action, + )}. Expected one of "plan", "renderChunk", "assemble".`, + ); + } + } + } catch (err) { + logEvent({ + event: "handler_error", + action: unwrapped.Action, + message: err instanceof Error ? err.message : String(err), + name: err instanceof Error ? err.name : undefined, + }); + throw err; + } +} + +// At most `{Payload: {Input: ...}}` is expected; 4 levels is 2× headroom +// and prevents infinite loops on malformed input. +const MAX_ENVELOPE_DEPTH = 4; + +// fallow-ignore-next-line complexity +export function unwrapEvent(event: CloudRunEvent): PlanEvent | RenderChunkEvent | AssembleEvent { + let cursor: CloudRunEvent = event; + for (let i = 0; i < MAX_ENVELOPE_DEPTH; i++) { + if (cursor && typeof cursor === "object") { + const obj = cursor as Record; + if (typeof obj.Action === "string" && isCloudRunAction(obj.Action)) { + return cursor as PlanEvent | RenderChunkEvent | AssembleEvent; + } + if ("Payload" in obj) { + cursor = obj.Payload as CloudRunEvent; + continue; + } + if ("Input" in obj) { + cursor = obj.Input as CloudRunEvent; + continue; + } + } + break; + } + throw new Error( + `[handler] body has no recognised Action; unwrapped ${MAX_ENVELOPE_DEPTH} levels of Payload/Input without finding one.`, + ); +} + +function isCloudRunAction(value: string): value is CloudRunAction { + return value === "plan" || value === "renderChunk" || value === "assemble"; +} + +/** + * Emit a single JSON line to stdout. Cloud Logging ingests each stdout line + * as a structured `jsonPayload` entry, so Logs Explorer can filter on + * `jsonPayload.event="handler_start"` and project specific fields when + * triaging without attaching a debugger. + */ +function logEvent(payload: Record): void { + console.log(JSON.stringify(payload)); +} + +/** + * Compact, non-PII summary of an event for logging. The full body can + * include the entire project config; we only emit the routable fields + * needed to triage a failure from Cloud Logging. + */ +function summarizeEvent( + event: PlanEvent | RenderChunkEvent | AssembleEvent, +): Record { + switch (event.Action) { + case "plan": + return { + projectGcsUri: event.ProjectGcsUri, + planOutputGcsPrefix: event.PlanOutputGcsPrefix, + format: event.Config.format, + fps: event.Config.fps, + }; + case "renderChunk": + return { + planGcsUri: event.PlanGcsUri, + chunkIndex: event.ChunkIndex, + format: event.Format, + }; + case "assemble": + return { + planGcsUri: event.PlanGcsUri, + chunkCount: event.ChunkGcsUris.length, + hasAudio: event.AudioGcsUri !== null, + outputGcsUri: event.OutputGcsUri, + format: event.Format, + }; + } +} + +/** + * Point the engine at the in-image Chrome binary. The OSS engine resolves + * Chrome via `PRODUCER_HEADLESS_SHELL_PATH` first; set it once per instance + * before invoking any browser-touching primitive. ffmpeg is on the image's + * PATH (apt-installed by the Dockerfile), so nothing to prime there. + */ +function primeChrome(deps?: HandlerDeps): void { + if (deps?.skipChromeResolution) return; + if (process.env.PRODUCER_HEADLESS_SHELL_PATH) return; + process.env.PRODUCER_HEADLESS_SHELL_PATH = resolveChromeExecutablePath(); +} + +// ── Plan ──────────────────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise { + const started = Date.now(); + const storage = deps?.storage ?? getStorage(); + const primitive = deps?.primitives?.plan ?? plan; + + // The producer's probe stage launches Chromium whenever the composition + // needs a runtime duration probe or has unresolved sub-compositions, so + // plan has to resolve Chrome the same way renderChunk does. + primeChrome(deps); + + const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-plan-")); + const projectArchive = join(work, "project.tar.gz"); + const projectDir = join(work, "project"); + const planDir = join(work, "plan"); + + try { + await downloadGcsObjectToFile(storage, event.ProjectGcsUri, projectArchive); + await untarDirectory(projectArchive, projectDir); + + const config: DistributedRenderConfig = { + ...event.Config, + }; + const result: PlanResult = await primitive(projectDir, config, planDir); + + // Upload the planDir as a single tarball. The workflow cannot pass a + // directory-shaped artifact between steps; we serialize and rely on the + // consumer (renderChunk / assemble) to untar. `audio.aac` lives inside + // planDir, so it already rides along in this tarball — every consumer + // (including assemble) gets it from the untar. We deliberately do NOT + // upload a separate audio object: it would duplicate the bytes on every + // plan upload and be re-downloaded + overwritten by assemble. `AudioGcsUri` + // stays in the result shape for wire compatibility but is null. + const planTar = join(work, "plan.tar.gz"); + await tarDirectory(planDir, planTar); + const planTarUri = `${trimTrailingSlash(event.PlanOutputGcsPrefix)}/plan.tar.gz`; + const audioPath = join(planDir, "audio.aac"); + const hasAudio = existsSync(audioPath) && statSync(audioPath).size > 0; + await uploadFileToGcs(storage, planTar, planTarUri, "application/gzip"); + + return { + Action: "plan", + PlanGcsUri: planTarUri, + PlanHash: result.planHash, + ChunkCount: result.chunkCount, + TotalFrames: result.totalFrames, + Fps: result.fps, + Width: result.width, + Height: result.height, + Format: result.format, + HasAudio: hasAudio, + AudioGcsUri: null, + FfmpegVersion: result.ffmpegVersion, + ProducerVersion: result.producerVersion, + DurationMs: Date.now() - started, + }; + } finally { + cleanupDir(work); + } +} + +// ── RenderChunk ───────────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +async function handleRenderChunk( + event: RenderChunkEvent, + deps?: HandlerDeps, +): Promise { + const started = Date.now(); + const storage = deps?.storage ?? getStorage(); + const primitive = deps?.primitives?.renderChunk ?? renderChunk; + + primeChrome(deps); + + const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-chunk-")); + const planTar = join(work, "plan.tar.gz"); + const planDir = join(work, "plan"); + + try { + await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar); + await untarDirectory(planTar, planDir); + + // Verify the plan's hash matches what the workflow told us to render. + // The producer's renderChunk re-checks internally (defense-in-depth), + // but doing it here at the handler boundary lets us fail before paying + // the Chrome-launch + render cost on a misrouted chunk. Throws a typed + // PLAN_HASH_MISMATCH the workflow can route as non-retryable. + verifyPlanHash(planDir, event.PlanHash); + + const chunkOutputBase = join( + work, + event.Format === "png-sequence" + ? `chunk-${pad(event.ChunkIndex)}` + : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`, + ); + + const result: ChunkResult = await primitive(planDir, event.ChunkIndex, chunkOutputBase); + + const chunkUri = await uploadChunkOutput( + storage, + result, + event.ChunkOutputGcsPrefix, + event.ChunkIndex, + ); + + return { + Action: "renderChunk", + ChunkGcsUri: chunkUri, + ChunkIndex: event.ChunkIndex, + Sha256: result.sha256, + FramesEncoded: result.framesEncoded, + DurationMs: Date.now() - started, + }; + } finally { + cleanupDir(work); + } +} + +async function uploadChunkOutput( + storage: Storage, + result: ChunkResult, + prefix: string, + chunkIndex: number, +): Promise { + const trimmed = trimTrailingSlash(prefix); + if (result.outputKind === "file") { + const ext = extname(result.outputPath); + const uri = `${trimmed}/chunks/${pad(chunkIndex)}${ext}`; + await uploadFileToGcs(storage, result.outputPath, uri); + return uri; + } + // frame-dir: upload as a tarball so a single GCS object represents the + // chunk. Assemble's png-sequence path expects a directory per chunk; it + // untars on its end. + const tarball = `${result.outputPath}.tar.gz`; + await tarDirectory(result.outputPath, tarball); + const uri = `${trimmed}/chunks/${pad(chunkIndex)}.tar.gz`; + await uploadFileToGcs(storage, tarball, uri, "application/gzip"); + return uri; +} + +// ── Assemble ──────────────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +async function handleAssemble( + event: AssembleEvent, + deps?: HandlerDeps, +): Promise { + const started = Date.now(); + const storage = deps?.storage ?? getStorage(); + const primitive = deps?.primitives?.assemble ?? assemble; + + const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-assemble-")); + const planTar = join(work, "plan.tar.gz"); + const planDir = join(work, "plan"); + + try { + await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar); + await untarDirectory(planTar, planDir); + + const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format); + + // Audio rides inside the plan tarball, so it's already on disk after the + // untar above — no separate download. Fall back to a supplied AudioGcsUri + // only for backward compatibility with an older Plan that uploaded it + // standalone. + let audioPath: string | null = null; + const planAudio = join(planDir, "audio.aac"); + if (existsSync(planAudio) && statSync(planAudio).size > 0) { + audioPath = planAudio; + } else if (event.AudioGcsUri) { + audioPath = planAudio; + await downloadGcsObjectToFile(storage, event.AudioGcsUri, audioPath); + } + + const finalOutput = + event.Format === "png-sequence" + ? join(work, "output-frames") + : join(work, `output${formatExtension(event.Format)}`); + + const result: AssembleResult = await primitive(planDir, chunkPaths, audioPath, finalOutput, { + cfr: event.Cfr === true, + }); + + if (event.Format === "png-sequence") { + const tarball = `${finalOutput}.tar.gz`; + await tarDirectory(finalOutput, tarball); + await uploadFileToGcs(storage, tarball, event.OutputGcsUri, "application/gzip"); + } else { + await uploadFileToGcs(storage, finalOutput, event.OutputGcsUri); + } + + return { + Action: "assemble", + OutputGcsUri: event.OutputGcsUri, + FramesEncoded: result.framesEncoded, + FileSize: result.fileSize, + DurationMs: Date.now() - started, + }; + } finally { + cleanupDir(work); + } +} + +async function downloadChunkObjects( + storage: Storage, + uris: string[], + workDir: string, + format: DistributedFormat, +): Promise { + const chunksDir = join(workDir, "chunks"); + mkdirSync(chunksDir, { recursive: true }); + // Each chunk is an independent GCS GET (+ untar for png-sequence). Run + // them in parallel — assemble's wall-clock is otherwise dominated by + // `Σ chunk-download-ms` instead of `max(chunk-download-ms)`. Preserve the + // input order by writing into a pre-sized array rather than pushing as + // each task settles. + const local: string[] = new Array(uris.length); + await Promise.all( + uris.map(async (uri, i) => { + if (!uri) { + throw new Error(`[handler] chunk URI at index ${i} is empty`); + } + const { key } = parseGcsUri(uri); + const localPath = join(chunksDir, basename(key)); + await downloadGcsObjectToFile(storage, uri, localPath); + if (format === "png-sequence") { + const dirPath = join(chunksDir, `frames-${pad(i)}`); + await untarDirectory(localPath, dirPath); + local[i] = dirPath; + } else { + local[i] = localPath; + } + }), + ); + return local; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Collect every GCS URI that the handler will touch for a given event. */ +function getEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] { + switch (event.Action) { + case "plan": + return [event.ProjectGcsUri, event.PlanOutputGcsPrefix]; + case "renderChunk": + return [event.PlanGcsUri, event.ChunkOutputGcsPrefix]; + case "assemble": + return [ + event.PlanGcsUri, + ...event.ChunkGcsUris, + event.OutputGcsUri, + event.AudioGcsUri, + ].filter((u): u is string => u != null); + } +} + +/** Emit the "guard disabled" warning at most once per instance. */ +let warnedAllowlistDisabled = false; + +/** + * Verify every GCS URI in the event resolves to the configured render + * bucket. Throws `GCS_URI_NOT_ALLOWED` (non-retryable) when a URI targets a + * different bucket, preventing request injection from reading or writing + * arbitrary GCS data. + * + * Opt-out is explicit: set `HYPERFRAMES_RENDER_BUCKET="*"` to disable the + * guard intentionally. If the env var is simply unset (or empty), the guard + * is disabled but a warning is logged once so the gap is visible in Cloud + * Logging — it shouldn't silently fail open. The Terraform module always + * wires the bucket name, so the prod path enforces. + */ +// fallow-ignore-next-line complexity +function validateEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): void { + const allowedBucket = process.env.HYPERFRAMES_RENDER_BUCKET?.trim(); + if (allowedBucket === "*") return; // explicit, intentional opt-out + if (!allowedBucket) { + if (!warnedAllowlistDisabled) { + warnedAllowlistDisabled = true; + logEvent({ + event: "bucket_allowlist_disabled", + level: "WARNING", + message: + "HYPERFRAMES_RENDER_BUCKET is unset — the GCS bucket-allowlist guard is DISABLED. " + + 'Set it to the render bucket name to enforce, or to "*" to opt out intentionally.', + }); + } + return; + } + + for (const uri of getEventGcsUris(event)) { + const { bucket } = parseGcsUri(uri); + if (bucket !== allowedBucket) { + const err = new Error( + `[handler] GCS_URI_NOT_ALLOWED: URI ${JSON.stringify(uri)} targets bucket "${bucket}" but only "${allowedBucket}" is permitted`, + ); + err.name = "GCS_URI_NOT_ALLOWED"; + throw err; + } + } +} + +function pad(n: number): string { + return n.toString().padStart(4, "0"); +} + +function trimTrailingSlash(prefix: string): string { + return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; +} + +function cleanupDir(dir: string): void { + try { + // Cloud Run re-uses an instance's filesystem across requests; clean up + // aggressively so we don't leak a chunk-sized footprint between renders + // (the writable filesystem counts against the instance's memory). + rmSync(dir, { recursive: true, force: true }); + } catch { + // Best-effort — leak is preferable to crashing on the success path. + } +} + +/** + * Read the untarred planDir's `plan.json` and assert its `planHash` matches + * what the workflow event claims. Throws on mismatch with a typed + * `PLAN_HASH_MISMATCH` error name so the workflow's non-retryable list + * routes it correctly. Defense-in-depth — the producer's `renderChunk` does + * the same check internally — but performing it here lets us fail before + * paying the Chrome-launch + per-frame capture cost on a misrouted chunk. + */ +// fallow-ignore-next-line complexity +function verifyPlanHash(planDir: string, expected: string): void { + const planJsonPath = join(planDir, "plan.json"); + let parsed: { planHash?: unknown }; + try { + parsed = JSON.parse(readFileSync(planJsonPath, "utf-8")) as { planHash?: unknown }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`); + error.name = "PLAN_HASH_MISMATCH"; + throw error; + } + const actual = parsed.planHash; + if (typeof actual !== "string" || actual !== expected) { + const error = new Error( + `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`, + ); + error.name = "PLAN_HASH_MISMATCH"; + throw error; + } +} + +// ── HTTP shell ─────────────────────────────────────────────────────────────── + +/** + * Error names the workflow treats as non-retryable. A request that fails + * with one of these is the caller's fault (bad input, misrouted chunk) and + * retrying it just burns instance-seconds, so we map them to HTTP 400 while + * any other failure maps to 500 (which the workflow retry policy backs off + * and re-attempts). Keep this list in sync with the `retry` predicate in + * `packages/gcp-cloud-run/terraform/workflow.yaml`. + */ +const NON_RETRYABLE_ERROR_NAMES = new Set([ + // Handler-boundary guards. + "GCS_URI_NOT_ALLOWED", + "PLAN_HASH_MISMATCH", + // Producer error class names (`.name`) + their string code aliases — the + // class sets `.name` to the class name but wraps a `code`; cover both so a + // raw-code throw is caught too. Mirrors the AWS state machine's + // non-retryable list. + "FormatNotSupportedInDistributedError", + "PlanTooLargeError", + "RenderChunkValidationError", + "FFMPEG_VERSION_MISMATCH", + "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", + "PLAN_TOO_LARGE", + "BROWSER_GPU_NOT_SOFTWARE", + "FONT_FETCH_FAILED", + "ChromeBinaryUnavailableError", +]); + +/** + * Build the Hono app. A single `POST /` endpoint dispatches on the body's + * `Action` field — the workflow points every step (plan, each renderChunk, + * assemble) at the same URL and varies only the body. `GET /healthz` backs + * the Cloud Run startup/liveness probe. + * + * `deps` is threaded through so tests can drive the real HTTP surface with + * an injected Storage double + mocked primitives. + */ +export function createApp(deps?: HandlerDeps): Hono { + const app = new Hono(); + + app.get("/healthz", (c) => c.json({ status: "ok" })); + + // fallow-ignore-next-line complexity + app.post("/", async (c) => { + let body: CloudRunEvent; + try { + body = (await c.req.json()) as CloudRunEvent; + } catch { + return c.json({ error: "BAD_REQUEST", message: "request body must be JSON" }, 400); + } + try { + const result = await dispatch(body, deps); + return c.json(result, 200); + } catch (err) { + const name = err instanceof Error ? err.name : undefined; + const message = err instanceof Error ? err.message : String(err); + const status = name && NON_RETRYABLE_ERROR_NAMES.has(name) ? 400 : 500; + // Surface `error` (the name) as the discriminator the workflow's + // retry predicate keys off, plus `message` for human triage. + return c.json({ error: name ?? "RenderError", message }, status); + } + }); + + return app; +} + +/** Start the HTTP server. Cloud Run injects `PORT` (default 8080). */ +export function startServer(): void { + const port = Number(process.env.PORT ?? 8080); + const app = createApp(); + serve({ fetch: app.fetch, port }, (info) => { + logEvent({ event: "server_listening", port: info.port }); + }); +} + +// Boot when executed directly (the Dockerfile runs `node dist/server.js`), +// but not when imported by tests or the SDK. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + startServer(); +} diff --git a/packages/gcp-cloud-run/terraform/.gitignore b/packages/gcp-cloud-run/terraform/.gitignore new file mode 100644 index 000000000..f12ca34f6 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/.gitignore @@ -0,0 +1,7 @@ +# Local Terraform working files — never commit these. +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +crash.log diff --git a/packages/gcp-cloud-run/terraform/main.tf b/packages/gcp-cloud-run/terraform/main.tf new file mode 100644 index 000000000..a0bb97869 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/main.tf @@ -0,0 +1,197 @@ +# HyperFrames distributed render stack on Google Cloud. +# +# Topology (the GCP twin of the AWS Lambda adapter's SAM template): +# +# GCS bucket ←→ Cloud Run service (plan / renderChunk / assemble) +# ▲ +# │ OIDC-authenticated http.post per step +# │ +# Cloud Workflows (Plan → parallel RenderChunk → Assemble) +# +# Two service accounts keep least-privilege boundaries: +# - run_sa : the render service's identity; read/write the bucket only. +# - workflow_sa: the workflow's identity; invoke the render service only. + +locals { + workflow_source = var.workflow_source_path != "" ? var.workflow_source_path : "${path.module}/workflow.yaml" + name = var.project_name +} + +# ── Storage: plan tarballs, chunk outputs, final renders ───────────────────── +resource "google_storage_bucket" "render" { + name = "${local.name}-render-${var.project_id}" + project = var.project_id + location = var.region + uniform_bucket_level_access = true + force_destroy = var.bucket_force_destroy + + # Render artifacts (plan tarballs, chunk files) are disposable scratch. + # Sweep them after 7 days so the bucket doesn't accumulate cost; final + # outputs that adopters want to keep should be copied elsewhere. + lifecycle_rule { + condition { + age = 7 + } + action { + type = "Delete" + } + } +} + +# ── Service accounts ───────────────────────────────────────────────────────── +resource "google_service_account" "run_sa" { + account_id = "${local.name}-run" + project = var.project_id + display_name = "HyperFrames render service (Cloud Run)" +} + +resource "google_service_account" "workflow_sa" { + account_id = "${local.name}-wf" + project = var.project_id + display_name = "HyperFrames render orchestration (Workflows)" +} + +# Render service reads inputs + writes outputs in the render bucket only. +resource "google_storage_bucket_iam_member" "run_sa_bucket" { + bucket = google_storage_bucket.render.name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.run_sa.email}" +} + +# ── Cloud Run render service ───────────────────────────────────────────────── +resource "google_cloud_run_v2_service" "render" { + name = "${local.name}-render" + project = var.project_id + location = var.region + # Authenticated only — no public invoker binding. Only the workflow SA can + # call it. Ingress stays "all" because Workflows reaches the service over + # Google's front door, not the VPC. + ingress = "INGRESS_TRAFFIC_ALL" + # Let `terraform destroy` (and replacement on image bumps) remove the + # service without a manual console step. The render service is stateless — + # all durable artifacts live in GCS. + deletion_protection = false + + template { + service_account = google_service_account.run_sa.email + timeout = "${var.request_timeout_seconds}s" + # One render (chunk / plan / assemble) per instance — each uses the whole + # box's CPU + memory + /tmp. The workflow's concurrency_limit governs how + # many instances run at once. + max_instance_request_concurrency = 1 + + scaling { + min_instance_count = var.min_instances + max_instance_count = var.max_instances + } + + containers { + image = var.image + + resources { + limits = { + cpu = var.cpu + memory = var.memory + } + # Keep CPU allocated only during request processing (request-based + # billing). Renders are entirely request-scoped. + cpu_idle = true + } + + env { + # Scopes every event's GCS URIs to this bucket (the handler's + # GCS_URI_NOT_ALLOWED guard). Defense against request injection. + name = "HYPERFRAMES_RENDER_BUCKET" + value = google_storage_bucket.render.name + } + + startup_probe { + http_get { + path = "/healthz" + } + timeout_seconds = 5 + period_seconds = 10 + failure_threshold = 6 + } + } + } +} + +# Only the workflow's identity may invoke the render service. +resource "google_cloud_run_v2_service_iam_member" "workflow_invokes_run" { + name = google_cloud_run_v2_service.render.name + project = var.project_id + location = var.region + role = "roles/run.invoker" + member = "serviceAccount:${google_service_account.workflow_sa.email}" +} + +# ── Cloud Workflows orchestration ──────────────────────────────────────────── +resource "google_workflows_workflow" "render" { + name = "${local.name}-render" + project = var.project_id + region = var.region + service_account = google_service_account.workflow_sa.id + source_contents = file(local.workflow_source) + # Allow `terraform destroy` to remove the workflow without a manual step; + # the definition is reproducible from this module. + deletion_protection = false +} + +# ── Runaway-request alert (backstop against a fan-out bug) ──────────────────── +resource "google_monitoring_alert_policy" "runaway_requests" { + project = var.project_id + display_name = "${local.name}-render runaway request count" + combiner = "OR" + + conditions { + display_name = "Render service request count > threshold (1h)" + condition_threshold { + filter = join(" AND ", [ + "resource.type = \"cloud_run_revision\"", + "resource.labels.service_name = \"${google_cloud_run_v2_service.render.name}\"", + "metric.type = \"run.googleapis.com/request_count\"", + ]) + comparison = "COMPARISON_GT" + threshold_value = var.render_request_alarm_threshold + duration = "0s" + aggregations { + alignment_period = "3600s" + per_series_aligner = "ALIGN_SUM" + } + } + } + + notification_channels = var.notification_channels +} + +# ── Workflow-failure alert ─────────────────────────────────────────────────── +# Request-count alone misses a render that fails 100% of the time at low +# volume. Alert on any FAILED workflow execution so a broken render path is +# visible even when traffic is light. +resource "google_monitoring_alert_policy" "workflow_failures" { + project = var.project_id + display_name = "${local.name}-render workflow execution failures" + combiner = "OR" + + conditions { + display_name = "Failed workflow executions (5m)" + condition_threshold { + filter = join(" AND ", [ + "resource.type = \"workflows.googleapis.com/Workflow\"", + "resource.labels.workflow_id = \"${google_workflows_workflow.render.name}\"", + "metric.type = \"workflows.googleapis.com/finished_execution_count\"", + "metric.labels.status = \"FAILED\"", + ]) + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + } + } + } + + notification_channels = var.notification_channels +} diff --git a/packages/gcp-cloud-run/terraform/outputs.tf b/packages/gcp-cloud-run/terraform/outputs.tf new file mode 100644 index 000000000..f4b4ac9a7 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/outputs.tf @@ -0,0 +1,34 @@ +output "render_bucket_name" { + description = "GCS bucket holding plan tarballs, chunk outputs, and final renders. Pass as renderToCloudRun({ bucketName })." + value = google_storage_bucket.render.name +} + +output "service_url" { + description = "HTTPS URL of the Cloud Run render service. Pass as renderToCloudRun({ serviceUrl })." + value = google_cloud_run_v2_service.render.uri +} + +output "workflow_name" { + description = "Workflow id. Pass as renderToCloudRun({ workflowId })." + value = google_workflows_workflow.render.name +} + +output "workflow_id_full" { + description = "Fully-qualified workflow resource name." + value = google_workflows_workflow.render.id +} + +output "run_service_account_email" { + description = "Render service identity (read/write the render bucket)." + value = google_service_account.run_sa.email +} + +output "workflow_service_account_email" { + description = "Workflow identity (invokes the render service)." + value = google_service_account.workflow_sa.email +} + +output "region" { + description = "Region everything was deployed into. Pass as renderToCloudRun({ location })." + value = var.region +} diff --git a/packages/gcp-cloud-run/terraform/providers.tf b/packages/gcp-cloud-run/terraform/providers.tf new file mode 100644 index 000000000..7fc1bc4d9 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/providers.tf @@ -0,0 +1,12 @@ +# This module is applied directly (by `examples/gcp-cloud-run/scripts/smoke.sh` +# and `hyperframes cloudrun deploy`), so it configures the google provider +# from its own variables. Credentials come from the environment — either +# Application Default Credentials (`gcloud auth application-default login`) +# or a `GOOGLE_OAUTH_ACCESS_TOKEN` env var. +# +# If you instead embed this as a CHILD module, delete this block and pass a +# configured provider from your root module. +provider "google" { + project = var.project_id + region = var.region +} diff --git a/packages/gcp-cloud-run/terraform/variables.tf b/packages/gcp-cloud-run/terraform/variables.tf new file mode 100644 index 000000000..5b9a41b57 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/variables.tf @@ -0,0 +1,75 @@ +variable "project_id" { + type = string + description = "GCP project id to deploy the render stack into." +} + +variable "region" { + type = string + description = "Region for the Cloud Run service, Workflow, and bucket." + default = "us-central1" +} + +variable "project_name" { + type = string + description = "Name prefix applied to the service / workflow / bucket / service accounts." + default = "hyperframes" +} + +variable "image" { + type = string + description = "Fully-qualified container image for the render service (e.g. us-central1-docker.pkg.dev/PROJECT/REPO/hyperframes-render:TAG), built from packages/gcp-cloud-run/Dockerfile." +} + +variable "cpu" { + type = string + description = "vCPU per Cloud Run instance. Allowed: 1, 2, 4, 8. Renders are CPU-bound; 4 is a good default." + default = "4" +} + +variable "memory" { + type = string + description = "Memory per Cloud Run instance. Must be ≥ 2Gi per vCPU at cpu=4. Headroom for Chrome + ffmpeg + the chunk's frames in /tmp." + default = "16Gi" +} + +variable "request_timeout_seconds" { + type = number + description = "Per-request timeout. Cloud Run hard cap is 3600s; a single chunk should finish well inside this." + default = 3600 +} + +variable "min_instances" { + type = number + description = "Min Cloud Run instances. Default 0 (scale-to-zero) is cheapest but means the first render after idle pays a cold start (image pull + Chrome + bun boot, ~20-30s). Set to 1 to keep one warm if first-render latency matters." + default = 0 +} + +variable "max_instances" { + type = number + description = "Max Cloud Run instances. Each chunk pins one instance (request concurrency = 1), and the workflow fans out up to the plan's chunk count (which never exceeds Config.maxParallelChunks). Keep this >= the largest maxParallelChunks you render with, or excess chunks queue behind 429s + retry backoff. Also a runaway-cost backstop." + default = 100 +} + +variable "workflow_source_path" { + type = string + description = "Path to the Cloud Workflows YAML. Defaults to the copy shipped with this module." + default = "" +} + +variable "render_request_alarm_threshold" { + type = number + description = "Cloud Monitoring alert fires when render-service request count exceeds this in a 1-hour window. Backstop against a runaway fan-out." + default = 1000 +} + +variable "notification_channels" { + type = list(string) + description = "Cloud Monitoring notification channel ids for the runaway-request alert. Empty disables notifications (the policy still records)." + default = [] +} + +variable "bucket_force_destroy" { + type = bool + description = "Allow `terraform destroy` to delete the render bucket even when it still holds objects. Off by default to match the AWS adapter's RETAIN policy." + default = false +} diff --git a/packages/gcp-cloud-run/terraform/versions.tf b/packages/gcp-cloud-run/terraform/versions.tf new file mode 100644 index 000000000..f8bc2b626 --- /dev/null +++ b/packages/gcp-cloud-run/terraform/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.5.0" + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0.0" + } + } +} diff --git a/packages/gcp-cloud-run/terraform/workflow.yaml b/packages/gcp-cloud-run/terraform/workflow.yaml new file mode 100644 index 000000000..072cd197f --- /dev/null +++ b/packages/gcp-cloud-run/terraform/workflow.yaml @@ -0,0 +1,179 @@ +# HyperFrames distributed render orchestration on Cloud Workflows. +# +# Plan → BuildChunkList → AssertChunkCount → RenderChunks (parallel) → Assemble +# +# Mirrors the Step Functions state machine in +# `examples/aws-lambda/template.yaml`. Every step POSTs to the same Cloud Run +# service URL (passed in as `args.ServiceUrl`) and varies only the body's +# `Action`. The service returns the step's small result body on 2xx; on a +# non-retryable failure it returns HTTP 400, on a retryable failure HTTP 5xx — +# the `retryable` predicate below keys off exactly that split. +# +# The final returned object accumulates every step's result body so +# `getRenderProgress` can read frame totals + per-step durations on success: +# { Plan: {...}, Chunks: [{...}, ...], Assemble: {...} } +# +# Deploy with `gcloud workflows deploy` (the Terraform module / the +# `hyperframes cloudrun deploy` command do this for you). + +main: + params: [args] + steps: + - init: + assign: + - serviceUrl: ${args.ServiceUrl} + - projectGcsUri: ${args.ProjectGcsUri} + - planOutputGcsPrefix: ${args.PlanOutputGcsPrefix} + - outputGcsUri: ${args.OutputGcsUri} + - config: ${args.Config} + + # ── Plan (Activity A) ──────────────────────────────────────────────────── + - plan: + try: + call: http.post + args: + url: ${serviceUrl} + timeout: 1800 + auth: + type: OIDC + body: + Action: plan + ProjectGcsUri: ${projectGcsUri} + PlanOutputGcsPrefix: ${planOutputGcsPrefix} + Config: ${config} + result: planResp + retry: + predicate: ${retryable} + max_retries: 4 + backoff: + initial_delay: 2 + max_delay: 60 + multiplier: 2 + - capturePlan: + assign: + - planResult: ${planResp.body} + - chunkCount: ${planResult.ChunkCount} + + # ── BuildChunkList + AssertChunkCount ────────────────────────────────────── + - assertChunkCount: + switch: + - condition: ${chunkCount > 0} + next: buildChunkList + next: planProducedZeroChunks + - planProducedZeroChunks: + raise: + code: PLAN_PRODUCED_ZERO_CHUNKS + message: "Plan returned ChunkCount=0 — the composition produced no frames. Non-retryable producer-side invariant violation." + - buildChunkList: + # Pre-size the ordered chunk-URI + per-chunk result lists so the + # parallel branches below assign by index (distinct indices, no + # read-modify-write race on a shared accumulator). + assign: + - chunkIndexes: [] + - chunkUris: [] + - chunkResults: [] + - fillLists: + for: + value: i + range: [0, ${chunkCount - 1}] + steps: + - appendSlots: + assign: + - chunkIndexes: ${list.concat(chunkIndexes, i)} + - chunkUris: ${list.concat(chunkUris, "")} + - chunkResults: ${list.concat(chunkResults, "")} + + # ── RenderChunks (Activity B, fanned out) ────────────────────────────────── + - renderChunks: + parallel: + shared: [chunkUris, chunkResults] + # Run up to chunkCount chunks at once, clamped to 20 — Cloud + # Workflows hard-caps concurrent branches/iterations per execution + # at 20 (https://cloud.google.com/workflows/quotas). Above that, + # iterations queue regardless of concurrency_limit, so a config with + # maxParallelChunks > 20 still renders correctly; the extra chunks + # just wait. All chunkCount iterations always run. + concurrency_limit: ${math.min(chunkCount, 20)} + for: + value: idx + in: ${chunkIndexes} + steps: + - renderOneChunk: + try: + call: http.post + args: + url: ${serviceUrl} + timeout: 1800 + auth: + type: OIDC + body: + Action: renderChunk + ChunkIndex: ${idx} + PlanGcsUri: ${planResult.PlanGcsUri} + PlanHash: ${planResult.PlanHash} + ChunkOutputGcsPrefix: ${planOutputGcsPrefix} + Format: ${planResult.Format} + result: chunkResp + retry: + predicate: ${retryable} + max_retries: 4 + backoff: + initial_delay: 2 + max_delay: 60 + multiplier: 2 + - storeChunk: + assign: + - chunkUris[idx]: ${chunkResp.body.ChunkGcsUri} + - chunkResults[idx]: ${chunkResp.body} + + # ── Assemble (Activity C) ────────────────────────────────────────────────── + - assemble: + try: + call: http.post + args: + url: ${serviceUrl} + timeout: 1800 + auth: + type: OIDC + body: + Action: assemble + PlanGcsUri: ${planResult.PlanGcsUri} + ChunkGcsUris: ${chunkUris} + AudioGcsUri: ${planResult.AudioGcsUri} + OutputGcsUri: ${outputGcsUri} + Format: ${planResult.Format} + # Forward the caller's exact-CFR request (Config.cfr) to assemble. + # `"cfr" in config` guards the optional key; when unset this is + # false, which the handler reads as the default -c copy path. + Cfr: ${("cfr" in config) and config.cfr} + result: assembleResp + retry: + predicate: ${retryable} + max_retries: 4 + backoff: + initial_delay: 2 + max_delay: 60 + multiplier: 2 + + - done: + return: + Plan: ${planResult} + Chunks: ${chunkResults} + Assemble: ${assembleResp.body} + +# Retry predicate: retry transient/server failures (429 + 5xx), never the +# handler's non-retryable 400s (bad input, plan-hash mismatch, unsupported +# format, …). Connection / timeout errors carry no `.code`; retry those too. +retryable: + params: [e] + steps: + - classify: + switch: + - condition: ${not("code" in e)} + return: true + - condition: ${e.code == 429} + return: true + - condition: ${e.code >= 500 and e.code < 600} + return: true + - nonRetryable: + return: false diff --git a/packages/gcp-cloud-run/tsconfig.build.json b/packages/gcp-cloud-run/tsconfig.build.json new file mode 100644 index 000000000..c93bd474e --- /dev/null +++ b/packages/gcp-cloud-run/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": {}, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} diff --git a/packages/gcp-cloud-run/tsconfig.json b/packages/gcp-cloud-run/tsconfig.json new file mode 100644 index 000000000..b15c984cb --- /dev/null +++ b/packages/gcp-cloud-run/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@hyperframes/producer": ["../producer/src/index.ts"], + "@hyperframes/producer/distributed": ["../producer/src/distributed.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__fixtures__/**", "scripts"] +} diff --git a/packages/producer/src/distributed.ts b/packages/producer/src/distributed.ts index f2db8f660..12343ddb1 100644 --- a/packages/producer/src/distributed.ts +++ b/packages/producer/src/distributed.ts @@ -70,6 +70,18 @@ export { // ── Assemble (Activity C) ─────────────────────────────────────────────────── export { assemble, type AssembleResult } from "./services/distributed/assemble.js"; +// ── Cloud-agnostic adapter helpers ────────────────────────────────────────── +// Shared by the distributed-render adapters (aws-lambda, gcp-cloud-run, …) so +// the config-shape validator lives in one place; each adapter layers only its +// own wire-format size cap on top. +export { + InvalidConfigError, + type SerializableDistributedRenderConfig, + validateDistributedRenderConfig, + validateVariablesPayload, +} from "./services/distributed/renderConfigValidation.js"; +export { hashProjectDir } from "./services/distributed/projectHash.js"; + // ── Format union ──────────────────────────────────────────────────────────── // Canonical output-format type. The aws-lambda package re-exports it so // CLI / adopter SDKs can derive runtime allowlists from one source. diff --git a/packages/producer/src/services/distributed/projectHash.ts b/packages/producer/src/services/distributed/projectHash.ts new file mode 100644 index 000000000..bc64c0315 --- /dev/null +++ b/packages/producer/src/services/distributed/projectHash.ts @@ -0,0 +1,52 @@ +/** + * Content-addressing for a project directory, shared by the distributed-render + * adapters' `deploySite` verbs. + * + * Each adapter uploads a project tarball to `…/sites//project.tar.gz` + * and short-circuits the upload when the object already exists. `siteId` is a + * SHA-256 over the project's files so identical content always maps to the + * same key — that contract has to be byte-identical across adapters, which is + * exactly why it lives here rather than being copy-pasted per adapter. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join, relative } from "node:path"; +import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "./plan.js"; + +/** + * SHA-256 over every regular file under `projectDir` (sorted by relative + * path) → 16-character hex prefix. The prefix is the `siteId`. + * + * The hash includes the relative path plus every byte of each file, so a + * same-bytes rename still yields a fresh id. We trim to 16 chars because the + * full 64 isn't useful in an object key for legibility. Top-level segments in + * {@link PLAN_PROJECT_DIR_SKIP_SEGMENTS} (e.g. `node_modules`) are skipped to + * match what the plan stage copies. + * + * Reads are synchronous: project trees are typically tens of MB at most + * (HTML/CSS/JS plus a few composition assets), so the simpler shape wins over + * a streaming pipeline. + */ +export function hashProjectDir(projectDir: string): string { + const hash = createHash("sha256"); + const files: string[] = []; + function walk(dir: string, isRoot: boolean): void { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + )) { + if (isRoot && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full, false); + else if (entry.isFile()) files.push(full); + } + } + walk(projectDir, true); + for (const file of files) { + const rel = relative(projectDir, file).replaceAll("\\", "/"); + hash.update(rel); + hash.update("\0"); + hash.update(readFileSync(file)); + } + return hash.digest("hex").slice(0, 16); +} diff --git a/packages/producer/src/services/distributed/renderConfigValidation.ts b/packages/producer/src/services/distributed/renderConfigValidation.ts new file mode 100644 index 000000000..9610d6c06 --- /dev/null +++ b/packages/producer/src/services/distributed/renderConfigValidation.ts @@ -0,0 +1,287 @@ +/** + * Cloud-agnostic validation of a serializable `DistributedRenderConfig`. + * + * The distributed-render adapters (`@hyperframes/aws-lambda`, + * `@hyperframes/gcp-cloud-run`, …) all need to fail fast on shape errors + * *before* they start a cloud execution — a caller staring at a runtime + * failure minutes into a Step Functions / Cloud Workflows run shouldn't have + * to dig through execution history to learn they passed an unsupported + * format. The shape validation is identical across adapters, so it lives + * here; each adapter layers only its own wire-format size cap (Step + * Functions' 256 KiB vs Cloud Workflows' 512 KiB) on top. + * + * The check is deliberately narrow — it covers the *shape* errors any caller + * could have surfaced with `tsc` if they passed a literal, plus the + * `force-hdr` rejection (HDR mp4 isn't supported in distributed mode). + * Anything deeper (font availability, plan size cap, GPU mode at runtime) + * needs the actual planner. + */ + +import { type DistributedFormat } from "./shared.js"; +import { type DistributedRenderConfig } from "./plan.js"; + +/** + * `DistributedRenderConfig` minus the runtime-only fields (`logger`, + * `abortSignal`, `producerConfig`) that can't cross a JSON wire boundary. + * The shape adapters serialize into their execution input. + */ +export type SerializableDistributedRenderConfig = Omit< + DistributedRenderConfig, + "logger" | "abortSignal" | "producerConfig" +>; + +/** Thrown for any client-side `SerializableDistributedRenderConfig` violation. */ +export class InvalidConfigError extends Error { + // Read via Error.prototype.toString; fallow can't see it. + // fallow-ignore-next-line unused-class-member + override readonly name = "InvalidConfigError"; + /** Dotted JSON-pointer-ish path to the offending field, e.g. `config.fps`. */ + readonly field: string; + constructor(field: string, message: string) { + super(`[validateConfig] ${field}: ${message}`); + this.field = field; + } +} + +const ALLOWED_FPS = [24, 30, 60] as const; +const ALLOWED_FORMATS = [ + "mp4", + "mov", + "png-sequence", + "webm", +] as const satisfies readonly DistributedFormat[]; +const ALLOWED_CODECS = ["h264", "h265"] as const; +const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const; +const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const; +const ALLOWED_HDR_MODES = ["auto", "force-sdr"] as const; + +const MAX_DIMENSION = 7680; +const MIN_DIMENSION = 16; +const MAX_CHUNK_SIZE = 3600; +const MAX_PARALLEL_CHUNKS_CEILING = 256; + +/** + * Throw an `InvalidConfigError` if `config` is not a valid + * `SerializableDistributedRenderConfig`. Returns the same reference on + * success so the call site reads: + * + * const validated = validateDistributedRenderConfig(input); + */ +// fallow-ignore-next-line complexity +export function validateDistributedRenderConfig( + config: SerializableDistributedRenderConfig, +): SerializableDistributedRenderConfig { + if (config === null || typeof config !== "object") { + throw new InvalidConfigError("config", "must be an object"); + } + + if (!ALLOWED_FPS.includes(config.fps as 24 | 30 | 60)) { + throw new InvalidConfigError( + "config.fps", + `must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`, + ); + } + + validateIntDimension("config.width", config.width); + validateIntDimension("config.height", config.height); + + if (!ALLOWED_FORMATS.includes(config.format)) { + throw new InvalidConfigError( + "config.format", + `must be one of ${ALLOWED_FORMATS.join(", ")}; got ${String(config.format)}`, + ); + } + + if (config.codec !== undefined) { + if (config.format !== "mp4") { + throw new InvalidConfigError( + "config.codec", + `is only valid with format="mp4"; got format=${String(config.format)}`, + ); + } + if (!ALLOWED_CODECS.includes(config.codec)) { + throw new InvalidConfigError( + "config.codec", + `must be one of ${ALLOWED_CODECS.join(", ")}; got ${String(config.codec)}`, + ); + } + } + + if (config.quality !== undefined && !ALLOWED_QUALITIES.includes(config.quality)) { + throw new InvalidConfigError( + "config.quality", + `must be one of ${ALLOWED_QUALITIES.join(", ")}; got ${String(config.quality)}`, + ); + } + + if (config.crf !== undefined && config.bitrate !== undefined) { + throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate"); + } + if ( + config.crf !== undefined && + (!Number.isInteger(config.crf) || config.crf < 0 || config.crf > 51) + ) { + throw new InvalidConfigError("config.crf", `must be an integer in [0, 51]; got ${config.crf}`); + } + if (config.bitrate !== undefined && !/^\d+(\.\d+)?[kKmM]?$/.test(config.bitrate)) { + throw new InvalidConfigError( + "config.bitrate", + `must look like "10M" or "5000k"; got ${JSON.stringify(config.bitrate)}`, + ); + } + + if (config.chunkSize !== undefined) { + if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) { + throw new InvalidConfigError( + "config.chunkSize", + `must be a positive integer; got ${config.chunkSize}`, + ); + } + if (config.chunkSize > MAX_CHUNK_SIZE) { + throw new InvalidConfigError( + "config.chunkSize", + `must be <= ${MAX_CHUNK_SIZE}; got ${config.chunkSize}`, + ); + } + } + + if (config.maxParallelChunks !== undefined) { + if (!Number.isInteger(config.maxParallelChunks) || config.maxParallelChunks < 1) { + throw new InvalidConfigError( + "config.maxParallelChunks", + `must be a positive integer; got ${config.maxParallelChunks}`, + ); + } + if (config.maxParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) { + throw new InvalidConfigError( + "config.maxParallelChunks", + `must be <= ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.maxParallelChunks}`, + ); + } + } + + if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) { + throw new InvalidConfigError( + "config.runtimeCap", + `must be one of ${ALLOWED_RUNTIME_CAPS.join(", ")}; got ${String(config.runtimeCap)}`, + ); + } + + if (config.hdrMode !== undefined && !ALLOWED_HDR_MODES.includes(config.hdrMode)) { + // `force-hdr` is rejected on top of the producer's plan-stage rejection — + // it makes the typical typo (`"force-hdr"` copy-pasted from in-process + // config) surface synchronously instead of as a typed failure minutes in. + throw new InvalidConfigError( + "config.hdrMode", + `distributed mode supports only ${ALLOWED_HDR_MODES.join(", ")}; got ${String(config.hdrMode)}`, + ); + } + + if (config.variables !== undefined) { + validateVariablesPayload(config.variables); + } + + return config; +} + +/** + * Validate that `variables` is a plain JSON-safe object — no functions, + * Symbols, `undefined` leaves, BigInts, non-finite numbers, or non-plain + * objects (Dates, Maps, Sets, class instances). Rejected values would either + * round-trip incorrectly through the execution input (`undefined` is silently + * dropped by `JSON.stringify`) or throw at the wire boundary (`bigint`), so + * we surface the offending path synchronously. + * + * The check is purely structural — semantic constraints (e.g. "is this + * variable declared in `data-composition-variables`?") belong to the CLI + * layer where the project's HTML is on disk. + */ +export function validateVariablesPayload(value: unknown): void { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InvalidConfigError( + "config.variables", + `must be a plain JSON object (got ${describeValue(value)})`, + ); + } + walkVariables(value, "config.variables", new WeakSet()); +} + +/** Per-typeof rejection messages for JSON-unsafe leaves. */ +const LEAF_REJECTIONS: Partial> = { + undefined: + "undefined leaves are silently dropped by JSON.stringify — use null if you mean an absent value", + function: "functions are not JSON-serializable", + symbol: "Symbols are not JSON-serializable", + bigint: "BigInt values throw at JSON.stringify — encode as a string if you need 64-bit integers", +}; + +// fallow-ignore-next-line complexity +function walkVariables(value: unknown, path: string, seen: WeakSet): void { + const t = typeof value; + if (value === null || t === "string" || t === "boolean") return; + if (t === "number") { + if (!Number.isFinite(value as number)) { + throw new InvalidConfigError( + path, + `non-finite numbers (NaN / Infinity) are not JSON-serializable; got ${String(value)}`, + ); + } + return; + } + const leafReject = LEAF_REJECTIONS[t]; + if (leafReject !== undefined) { + throw new InvalidConfigError(path, leafReject); + } + // t === "object" from here on. Reject circular refs up front — recursing + // through a back-edge would stack-overflow with no actionable error. + if (seen.has(value as object)) { + throw new InvalidConfigError( + path, + "circular reference detected — JSON.stringify cannot serialize cycles", + ); + } + seen.add(value as object); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + walkVariables(value[i], `${path}[${i}]`, seen); + } + return; + } + // Reject non-plain objects (Date, Map, Set, class instances) up front. + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new InvalidConfigError( + path, + `non-plain objects are not supported (got ${describeValue(value)}); use a plain {…} object`, + ); + } + for (const key of Object.keys(value as Record)) { + walkVariables((value as Record)[key], `${path}.${key}`, seen); + } +} + +// fallow-ignore-next-line complexity +function describeValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value !== "object") return typeof value; + const ctorName = (value as { constructor?: { name?: string } }).constructor?.name ?? "Object"; + return ctorName === "Object" ? "object" : ctorName; +} + +function validateIntDimension(field: string, value: unknown): void { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new InvalidConfigError(field, `must be an integer; got ${String(value)}`); + } + if (value < MIN_DIMENSION || value > MAX_DIMENSION) { + throw new InvalidConfigError( + field, + `must be in [${MIN_DIMENSION}, ${MAX_DIMENSION}]; got ${value}`, + ); + } + if (value % 2 !== 0) { + // libx264 / libx265 yuv420p require even dimensions; rejecting now beats a + // Plan-stage ffmpeg crash on dimension parity. + throw new InvalidConfigError(field, `must be even (yuv420p constraint); got ${value}`); + } +} diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 059a99103..ddf68f8a9 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -29,6 +29,7 @@ const PACKAGES = [ "packages/studio", "packages/cli", "packages/aws-lambda", + "packages/gcp-cloud-run", ]; const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"];