mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,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/",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user