feat(cloud): default distributed plans to v2 (#3311)

* feat(cloud): default distributed plans to v2

* fix(cloud): address plan v2 review feedback

* fix(examples): document explicit v2 samples
This commit is contained in:
James Russo
2026-08-17 17:24:31 -04:00
committed by GitHub
parent 6b17c24f98
commit 17a2a00ed5
36 changed files with 403 additions and 143 deletions
+11 -5
View File
@@ -70,7 +70,6 @@ const site = await deploySite({
const handle = await renderToLambda({ const handle = await renderToLambda({
siteHandle: site, siteHandle: site,
planProtocol: "v2",
bucketName: site.bucketName, bucketName: site.bucketName,
stateMachineArn: "arn:aws:states:us-east-1:123456789012:stateMachine:hyperframes-render", stateMachineArn: "arn:aws:states:us-east-1:123456789012:stateMachine:hyperframes-render",
config: { config: {
@@ -88,10 +87,17 @@ const progress = await getRenderProgress({ executionArn: handle.executionArn });
console.log(progress.status, progress.overallProgress, progress.costs.displayCost); console.log(progress.status, progress.overallProgress, progress.costs.displayCost);
``` ```
Plan v2 is recommended for new integrations because workers fetch Plan v2 is the default because workers fetch manifest-selected
manifest-selected content-addressed artifacts. For backwards compatibility, content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
omitting `planProtocol` still selects v1; existing callers do not change omitted. Deprecated v1 compatibility remains available by passing
behavior until they opt in. `planProtocol: "v1"`.
<Warning>
For an existing installation, pause new renders and drain active Step
Functions executions. Redeploy the Lambda handler and SAM/CDK state machine
from the same package version before upgrading the application SDK. Older
infrastructure may default omission to v1 or lack v2 support.
</Warning>
`renderToLambda()` validates the distributed render config before starting the Step Functions execution, so invalid dimensions, formats, chunk sizes, or payload sizes fail synchronously. `renderToLambda()` validates the distributed render config before starting the Step Functions execution, so invalid dimensions, formats, chunk sizes, or payload sizes fail synchronously.
+11 -5
View File
@@ -79,7 +79,6 @@ import { getRenderProgress, renderToCloudRun } from "@hyperframes/gcp-cloud-run/
const handle = await renderToCloudRun({ const handle = await renderToCloudRun({
projectDir: "./my-composition", projectDir: "./my-composition",
planProtocol: "v2",
config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
bucketName: "hyperframes-render-my-project", bucketName: "hyperframes-render-my-project",
projectId: "my-project", projectId: "my-project",
@@ -97,10 +96,17 @@ while (progress.status === "running") {
console.log(progress.status, progress.outputFile, progress.costs.displayCost); console.log(progress.status, progress.outputFile, progress.costs.displayCost);
``` ```
Plan v2 is recommended for new integrations because workers fetch Plan v2 is the default because workers fetch manifest-selected
manifest-selected content-addressed artifacts. For backwards compatibility, content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
omitting `planProtocol` still selects v1; existing callers do not change omitted. Deprecated v1 compatibility remains available by passing
behavior until they opt in. `planProtocol: "v1"`.
<Warning>
For an existing installation, pause new renders and drain active workflow
executions. Redeploy the Cloud Run image and Cloud Workflows definition from
the same package version before upgrading the application SDK. Older
workflows may default omission to v1 or lack v2 support.
</Warning>
Pass `projectDir` for one-shot uploads, or call `deploySite()` separately and reuse the returned site handle across many renders. Pass `projectDir` for one-shot uploads, or call `deploySite()` separately and reuse the returned site handle across many renders.
+22 -10
View File
@@ -75,7 +75,6 @@ aws stepfunctions start-execution \
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz", "ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/", "PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4", "OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
"PlanProtocol": "v2",
"Config": { "Config": {
"fps": 30, "fps": 30,
"width": 1920, "width": 1920,
@@ -92,10 +91,20 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`. the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
Plan v2 is recommended for new integrations. `PlanProtocol` may be `"v1"` or Plan v2 is the default when `PlanProtocol` is absent. V2 uses separate
`"v2"`; absent still defaults to v1 for backwards compatibility. V2 uses manifest and content-addressed artifact locators throughout the workflow and
separate manifest and content-addressed artifact locators throughout the never places a v2 object in `PlanS3Uri`. The deprecated v1 transport remains
workflow and never places a v2 object in `PlanS3Uri`. available by sending `"PlanProtocol": "v1"` explicitly.
### Upgrading an existing stack
Pause new renders and let active Step Functions executions drain before the
upgrade. Redeploy the Lambda handler and this state machine (or the matching
CDK construct) from the same package version before upgrading the application
that calls `renderToLambda`. The new SDK sends explicit v2 by default, while
older infrastructure may default omission to v1 or lack v2 support. Keep
passing `planProtocol: "v1"` until the infrastructure redeploy completes if
you need a staged migration.
## Local invocation ## Local invocation
@@ -112,10 +121,13 @@ sam validate
sam local invoke RenderFunction --event sample-events/plan.json sam local invoke RenderFunction --event sample-events/plan.json
``` ```
The `sample-events/` directory ships small JSON payloads for each of the The `sample-events/` directory ships three tiers for each action:
three actions. They reference fake S3 URIs — useful for sanity-checking `*.json` demonstrates default v2 with `PlanProtocol` omitted, `*-v1.json`
the handler's dispatch logic; not for full end-to-end testing (real S3 demonstrates deprecated explicit-v1 compatibility, and `*-v2.json`
calls require credentials and a project zip to actually exist). demonstrates callers that stamp v2 explicitly. They reference fake S3 URIs —
useful for sanity-checking the handler's dispatch logic; not for full
end-to-end testing (real S3 calls require credentials and a project zip to
actually exist).
## End-to-end smoke + benchmark ## End-to-end smoke + benchmark
@@ -124,7 +136,7 @@ the architecture works on a deployed Lambda — use the local smoke
script: script:
```bash ```bash
# Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr). # Defaults use Plan v2 and the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
./scripts/smoke.sh ./scripts/smoke.sh
# Customised: # Customised:
@@ -0,0 +1,12 @@
{
"Action": "assemble",
"PlanProtocol": "v1",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
"ChunkS3Uris": [
"s3://example-bucket/renders/sample/chunks/0000.mp4",
"s3://example-bucket/renders/sample/chunks/0001.mp4"
],
"AudioS3Uri": null,
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
"Format": "mp4"
}
@@ -1,6 +1,8 @@
{ {
"Action": "assemble", "Action": "assemble",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkS3Uris": [ "ChunkS3Uris": [
"s3://example-bucket/renders/sample/chunks/0000.mp4", "s3://example-bucket/renders/sample/chunks/0000.mp4",
"s3://example-bucket/renders/sample/chunks/0001.mp4", "s3://example-bucket/renders/sample/chunks/0001.mp4",
@@ -0,0 +1,15 @@
{
"Action": "plan",
"PlanProtocol": "v1",
"ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": {
"fps": 30,
"width": 1920,
"height": 1080,
"format": "mp4",
"chunkSize": 240,
"maxParallelChunks": 8,
"runtimeCap": "lambda"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"Action": "plan", "Action": "plan",
"ProjectS3Uri": "s3://example-bucket/projects/sample-composition.tar.gz", "ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/", "PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": { "Config": {
"fps": 30, "fps": 30,
@@ -0,0 +1,9 @@
{
"Action": "renderChunk",
"PlanProtocol": "v1",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Format": "mp4"
}
@@ -1,6 +1,7 @@
{ {
"Action": "renderChunk", "Action": "renderChunk",
"PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000", "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0, "ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/", "ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
+3 -3
View File
@@ -31,7 +31,7 @@
# --region <region> (default: $AWS_REGION or us-east-1) # --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS # --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain) # default profile resolution chain)
# --plan-protocol <v1|v2|both> (default: v1) # --plan-protocol <v1|v2|both> (default: v2)
# --keep-stack (skip `sam delete` at the end) # --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one) # --skip-build (skip the ZIP rebuild; use the existing one)
# #
@@ -82,7 +82,7 @@ SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}" STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}" AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}" AWS_PROFILE="${AWS_PROFILE:-}"
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}" PLAN_PROTOCOL="${PLAN_PROTOCOL:-v2}"
KEEP_STACK="false" KEEP_STACK="false"
SKIP_BUILD="false" SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}" REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
@@ -110,7 +110,7 @@ Flags:
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>) --stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
--region <region> AWS region (default: $AWS_REGION or us-east-1) --region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE) --profile <name> AWS profile (default: $AWS_PROFILE)
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1) --plan-protocol <v1|v2|both> plan transport(s) to compare (default: v2)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16) --reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later) --keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output --require-encoded-sha-equal also gate byte-identical encoded MP4 output
+5 -2
View File
@@ -222,12 +222,12 @@ Resources:
- Variable: $.PlanProtocol - Variable: $.PlanProtocol
IsPresent: true IsPresent: true
Next: UnsupportedPlanProtocol Next: UnsupportedPlanProtocol
Default: Plan Default: PlanV2
UnsupportedPlanProtocol: UnsupportedPlanProtocol:
Type: Fail Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED Error: PLAN_PROTOCOL_UNSUPPORTED
Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1). Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v2).
Plan: Plan:
Type: Task Type: Task
@@ -236,6 +236,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn FunctionName: !GetAtt RenderFunction.Arn
Payload: Payload:
Action: plan Action: plan
PlanProtocol: v1
ProjectS3Uri.$: "$.ProjectS3Uri" ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix" PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config" Config.$: "$.Config"
@@ -391,6 +392,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn FunctionName: !GetAtt RenderFunction.Arn
Payload: Payload:
Action: renderChunk Action: renderChunk
PlanProtocol: v1
ChunkIndex.$: "$.ChunkIndex" ChunkIndex.$: "$.ChunkIndex"
PlanS3Uri.$: "$.PlanS3Uri" PlanS3Uri.$: "$.PlanS3Uri"
PlanHash.$: "$.PlanHash" PlanHash.$: "$.PlanHash"
@@ -427,6 +429,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn FunctionName: !GetAtt RenderFunction.Arn
Payload: Payload:
Action: assemble Action: assemble
PlanProtocol: v1
PlanS3Uri.$: "$.Plan.PlanS3Uri" PlanS3Uri.$: "$.Plan.PlanS3Uri"
ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri" ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
AudioS3Uri.$: "$.Plan.AudioS3Uri" AudioS3Uri.$: "$.Plan.AudioS3Uri"
+21 -9
View File
@@ -8,7 +8,7 @@ Cloud Workflows adapter for HyperFrames distributed rendering.
```text ```text
scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup
sample-events/ v1 and v2 handler request examples sample-events/ Default-v2, explicit-v1, and explicit-v2 request examples
``` ```
The Terraform module and Cloud Workflows definition live in The Terraform module and Cloud Workflows definition live in
@@ -16,8 +16,9 @@ The Terraform module and Cloud Workflows definition live in
## Protocol rollout ## Protocol rollout
The workflow defaults to plan protocol v1 when `PlanProtocol` is absent. V2 is The workflow defaults to Plan v2 when `PlanProtocol` is absent. Deprecated v1
accepted only when the caller explicitly sends `PlanProtocol: "v2"`. compatibility remains available only when the caller explicitly sends
`PlanProtocol: "v1"`.
V1 and v2 use disjoint plan locators: V1 and v2 use disjoint plan locators:
@@ -26,9 +27,15 @@ V1 and v2 use disjoint plan locators:
The workflow validates that the plan response matches the selected protocol The workflow validates that the plan response matches the selected protocol
before starting chunk fan-out. It never silently falls back from v2 to v1. before starting chunk fan-out. It never silently falls back from v2 to v1.
Deploy the v2 workflow only with a Cloud Run image whose handler implements Deploy the workflow only with a Cloud Run image whose handler implements the
the matching v2 request/response contract. An older v1-only handler will keep matching v2 request/response contract.
serving default v1 requests, but explicit v2 smoke executions will fail closed.
For an existing installation, pause new renders and drain active workflow
executions. Redeploy the Cloud Run image and workflow from the same package
version before upgrading the application SDK. The new SDK sends explicit v2;
older workflows may still default omission to v1 or lack v2 support. Keep
passing `planProtocol: "v1"` until the infrastructure redeploy completes if
you need a staged migration.
## Prerequisites ## Prerequisites
@@ -40,7 +47,7 @@ serving default v1 requests, but explicit v2 smoke executions will fail closed.
## Run the smoke ## Run the smoke
V1 remains the safe default: Plan v2 is the normal smoke path:
```bash ```bash
./scripts/smoke.sh \ ./scripts/smoke.sh \
@@ -120,12 +127,17 @@ old invocation's state or image implicitly.
The sample events mirror the request bodies sent by Cloud Workflows: The sample events mirror the request bodies sent by Cloud Workflows:
```bash ```bash
# V1 # Default v2 (PlanProtocol omitted)
curl -sX POST localhost:8080/ \ curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
--data @sample-events/plan.json | jq . --data @sample-events/plan.json | jq .
# Explicit v2 # Deprecated explicit v1 compatibility
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan-v1.json | jq .
# Explicit v2 for callers that always stamp the protocol
curl -sX POST localhost:8080/ \ curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
--data @sample-events/plan-v2.json | jq . --data @sample-events/plan-v2.json | jq .
@@ -0,0 +1,12 @@
{
"Action": "assemble",
"PlanProtocol": "v1",
"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"
}
@@ -1,7 +1,8 @@
{ {
"Action": "assemble", "Action": "assemble",
"PlanProtocol": "v1", "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkGcsUris": [ "ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4", "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4" "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4"
@@ -0,0 +1,7 @@
{
"Action": "plan",
"PlanProtocol": "v1",
"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" }
}
@@ -1,6 +1,5 @@
{ {
"Action": "plan", "Action": "plan",
"PlanProtocol": "v1",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz", "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" } "Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
@@ -0,0 +1,9 @@
{
"Action": "renderChunk",
"PlanProtocol": "v1",
"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"
}
@@ -1,7 +1,7 @@
{ {
"Action": "renderChunk", "Action": "renderChunk",
"PlanProtocol": "v1", "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
"PlanHash": "REPLACE_WITH_PLAN_HASH", "PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0, "ChunkIndex": 0,
"ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", "ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
+6 -6
View File
@@ -2,11 +2,11 @@
# Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames # Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames
# Cloud Run adapter. # Cloud Run adapter.
# #
# The default is intentionally v1-only. Plan protocol v2 must be opted into # The default exercises Plan v2. Deprecated v1 compatibility can be selected
# explicitly with --protocols v1,v2. Every invocation derives a unique, # explicitly with --protocols v1 or compared with --protocols v1,v2. Every
# length-safe resource prefix and uses an isolated Terraform working directory # invocation derives a unique, length-safe resource prefix and uses an isolated
# and state file. Cleanup verifies every owned resource is absent and fails # Terraform working directory and state file. Cleanup verifies every owned
# closed on API/authentication errors. # resource is absent and fails closed on API/authentication errors.
# #
# Usage: # Usage:
# ./smoke.sh --project <gcp-project> # ./smoke.sh --project <gcp-project>
@@ -33,7 +33,7 @@ REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}" FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}" CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}" PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
PROTOCOLS="${PROTOCOLS:-v1}" PROTOCOLS="${PROTOCOLS:-v2}"
OWNER="${HYPERFRAMES_SMOKE_OWNER:-}" OWNER="${HYPERFRAMES_SMOKE_OWNER:-}"
AR_REPO="${AR_REPO:-}" AR_REPO="${AR_REPO:-}"
AR_REPO_WAS_EXPLICIT=0 AR_REPO_WAS_EXPLICIT=0
+21 -5
View File
@@ -47,14 +47,13 @@ inside Step Functions' history budget (under 200 bytes per chunk).
### Plan transport selection ### Plan transport selection
Plan v2 is recommended for new integrations. `renderToLambda` still defaults Plan v2 is the default for new renders. When `planProtocol` is omitted,
an omitted `planProtocol` to the existing monolithic v1 transport for `renderToLambda` sends an explicit `PlanProtocol: "v2"` so the SDK and the
backwards compatibility, so select v2 explicitly: deployed state machine agree:
```ts ```ts
await renderToLambda({ await renderToLambda({
// ...bucket, state machine, project, and config... // ...bucket, state machine, project, and config...
planProtocol: "v2",
}); });
``` ```
@@ -64,7 +63,24 @@ only manifest-selected chunk artifacts, while the assembler fetches its
own metadata and audio subset. Blobs are immutable SHA-256-addressed own metadata and audio subset. Blobs are immutable SHA-256-addressed
objects, verified on upload and download, and the manifest is published objects, verified on upload and download, and the manifest is published
last. Unknown protocols and digest mismatches are terminal Step Functions last. Unknown protocols and digest mismatches are terminal Step Functions
errors. Omit the selector—or use `"v1"`—to retain the prior wire contract. errors. The monolithic v1 transport remains available as deprecated
compatibility by passing `planProtocol: "v1"` explicitly.
#### Upgrade order
This default changes application behavior and requires a coordinated
infrastructure upgrade. Before upgrading an application that calls
`renderToLambda`:
1. Pause new renders and let existing Step Functions executions drain.
2. Redeploy the Lambda handler and SAM template or CDK construct from the
same new package version.
3. Resume renders, then upgrade the application/SDK dependency.
Older state machines can default missing protocol fields to v1 or lack v2
branches, while the new SDK sends explicit v2. If infrastructure cannot be
redeployed first, keep the application on its previous package version or
pass `planProtocol: "v1"` explicitly until the redeploy is complete.
## Chrome runtime ## Chrome runtime
@@ -151,6 +151,18 @@ describe("HyperframesRenderStack — snapshot", () => {
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort()); expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
}); });
it("defaults omitted plan protocol to v2 and preserves the explicit v1 branch", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
const selection = requireRecord(
definition.States.SelectPlanProtocol,
"SelectPlanProtocol state",
);
expect(selection.Default).toBe("PlanV2");
expect(JSON.stringify(selection)).toContain('"StringEquals":"v1"');
expect(JSON.stringify(definition.States.Plan)).toContain('"PlanProtocol":"v1"');
}
});
it("preserves every typed non-retryable error name across the three Lambda tasks", () => { it("preserves every typed non-retryable error name across the three Lambda tasks", () => {
const { definition } = SYNTHED; const { definition } = SYNTHED;
const collected = new Set<string>(); const collected = new Set<string>();
@@ -247,6 +247,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction, lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({ payload: sfn.TaskInput.fromObject({
Action: "plan", Action: "plan",
PlanProtocol: "v1",
"ProjectS3Uri.$": "$.ProjectS3Uri", "ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix", "PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config", "Config.$": "$.Config",
@@ -319,6 +320,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction, lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({ payload: sfn.TaskInput.fromObject({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
"ChunkIndex.$": "$.ChunkIndex", "ChunkIndex.$": "$.ChunkIndex",
"PlanS3Uri.$": "$.PlanS3Uri", "PlanS3Uri.$": "$.PlanS3Uri",
"PlanHash.$": "$.PlanHash", "PlanHash.$": "$.PlanHash",
@@ -361,6 +363,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction, lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({ payload: sfn.TaskInput.fromObject({
Action: "assemble", Action: "assemble",
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Plan.PlanS3Uri", "PlanS3Uri.$": "$.Plan.PlanS3Uri",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri", "ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
"AudioS3Uri.$": "$.Plan.AudioS3Uri", "AudioS3Uri.$": "$.Plan.AudioS3Uri",
@@ -473,13 +476,13 @@ export class HyperframesRenderStack extends Construct {
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", { const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED", error: "PLAN_PROTOCOL_UNSUPPORTED",
cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).', cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v2).',
}); });
return new sfn.Choice(this, "SelectPlanProtocol") return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2) .when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan) .when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol) .when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(plan); .otherwise(planV2);
} }
} }
+10 -10
View File
@@ -55,17 +55,17 @@ interface PlanEventBase {
} }
/** /**
* Legacy/default plan transport. Absence is deliberately interpreted as v1. * Legacy plan transport. Callers must select it explicitly.
* *
* @deprecated Use {@link PlanV2Event} for new integrations. * @deprecated Use {@link PlanV2Event} for new integrations.
*/ */
export interface PlanV1Event extends PlanEventBase { export interface PlanV1Event extends PlanEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
} }
/** Explicit opt-in to the content-addressed v2 plan transport. */ /** Default content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase { export interface PlanV2Event extends PlanEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
} }
export type PlanEvent = PlanV1Event | PlanV2Event; export type PlanEvent = PlanV1Event | PlanV2Event;
@@ -90,12 +90,12 @@ interface RenderChunkEventBase {
} }
/** /**
* Legacy/default chunk event. * Legacy chunk event. Callers must select it explicitly.
* *
* @deprecated Use {@link RenderChunkV2Event} for new integrations. * @deprecated Use {@link RenderChunkV2Event} for new integrations.
*/ */
export interface RenderChunkV1Event extends RenderChunkEventBase { export interface RenderChunkV1Event extends RenderChunkEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */ /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string; PlanS3Uri: string;
} }
@@ -105,7 +105,7 @@ export interface RenderChunkV1Event extends RenderChunkEventBase {
* describes the exact content-addressed artifacts needed by this chunk. * describes the exact content-addressed artifacts needed by this chunk.
*/ */
export interface RenderChunkV2Event extends RenderChunkEventBase { export interface RenderChunkV2Event extends RenderChunkEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string; PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string; PlanV2ArtifactS3Prefix: string;
} }
@@ -136,19 +136,19 @@ interface AssembleEventBase {
} }
/** /**
* Legacy/default assemble event. * Legacy assemble event. Callers must select it explicitly.
* *
* @deprecated Use {@link AssembleV2Event} for new integrations. * @deprecated Use {@link AssembleV2Event} for new integrations.
*/ */
export interface AssembleV1Event extends AssembleEventBase { export interface AssembleV1Event extends AssembleEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */ /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string; PlanS3Uri: string;
} }
/** V2 assemble event, scoped to manifest-declared assembler artifacts. */ /** V2 assemble event, scoped to manifest-declared assembler artifacts. */
export interface AssembleV2Event extends AssembleEventBase { export interface AssembleV2Event extends AssembleEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string; PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string; PlanV2ArtifactS3Prefix: string;
PlanHash: string; PlanHash: string;
+61 -5
View File
@@ -145,6 +145,7 @@ describe("unwrapEvent", () => {
it("unwraps a Step Functions { Payload } envelope", () => { it("unwraps a Step Functions { Payload } envelope", () => {
const inner: RenderChunkEvent = { const inner: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz", PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "deadbeef", PlanHash: "deadbeef",
ChunkIndex: 3, ChunkIndex: 3,
@@ -158,6 +159,7 @@ describe("unwrapEvent", () => {
it("unwraps multiple levels of envelopes", () => { it("unwraps multiple levels of envelopes", () => {
const inner: AssembleEvent = { const inner: AssembleEvent = {
Action: "assemble", Action: "assemble",
PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz", PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"], ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"],
AudioS3Uri: null, AudioS3Uri: null,
@@ -176,7 +178,7 @@ describe("unwrapEvent", () => {
}); });
describe("handler dispatch", () => { describe("handler dispatch", () => {
it("routes Action='plan' to the plan primitive", async () => { it("preserves explicit v1 plan compatibility", async () => {
const tmpRoot = makeTmpRoot(); const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client(); const s3 = new FakeS3Client();
// Seed a fake project tarball so the untar step has something to chew on. // Seed a fake project tarball so the untar step has something to chew on.
@@ -213,6 +215,7 @@ describe("handler dispatch", () => {
const event: PlanEvent = { const event: PlanEvent = {
Action: "plan", Action: "plan",
PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz", ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/", PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
@@ -270,6 +273,7 @@ describe("handler dispatch", () => {
handler( handler(
{ {
Action: "plan", Action: "plan",
PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz", ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/terminal/", PlanOutputS3Prefix: "s3://bucket/renders/terminal/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" }, Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -332,6 +336,7 @@ describe("handler dispatch", () => {
const event: PlanEvent = { const event: PlanEvent = {
Action: "plan", Action: "plan",
PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz", ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/", PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
@@ -406,6 +411,7 @@ describe("handler dispatch", () => {
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz", PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "fakehash", PlanHash: "fakehash",
ChunkIndex: 2, ChunkIndex: 2,
@@ -455,6 +461,7 @@ describe("handler dispatch", () => {
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz", PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "not-the-real-hash", PlanHash: "not-the-real-hash",
ChunkIndex: 0, ChunkIndex: 0,
@@ -511,6 +518,7 @@ describe("handler dispatch", () => {
const event: AssembleEvent = { const event: AssembleEvent = {
Action: "assemble", Action: "assemble",
PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz", PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"], ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"],
AudioS3Uri: null, AudioS3Uri: null,
@@ -541,7 +549,7 @@ describe("handler dispatch", () => {
expect(assembleMock).toHaveBeenCalledTimes(1); expect(assembleMock).toHaveBeenCalledTimes(1);
}); });
it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => { it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
const tmpRoot = makeTmpRoot(); const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client(); const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar()); s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
@@ -614,7 +622,6 @@ describe("handler dispatch", () => {
const planned = await handler( const planned = await handler(
{ {
Action: "plan", Action: "plan",
PlanProtocol: "v2",
ProjectS3Uri: "s3://bucket/project.tar.gz", ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/v2/", PlanOutputS3Prefix: "s3://bucket/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" }, Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -642,7 +649,6 @@ describe("handler dispatch", () => {
const chunk = await handler( const chunk = await handler(
{ {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri, PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix, PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash, PlanHash: planned.PlanHash,
@@ -661,7 +667,6 @@ describe("handler dispatch", () => {
await handler( await handler(
{ {
Action: "assemble", Action: "assemble",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri, PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix, PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash, PlanHash: planned.PlanHash,
@@ -677,6 +682,56 @@ describe("handler dispatch", () => {
).toBe(true); ).toBe(true);
}); });
it("rejects unknown plan protocol values", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
await expect(
handler(
{
Action: "plan",
PlanProtocol: "v3",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/invalid/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
} as unknown as LambdaEvent,
{
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
tmpRoot,
skipChromeResolution: true,
},
),
).rejects.toMatchObject({ name: "PLAN_PROTOCOL_UNSUPPORTED" });
expect(s3.ops).toHaveLength(0);
});
it("rejects mixed v1/v2 plan locators at runtime", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
await expect(
handler(
{
Action: "renderChunk",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "fakehash",
ChunkIndex: 0,
ChunkOutputS3Prefix: "s3://bucket/renders/mixed/",
Format: "mp4",
} as unknown as LambdaEvent,
{
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
tmpRoot,
skipChromeResolution: true,
},
),
).rejects.toMatchObject({
name: "PLAN_PROTOCOL_UNSUPPORTED",
message: expect.stringContaining("mixed or missing plan locators"),
});
expect(s3.ops).toHaveLength(0);
});
it("rejects unknown actions", async () => { it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot(); const tmpRoot = makeTmpRoot();
await expect( await expect(
@@ -735,6 +790,7 @@ describe("handler — S3 URI allowlist (security: F-004)", () => {
const event: AssembleEvent = { const event: AssembleEvent = {
Action: "assemble", Action: "assemble",
PlanProtocol: "v1",
PlanS3Uri: "s3://good-bucket/plan.tar.gz", PlanS3Uri: "s3://good-bucket/plan.tar.gz",
ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"], ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"],
AudioS3Uri: null, AudioS3Uri: null,
+54 -13
View File
@@ -44,9 +44,12 @@ import type {
LambdaEvent, LambdaEvent,
LambdaResult, LambdaResult,
PlanEvent, PlanEvent,
PlanV2Event,
PlanLambdaResult, PlanLambdaResult,
RenderChunkEvent, RenderChunkEvent,
RenderChunkV2Event,
RenderChunkLambdaResult, RenderChunkLambdaResult,
AssembleV2Event,
} from "./events.js"; } from "./events.js";
import { import {
downloadS3ObjectToFile, downloadS3ObjectToFile,
@@ -97,6 +100,7 @@ export interface HandlerDeps {
*/ */
export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<LambdaResult> { export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<LambdaResult> {
const unwrapped = unwrapEvent(event); const unwrapped = unwrapEvent(event);
validatePlanProtocolShape(unwrapped);
validateEventS3Uris(unwrapped); validateEventS3Uris(unwrapped);
primeRuntimeEnv(); primeRuntimeEnv();
// Single structured boot log line — CloudWatch Logs Insights queries // Single structured boot log line — CloudWatch Logs Insights queries
@@ -201,6 +205,43 @@ function isLambdaAction(value: string): value is LambdaAction {
return value === "plan" || value === "renderChunk" || value === "assemble"; return value === "plan" || value === "renderChunk" || value === "assemble";
} }
// This is the single fail-closed boundary for the wire union. Keeping all
// forbidden locator combinations together makes mixed-protocol input auditable.
// fallow-ignore-next-line complexity
function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | AssembleEvent): void {
const raw = event as unknown as Record<string, unknown>;
const protocol = raw.PlanProtocol;
if (protocol !== undefined && protocol !== "v1" && protocol !== "v2") {
const error = new Error(
`[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
if (event.Action === "plan") return;
const effectiveProtocol = protocol ?? "v2";
const hasV1Locator = typeof raw.PlanS3Uri === "string";
const hasV2Manifest = typeof raw.PlanV2ManifestS3Uri === "string";
const hasV2Prefix = typeof raw.PlanV2ArtifactS3Prefix === "string";
const valid =
effectiveProtocol === "v2"
? !hasV1Locator && hasV2Manifest && hasV2Prefix
: hasV1Locator && !hasV2Manifest && !hasV2Prefix;
if (!valid) {
const error = new Error(
`[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioS3Uri !== null) {
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
}
/** /**
* Emit a single JSON line to stdout. CloudWatch ingests each line as a * Emit a single JSON line to stdout. CloudWatch ingests each line as a
* structured event; Logs Insights queries can `filter event="..."` and * structured event; Logs Insights queries can `filter event="..."` and
@@ -229,14 +270,14 @@ function summarizeEvent(
return { return {
projectS3Uri: event.ProjectS3Uri, projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix, planOutputS3Prefix: event.PlanOutputS3Prefix,
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
format: event.Config.format, format: event.Config.format,
fps: event.Config.fps, fps: event.Config.fps,
}; };
case "renderChunk": case "renderChunk":
return { return {
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }), : { planS3Uri: event.PlanS3Uri }),
chunkIndex: event.ChunkIndex, chunkIndex: event.ChunkIndex,
@@ -244,8 +285,8 @@ function summarizeEvent(
}; };
case "assemble": case "assemble":
return { return {
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }), : { planS3Uri: event.PlanS3Uri }),
chunkCount: event.ChunkS3Uris.length, chunkCount: event.ChunkS3Uris.length,
@@ -278,7 +319,7 @@ function primeRuntimeEnv(): void {
// The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle. // The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> { async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handlePlanV2(event, deps); return handlePlanV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -358,7 +399,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLam
// and manifest-last publication must stay ordered and fail together. // and manifest-last publication must stay ordered and fail together.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handlePlanV2( async function handlePlanV2(
event: Extract<PlanEvent, { PlanProtocol: "v2" }>, event: PlanV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<Extract<PlanLambdaResult, { PlanProtocol: "v2" }>> { ): Promise<Extract<PlanLambdaResult, { PlanProtocol: "v2" }>> {
const started = Date.now(); const started = Date.now();
@@ -412,7 +453,7 @@ async function handleRenderChunk(
event: RenderChunkEvent, event: RenderChunkEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> { ): Promise<RenderChunkLambdaResult> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handleRenderChunkV2(event, deps); return handleRenderChunkV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -481,7 +522,7 @@ async function handleRenderChunk(
// render, and upload in one lifecycle so cleanup and errors remain atomic. // render, and upload in one lifecycle so cleanup and errors remain atomic.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handleRenderChunkV2( async function handleRenderChunkV2(
event: Extract<RenderChunkEvent, { PlanProtocol: "v2" }>, event: RenderChunkV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> { ): Promise<RenderChunkLambdaResult> {
const started = Date.now(); const started = Date.now();
@@ -554,7 +595,7 @@ async function handleAssemble(
event: AssembleEvent, event: AssembleEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<AssembleLambdaResult> { ): Promise<AssembleLambdaResult> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handleAssembleV2(event, deps); return handleAssembleV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -610,7 +651,7 @@ async function handleAssemble(
// keeping the steps local makes its temporary-storage ownership explicit. // keeping the steps local makes its temporary-storage ownership explicit.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handleAssembleV2( async function handleAssembleV2(
event: Extract<AssembleEvent, { PlanProtocol: "v2" }>, event: AssembleV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<AssembleLambdaResult> { ): Promise<AssembleLambdaResult> {
const started = Date.now(); const started = Date.now();
@@ -776,12 +817,12 @@ function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): st
case "plan": case "plan":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix]; return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk": case "renderChunk":
return event.PlanProtocol === "v2" return event.PlanProtocol !== "v1"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
: [event.PlanS3Uri, event.ChunkOutputS3Prefix]; : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
case "assemble": case "assemble":
return [ return [
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix] ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
: [event.PlanS3Uri]), : [event.PlanS3Uri]),
...event.ChunkS3Uris, ...event.ChunkS3Uris,
@@ -98,11 +98,11 @@ describe("renderToLambda", () => {
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/", PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4", OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig, Config: baseConfig,
PlanProtocol: "v1", PlanProtocol: "v2",
}); });
}); });
it("opts the complete execution into plan protocol v2 explicitly", async () => { it("preserves explicit plan protocol v1 compatibility", async () => {
const sfn = new FakeSFN(); const sfn = new FakeSFN();
const s3 = new FakeS3(); const s3 = new FakeS3();
const handle = await renderToLambda({ const handle = await renderToLambda({
@@ -110,18 +110,18 @@ describe("renderToLambda", () => {
bucketName: "test-bucket", bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf", stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig, config: baseConfig,
executionName: "smoke-v2", executionName: "smoke-v1",
planProtocol: "v2", planProtocol: "v1",
sfn: asSFNClient(sfn), sfn: asSFNClient(sfn),
s3: asS3Client(s3), s3: asS3Client(s3),
}); });
expect(sfn.starts[0]?.input).toEqual({ expect(sfn.starts[0]?.input).toEqual({
ProjectS3Uri: handle.projectS3Uri, ProjectS3Uri: handle.projectS3Uri,
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v2/", PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-v2/output.mp4", OutputS3Uri: "s3://test-bucket/renders/smoke-v1/output.mp4",
Config: baseConfig, Config: baseConfig,
PlanProtocol: "v2", PlanProtocol: "v1",
}); });
}); });
@@ -38,8 +38,8 @@ export interface RenderToLambdaOptions {
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig; config: SerializableDistributedRenderConfig;
/** /**
* Distributed plan transport. Defaults to `"v1"` for backwards * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
* compatibility. New integrations should explicitly select `"v2"`. * explicitly only for deprecated compatibility with the monolithic plan.
*/ */
planProtocol?: LambdaPlanProtocol; planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */ /** S3 bucket from the SAM stack output (`RenderBucketName`). */
@@ -115,7 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
PlanOutputS3Prefix: planOutputS3Prefix, PlanOutputS3Prefix: planOutputS3Prefix,
OutputS3Uri: outputS3Uri, OutputS3Uri: outputS3Uri,
Config: opts.config, Config: opts.config,
PlanProtocol: opts.planProtocol ?? "v1", PlanProtocol: opts.planProtocol ?? "v2",
}; };
// Reject oversize input client-side. Step Functions Standard caps the // Reject oversize input client-side. Step Functions Standard caps the
+17 -4
View File
@@ -45,14 +45,13 @@ per-step durations on success.
### Plan transport selection ### Plan transport selection
Plan v2 is recommended for new integrations. `renderToCloudRun` still Plan v2 is the default for new renders. When `planProtocol` is omitted,
interprets an omitted `planProtocol` as `"v1"` for backwards compatibility, `renderToCloudRun` sends an explicit `PlanProtocol: "v2"` so the SDK and
so new callers should select v2 explicitly: the deployed workflow agree:
```ts ```ts
await renderToCloudRun({ await renderToCloudRun({
// ...project, bucket, workflow, service, and config... // ...project, bucket, workflow, service, and config...
planProtocol: "v2",
}); });
``` ```
@@ -60,6 +59,20 @@ V2 uses separate manifest and content-addressed artifact locators throughout
the workflow. Unknown protocols and integrity failures fail closed; a render the workflow. Unknown protocols and integrity failures fail closed; a render
never mixes v1 and v2 artifacts. never mixes v1 and v2 artifacts.
The monolithic v1 transport remains available as deprecated compatibility by
passing `planProtocol: "v1"` explicitly.
#### Upgrade order
Redeploy the Cloud Run image and Cloud Workflows definition from the same new
package version before upgrading an application that calls
`renderToCloudRun`. Pause new renders and drain active workflow executions
during the infrastructure update. Older workflows can default omission to v1
or lack the v2 branch, while the new SDK sends explicit v2. If infrastructure
cannot be redeployed first, keep the previous SDK version or pass
`planProtocol: "v1"` explicitly until the Terraform/workflow redeploy is
complete.
## Chrome runtime ## Chrome runtime
Unlike the Lambda adapter — which fights a 250 MB ZIP ceiling and Unlike the Lambda adapter — which fights a 250 MB ZIP ceiling and
+10 -10
View File
@@ -61,17 +61,17 @@ interface PlanEventBase {
} }
/** /**
* Legacy/default plan transport. Absence is deliberately interpreted as v1. * Legacy plan transport. Callers must select it explicitly.
* *
* @deprecated Use {@link PlanV2Event} for new integrations. * @deprecated Use {@link PlanV2Event} for new integrations.
*/ */
export interface PlanV1Event extends PlanEventBase { export interface PlanV1Event extends PlanEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
} }
/** Explicit opt-in to the content-addressed v2 plan transport. */ /** Default content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase { export interface PlanV2Event extends PlanEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
} }
export type PlanEvent = PlanV1Event | PlanV2Event; export type PlanEvent = PlanV1Event | PlanV2Event;
@@ -96,12 +96,12 @@ interface RenderChunkEventBase {
} }
/** /**
* Legacy/default chunk event. * Legacy chunk event. Callers must select it explicitly.
* *
* @deprecated Use {@link RenderChunkV2Event} for new integrations. * @deprecated Use {@link RenderChunkV2Event} for new integrations.
*/ */
export interface RenderChunkV1Event extends RenderChunkEventBase { export interface RenderChunkV1Event extends RenderChunkEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
/** GCS URI of the v1 plan tar produced by a PlanEvent invocation. */ /** GCS URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanGcsUri: string; PlanGcsUri: string;
PlanV2ManifestGcsUri?: never; PlanV2ManifestGcsUri?: never;
@@ -113,7 +113,7 @@ export interface RenderChunkV1Event extends RenderChunkEventBase {
* describes the exact content-addressed artifacts needed by this chunk. * describes the exact content-addressed artifacts needed by this chunk.
*/ */
export interface RenderChunkV2Event extends RenderChunkEventBase { export interface RenderChunkV2Event extends RenderChunkEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
PlanV2ManifestGcsUri: string; PlanV2ManifestGcsUri: string;
PlanV2ArtifactGcsPrefix: string; PlanV2ArtifactGcsPrefix: string;
PlanGcsUri?: never; PlanGcsUri?: never;
@@ -143,12 +143,12 @@ interface AssembleEventBase {
} }
/** /**
* Legacy/default assemble event. * Legacy assemble event. Callers must select it explicitly.
* *
* @deprecated Use {@link AssembleV2Event} for new integrations. * @deprecated Use {@link AssembleV2Event} for new integrations.
*/ */
export interface AssembleV1Event extends AssembleEventBase { export interface AssembleV1Event extends AssembleEventBase {
PlanProtocol?: "v1"; PlanProtocol: "v1";
/** GCS URI of the v1 plan tar produced by a PlanEvent invocation. */ /** GCS URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanGcsUri: string; PlanGcsUri: string;
/** Legacy standalone audio locator; `null` when audio is embedded in the v1 plan tar. */ /** Legacy standalone audio locator; `null` when audio is embedded in the v1 plan tar. */
@@ -159,7 +159,7 @@ export interface AssembleV1Event extends AssembleEventBase {
/** V2 assemble event, scoped to manifest-declared assembler artifacts. */ /** V2 assemble event, scoped to manifest-declared assembler artifacts. */
export interface AssembleV2Event extends AssembleEventBase { export interface AssembleV2Event extends AssembleEventBase {
PlanProtocol: "v2"; PlanProtocol?: "v2";
PlanV2ManifestGcsUri: string; PlanV2ManifestGcsUri: string;
PlanV2ArtifactGcsPrefix: string; PlanV2ArtifactGcsPrefix: string;
PlanHash: string; PlanHash: string;
@@ -88,16 +88,16 @@ describe("renderToCloudRun", () => {
OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4", OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4",
ServiceUrl: "https://render-abc.run.app", ServiceUrl: "https://render-abc.run.app",
Config: config, Config: config,
PlanProtocol: "v1", PlanProtocol: "v2",
}); });
expect(fake.lastParent).toBe( expect(fake.lastParent).toBe(
"projects/proj/locations/us-central1/workflows/hyperframes-render", "projects/proj/locations/us-central1/workflows/hyperframes-render",
); );
}); });
it("forwards an explicit v2 whole-render opt-in", async () => { it("preserves explicit plan protocol v1 compatibility", async () => {
const fake = new FakeExecutions(); const fake = new FakeExecutions();
await renderToCloudRun({ ...opts(fake), planProtocol: "v2" }); await renderToCloudRun({ ...opts(fake), planProtocol: "v1" });
const arg = JSON.parse(fake.lastArgument ?? "{}"); const arg = JSON.parse(fake.lastArgument ?? "{}");
expect(arg).toEqual({ expect(arg).toEqual({
RenderId: "hf-render-fixed", RenderId: "hf-render-fixed",
@@ -106,7 +106,7 @@ describe("renderToCloudRun", () => {
OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4", OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4",
ServiceUrl: "https://render-abc.run.app", ServiceUrl: "https://render-abc.run.app",
Config: config, Config: config,
PlanProtocol: "v2", PlanProtocol: "v1",
}); });
}); });
@@ -53,8 +53,8 @@ export interface RenderToCloudRunOptions {
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig; config: SerializableDistributedRenderConfig;
/** /**
* Distributed plan transport. Defaults to `"v1"` for backwards * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
* compatibility. New integrations should explicitly select `"v2"`. * explicitly only for deprecated compatibility with the monolithic plan.
*/ */
planProtocol?: CloudRunPlanProtocol; planProtocol?: CloudRunPlanProtocol;
/** GCS bucket from the Terraform output (`render_bucket_name`). */ /** GCS bucket from the Terraform output (`render_bucket_name`). */
@@ -149,7 +149,7 @@ export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise<R
OutputGcsUri: outputGcsUri, OutputGcsUri: outputGcsUri,
ServiceUrl: opts.serviceUrl, ServiceUrl: opts.serviceUrl,
Config: opts.config, Config: opts.config,
PlanProtocol: opts.planProtocol ?? "v1", PlanProtocol: opts.planProtocol ?? "v2",
}; };
// Reject oversize input client-side. Cloud Workflows caps the execution // Reject oversize input client-side. Cloud Workflows caps the execution
+15 -6
View File
@@ -178,11 +178,12 @@ describe("unwrapEvent", () => {
}); });
describe("dispatch", () => { describe("dispatch", () => {
it("routes plan, uploads the plan tarball", async () => { it("preserves explicit v1 plan compatibility", async () => {
const gcs = new FakeGcs(); const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz"); await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz");
const event: PlanEvent = { const event: PlanEvent = {
Action: "plan", Action: "plan",
PlanProtocol: "v1",
ProjectGcsUri: "gs://b/sites/x/project.tar.gz", ProjectGcsUri: "gs://b/sites/x/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/r1/", PlanOutputGcsPrefix: "gs://b/renders/r1/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"], Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"],
@@ -201,6 +202,7 @@ describe("dispatch", () => {
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 2, ChunkIndex: 2,
@@ -220,6 +222,7 @@ describe("dispatch", () => {
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: "WRONG_HASH", PlanHash: "WRONG_HASH",
ChunkIndex: 0, ChunkIndex: 0,
@@ -236,6 +239,7 @@ describe("dispatch", () => {
gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1")); gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1"));
const event: AssembleEvent = { const event: AssembleEvent = {
Action: "assemble", Action: "assemble",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"], ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"],
AudioGcsUri: null, AudioGcsUri: null,
@@ -251,7 +255,7 @@ describe("dispatch", () => {
// This end-to-end adapter contract is intentionally one narrative test: it // This end-to-end adapter contract is intentionally one narrative test: it
// verifies ordering and target isolation across all three handler roles. // verifies ordering and target isolation across all three handler roles.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
it("runs v2 plan → target-scoped chunk → assemble with manifest-last CAS", async () => { it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
const gcs = new FakeGcs(); const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/v2/project.tar.gz"); await seedProjectTar(gcs, "gs://b/sites/v2/project.tar.gz");
const root = mkTmp("hf-v2-e2e-"); const root = mkTmp("hf-v2-e2e-");
@@ -306,7 +310,6 @@ describe("dispatch", () => {
const planned = await dispatch( const planned = await dispatch(
{ {
Action: "plan", Action: "plan",
PlanProtocol: "v2",
ProjectGcsUri: "gs://b/sites/v2/project.tar.gz", ProjectGcsUri: "gs://b/sites/v2/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/v2/", PlanOutputGcsPrefix: "gs://b/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" }, Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -326,7 +329,6 @@ describe("dispatch", () => {
await dispatch( await dispatch(
{ {
Action: "plan", Action: "plan",
PlanProtocol: "v2",
ProjectGcsUri: "gs://b/sites/v2/project.tar.gz", ProjectGcsUri: "gs://b/sites/v2/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/v2/", PlanOutputGcsPrefix: "gs://b/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" }, Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -342,7 +344,6 @@ describe("dispatch", () => {
const chunk = await dispatch( const chunk = await dispatch(
{ {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v2",
PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri, PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri,
PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix, PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix,
PlanHash: planned.PlanHash, PlanHash: planned.PlanHash,
@@ -360,7 +361,6 @@ describe("dispatch", () => {
await dispatch( await dispatch(
{ {
Action: "assemble", Action: "assemble",
PlanProtocol: "v2",
PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri, PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri,
PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix, PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix,
PlanHash: planned.PlanHash, PlanHash: planned.PlanHash,
@@ -409,6 +409,7 @@ describe("bucket allowlist guard", () => {
try { try {
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://evil-bucket/plan.tar.gz", PlanGcsUri: "gs://evil-bucket/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -451,6 +452,7 @@ describe("bucket allowlist guard", () => {
try { try {
const event: RenderChunkEvent = { const event: RenderChunkEvent = {
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz", PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -476,6 +478,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -497,6 +500,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: "WRONG", PlanHash: "WRONG",
ChunkIndex: 0, ChunkIndex: 0,
@@ -524,6 +528,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -561,6 +566,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -598,6 +604,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
@@ -626,6 +633,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "plan", Action: "plan",
PlanProtocol: "v1",
ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz", ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/", PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" }, Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -645,6 +653,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
Action: "renderChunk", Action: "renderChunk",
PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/missing.tar.gz", PlanGcsUri: "gs://b/renders/r1/missing.tar.gz",
PlanHash: PLAN_HASH, PlanHash: PLAN_HASH,
ChunkIndex: 0, ChunkIndex: 0,
+20 -16
View File
@@ -46,13 +46,16 @@ import {
import { resolveChromeExecutablePath } from "./chromium.js"; import { resolveChromeExecutablePath } from "./chromium.js";
import type { import type {
AssembleEvent, AssembleEvent,
AssembleV2Event,
AssembleResultBody, AssembleResultBody,
CloudRunAction, CloudRunAction,
CloudRunEvent, CloudRunEvent,
CloudRunResult, CloudRunResult,
PlanEvent, PlanEvent,
PlanV2Event,
PlanResultBody, PlanResultBody,
RenderChunkEvent, RenderChunkEvent,
RenderChunkV2Event,
RenderChunkResultBody, RenderChunkResultBody,
} from "./events.js"; } from "./events.js";
import { type DistributedFormat, formatExtension } from "./formatExtension.js"; import { type DistributedFormat, formatExtension } from "./formatExtension.js";
@@ -155,21 +158,22 @@ function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | Assembl
} }
if (event.Action === "plan") return; if (event.Action === "plan") return;
const effectiveProtocol = protocol ?? "v2";
const hasV1Locator = typeof raw.PlanGcsUri === "string"; const hasV1Locator = typeof raw.PlanGcsUri === "string";
const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string"; const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string";
const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string"; const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string";
const valid = const valid =
protocol === "v2" effectiveProtocol === "v2"
? !hasV1Locator && hasV2Manifest && hasV2Prefix ? !hasV1Locator && hasV2Manifest && hasV2Prefix
: hasV1Locator && !hasV2Manifest && !hasV2Prefix; : hasV1Locator && !hasV2Manifest && !hasV2Prefix;
if (!valid) { if (!valid) {
const error = new Error( const error = new Error(
`[handler] ${protocol === "v2" ? "v2" : "v1"} ${event.Action} event has mixed or missing plan locators`, `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
); );
error.name = "PLAN_PROTOCOL_UNSUPPORTED"; error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error; throw error;
} }
if (protocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) { if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) {
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest"); const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
error.name = "PLAN_PROTOCOL_UNSUPPORTED"; error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error; throw error;
@@ -254,14 +258,14 @@ function summarizeEvent(
return { return {
projectGcsUri: event.ProjectGcsUri, projectGcsUri: event.ProjectGcsUri,
planOutputGcsPrefix: event.PlanOutputGcsPrefix, planOutputGcsPrefix: event.PlanOutputGcsPrefix,
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
format: event.Config.format, format: event.Config.format,
fps: event.Config.fps, fps: event.Config.fps,
}; };
case "renderChunk": case "renderChunk":
return { return {
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
: { planGcsUri: event.PlanGcsUri }), : { planGcsUri: event.PlanGcsUri }),
chunkIndex: event.ChunkIndex, chunkIndex: event.ChunkIndex,
@@ -269,8 +273,8 @@ function summarizeEvent(
}; };
case "assemble": case "assemble":
return { return {
planProtocol: event.PlanProtocol ?? "v1", planProtocol: event.PlanProtocol ?? "v2",
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
: { planGcsUri: event.PlanGcsUri }), : { planGcsUri: event.PlanGcsUri }),
chunkCount: event.ChunkGcsUris.length, chunkCount: event.ChunkGcsUris.length,
@@ -297,7 +301,7 @@ function primeChrome(deps?: HandlerDeps): void {
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanResultBody> { async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanResultBody> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handlePlanV2(event, deps); return handlePlanV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -365,7 +369,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanRes
*/ */
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handlePlanV2( async function handlePlanV2(
event: Extract<PlanEvent, { PlanProtocol: "v2" }>, event: PlanV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<Extract<PlanResultBody, { PlanProtocol: "v2" }>> { ): Promise<Extract<PlanResultBody, { PlanProtocol: "v2" }>> {
const started = Date.now(); const started = Date.now();
@@ -418,7 +422,7 @@ async function handleRenderChunk(
event: RenderChunkEvent, event: RenderChunkEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<RenderChunkResultBody> { ): Promise<RenderChunkResultBody> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handleRenderChunkV2(event, deps); return handleRenderChunkV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -475,7 +479,7 @@ async function handleRenderChunk(
/** Materialize only this chunk's verified v2 dependencies before rendering. */ /** Materialize only this chunk's verified v2 dependencies before rendering. */
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handleRenderChunkV2( async function handleRenderChunkV2(
event: Extract<RenderChunkEvent, { PlanProtocol: "v2" }>, event: RenderChunkV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<RenderChunkResultBody> { ): Promise<RenderChunkResultBody> {
const started = Date.now(); const started = Date.now();
@@ -548,7 +552,7 @@ async function handleAssemble(
event: AssembleEvent, event: AssembleEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<AssembleResultBody> { ): Promise<AssembleResultBody> {
if (event.PlanProtocol === "v2") { if (event.PlanProtocol !== "v1") {
return handleAssembleV2(event, deps); return handleAssembleV2(event, deps);
} }
const started = Date.now(); const started = Date.now();
@@ -613,7 +617,7 @@ async function handleAssemble(
*/ */
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function handleAssembleV2( async function handleAssembleV2(
event: Extract<AssembleEvent, { PlanProtocol: "v2" }>, event: AssembleV2Event,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<AssembleResultBody> { ): Promise<AssembleResultBody> {
const started = Date.now(); const started = Date.now();
@@ -781,12 +785,12 @@ function getEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): s
case "plan": case "plan":
return [event.ProjectGcsUri, event.PlanOutputGcsPrefix]; return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
case "renderChunk": case "renderChunk":
return event.PlanProtocol === "v2" return event.PlanProtocol !== "v1"
? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix] ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix]
: [event.PlanGcsUri, event.ChunkOutputGcsPrefix]; : [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
case "assemble": case "assemble":
return [ return [
...(event.PlanProtocol === "v2" ...(event.PlanProtocol !== "v1"
? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix] ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix]
: [event.PlanGcsUri]), : [event.PlanGcsUri]),
...event.ChunkGcsUris, ...event.ChunkGcsUris,
@@ -7,8 +7,8 @@ const smoke = readFileSync(smokePath, "utf-8");
const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8"); const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8");
describe("GCP smoke ownership and protocol safety", () => { describe("GCP smoke ownership and protocol safety", () => {
it("defaults to v1 and requires an explicit v2 protocol argument", () => { it("defaults to v2 and retains explicit v1/v2 protocol arguments", () => {
expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v1}"'); expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v2}"');
expect(smoke).toContain("--protocols)"); expect(smoke).toContain("--protocols)");
expect(smoke).toContain("PlanProtocol: $protocol"); expect(smoke).toContain("PlanProtocol: $protocol");
expect(smoke).toContain("decodedFramesEqual"); expect(smoke).toContain("decodedFramesEqual");
@@ -77,8 +77,8 @@ describe("Cloud Workflows plan protocol routing", () => {
expect(source.match(/max_retries: 4/g)).toHaveLength(4); expect(source.match(/max_retries: 4/g)).toHaveLength(4);
}); });
it("keeps v1 as the default and rejects unknown protocols before plan", () => { it("defaults omitted protocol to v2 and rejects unknown protocols before plan", () => {
expect(source).toContain('default(map.get(args, "PlanProtocol"), "v1")'); expect(source).toContain('default(map.get(args, "PlanProtocol"), "v2")');
expect(namedStep("selectPlanProtocol")).toMatchObject({ expect(namedStep("selectPlanProtocol")).toMatchObject({
next: "unsupportedPlanProtocol", next: "unsupportedPlanProtocol",
}); });
@@ -26,9 +26,9 @@ main:
- planOutputGcsPrefix: ${args.PlanOutputGcsPrefix} - planOutputGcsPrefix: ${args.PlanOutputGcsPrefix}
- outputGcsUri: ${args.OutputGcsUri} - outputGcsUri: ${args.OutputGcsUri}
- config: ${args.Config} - config: ${args.Config}
# Backward-compatible default. v2 is accepted only through an # Plan v2 is the default. Explicit v1 remains available during the
# explicit top-level PlanProtocol opt-in. # deprecated monolithic-plan compatibility window.
- planProtocol: ${default(map.get(args, "PlanProtocol"), "v1")} - planProtocol: ${default(map.get(args, "PlanProtocol"), "v2")}
# ── Plan (Activity A) ──────────────────────────────────────────────────── # ── Plan (Activity A) ────────────────────────────────────────────────────
- selectPlanProtocol: - selectPlanProtocol: