diff --git a/docs/packages/aws-lambda.mdx b/docs/packages/aws-lambda.mdx
index 90f14adb7e5..cc412a942d4 100644
--- a/docs/packages/aws-lambda.mdx
+++ b/docs/packages/aws-lambda.mdx
@@ -89,16 +89,15 @@ const progress = await getRenderProgress({ executionArn: handle.executionArn });
console.log(progress.status, progress.overallProgress, progress.costs.displayCost);
```
-Plan v2 is the default because workers fetch manifest-selected
-content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
-omitted. Deprecated v1 compatibility remains available by passing
-`planProtocol: "v1"`.
+Plan v2 is the only supported distributed plan transport. Workers fetch
+manifest-selected content-addressed artifacts, and the SDK sends explicit v2
+without requiring a protocol option.
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.
+ infrastructure may lack the Plan v2-only request and locator contract.
`renderToLambda()` validates the distributed render config before starting the Step Functions execution, so invalid dimensions, formats, chunk sizes, or payload sizes fail synchronously.
diff --git a/docs/packages/gcp-cloud-run.mdx b/docs/packages/gcp-cloud-run.mdx
index edf5317db24..f55b765147c 100644
--- a/docs/packages/gcp-cloud-run.mdx
+++ b/docs/packages/gcp-cloud-run.mdx
@@ -96,16 +96,15 @@ while (progress.status === "running") {
console.log(progress.status, progress.outputFile, progress.costs.displayCost);
```
-Plan v2 is the default because workers fetch manifest-selected
-content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
-omitted. Deprecated v1 compatibility remains available by passing
-`planProtocol: "v1"`.
+Plan v2 is the only supported distributed plan transport. Workers fetch
+manifest-selected content-addressed artifacts, and the SDK sends explicit v2
+without requiring a protocol option.
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.
+ workflows may lack the Plan v2-only request and locator contract.
Pass `projectDir` for one-shot uploads, or call `deploySite()` separately and reuse the returned site handle across many renders.
diff --git a/examples/aws-lambda/README.md b/examples/aws-lambda/README.md
index 6c429820d5e..92d1b57fee2 100644
--- a/examples/aws-lambda/README.md
+++ b/examples/aws-lambda/README.md
@@ -91,20 +91,17 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
-Plan v2 is the default when `PlanProtocol` is absent. V2 uses separate
-manifest and content-addressed artifact locators throughout the workflow and
-never places a v2 object in `PlanS3Uri`. The deprecated v1 transport remains
-available by sending `"PlanProtocol": "v1"` explicitly.
+Plan v2 is the only supported plan transport. It uses separate manifest and
+content-addressed artifact locators throughout the workflow.
### 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.
+that calls `renderToLambda`. The new SDK sends explicit v2, while older
+infrastructure may lack the Plan v2-only contract. Keep the application on its
+previous package version until the infrastructure redeploy completes.
## Local invocation
@@ -121,10 +118,8 @@ sam validate
sam local invoke RenderFunction --event sample-events/plan.json
```
-The `sample-events/` directory ships three tiers for each action:
-`*.json` demonstrates default v2 with `PlanProtocol` omitted, `*-v1.json`
-demonstrates deprecated explicit-v1 compatibility, and `*-v2.json`
-demonstrates callers that stamp v2 explicitly. They reference fake S3 URIs —
+The `sample-events/` directory ships default-v2 events with `PlanProtocol`
+omitted and `*-v2.json` events 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).
@@ -143,7 +138,6 @@ script:
./scripts/smoke.sh \
--fixture mp4-h264-sdr \
--chunk-counts 2,4,8,16 \
- --plan-protocol both \
--psnr-threshold 40 \
--reserved-concurrency 8
@@ -159,13 +153,8 @@ per-run stack name, renders the fixture at each chunk count via the
Step Functions state machine, PSNR-compares against the in-process
baseline (which is git-LFS tracked under
`packages/producer/tests/distributed//output/`), captures
-per-execution Step Functions history, and tears the stack down. Use
-`--plan-protocol both` to run v1 and v2 through the same deployed Lambda
-package and baseline. Each v1/v2 pair is also gated directly on per-chunk
-hashes from Step Functions history, normalized decoded RGBA frame hashes,
-decoded 48 kHz stereo s16le PCM hashes and byte counts, normalized stream
-metadata, and duration. Encoded MP4 SHA equality is reported but is
-informational unless `--require-encoded-sha-equal` is set. The script
+per-execution Step Functions history, and tears the stack down. Encoded MP4
+SHA equality can be required with `--require-encoded-sha-equal`. The script
assigns unique function/state-machine names, uses a
dedicated temporary SAM artifact bucket, and removes render objects,
retained buckets, the implicit Lambda log group, and deployment artifacts
@@ -194,11 +183,9 @@ spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
Outputs land under `/lambda-smoke-artifacts/`:
-- `results.json` — `planProtocol × chunkCount × wallClockMs × psnrAvgDb`
-- `semantic-comparisons.json` — direct v1/v2 semantic gate results
-- `renders/-N-output.mp4` — each rendered variant
-- `renders/-N-history.json` — full Step Functions execution history
-- `renders/v1-v2-N.*` — normalized frame hashes, ffprobe metadata, and comparison JSON
+- generated results summary — `chunkCount × wallClockMs × psnrAvgDb`
+- `renders/v2-N-output.mp4` — each rendered variant
+- `renders/v2-N-history.json` — full Step Functions execution history
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
`jq`, `zip`. AWS credentials come from the standard resolution chain
diff --git a/examples/aws-lambda/sample-events/assemble-v1.json b/examples/aws-lambda/sample-events/assemble-v1.json
deleted file mode 100644
index 6dcc2aa8cc8..00000000000
--- a/examples/aws-lambda/sample-events/assemble-v1.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "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"
-}
diff --git a/examples/aws-lambda/sample-events/plan-v1.json b/examples/aws-lambda/sample-events/plan-v1.json
deleted file mode 100644
index 28e17411036..00000000000
--- a/examples/aws-lambda/sample-events/plan-v1.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "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"
- }
-}
diff --git a/examples/aws-lambda/sample-events/render-chunk-v1.json b/examples/aws-lambda/sample-events/render-chunk-v1.json
deleted file mode 100644
index bc59ed151a7..00000000000
--- a/examples/aws-lambda/sample-events/render-chunk-v1.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "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"
-}
diff --git a/examples/aws-lambda/scripts/_semantic-compare.sh b/examples/aws-lambda/scripts/_semantic-compare.sh
deleted file mode 100755
index cd57e9f6a44..00000000000
--- a/examples/aws-lambda/scripts/_semantic-compare.sh
+++ /dev/null
@@ -1,239 +0,0 @@
-#!/usr/bin/env bash
-# Canonical decoded-output comparison shared by the real-AWS smoke test and
-# its local unit test. This file defines functions only; sourcing it has no
-# side effects.
-
-hf_sha256() {
- sha256sum "$1" | awk '{print $1}'
-}
-
-hf_canonical_video_framemd5() {
- local input="$1" output="$2" raw
- raw=$(mktemp)
- if ! ffmpeg -nostdin -v error -y -i "$input" \
- -map 0:v:0 -an -vf format=rgba -fps_mode passthrough \
- -f framemd5 "$raw"; then
- rm -f "$raw"
- return 1
- fi
- # Ignore container timestamp/header differences here: normalized ffprobe
- # metadata and duration are gated separately. This file pins decoded pixel
- # bytes, frame order, frame count, and per-frame byte size.
- awk -F',' '
- !/^#/ && NF >= 6 {
- size=$5; hash=$6
- gsub(/[[:space:]]/, "", size)
- gsub(/[[:space:]]/, "", hash)
- print size "," hash
- }
- ' "$raw" > "$output"
- rm -f "$raw"
- [ -s "$output" ]
-}
-
-hf_normalized_ffprobe_metadata() {
- local input="$1" output="$2"
- ffprobe -v error \
- -show_entries \
-stream=index,codec_type,codec_name,profile,pix_fmt,width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate,avg_frame_rate,time_base,color_range,color_space,color_transfer,color_primaries,chroma_location,field_order,sample_fmt,sample_rate,channels,channel_layout \
- -of json "$input" |
- jq -S '{
- streams: ((.streams // [])
- | sort_by(.codec_type, .index)
- | map(del(.index)))
- }' > "$output"
-}
-
-hf_duration_seconds() {
- ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1"
-}
-
-hf_has_audio() {
- [ -n "$(ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$1" 2>/dev/null | head -1)" ]
-}
-
-hf_decode_pcm() {
- ffmpeg -nostdin -v error -i "$1" -map 0:a:0 -vn \
- -ac 2 -ar 48000 -c:a pcm_s16le -f s16le "$2"
-}
-
-# Chunks run in Distributed Map child executions; the Map's exit output carries every result.
-hf_extract_chunk_hashes() {
- local history="$1" output="$2"
- jq '[
- .events[] |
- select(.type == "MapStateExited") |
- .stateExitedEventDetails |
- select(.name == "RenderChunks" or .name == "RenderChunksV2") |
- (.output | fromjson | .Chunks // [])[] |
- {ChunkIndex, Sha256, FramesEncoded}
- ] | sort_by(.ChunkIndex)' "$history" > "$output"
-}
-
-# Compare two rendered outputs. Writes durable evidence at .* and a
-# machine-readable .json. Returns 0 only for semantic equivalence.
-hf_compare_render_semantics() {
- local v1="$1" v2="$2" prefix="$3"
- local v1_history="${4:-}" v2_history="${5:-}"
- local tolerance="${SEMANTIC_DURATION_TOLERANCE_SECONDS:-0.001}"
- local work
- work=$(mktemp -d)
-
- local v1_frames="${prefix}.v1.framemd5"
- local v2_frames="${prefix}.v2.framemd5"
- local v1_meta="${prefix}.v1.ffprobe.json"
- local v2_meta="${prefix}.v2.ffprobe.json"
- local v1_duration v2_duration duration_delta
- local v1_encoded_sha v2_encoded_sha encoded_equal
- local video_equal metadata_equal duration_equal
- local audio_state audio_equal v1_audio_sha="" v2_audio_sha=""
- local v1_audio_bytes=0 v2_audio_bytes=0
- local encoded_gated=false
- local chunks_checked=false chunks_equal=true v1_chunk_count=0 v2_chunk_count=0
- if [ "${REQUIRE_ENCODED_SHA_EQUAL:-false}" = true ]; then encoded_gated=true; fi
-
- if ! hf_canonical_video_framemd5 "$v1" "$v1_frames" ||
- ! hf_canonical_video_framemd5 "$v2" "$v2_frames" ||
- ! hf_normalized_ffprobe_metadata "$v1" "$v1_meta" ||
- ! hf_normalized_ffprobe_metadata "$v2" "$v2_meta"; then
- rm -rf "$work"
- return 2
- fi
-
- if cmp -s "$v1_frames" "$v2_frames"; then video_equal=true; else video_equal=false; fi
- if cmp -s "$v1_meta" "$v2_meta"; then metadata_equal=true; else metadata_equal=false; fi
-
- if ! v1_duration=$(hf_duration_seconds "$v1") ||
- ! v2_duration=$(hf_duration_seconds "$v2"); then
- rm -rf "$work"
- return 2
- fi
- duration_delta=$(awk -v a="$v1_duration" -v b="$v2_duration" \
- 'BEGIN { d=a-b; if (d<0) d=-d; printf("%.9f", d) }')
- if awk -v d="$duration_delta" -v t="$tolerance" 'BEGIN { exit !(d <= t) }'; then
- duration_equal=true
- else
- duration_equal=false
- fi
-
- local v1_has_audio=false v2_has_audio=false
- if hf_has_audio "$v1"; then v1_has_audio=true; fi
- if hf_has_audio "$v2"; then v2_has_audio=true; fi
- if [ "$v1_has_audio" = false ] && [ "$v2_has_audio" = false ]; then
- audio_state="no-audio-on-either"
- audio_equal=true
- elif [ "$v1_has_audio" != "$v2_has_audio" ]; then
- audio_state="audio-stream-mismatch"
- audio_equal=false
- else
- audio_state="decoded-pcm"
- if ! hf_decode_pcm "$v1" "$work/v1.pcm" ||
- ! hf_decode_pcm "$v2" "$work/v2.pcm"; then
- rm -rf "$work"
- return 2
- fi
- v1_audio_sha=$(hf_sha256 "$work/v1.pcm")
- v2_audio_sha=$(hf_sha256 "$work/v2.pcm")
- v1_audio_bytes=$(wc -c < "$work/v1.pcm" | tr -d '[:space:]')
- v2_audio_bytes=$(wc -c < "$work/v2.pcm" | tr -d '[:space:]')
- if [ "$v1_audio_sha" = "$v2_audio_sha" ] && [ "$v1_audio_bytes" = "$v2_audio_bytes" ]; then
- audio_equal=true
- else
- audio_equal=false
- fi
- fi
-
- v1_encoded_sha=$(hf_sha256 "$v1")
- v2_encoded_sha=$(hf_sha256 "$v2")
- if [ "$v1_encoded_sha" = "$v2_encoded_sha" ]; then encoded_equal=true; else encoded_equal=false; fi
-
- local semantic_equal=false
- if [ "$video_equal" = true ] &&
- [ "$metadata_equal" = true ] &&
- [ "$duration_equal" = true ] &&
- [ "$audio_equal" = true ]; then
- semantic_equal=true
- fi
- if [ "$encoded_gated" = true ] && [ "$encoded_equal" != true ]; then
- semantic_equal=false
- fi
-
- if [ -n "$v1_history" ] || [ -n "$v2_history" ]; then
- chunks_checked=true
- if [ -z "$v1_history" ] || [ -z "$v2_history" ] ||
- ! hf_extract_chunk_hashes "$v1_history" "${prefix}.v1.chunk-hashes.json" ||
- ! hf_extract_chunk_hashes "$v2_history" "${prefix}.v2.chunk-hashes.json"; then
- rm -rf "$work"
- return 2
- fi
- v1_chunk_count=$(jq 'length' "${prefix}.v1.chunk-hashes.json")
- v2_chunk_count=$(jq 'length' "${prefix}.v2.chunk-hashes.json")
- if [ "$v1_chunk_count" -eq 0 ] ||
- [ "$v2_chunk_count" -eq 0 ] ||
- ! cmp -s "${prefix}.v1.chunk-hashes.json" "${prefix}.v2.chunk-hashes.json"; then
- chunks_equal=false
- semantic_equal=false
- fi
- fi
-
- jq -n \
- --arg v1 "$v1" --arg v2 "$v2" \
- --argjson semanticEqual "$semantic_equal" \
- --argjson videoEqual "$video_equal" \
- --argjson v1VideoFrameCount "$(wc -l < "$v1_frames" | tr -d '[:space:]')" \
- --argjson v2VideoFrameCount "$(wc -l < "$v2_frames" | tr -d '[:space:]')" \
- --argjson metadataEqual "$metadata_equal" \
- --arg v1Duration "$v1_duration" --arg v2Duration "$v2_duration" \
- --arg durationDelta "$duration_delta" --arg durationTolerance "$tolerance" \
- --arg audioState "$audio_state" --argjson audioEqual "$audio_equal" \
- --arg v1AudioSha256 "$v1_audio_sha" --arg v2AudioSha256 "$v2_audio_sha" \
- --argjson v1AudioBytes "$v1_audio_bytes" --argjson v2AudioBytes "$v2_audio_bytes" \
- --arg v1EncodedSha256 "$v1_encoded_sha" --arg v2EncodedSha256 "$v2_encoded_sha" \
- --argjson encodedShaEqual "$encoded_equal" \
- --argjson encodedShaGated "$encoded_gated" \
- --argjson chunksChecked "$chunks_checked" \
- --argjson chunksEqual "$chunks_equal" \
- --argjson v1ChunkCount "$v1_chunk_count" \
- --argjson v2ChunkCount "$v2_chunk_count" \
- '{
- v1: $v1,
- v2: $v2,
- semanticEqual: $semanticEqual,
- video: {
- equal: $videoEqual,
- v1FrameCount: $v1VideoFrameCount,
- v2FrameCount: $v2VideoFrameCount
- },
- metadata: {equal: $metadataEqual},
- chunks: {
- checked: $chunksChecked,
- equal: $chunksEqual,
- v1Count: $v1ChunkCount,
- v2Count: $v2ChunkCount
- },
- duration: {
- v1Seconds: ($v1Duration | tonumber),
- v2Seconds: ($v2Duration | tonumber),
- deltaSeconds: ($durationDelta | tonumber),
- toleranceSeconds: ($durationTolerance | tonumber),
- equal: (($durationDelta | tonumber) <= ($durationTolerance | tonumber))
- },
- audio: {
- state: $audioState,
- equal: $audioEqual,
- v1Sha256: $v1AudioSha256,
- v2Sha256: $v2AudioSha256,
- v1Bytes: $v1AudioBytes,
- v2Bytes: $v2AudioBytes
- },
- encoded: {
- equal: $encodedShaEqual,
- gated: $encodedShaGated,
- v1Sha256: $v1EncodedSha256,
- v2Sha256: $v2EncodedSha256
- }
- }' > "${prefix}.json"
-
- rm -rf "$work"
- [ "$semantic_equal" = true ]
-}
diff --git a/examples/aws-lambda/scripts/semantic-compare.test.sh b/examples/aws-lambda/scripts/semantic-compare.test.sh
deleted file mode 100755
index 22841d357cf..00000000000
--- a/examples/aws-lambda/scripts/semantic-compare.test.sh
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=./_semantic-compare.sh
-source "$SCRIPT_DIR/_semantic-compare.sh"
-# shellcheck source=./_smoke-config.sh
-source "$SCRIPT_DIR/_smoke-config.sh"
-
-for cmd in ffmpeg ffprobe jq sha256sum cmp; do
- command -v "$cmd" >/dev/null 2>&1 || {
- echo "missing test dependency: $cmd" >&2
- exit 1
- }
-done
-
-WORK=$(mktemp -d)
-trap 'rm -rf "$WORK"' EXIT
-
-printf '{"minPsnr":25}\n' > "$WORK/meta.json"
-[ "$(hf_resolve_psnr_threshold "" "$WORK/meta.json")" = "25" ]
-[ "$(hf_resolve_psnr_threshold "37.5" "$WORK/meta.json")" = "37.5" ]
-
-ffmpeg -nostdin -v error -y \
- -f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
- -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
- -shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/original.mp4"
-
-# Rewrapping changes encoded bytes while preserving decoded semantics.
-ffmpeg -nostdin -v error -y -i "$WORK/original.mp4" \
- -map 0 -c copy -metadata comment="different container bytes" "$WORK/rewrapped.mp4"
-hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/equal"
-jq -e '
- .semanticEqual == true and
- .video.equal == true and
- .audio.equal == true and
- .metadata.equal == true and
- .duration.equal == true and
- .encoded.equal == false
-' "$WORK/equal.json" >/dev/null
-
-# Map exit output as Step Functions records it: the whole state, chunk results under Chunks.
-map_exit() {
- jq -nc --arg name "$1" --argjson chunks "$2" \
- '{type: "MapStateExited", stateExitedEventDetails: {name: $name, output: ({Plan: {ChunkCount: 2}, Chunks: $chunks} | tojson)}}'
-}
-CHUNKS='[{"ChunkIndex":1,"Sha256":"bbb","FramesEncoded":12,"DurationMs":9},{"ChunkIndex":0,"Sha256":"aaa","FramesEncoded":12,"DurationMs":8}]'
-jq -n --argjson exit "$(map_exit RenderChunks "$CHUNKS")" \
- '{events: [{type: "TaskSucceeded", taskSucceededEventDetails: {output: "{}"}}, $exit]}' \
- > "$WORK/v1-history.json"
-jq -n --argjson exit "$(map_exit RenderChunksV2 "$CHUNKS")" '{events: [$exit]}' \
- > "$WORK/v2-history.json"
-hf_compare_render_semantics \
- "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-equal" \
- "$WORK/v1-history.json" "$WORK/v2-history.json"
-jq -e '.semanticEqual == true and .chunks == {checked:true,equal:true,v1Count:2,v2Count:2}' \
- "$WORK/chunks-equal.json" >/dev/null
-
-jq -n --argjson exit "$(map_exit RenderChunksV2 "$(jq -c '.[1].Sha256 = "different"' <<<"$CHUNKS")")" \
- '{events: [$exit]}' > "$WORK/v2-history-mismatch.json"
-if hf_compare_render_semantics \
- "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-mismatch" \
- "$WORK/v1-history.json" "$WORK/v2-history-mismatch.json"; then
- echo "expected chunk-hash mismatch" >&2
- exit 1
-fi
-jq -e '.semanticEqual == false and .chunks.equal == false' \
- "$WORK/chunks-mismatch.json" >/dev/null
-
-if REQUIRE_ENCODED_SHA_EQUAL=true \
- hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/encoded-gated"; then
- echo "expected encoded-SHA gate to reject rewrapped output" >&2
- exit 1
-fi
-
-ffmpeg -nostdin -v error -y \
- -f lavfi -i "color=c=blue:s=64x64:r=24:d=0.5" \
- -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
- -shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/video-mismatch.mp4"
-if hf_compare_render_semantics \
- "$WORK/original.mp4" "$WORK/video-mismatch.mp4" "$WORK/video-mismatch"; then
- echo "expected decoded-video mismatch" >&2
- exit 1
-fi
-jq -e '.semanticEqual == false and .video.equal == false' \
- "$WORK/video-mismatch.json" >/dev/null
-
-ffmpeg -nostdin -v error -y \
- -f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
- -f lavfi -i "sine=frequency=880:sample_rate=48000:duration=0.5" \
- -shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/audio-mismatch.mp4"
-if hf_compare_render_semantics \
- "$WORK/original.mp4" "$WORK/audio-mismatch.mp4" "$WORK/audio-mismatch"; then
- echo "expected decoded-audio mismatch" >&2
- exit 1
-fi
-jq -e '.semanticEqual == false and .video.equal == true and .audio.equal == false' \
- "$WORK/audio-mismatch.json" >/dev/null
-
-ffmpeg -nostdin -v error -y \
- -f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
- -an -c:v mpeg4 -q:v 3 "$WORK/silent.mp4"
-ffmpeg -nostdin -v error -y -i "$WORK/silent.mp4" \
- -map 0 -c copy -metadata comment="silent rewrap" "$WORK/silent-rewrapped.mp4"
-hf_compare_render_semantics \
- "$WORK/silent.mp4" "$WORK/silent-rewrapped.mp4" "$WORK/silent-equal"
-jq -e '.semanticEqual == true and .audio.state == "no-audio-on-either"' \
- "$WORK/silent-equal.json" >/dev/null
-
-if hf_compare_render_semantics \
- "$WORK/original.mp4" "$WORK/silent.mp4" "$WORK/audio-stream-mismatch"; then
- echo "expected audio-stream presence mismatch" >&2
- exit 1
-fi
-jq -e '.semanticEqual == false and .audio.state == "audio-stream-mismatch"' \
- "$WORK/audio-stream-mismatch.json" >/dev/null
-
-echo "semantic compare shell tests passed"
diff --git a/examples/aws-lambda/scripts/smoke.sh b/examples/aws-lambda/scripts/smoke.sh
index f2f2ff6c9ba..d0a1e6cc070 100755
--- a/examples/aws-lambda/scripts/smoke.sh
+++ b/examples/aws-lambda/scripts/smoke.sh
@@ -31,7 +31,6 @@
# --region (default: $AWS_REGION or us-east-1)
# --profile (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
-# --plan-protocol (default: v2)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
@@ -54,8 +53,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.."
-# shellcheck source=./_semantic-compare.sh
-source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
# shellcheck source=./_smoke-config.sh
@@ -82,7 +79,6 @@ SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
-PLAN_PROTOCOL="${PLAN_PROTOCOL:-v2}"
KEEP_STACK="false"
SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
@@ -110,7 +106,6 @@ Flags:
--stack-name SAM stack name (default: hyperframes-lambda-smoke-)
--region AWS region (default: $AWS_REGION or us-east-1)
--profile AWS profile (default: $AWS_PROFILE)
- --plan-protocol plan transport(s) to compare (default: v2)
--reserved-concurrency Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
@@ -138,7 +133,6 @@ while [ $# -gt 0 ]; do
--stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;;
- --plan-protocol) PLAN_PROTOCOL="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;;
--require-encoded-sha-equal) REQUIRE_ENCODED_SHA_EQUAL="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;;
@@ -148,10 +142,6 @@ while [ $# -gt 0 ]; do
esac
done
-if [ "$PLAN_PROTOCOL" != "v1" ] && [ "$PLAN_PROTOCOL" != "v2" ] && [ "$PLAN_PROTOCOL" != "both" ]; then
- echo "ERROR: --plan-protocol must be v1, v2, or both." >&2
- exit 1
-fi
PROJECT_NAME=$(hf_derive_project_name "$STACK_NAME")
# Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the
@@ -519,12 +509,7 @@ RESULTS_JSON="$ARTIFACT_DIR/results.json"
echo "[]" > "$RESULTS_JSON"
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
-if [ "$PLAN_PROTOCOL" = "both" ]; then
- PROTOCOLS=(v1 v2)
-else
- PROTOCOLS=("$PLAN_PROTOCOL")
-fi
-for PROTOCOL in "${PROTOCOLS[@]}"; do
+PROTOCOL="v2"
for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-$PROTOCOL-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
@@ -623,41 +608,6 @@ for N in "${COUNTS[@]}"; do
'. += [{planProtocol: $protocol, chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/\($protocol)-N\($n)-output.mp4", history: "renders/\($protocol)-N\($n)-history.json"}]' \
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
done
-done
-
-# ── 6. Direct v1 ↔ v2 semantic equivalence ────────────────────────────────
-SEMANTIC_FAILED=0
-SEMANTIC_RESULTS_JSON="$ARTIFACT_DIR/semantic-comparisons.json"
-echo "[]" > "$SEMANTIC_RESULTS_JSON"
-if [ "$PLAN_PROTOCOL" = "both" ]; then
- for N in "${COUNTS[@]}"; do
- V1_OUTPUT="$ARTIFACT_DIR/renders/v1-N$N-output.mp4"
- V2_OUTPUT="$ARTIFACT_DIR/renders/v2-N$N-output.mp4"
- V1_HISTORY="$ARTIFACT_DIR/renders/v1-N$N-history.json"
- V2_HISTORY="$ARTIFACT_DIR/renders/v2-N$N-history.json"
- SEMANTIC_PREFIX="$ARTIFACT_DIR/renders/v1-v2-N$N"
- COMPARE_STATUS=0
- if hf_compare_render_semantics \
- "$V1_OUTPUT" "$V2_OUTPUT" "$SEMANTIC_PREFIX" "$V1_HISTORY" "$V2_HISTORY"; then
- echo "PASS: v1/v2 semantic equivalence at N=$N"
- else
- COMPARE_STATUS=$?
- if [ ! -f "${SEMANTIC_PREFIX}.json" ]; then
- jq -n --argjson status "$COMPARE_STATUS" \
- '{semanticEqual: false, comparisonError: true, comparisonExitCode: $status}' \
- > "${SEMANTIC_PREFIX}.json"
- fi
- echo "FAIL: v1/v2 semantic comparison at N=$N exited $COMPARE_STATUS (see ${SEMANTIC_PREFIX}.json)" >&2
- SEMANTIC_FAILED=$((SEMANTIC_FAILED + 1))
- fi
- jq --argjson n "$N" --slurpfile comparison "${SEMANTIC_PREFIX}.json" \
- '. += [($comparison[0] + {chunkCount: $n})]' \
- "$SEMANTIC_RESULTS_JSON" > "$SEMANTIC_RESULTS_JSON.tmp" &&
- mv "$SEMANTIC_RESULTS_JSON.tmp" "$SEMANTIC_RESULTS_JSON"
- jq -r '" chunks=\(.chunks.equal // "error") decoded-video=\(.video.equal // "error") audio=\(.audio.equal // "error") metadata=\(.metadata.equal // "error") duration=\(.duration.equal // "error") encoded-sha=\(.encoded.equal // "error") (informational unless gated)"' \
- "${SEMANTIC_PREFIX}.json"
- done
-fi
# ── 7. Gate on baseline PSNR threshold ────────────────────────────────────
FAILED=0
@@ -680,10 +630,6 @@ jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RE
echo
echo "Artifacts: $ARTIFACT_DIR"
-if [ "$SEMANTIC_FAILED" -gt 0 ]; then
- echo "FAILED ($SEMANTIC_FAILED v1/v2 semantic mismatches)" >&2
- cleanup_and_exit 6
-fi
if [ "$FAILED" -gt 0 ]; then
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
cleanup_and_exit 5
diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml
index 20743446e1e..41d0bf0208a 100644
--- a/examples/aws-lambda/template.yaml
+++ b/examples/aws-lambda/template.yaml
@@ -274,9 +274,6 @@ Resources:
- Variable: $.PlanProtocol
StringEquals: v2
Next: PlanV2
- - Variable: $.PlanProtocol
- StringEquals: v1
- Next: Plan
- Variable: $.PlanProtocol
IsPresent: true
Next: UnsupportedPlanProtocol
@@ -302,72 +299,7 @@ Resources:
UnsupportedPlanProtocol:
Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED
- Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v2).
-
- Plan:
- Type: Task
- Resource: arn:aws:states:::lambda:invoke
- Parameters:
- # Chunks must run the ffmpeg build the plan records, so the plan runs where they do.
- FunctionName:
- !If [
- HasVideoFunction,
- !GetAtt VideoFunctionStack.Outputs.FunctionArn,
- !GetAtt RenderFunction.Arn,
- ]
- Payload:
- Action: plan
- PlanProtocol: v1
- ProjectS3Uri.$: "$.ProjectS3Uri"
- PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
- Config.$: "$.Config"
- ResultSelector:
- PlanProtocol: v1
- PlanS3Uri.$: "$.Payload.PlanS3Uri"
- PlanHash.$: "$.Payload.PlanHash"
- ChunkCount.$: "$.Payload.ChunkCount"
- MaxChunkConcurrency.$: "$.Payload.MaxChunkConcurrency"
- Format.$: "$.Payload.Format"
- HasAudio.$: "$.Payload.HasAudio"
- AudioS3Uri.$: "$.Payload.AudioS3Uri"
- ResultPath: $.Plan
- Retry:
- - ErrorEquals:
- # These error names are thrown by the producer's plan
- # stage when retrying can never help — version skew,
- # determinism violations, GPU misconfiguration, font
- # fetch failures, plan-size cap, unsupported format.
- # Fail fast rather than burning ~120s of retry budget.
- - FFMPEG_VERSION_MISMATCH
- - PLAN_HASH_MISMATCH
- - BROWSER_GPU_NOT_SOFTWARE
- - FONT_FETCH_FAILED
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- - PLAN_PROTOCOL_UNSUPPORTED
- - PlanProtocolUnsupportedError
- - VIDEO_SOURCE_UNRENDERABLE
- - INVALID_VIDEO_METADATA
- - NOT_MEDIA_PAYLOAD
- - NotMediaPayloadError
- - PLAN_ARTIFACT_DIGEST_MISMATCH
- - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- - CHUNK_EXCEEDS_WORKER_TIMEOUT
- - SITE_MANIFEST_INVALID
- MaxAttempts: 0
- # Another render on the same function can still take the reserved slots; wait minutes.
- - ErrorEquals: [Lambda.TooManyRequestsException]
- IntervalSeconds: 5
- MaxAttempts: 10
- BackoffRate: 2
- MaxDelaySeconds: 60
- JitterStrategy: FULL
- - ErrorEquals: [States.ALL]
- IntervalSeconds: 2
- MaxAttempts: 4
- BackoffRate: 2
- MaxDelaySeconds: 60
- Next: BuildChunkList
+ Cause: PlanProtocol must be "v2" or absent (defaults to v2).
PlanV2:
Type: Task
@@ -403,8 +335,6 @@ Resources:
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
@@ -453,140 +383,14 @@ Resources:
Choices:
- Variable: $.Plan.ChunkCount
NumericGreaterThan: 0
- Next: SelectWorkerProtocol
+ Next: RenderV2
Default: PlanProducedZeroChunks
PlanProducedZeroChunks:
Type: Fail
- Error: PLAN_TOO_LARGE
+ Error: PLAN_EMPTY
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
- SelectWorkerProtocol:
- Type: Choice
- Choices:
- - Variable: $.Plan.PlanProtocol
- StringEquals: v2
- Next: RenderV2
- Default: RenderChunks
-
- RenderChunks:
- Type: Map
- Label: RenderChunks
- ItemsPath: $.Iterator.ChunkIndexes
- ItemSelector:
- ChunkIndex.$: "$$.Map.Item.Value"
- PlanS3Uri.$: "$.Plan.PlanS3Uri"
- PlanHash.$: "$.Plan.PlanHash"
- ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
- Format.$: "$.Plan.Format"
- # Chunks past the plan's concurrency queue here instead of being throttled by Lambda.
- MaxConcurrencyPath: $.Plan.MaxChunkConcurrency
- ResultPath: $.Chunks
- ItemProcessor:
- ProcessorConfig:
- # Inline Map runs at most 40 iterations at once. STANDARD children
- # because EXPRESS ones stop at 5 minutes and a chunk may run 15.
- Mode: DISTRIBUTED
- ExecutionType: STANDARD
- StartAt: RenderChunk
- States:
- RenderChunk:
- Type: Task
- Resource: arn:aws:states:::lambda:invoke
- Parameters:
- FunctionName:
- !If [
- HasVideoFunction,
- !GetAtt VideoFunctionStack.Outputs.FunctionArn,
- !GetAtt RenderFunction.Arn,
- ]
- Payload:
- Action: renderChunk
- PlanProtocol: v1
- ChunkIndex.$: "$.ChunkIndex"
- PlanS3Uri.$: "$.PlanS3Uri"
- PlanHash.$: "$.PlanHash"
- ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
- Format.$: "$.Format"
- ResultSelector:
- ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
- ChunkIndex.$: "$.Payload.ChunkIndex"
- Sha256.$: "$.Payload.Sha256"
- FramesEncoded.$: "$.Payload.FramesEncoded"
- DurationMs.$: "$.Payload.DurationMs"
- Retry:
- - ErrorEquals:
- - FFMPEG_VERSION_MISMATCH
- - PLAN_HASH_MISMATCH
- - BROWSER_GPU_NOT_SOFTWARE
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- - PLAN_PROTOCOL_UNSUPPORTED
- - PlanProtocolUnsupportedError
- - INVALID_VIDEO_METADATA
- - PLAN_ARTIFACT_DIGEST_MISMATCH
- MaxAttempts: 0
- - ErrorEquals: [Lambda.TooManyRequestsException]
- IntervalSeconds: 5
- MaxAttempts: 10
- BackoffRate: 2
- MaxDelaySeconds: 60
- JitterStrategy: FULL
- - ErrorEquals: [States.ALL]
- IntervalSeconds: 2
- MaxAttempts: 4
- BackoffRate: 2
- MaxDelaySeconds: 60
- End: true
- Next: Assemble
-
- Assemble:
- Type: Task
- Resource: arn:aws:states:::lambda:invoke
- Parameters:
- FunctionName: !GetAtt RenderFunction.Arn
- Payload:
- Action: assemble
- PlanProtocol: v1
- PlanS3Uri.$: "$.Plan.PlanS3Uri"
- ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
- AudioS3Uri.$: "$.Plan.AudioS3Uri"
- OutputS3Uri.$: "$.OutputS3Uri"
- Format.$: "$.Plan.Format"
- ResultSelector:
- OutputS3Uri.$: "$.Payload.OutputS3Uri"
- FramesEncoded.$: "$.Payload.FramesEncoded"
- FileSize.$: "$.Payload.FileSize"
- ResultPath: $.Output
- Retry:
- - ErrorEquals:
- # Same non-retryable error names as the Plan state's
- # gate — these surface at assemble time too because
- # ffmpeg-driven concat picks up version drift and we
- # re-verify plan hash + format at assemble. Skip the
- # retry storm; fail fast.
- - FFMPEG_VERSION_MISMATCH
- - PLAN_HASH_MISMATCH
- - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- - PLAN_PROTOCOL_UNSUPPORTED
- - PlanProtocolUnsupportedError
- - PLAN_ARTIFACT_DIGEST_MISMATCH
- MaxAttempts: 0
- - ErrorEquals: [Lambda.TooManyRequestsException]
- IntervalSeconds: 5
- MaxAttempts: 10
- BackoffRate: 2
- MaxDelaySeconds: 60
- JitterStrategy: FULL
- - ErrorEquals: [States.ALL]
- IntervalSeconds: 2
- MaxAttempts: 4
- BackoffRate: 2
- MaxDelaySeconds: 60
- End: true
-
RenderV2:
# Chunks render video only while the audio task mixes the track AssembleV2 stitches in.
Type: Parallel
@@ -644,8 +448,6 @@ Resources:
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
@@ -705,8 +507,6 @@ Resources:
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
@@ -765,8 +565,6 @@ Resources:
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- - PLAN_TOO_LARGE
- - PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
diff --git a/examples/gcp-cloud-run/README.md b/examples/gcp-cloud-run/README.md
index 0f92ca82638..2314bd8c4a1 100644
--- a/examples/gcp-cloud-run/README.md
+++ b/examples/gcp-cloud-run/README.md
@@ -7,35 +7,26 @@ Cloud Workflows adapter for HyperFrames distributed rendering.
## Layout
```text
-scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup
-sample-events/ Default-v2, explicit-v1, and explicit-v2 request examples
+scripts/smoke.sh Owner-isolated real-GCP deploy, render, validation, cleanup
+sample-events/ Default-v2 and explicit-v2 request examples
```
The Terraform module and Cloud Workflows definition live in
`packages/gcp-cloud-run/terraform/`.
-## Protocol rollout
+## Plan transport
-The workflow defaults to Plan v2 when `PlanProtocol` is absent. Deprecated v1
-compatibility remains available only when the caller explicitly sends
-`PlanProtocol: "v1"`.
-
-V1 and v2 use disjoint plan locators:
-
-- v1: `PlanGcsUri`
-- v2: `PlanV2ManifestGcsUri` and `PlanV2ArtifactGcsPrefix`
-
-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.
+Plan v2 is the only supported plan transport. The workflow uses
+`PlanV2ManifestGcsUri` and `PlanV2ArtifactGcsPrefix` and validates the plan
+response before starting chunk fan-out.
Deploy the workflow only with a Cloud Run image whose handler implements the
matching v2 request/response contract.
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.
+older workflows may lack the Plan v2-only contract. Keep the application on
+its previous package version until the infrastructure redeploy completes.
## Prerequisites
@@ -55,27 +46,18 @@ Plan v2 is the normal smoke path:
--region us-central1
```
-Explicitly run v1/v2 end-to-end parity at one or more chunk sizes:
+Run one or more chunk sizes:
```bash
./scripts/smoke.sh \
--project YOUR_GCP_PROJECT \
--region us-central1 \
- --protocols v1,v2 \
--chunk-sizes 30,15,10 \
- --owner plan-v2-parity
+ --owner plan-v2-smoke
```
-For each chunk size, parity requires exact equality of:
-
-- decoded RGBA video frames
-- decoded 48 kHz stereo PCM audio
-- normalized `ffprobe` stream and duration metadata
-
-The encoded MP4 hash and byte count are recorded but are not the equality
-oracle because mux metadata can differ without changing decoded output.
-Each render is also PSNR-compared with the checked-in in-process fixture
-baseline.
+Each render is PSNR-compared with the checked-in in-process fixture baseline.
+The encoded MP4 hash and byte count are recorded as additional evidence.
## Isolation and cleanup
@@ -112,7 +94,6 @@ Evidence lands under:
```text
scripts/gcp-smoke-artifacts//
results.json
- parity.json
renders/
terraform/
terraform-data/
@@ -132,11 +113,6 @@ curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan.json | jq .
-# 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/ \
-H 'content-type: application/json' \
diff --git a/examples/gcp-cloud-run/sample-events/assemble-v1.json b/examples/gcp-cloud-run/sample-events/assemble-v1.json
deleted file mode 100644
index 4be98aa21d7..00000000000
--- a/examples/gcp-cloud-run/sample-events/assemble-v1.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "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"
-}
diff --git a/examples/gcp-cloud-run/sample-events/plan-v1.json b/examples/gcp-cloud-run/sample-events/plan-v1.json
deleted file mode 100644
index 6f0856668f3..00000000000
--- a/examples/gcp-cloud-run/sample-events/plan-v1.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "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" }
-}
diff --git a/examples/gcp-cloud-run/sample-events/render-chunk-v1.json b/examples/gcp-cloud-run/sample-events/render-chunk-v1.json
deleted file mode 100644
index c11bd441ae0..00000000000
--- a/examples/gcp-cloud-run/sample-events/render-chunk-v1.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "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"
-}
diff --git a/examples/gcp-cloud-run/scripts/smoke.sh b/examples/gcp-cloud-run/scripts/smoke.sh
index 669b82621f8..85d156dd25b 100755
--- a/examples/gcp-cloud-run/scripts/smoke.sh
+++ b/examples/gcp-cloud-run/scripts/smoke.sh
@@ -1,16 +1,15 @@
#!/usr/bin/env bash
-# Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames
+# Owner-isolated real-GCP smoke test for the HyperFrames
# Cloud Run adapter.
#
-# The default exercises Plan v2. Deprecated v1 compatibility can be selected
-# explicitly with --protocols v1 or compared with --protocols v1,v2. Every
-# invocation derives a unique, length-safe resource prefix and uses an isolated
+# The smoke exercises Plan v2. Every invocation derives a unique, length-safe
+# resource prefix and uses an isolated
# Terraform working directory and state file. Cleanup verifies every owned
# resource is absent and fails closed on API/authentication errors.
#
# Usage:
# ./smoke.sh --project
-# ./smoke.sh --project p --protocols v1,v2 --chunk-sizes 15,30
+# ./smoke.sh --project p --chunk-sizes 15,30
# ./smoke.sh --project p --owner james-plan-v2 --keep-stack
#
# Required tools:
@@ -20,7 +19,7 @@
# 0 success 1 arguments/pre-flight
# 2 image build/push failed 3 terraform apply failed
# 4 render failed 5 baseline PSNR failed
-# 6 v1/v2 parity failed 7 cleanup or cleanup verification failed
+# 6 semantic validation failed 7 cleanup or cleanup verification failed
set -euo pipefail
@@ -33,7 +32,6 @@ REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
-PROTOCOLS="${PROTOCOLS:-v2}"
OWNER="${HYPERFRAMES_SMOKE_OWNER:-}"
AR_REPO="${AR_REPO:-}"
AR_REPO_WAS_EXPLICIT=0
@@ -49,7 +47,6 @@ while [ $# -gt 0 ]; do
--fixture) FIXTURE="$2"; shift 2 ;;
--chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
- --protocols) PROTOCOLS="$2"; shift 2 ;;
--owner) OWNER="$2"; shift 2 ;;
--repo) AR_REPO="$2"; AR_REPO_WAS_EXPLICIT=1; shift 2 ;;
--image) EXPLICIT_IMAGE="$2"; shift 2 ;;
@@ -72,24 +69,7 @@ for tool in gcloud terraform ffmpeg ffprobe jq tar sha256sum; do
}
done
-PROTOCOLS="${PROTOCOLS//[[:space:]]/}"
-case ",$PROTOCOLS," in
- *,v1,*|*,v2,*) ;;
- *) echo "ERROR: --protocols must contain v1 and/or v2" >&2; exit 1 ;;
-esac
-IFS=',' read -ra PROTOCOL_LIST <<< "$PROTOCOLS"
-declare -A SEEN_PROTOCOLS=()
-for protocol in "${PROTOCOL_LIST[@]}"; do
- case "$protocol" in
- v1|v2) ;;
- *) echo "ERROR: unsupported protocol '$protocol'; expected v1 or v2" >&2; exit 1 ;;
- esac
- [ -z "${SEEN_PROTOCOLS[$protocol]:-}" ] || {
- echo "ERROR: duplicate protocol '$protocol'" >&2
- exit 1
- }
- SEEN_PROTOCOLS[$protocol]=1
-done
+PROTOCOL="v2"
if [ -z "$OWNER" ]; then
OWNER="$(id -un)-$(date -u +%Y%m%dT%H%M%SZ)-$$"
@@ -298,7 +278,7 @@ echo "→ Project: $PROJECT"
echo " Region: $REGION"
echo " Owner: $OWNER ($OWNER_HASH)"
echo " Resource prefix: $STACK_NAME"
-echo " Protocols: $PROTOCOLS"
+echo " Protocol: $PROTOCOL"
echo " Isolated Terraform directory: $TF_WORK_DIR"
echo "→ Verifying required project APIs are already enabled"
@@ -494,11 +474,10 @@ for chunk_size in "${SIZES[@]}"; do
done
jq -n '[]' > "$ARTIFACT_DIR/results.json"
-jq -n '[]' > "$ARTIFACT_DIR/parity.json"
OVERALL_RC=0
-for protocol in "${PROTOCOL_LIST[@]}"; do
- for chunk_size in "${SIZES[@]}"; do
+protocol="$PROTOCOL"
+for chunk_size in "${SIZES[@]}"; do
RENDER_ID="$STACK_NAME-$protocol-c$chunk_size"
OUTPUT_GCS="gs://$BUCKET/renders/smoke/$STACK_NAME/$protocol/c$chunk_size/output.mp4"
PLAN_PREFIX="gs://$BUCKET/renders/smoke/$STACK_NAME/$protocol/c$chunk_size/plan/"
@@ -598,89 +577,9 @@ for protocol in "${PROTOCOL_LIST[@]}"; do
echo " ✗ PSNR ${PSNR_AVG}dB below threshold ${PSNR_THRESHOLD}dB"
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=5
fi
- done
done
-canonicalize_output() {
- local media_file="$1"
- local output_prefix="$2"
- ffmpeg -v error -i "$media_file" -map 0:v:0 -pix_fmt rgba \
- -f framemd5 "$output_prefix.frames.md5"
- grep -v '^#' "$output_prefix.frames.md5" > "$output_prefix.frames.data"
-
- if ffprobe -v error -select_streams a:0 -show_entries stream=index \
- -of csv=p=0 "$media_file" | grep -q .; then
- ffmpeg -v error -i "$media_file" -map 0:a:0 -vn -ac 2 -ar 48000 \
- -f hash -hash sha256 - > "$output_prefix.audio.sha256"
- else
- printf '%s\n' "NO_AUDIO" > "$output_prefix.audio.sha256"
- fi
-
- ffprobe -v error \
- -show_entries \
- format=duration:stream=codec_type,codec_name,width,height,pix_fmt,avg_frame_rate,r_frame_rate,nb_frames,sample_rate,channels,channel_layout \
- -of json "$media_file" | jq -S . > "$output_prefix.probe.json"
-}
-
-if [ -n "${SEEN_PROTOCOLS[v1]:-}" ] && [ -n "${SEEN_PROTOCOLS[v2]:-}" ]; then
- echo "→ Comparing v1 and v2 decoded outputs"
- for chunk_size in "${SIZES[@]}"; do
- V1_OUTPUT="$RENDER_DIR/v1-c$chunk_size-output.mp4"
- V2_OUTPUT="$RENDER_DIR/v2-c$chunk_size-output.mp4"
- if [ ! -f "$V1_OUTPUT" ] || [ ! -f "$V2_OUTPUT" ]; then
- echo " ✗ c$chunk_size parity unavailable because one protocol did not render"
- [ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
- continue
- fi
-
- V1_PREFIX="$RENDER_DIR/v1-c$chunk_size-canonical"
- V2_PREFIX="$RENDER_DIR/v2-c$chunk_size-canonical"
- canonicalize_output "$V1_OUTPUT" "$V1_PREFIX"
- canonicalize_output "$V2_OUTPUT" "$V2_PREFIX"
-
- FRAMES_EQUAL=false
- AUDIO_EQUAL=false
- METADATA_EQUAL=false
- cmp -s "$V1_PREFIX.frames.data" "$V2_PREFIX.frames.data" && FRAMES_EQUAL=true
- cmp -s "$V1_PREFIX.audio.sha256" "$V2_PREFIX.audio.sha256" && AUDIO_EQUAL=true
- cmp -s "$V1_PREFIX.probe.json" "$V2_PREFIX.probe.json" && METADATA_EQUAL=true
-
- jq \
- --argjson chunk_size "$chunk_size" \
- --argjson frames_equal "$FRAMES_EQUAL" \
- --argjson audio_equal "$AUDIO_EQUAL" \
- --argjson metadata_equal "$METADATA_EQUAL" \
- --arg v1_frame_sha "$(sha256sum "$V1_PREFIX.frames.data" | cut -d' ' -f1)" \
- --arg v2_frame_sha "$(sha256sum "$V2_PREFIX.frames.data" | cut -d' ' -f1)" \
- --arg v1_audio_sha "$(sed -n '1p' "$V1_PREFIX.audio.sha256")" \
- --arg v2_audio_sha "$(sed -n '1p' "$V2_PREFIX.audio.sha256")" \
- '. += [{
- chunkSize: $chunk_size,
- decodedFramesEqual: $frames_equal,
- decodedAudioEqual: $audio_equal,
- normalizedMetadataEqual: $metadata_equal,
- v1FrameManifestSha256: $v1_frame_sha,
- v2FrameManifestSha256: $v2_frame_sha,
- v1AudioSha256: $v1_audio_sha,
- v2AudioSha256: $v2_audio_sha
- }]' \
- "$ARTIFACT_DIR/parity.json" > "$ARTIFACT_DIR/parity.json.tmp"
- mv "$ARTIFACT_DIR/parity.json.tmp" "$ARTIFACT_DIR/parity.json"
-
- if [ "$FRAMES_EQUAL" = true ] && [ "$AUDIO_EQUAL" = true ] && [ "$METADATA_EQUAL" = true ]; then
- echo " ✓ c$chunk_size decoded video, audio, and metadata match"
- else
- echo " ✗ c$chunk_size parity mismatch: frames=$FRAMES_EQUAL audio=$AUDIO_EQUAL metadata=$METADATA_EQUAL"
- [ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
- fi
- done
-fi
-
echo "→ Results"
jq . "$ARTIFACT_DIR/results.json"
-if [ -n "${SEEN_PROTOCOLS[v1]:-}" ] && [ -n "${SEEN_PROTOCOLS[v2]:-}" ]; then
- echo "→ Parity"
- jq . "$ARTIFACT_DIR/parity.json"
-fi
echo "→ Evidence: $ARTIFACT_DIR"
exit "$OVERALL_RC"
diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md
index 0ae0cf54e8c..b9abd6274f8 100644
--- a/packages/aws-lambda/README.md
+++ b/packages/aws-lambda/README.md
@@ -37,7 +37,7 @@ smoke flow; the SDK + CDK are the supported public surface for adopters.
│ pure functions over local paths
▼
┌──────────────────────────────────────────────────────────────────┐
-│ S3 bucket — v1 plan tar or v2 manifest/blobs + chunks + output │
+│ S3 bucket — Plan v2 manifest/blobs + chunks + output │
└──────────────────────────────────────────────────────────────────┘
```
@@ -45,11 +45,11 @@ The handler downloads inputs from S3 into `/tmp`, calls the OSS primitive,
uploads outputs back to S3, and returns a small JSON result that fits
inside Step Functions' history budget (under 200 bytes per chunk).
-### Plan transport selection
+### Plan transport
-Plan v2 is the default for new renders. When `planProtocol` is omitted,
-`renderToLambda` sends an explicit `PlanProtocol: "v2"` so the SDK and the
-deployed state machine agree:
+Plan v2 is the only supported distributed plan transport.
+`renderToLambda` sends `PlanProtocol: "v2"` without requiring a protocol
+option:
```ts
await renderToLambda({
@@ -63,8 +63,7 @@ only manifest-selected chunk artifacts, while the assembler fetches its
own metadata and audio subset. Blobs are immutable SHA-256-addressed
objects, verified on upload and download, and the manifest is published
last. Unknown protocols and digest mismatches are terminal Step Functions
-errors. The monolithic v1 transport remains available as deprecated
-compatibility by passing `planProtocol: "v1"` explicitly.
+errors.
#### Upgrade order
@@ -78,7 +77,7 @@ infrastructure upgrade. Before upgrading an application that calls
3. Resume renders, then upgrade the application/SDK dependency.
A site is a `sites//files.json` file list that an older stack cannot
-read, under either plan protocol. Before it uploads anything,
+read. Before it uploads anything,
`renderToLambda` reads the state machine definition
(`states:DescribeStateMachine`) and fails with `STACK_TOO_OLD` when the
stack predates that format. If infrastructure cannot be redeployed first,
diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
index bdc51a8c018..29005916e9c 100644
--- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
+++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
@@ -53,19 +53,14 @@ const EXPECTED_RESOURCE_COUNTS: Record = {
"AWS::IAM::Policy": 3,
};
-// Top-level state names emitted by ASL. The Map state's inner
-// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
-// not at this level — we cover it separately in the contract test.
+// Top-level state names emitted by ASL. The chunk task lives inside the
+// Plan v2 parallel branch, so it is covered separately below.
const EXPECTED_STATE_NAMES = [
"SelectPlanProtocol",
- "Plan",
"PlanV2",
"BuildChunkList",
"AssertChunkCount",
- "SelectWorkerProtocol",
- "RenderChunks",
"RenderV2",
- "Assemble",
"AssembleV2",
"PlanProducedZeroChunks",
"UnsupportedPlanProtocol",
@@ -77,8 +72,6 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
- "PLAN_TOO_LARGE",
- "PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
@@ -179,35 +172,26 @@ describe("HyperframesRenderStack — snapshot", () => {
});
});
- it("defaults omitted plan protocol to v2 and preserves the explicit v1 branch", () => {
+ it("defaults omitted plan protocol to v2 and rejects every other explicit protocol", () => {
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"');
+ expect(JSON.stringify(selection)).not.toContain('"StringEquals":"v1"');
+ expect(JSON.stringify(selection)).toContain('"Next":"UnsupportedPlanProtocol"');
+ expect(JSON.stringify(definition.States)).not.toContain('"PlanProtocol":"v1"');
}
});
it("preserves every typed non-retryable error name across the three Lambda tasks", () => {
const { definition } = SYNTHED;
const collected = new Set();
- // Plan + Assemble are top-level states; RenderChunk is nested inside
- // the Map's Iterator definition.
- const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
+ const topLevelStates = ["PlanV2", "AssembleV2"] as const;
for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected);
}
- const renderChunks = definition.States.RenderChunks as
- | {
- Iterator?: { States?: Record };
- ItemProcessor?: { States?: Record };
- }
- | undefined;
- const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
- collectNonRetryableErrors(innerStates.RenderChunk, collected);
collectNonRetryableErrors(getV2TaskStates(definition).RenderChunkV2, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
@@ -232,11 +216,9 @@ describe("HyperframesRenderStack — snapshot", () => {
it("lists the error the SDK checks for before rendering in every SAM and CDK plan task", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
- for (const plan of [getV1TaskStates(definition).Plan, getV2TaskStates(definition).PlanV2]) {
- const errors = new Set();
- collectNonRetryableErrors(plan, errors);
- expect(errors.has(SITE_MANIFEST_INVALID)).toBe(true);
- }
+ const errors = new Set();
+ collectNonRetryableErrors(getV2TaskStates(definition).PlanV2, errors);
+ expect(errors.has(SITE_MANIFEST_INVALID)).toBe(true);
}
});
@@ -256,22 +238,17 @@ describe("HyperframesRenderStack — snapshot", () => {
}
});
- it("routes video failures consistently across SAM/CDK and both plan protocols", () => {
+ it("routes video failures consistently across SAM and CDK", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
- const v1 = getV1TaskStates(definition);
const v2 = getV2TaskStates(definition);
- for (const planState of [v1.Plan, v2.PlanV2]) {
- const errors = new Set();
- collectNonRetryableErrors(planState, errors);
- expect(errors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
- expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
- expect(errors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
- }
- for (const chunkState of [v1.RenderChunk, v2.RenderChunkV2]) {
- const errors = new Set();
- collectNonRetryableErrors(chunkState, errors);
- expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
- }
+ const planErrors = new Set();
+ collectNonRetryableErrors(v2.PlanV2, planErrors);
+ expect(planErrors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
+ expect(planErrors.has("INVALID_VIDEO_METADATA")).toBe(true);
+ expect(planErrors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
+ const chunkErrors = new Set();
+ collectNonRetryableErrors(v2.RenderChunkV2, chunkErrors);
+ expect(chunkErrors.has("INVALID_VIDEO_METADATA")).toBe(true);
}
});
@@ -309,20 +286,13 @@ describe("HyperframesRenderStack — snapshot", () => {
}
});
- it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
+ it("uses only Plan v2 locators across orchestration", () => {
const { definition } = SYNTHED;
- const v1 = JSON.stringify({
- plan: definition.States.Plan,
- chunks: definition.States.RenderChunks,
- assemble: definition.States.Assemble,
- });
const v2 = JSON.stringify({
plan: definition.States.PlanV2,
chunks: renderV2Branch(definition, "RenderChunksV2"),
assemble: definition.States.AssembleV2,
});
- expect(v1).toContain("PlanS3Uri");
- expect(v1).not.toContain("PlanV2ManifestS3Uri");
expect(v2).toContain("PlanV2ManifestS3Uri");
expect(v2).toContain("PlanV2ArtifactS3Prefix");
expect(v2).not.toContain("PlanS3Uri");
@@ -354,7 +324,7 @@ describe("HyperframesRenderStack — snapshot", () => {
const cdkTasks = lambdaTasks(SYNTHED.definition.States);
const { WarmRender: _warm, ...samTasks } = lambdaTasks(readSamDefinition().States);
expect(Object.keys(cdkTasks).sort()).toEqual(Object.keys(samTasks).sort());
- expect(Object.keys(cdkTasks).length).toBeGreaterThanOrEqual(6);
+ expect(Object.keys(cdkTasks).length).toBeGreaterThanOrEqual(4);
for (const [taskName, cdkTask] of Object.entries(cdkTasks)) {
expect({ taskName, payload: sentPayload(cdkTask) }).toEqual({
taskName,
@@ -434,9 +404,8 @@ describe("HyperframesRenderStack — snapshot", () => {
it("runs every chunk in a Distributed Map with STANDARD children in CDK and SAM", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
- for (const name of ["RenderChunks", "RenderChunksV2"]) {
- const state =
- name === "RenderChunksV2" ? renderV2Branch(definition, name) : definition.States[name];
+ for (const name of ["RenderChunksV2"] as const) {
+ const state = renderV2Branch(definition, name);
const map = requireRecord(state, `${name} state`);
const processor = requireRecordProperty(map, "ItemProcessor", `${name} processor`);
expect({ name, config: processor.ProcessorConfig }).toEqual({
@@ -453,7 +422,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("caps chunk fan-out at the concurrency the plan returns, in CDK and SAM", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
- for (const name of ["Plan", "PlanV2"]) {
+ for (const name of ["PlanV2"] as const) {
const selector = requireRecordProperty(
requireRecord(definition.States[name], `${name} state`),
"ResultSelector",
@@ -529,7 +498,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("fails at once when a chunk cannot fit the worker timeout, in CDK and SAM", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
- for (const name of ["Plan", "PlanV2"]) {
+ for (const name of ["PlanV2"]) {
const errors = new Set();
collectNonRetryableErrors(definition.States[name], errors);
expect({ name, terminal: errors.has("CHUNK_EXCEEDS_WORKER_TIMEOUT") }).toEqual({
@@ -600,19 +569,12 @@ describe("HyperframesRenderStack — snapshot", () => {
expect(payload["PlanV2ManifestS3Uri.$"]).toBe("$.Plan.PlanV2ManifestS3Uri");
});
- it("takes SAM v2 assembly audio from the audio task and keeps both v1 audio result paths", () => {
+ it("takes SAM v2 assembly audio from the audio task", () => {
const sam = readSamDefinition();
const assemble = requireRecord(sam.States.AssembleV2, "SAM AssembleV2 state");
const parameters = requireRecordProperty(assemble, "Parameters", "SAM AssembleV2 parameters");
const payload = requireRecordProperty(parameters, "Payload", "SAM AssembleV2 payload");
expect(payload["AudioS3Uri.$"]).toBe("$.Render.Audio.AudioS3Uri");
-
- for (const definition of [SYNTHED.definition, sam]) {
- const v1 = requireRecord(definition.States.Assemble, "Assemble state");
- const params = requireRecordProperty(v1, "Parameters", "Assemble parameters");
- const v1Payload = requireRecordProperty(params, "Payload", "Assemble payload");
- expect(v1Payload["AudioS3Uri.$"]).toBe("$.Plan.AudioS3Uri");
- }
});
});
@@ -735,21 +697,6 @@ function getV2TaskStates(definition: {
};
}
-function getV1TaskStates(definition: {
- States: Record;
-}): Record<"Plan" | "RenderChunk" | "Assemble", unknown> {
- const renderChunks = requireRecord(definition.States.RenderChunks, "RenderChunks state");
- const processor = isRecord(renderChunks.Iterator)
- ? renderChunks.Iterator
- : requireRecord(renderChunks.ItemProcessor, "RenderChunks processor");
- const innerStates = requireRecord(processor.States, "RenderChunks processor states");
- return {
- Plan: definition.States.Plan,
- RenderChunk: innerStates.RenderChunk,
- Assemble: definition.States.Assemble,
- };
-}
-
function policyStatements(policy: unknown): Record[] {
const properties = requireRecordProperty(requireRecord(policy, "policy"), "Properties", "props");
const document = requireRecordProperty(properties, "PolicyDocument", "policy document");
diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
index 8dc9d61830e..1eddfbe79d4 100644
--- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
+++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
@@ -224,8 +224,6 @@ export class HyperframesRenderStack extends Construct {
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
- "PLAN_TOO_LARGE",
- "PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
@@ -245,8 +243,6 @@ export class HyperframesRenderStack extends Construct {
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
- "PLAN_TOO_LARGE",
- "PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
@@ -264,8 +260,6 @@ export class HyperframesRenderStack extends Construct {
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
- "PLAN_TOO_LARGE",
- "PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
@@ -279,40 +273,6 @@ export class HyperframesRenderStack extends Construct {
jitterStrategy: sfn.JitterType.FULL,
};
- const plan = new tasks.LambdaInvoke(this, "Plan", {
- lambdaFunction: this.renderFunction,
- payload: sfn.TaskInput.fromObject({
- Action: "plan",
- PlanProtocol: "v1",
- "ProjectS3Uri.$": "$.ProjectS3Uri",
- "PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
- "Config.$": "$.Config",
- }),
- resultSelector: {
- PlanProtocol: "v1",
- "PlanS3Uri.$": "$.Payload.PlanS3Uri",
- "PlanHash.$": "$.Payload.PlanHash",
- "ChunkCount.$": "$.Payload.ChunkCount",
- "MaxChunkConcurrency.$": "$.Payload.MaxChunkConcurrency",
- "Format.$": "$.Payload.Format",
- "HasAudio.$": "$.Payload.HasAudio",
- "AudioS3Uri.$": "$.Payload.AudioS3Uri",
- },
- resultPath: "$.Plan",
- });
- plan.addRetry({
- errors: NON_RETRYABLE_PLAN,
- maxAttempts: 0,
- });
- plan.addRetry(THROTTLED_RETRY);
- plan.addRetry({
- errors: ["States.ALL"],
- interval: Duration.seconds(2),
- maxAttempts: 4,
- backoffRate: 2,
- maxDelay: Duration.seconds(60),
- });
-
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
@@ -353,91 +313,10 @@ export class HyperframesRenderStack extends Construct {
});
const planProducedZero = new sfn.Fail(this, "PlanProducedZeroChunks", {
- error: "PLAN_TOO_LARGE",
+ error: "PLAN_EMPTY",
cause: "Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.",
});
- const renderChunkTask = new tasks.LambdaInvoke(this, "RenderChunk", {
- lambdaFunction: this.renderFunction,
- payload: sfn.TaskInput.fromObject({
- Action: "renderChunk",
- PlanProtocol: "v1",
- "ChunkIndex.$": "$.ChunkIndex",
- "PlanS3Uri.$": "$.PlanS3Uri",
- "PlanHash.$": "$.PlanHash",
- "ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
- "Format.$": "$.Format",
- }),
- resultSelector: {
- "ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
- "ChunkIndex.$": "$.Payload.ChunkIndex",
- "Sha256.$": "$.Payload.Sha256",
- "FramesEncoded.$": "$.Payload.FramesEncoded",
- "DurationMs.$": "$.Payload.DurationMs",
- },
- });
- renderChunkTask.addRetry({
- errors: NON_RETRYABLE_CHUNK,
- maxAttempts: 0,
- });
- renderChunkTask.addRetry(THROTTLED_RETRY);
- renderChunkTask.addRetry({
- errors: ["States.ALL"],
- interval: Duration.seconds(2),
- maxAttempts: 4,
- backoffRate: 2,
- maxDelay: Duration.seconds(60),
- });
-
- const renderChunks = new sfn.DistributedMap(this, "RenderChunks", {
- label: "RenderChunks",
- // Standard-type children: express ones stop at 5 minutes and a chunk may run the Lambda's full 15.
- mapExecutionType: sfn.StateMachineType.STANDARD,
- itemsPath: "$.Iterator.ChunkIndexes",
- itemSelector: {
- "ChunkIndex.$": "$$.Map.Item.Value",
- "PlanS3Uri.$": "$.Plan.PlanS3Uri",
- "PlanHash.$": "$.Plan.PlanHash",
- "ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
- "Format.$": "$.Plan.Format",
- },
- // Chunks past the plan's concurrency queue here instead of being throttled by Lambda.
- maxConcurrencyPath: "$.Plan.MaxChunkConcurrency",
- resultPath: "$.Chunks",
- });
- renderChunks.itemProcessor(renderChunkTask);
-
- const assemble = new tasks.LambdaInvoke(this, "Assemble", {
- lambdaFunction: this.renderFunction,
- payload: sfn.TaskInput.fromObject({
- Action: "assemble",
- PlanProtocol: "v1",
- "PlanS3Uri.$": "$.Plan.PlanS3Uri",
- "ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
- "AudioS3Uri.$": "$.Plan.AudioS3Uri",
- "OutputS3Uri.$": "$.OutputS3Uri",
- "Format.$": "$.Plan.Format",
- }),
- resultSelector: {
- "OutputS3Uri.$": "$.Payload.OutputS3Uri",
- "FramesEncoded.$": "$.Payload.FramesEncoded",
- "FileSize.$": "$.Payload.FileSize",
- },
- resultPath: "$.Output",
- });
- assemble.addRetry({
- errors: NON_RETRYABLE_ASSEMBLE,
- maxAttempts: 0,
- });
- assemble.addRetry(THROTTLED_RETRY);
- assemble.addRetry({
- errors: ["States.ALL"],
- interval: Duration.seconds(2),
- maxAttempts: 4,
- backoffRate: 2,
- maxDelay: Duration.seconds(60),
- });
-
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
@@ -554,24 +433,18 @@ export class HyperframesRenderStack extends Construct {
.branch(renderChunksV2)
.branch(audioNeeded);
- const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
- .when(sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"), renderV2.next(assembleV2))
- .otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
- .when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
+ .when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderV2.next(assembleV2))
.otherwise(planProducedZero);
- plan.next(buildChunkList);
planV2.next(buildChunkList);
buildChunkList.next(assertChunkCount);
-
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
- cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v2).',
+ cause: 'PlanProtocol must be "v2" or absent (defaults to v2).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
- .when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(planV2);
}
diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts
index 6727152fe2d..c21b2b2c218 100644
--- a/packages/aws-lambda/src/events.ts
+++ b/packages/aws-lambda/src/events.ts
@@ -5,7 +5,7 @@
* dispatches on the `Action` field. Each action maps 1:1 onto one of the
* three OSS distributed primitives:
*
- * "plan" → `plan(projectDir, config, planDir)` (Activity A)
+ * "plan" → `planV2WithPublisher(...)` (Activity A)
* "renderChunk" → `renderChunk(planDir, chunkIndex, output)` (Activity B)
* "assemble" → `assemble(planDir, chunkPaths, audio, out)` (Activity C)
*
@@ -36,9 +36,6 @@ export interface StitchPartsEvent {
UploadId: string;
Parts: { partNumber: number; ranges: { uri: string; start: number; end: number }[] }[];
}
-/** Transport protocol selected for one complete distributed render. */
-export type LambdaPlanProtocol = "v1" | "v2";
-
/**
* Top-level shape of any event the handler may receive.
*
@@ -58,7 +55,7 @@ export type LambdaEvent =
| { Input: LambdaEvent };
/** Activity A: produce a planDir, upload to S3. */
-interface PlanEventBase {
+export interface PlanEvent {
Action: "plan";
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
ProjectS3Uri: string;
@@ -66,38 +63,26 @@ interface PlanEventBase {
PlanOutputS3Prefix: string;
/** `DistributedRenderConfig` minus runtime-only fields (logger, abortSignal). */
Config: SerializableDistributedRenderConfig;
-}
-
-/**
- * Legacy plan transport. Callers must select it explicitly.
- *
- * @deprecated Use {@link PlanV2Event} for new integrations.
- */
-export interface PlanV1Event extends PlanEventBase {
- PlanProtocol: "v1";
-}
-
-/** Default content-addressed v2 plan transport. */
-export interface PlanV2Event extends PlanEventBase {
+ /** Optional for direct handler callers; the SDK and orchestrator send it explicitly. */
PlanProtocol?: "v2";
}
-export type PlanEvent = PlanV1Event | PlanV2Event;
+/** Compatibility alias for the only supported plan event. */
+export type PlanV2Event = PlanEvent;
/** Makes a v2 plan's audio beside its chunks, from the same project and config as the plan. */
-export interface AudioEvent extends Omit {
+export interface AudioEvent extends Omit {
Action: "audio";
}
/** Activity B: fetch planDir, render one chunk, upload result. */
-interface RenderChunkEventBase {
+export interface RenderChunkEvent {
Action: "renderChunk";
/**
- * `PlanResult.planHash` from the Plan invocation. For v1, the handler
- * verifies it against the untarred planDir's `plan.json`; for v2, it
- * verifies it against the content-addressed manifest before invoking the
- * producer. Divergence throws a typed `PLAN_HASH_MISMATCH` so the state
- * machine routes it as non-retryable.
+ * `PlanResult.planHash` from the Plan invocation. The handler verifies it
+ * against the content-addressed manifest before invoking the producer.
+ * Divergence throws a typed `PLAN_HASH_MISMATCH` so the state machine routes
+ * it as non-retryable.
*/
PlanHash: string;
/** 0-based chunk index this invocation should render. */
@@ -106,33 +91,16 @@ interface RenderChunkEventBase {
ChunkOutputS3Prefix: string;
/** Output container format from the plan's encoder.json; drives file vs frame-dir handling. */
Format: DistributedFormat;
-}
-
-/**
- * Legacy chunk event. Callers must select it explicitly.
- *
- * @deprecated Use {@link RenderChunkV2Event} for new integrations.
- */
-export interface RenderChunkV1Event extends RenderChunkEventBase {
- PlanProtocol: "v1";
- /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
- PlanS3Uri: string;
-}
-
-/**
- * V2 chunk event. It intentionally cannot carry `PlanS3Uri`: the manifest
- * describes the exact content-addressed artifacts needed by this chunk.
- */
-export interface RenderChunkV2Event extends RenderChunkEventBase {
PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
-export type RenderChunkEvent = RenderChunkV1Event | RenderChunkV2Event;
+/** Compatibility alias for the only supported chunk event. */
+export type RenderChunkV2Event = RenderChunkEvent;
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
-interface AssembleEventBase {
+export interface AssembleEvent {
Action: "assemble";
/** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */
ChunkS3Uris: string[];
@@ -152,33 +120,19 @@ interface AssembleEventBase {
* unset preserves current `-c copy` behavior.
*/
Cfr?: boolean;
-}
-
-/**
- * Legacy assemble event. Callers must select it explicitly.
- *
- * @deprecated Use {@link AssembleV2Event} for new integrations.
- */
-export interface AssembleV1Event extends AssembleEventBase {
- PlanProtocol: "v1";
- /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
- PlanS3Uri: string;
-}
-
-/** V2 assemble event, scoped to manifest-declared assembler artifacts. */
-export interface AssembleV2Event extends AssembleEventBase {
PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
PlanHash: string;
}
-export type AssembleEvent = AssembleV1Event | AssembleV2Event;
+/** Compatibility alias for the only supported assemble event. */
+export type AssembleV2Event = AssembleEvent;
// ── Result types — kept small to fit Step Functions history budgets ─────────
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
-interface PlanLambdaResultBase {
+export interface PlanLambdaResult {
Action: "plan";
PlanHash: string;
ChunkCount: number;
@@ -193,19 +147,6 @@ interface PlanLambdaResultBase {
FfmpegVersion: string;
ProducerVersion: string;
DurationMs: number;
-}
-
-/**
- * Existing v1 result. Kept unchanged for wire compatibility.
- *
- * @deprecated New integrations should consume {@link PlanV2LambdaResult}.
- */
-export interface PlanV1LambdaResult extends PlanLambdaResultBase {
- PlanS3Uri: string;
-}
-
-/** V2 result. The two v2 locators are never aliases for `PlanS3Uri`. */
-export interface PlanV2LambdaResult extends PlanLambdaResultBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
@@ -213,7 +154,8 @@ export interface PlanV2LambdaResult extends PlanLambdaResultBase {
SeparateAudio: boolean;
}
-export type PlanLambdaResult = PlanV1LambdaResult | PlanV2LambdaResult;
+/** Compatibility alias for the only supported plan result. */
+export type PlanV2LambdaResult = PlanLambdaResult;
export interface AudioLambdaResult {
Action: "audio";
diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts
index 873e1328fe5..bbd4784eca8 100644
--- a/packages/aws-lambda/src/handler.test.ts
+++ b/packages/aws-lambda/src/handler.test.ts
@@ -19,24 +19,19 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:te
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { availableParallelism, tmpdir } from "node:os";
+import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { LambdaClient } from "@aws-sdk/client-lambda";
import { S3Client } from "@aws-sdk/client-s3";
import {
- CURRENT_PLAN_PROTOCOL,
PLAN_V2_MATERIALIZATION_MARKER,
- PlanVideosMetadataError,
type AssembleResult,
type ChunkResult,
- type DistributedRenderConfig,
- type PlanResult,
type PlanV2ArtifactPublisher,
type PlanV2Manifest,
type PlanV2WithPublisherOptions,
- publishPlanV2FromExecutionPlan,
} from "@hyperframes/producer/distributed";
-import { resolveMaxParallelChunks } from "../../producer/src/services/distributed/plan.js";
+import { publishPlanV2FromExecutionPlan } from "../../producer/src/services/distributed/planV2.js";
import { recomputePlanHashFromPlanDir } from "../../producer/src/services/render/stages/freezePlan.js";
import type {
AssembleEvent,
@@ -228,8 +223,9 @@ describe("unwrapEvent", () => {
it("unwraps a Step Functions { Payload } envelope", () => {
const inner: RenderChunkEvent = {
Action: "renderChunk",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
+ PlanProtocol: "v2",
+ PlanV2ManifestS3Uri: "s3://bucket/v2/manifest.json",
+ PlanV2ArtifactS3Prefix: "s3://bucket/v2/artifacts/sha256",
PlanHash: "deadbeef",
ChunkIndex: 3,
ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
@@ -242,8 +238,10 @@ describe("unwrapEvent", () => {
it("unwraps multiple levels of envelopes", () => {
const inner: AssembleEvent = {
Action: "assemble",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
+ PlanProtocol: "v2",
+ PlanV2ManifestS3Uri: "s3://bucket/v2/manifest.json",
+ PlanV2ArtifactS3Prefix: "s3://bucket/v2/artifacts/sha256",
+ PlanHash: "deadbeef",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"],
AudioS3Uri: null,
OutputS3Uri: "s3://bucket/output.mp4",
@@ -274,636 +272,23 @@ describe("handler dispatch", () => {
expect(s3.ops).toEqual([]);
});
- it("preserves explicit v1 plan compatibility", async () => {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- // Seed a fake project tarball so the untar step has something to chew on.
- s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
-
- const planMock = mock(
- async (_projectDir: string, _config: unknown, planDir: string): Promise => {
- // Simulate plan() writing a minimal planDir.
- mkdirSync(planDir, { recursive: true });
- writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
- mkdirSync(join(planDir, "meta"), { recursive: true });
- writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
- return {
- planDir,
- planProtocol: CURRENT_PLAN_PROTOCOL,
- planHash: "fakehash",
- chunkCount: 4,
- totalFrames: 720,
- fps: 30 as const,
- width: 1920,
- height: 1080,
- format: "mp4" as const,
- ffmpegVersion: "6.0",
- producerVersion: "0.0.0-test",
- };
- },
- );
- const renderChunkMock = mock(async () => {
- throw new Error("should not be called");
- });
- const assembleMock = mock(async () => {
- throw new Error("should not be called");
- });
-
- const event: PlanEvent = {
- Action: "plan",
- PlanProtocol: "v1",
- ProjectS3Uri: "s3://bucket/project.tar.gz",
- PlanOutputS3Prefix: "s3://bucket/renders/abc/",
- Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
- };
-
- const result = await handler(event, {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk:
- renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- });
-
- expect(result.Action).toBe("plan");
- if (result.Action !== "plan") throw new Error("unreachable");
- expect(result.PlanHash).toBe("fakehash");
- expect(result.ChunkCount).toBe(4);
- expect(planMock).toHaveBeenCalledTimes(1);
- expect((planMock.mock.calls[0] as unknown[])[3]).toEqual({ chunksCarryAudio: true });
- expect(renderChunkMock).not.toHaveBeenCalled();
- expect(assembleMock).not.toHaveBeenCalled();
- // Plan should have downloaded the project zip and uploaded the plan tar.
- expect(
- s3.ops.some((o) => o.kind === "download" && o.uri === "s3://bucket/project.tar.gz"),
- ).toBe(true);
- });
-
- it("plans against the function's own timeout unless the config pins one", async () => {
- const seen: Array = [];
- for (const pinned of [undefined, 300]) {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
- await handler(
- {
- Action: "plan",
- PlanProtocol: "v1",
- ProjectS3Uri: "s3://bucket/project.tar.gz",
- PlanOutputS3Prefix: "s3://bucket/renders/timeout/",
- Config: { fps: 30, width: 640, height: 360, format: "mp4", workerTimeoutSeconds: pinned },
- },
- {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: mock(async (_dir: string, config: { workerTimeoutSeconds?: number }) => {
- seen.push(config.workerTimeoutSeconds);
- throw new Error("stop after config");
- }) as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- getRemainingTimeInMillis: () => 899_987,
- },
- ).catch(() => undefined);
- }
- expect(seen).toEqual([900, 300]);
- });
-
- describe("chunk concurrency when the render does not set one", () => {
- const THIRTY_MIN_FRAMES = 54_000;
- const MP_1080P = (1920 * 1080) / 1_000_000;
-
- function fakeLambda(reply: () => Promise) {
- const send = mock(reply);
- return { send, client: { send } as unknown as import("@aws-sdk/client-lambda").LambdaClient };
- }
-
- async function planThirtyMinutes(lambda: { client: unknown }, workerConcurrency?: number) {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
- const warnings: string[] = [];
- const log = { warn: (m: string) => warnings.push(m), error() {}, info() {}, debug() {} };
- let planned: DistributedRenderConfig | undefined;
- const result = await handler(
- {
- Action: "plan",
- PlanProtocol: "v1",
- ProjectS3Uri: "s3://bucket/project.tar.gz",
- PlanOutputS3Prefix: "s3://bucket/renders/conc/",
- Config: { fps: 30, width: 1920, height: 1080, format: "mp4", workerConcurrency },
- },
- {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- lambda: lambda.client as import("@aws-sdk/client-lambda").LambdaClient,
- primitives: {
- plan: mock(async (_dir: string, config: DistributedRenderConfig, planDir: string) => {
- planned = config;
- mkdirSync(planDir, { recursive: true });
- writeFileSync(join(planDir, "plan.json"), "{}");
- return {
- planDir,
- planProtocol: CURRENT_PLAN_PROTOCOL,
- planHash: "h",
- // The real sizing rule, so the cap and the chunk count are tested together.
- chunkCount: resolveMaxParallelChunks(config, THIRTY_MIN_FRAMES, MP_1080P, log),
- totalFrames: THIRTY_MIN_FRAMES,
- fps: 30 as const,
- width: 1920,
- height: 1080,
- format: "mp4" as const,
- ffmpegVersion: "6.0",
- producerVersion: "0.0.0-test",
- };
- }) as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- getRemainingTimeInMillis: () => 900_000,
- },
- );
- if (result.Action !== "plan") throw new Error("unreachable");
- return { result, planned, warnings };
- }
-
- it("runs every chunk at once when the account has room", async () => {
- const lambda = fakeLambda(async () => ({
- AccountLimit: { UnreservedConcurrentExecutions: 1000 },
- }));
- const { result, planned, warnings } = await planThirtyMinutes(lambda);
- expect(planned?.workerConcurrency).toBe(900);
- expect(result.ChunkCount).toBe(204);
- expect(result.MaxChunkConcurrency).toBe(result.ChunkCount);
- expect(warnings).toEqual([]);
- });
-
- it("caps at 90% of a small account and says the render runs in waves", async () => {
- const lambda = fakeLambda(async () => ({
- AccountLimit: { UnreservedConcurrentExecutions: 10 },
- }));
- const { result, warnings } = await planThirtyMinutes(lambda);
- expect(result.MaxChunkConcurrency).toBe(9);
- expect(result.ChunkCount).toBe(21);
- expect(warnings).toHaveLength(1);
- expect(warnings[0]).toContain("in 3 waves");
- expect(warnings[0]).toContain("--concurrency 21");
- });
-
- it("falls back to 16 at once and logs why when the account cannot be read", async () => {
- const lambda = fakeLambda(async () => {
- const denied = new Error("not authorized to perform: lambda:GetAccountSettings");
- denied.name = "AccessDeniedException";
- throw denied;
- });
- const logged = spyOn(console, "log").mockImplementation(() => undefined);
- try {
- const { result, planned } = await planThirtyMinutes(lambda);
- expect(planned?.workerConcurrency).toBe(16);
- expect(result.MaxChunkConcurrency).toBe(16);
- const lines = logged.mock.calls.map((call) => String(call[0]));
- const fallback = lines.find((line) => line.includes('"source":"fallback"'));
- expect(fallback).toContain('"level":"WARN"');
- expect(fallback).toContain("AccessDeniedException");
- expect(fallback).toContain("planning for 16 chunks at once");
- } finally {
- logged.mockRestore();
- }
- });
-
- it("keeps an explicit concurrency, then the reserved one, without asking the account", async () => {
- const lambda = fakeLambda(async () => ({
- AccountLimit: { UnreservedConcurrentExecutions: 1000 },
- }));
- const explicit = await planThirtyMinutes(lambda, 64);
- expect(explicit.planned?.workerConcurrency).toBe(64);
- expect(explicit.result.MaxChunkConcurrency).toBe(64);
- process.env.HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY = "32";
- try {
- const reserved = await planThirtyMinutes(lambda);
- expect(reserved.result.MaxChunkConcurrency).toBe(32);
- } finally {
- delete process.env.HYPERFRAMES_LAMBDA_RESERVED_CONCURRENCY;
- }
- expect(lambda.send).not.toHaveBeenCalled();
- });
- });
-
- it("normalizes producer workflow codes to Step Functions error names", async () => {
- for (const code of [
- "PLAN_TOO_LARGE",
- "PLAN_PROTOCOL_UNSUPPORTED",
- "PLAN_V2_INTEGRITY_UNRECOVERABLE",
- "FONT_FETCH_FAILED",
- "FONT_FETCH_UNAVAILABLE",
- "VIDEO_SOURCE_UNRENDERABLE",
- "VIDEO_EXTRACTION_FAILED",
- "INVALID_VIDEO_METADATA",
- "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
- "CHUNK_EXCEEDS_WORKER_TIMEOUT",
- ] as const) {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
- const terminal =
- code === "INVALID_VIDEO_METADATA"
- ? new PlanVideosMetadataError("test invalid plan video metadata")
- : Object.assign(new Error(`terminal: ${code}`), {
- code,
- name: "ProducerError",
- });
-
- await expect(
- handler(
- {
- Action: "plan",
- PlanProtocol: "v1",
- ProjectS3Uri: "s3://bucket/project.tar.gz",
- PlanOutputS3Prefix: "s3://bucket/renders/terminal/",
- Config: { fps: 30, width: 640, height: 360, format: "mp4" },
- },
- {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: mock(async () => {
- throw terminal;
- }) as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble: mock(async () => {
- throw new Error("unused");
- }) as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- },
- ),
- ).rejects.toMatchObject({ name: code });
- }
- });
-
- it("plan honors a pre-set PRODUCER_HEADLESS_SHELL_PATH instead of re-resolving Chrome", async () => {
- // Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
- // RIE smoke) seeds the path, handlePlan must not overwrite it.
- const tmpRoot = makeTmpRoot();
+ it("rejects explicit Plan v1 before touching S3", async () => {
const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
-
- const planMock = mock(
- async (_projectDir: string, _config: unknown, planDir: string): Promise => {
- mkdirSync(planDir, { recursive: true });
- writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
- mkdirSync(join(planDir, "meta"), { recursive: true });
- writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
- return {
- planDir,
- planProtocol: CURRENT_PLAN_PROTOCOL,
- planHash: "fakehash",
- chunkCount: 1,
- totalFrames: 30,
- fps: 30 as const,
- width: 1920,
- height: 1080,
- format: "mp4" as const,
- ffmpegVersion: "6.0",
- producerVersion: "0.0.0-test",
- };
- },
- );
- const renderChunkMock = mock(async () => {
- throw new Error("should not be called");
- });
- const assembleMock = mock(async () => {
- throw new Error("should not be called");
- });
-
- const event: PlanEvent = {
+ const event = {
Action: "plan",
PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
- };
-
- const sentinel = "/tmp/test-chrome-sentinel";
- const prev = process.env.PRODUCER_HEADLESS_SHELL_PATH;
- process.env.PRODUCER_HEADLESS_SHELL_PATH = sentinel;
- try {
- // Note: no skipChromeResolution flag — the guard must short-circuit
- // because PRODUCER_HEADLESS_SHELL_PATH is already set.
- await handler(event, {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk:
- renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- });
- expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe(sentinel);
- expect(planMock).toHaveBeenCalledTimes(1);
- } finally {
- if (prev === undefined) {
- delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
- } else {
- process.env.PRODUCER_HEADLESS_SHELL_PATH = prev;
- }
- }
- });
-
- it("routes Action='renderChunk' to the renderChunk primitive", async () => {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- // Seed a planDir tarball with a minimal structure renderChunk would
- // observe; the test mock doesn't read it, but the handler untar step does.
- s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
-
- const renderChunkMock = mock(
- async (
- _planDir: string,
- _chunkIndex: number,
- outputChunkPath: string,
- ): Promise => {
- // Write a fake chunk file so the upload step has bytes to send.
- writeFileSync(outputChunkPath, Buffer.from("FAKE-MP4-CHUNK"));
- return {
- outputPath: outputChunkPath,
- outputKind: "file",
- framesEncoded: 240,
- sha256: "0".repeat(64),
- durationMs: 12345,
- planHashMs: 1,
- sessionBootMs: 2,
- captureStageMs: 3,
- encodeStageMs: 4,
- workers: 1,
- captureMode: "beginframe",
- perfPath: outputChunkPath + ".perf.json",
- };
- },
- );
-
- const planMock = mock(async () => {
- throw new Error("should not be called");
- });
- const assembleMock = mock(async () => {
- throw new Error("should not be called");
- });
-
- const event: RenderChunkEvent = {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
- PlanHash: "fakehash",
- ChunkIndex: 2,
- ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
- Format: "mp4",
- };
-
- const result = await handler(event, {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk:
- renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- });
-
- expect(result.Action).toBe("renderChunk");
- if (result.Action !== "renderChunk") throw new Error("unreachable");
- expect(result.ChunkIndex).toBe(2);
- expect(result.Sha256).toBe("0".repeat(64));
- expect(result.FramesEncoded).toBe(240);
- expect(result.CaptureMode).toBe("beginframe");
- expect(renderChunkMock).toHaveBeenCalledTimes(1);
- });
-
- it("gives a chunk as many capture browsers as the function's memory can host", async () => {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
- const renderChunkMock = mock(
- async (
- _planDir: string,
- _chunkIndex: number,
- outputChunkPath: string,
- ): Promise => {
- writeFileSync(outputChunkPath, Buffer.from("FAKE-MP4-CHUNK"));
- return {
- outputPath: outputChunkPath,
- outputKind: "file",
- framesEncoded: 121,
- sha256: "0".repeat(64),
- durationMs: 1,
- planHashMs: 1,
- sessionBootMs: 1,
- captureStageMs: 1,
- encodeStageMs: 1,
- workers: 5,
- captureMode: "beginframe",
- perfPath: outputChunkPath + ".perf.json",
- };
- },
- );
- const notCalled = mock(async () => {
- throw new Error("should not be called");
- });
- const renderOnce = () =>
- handler(
- {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
- PlanHash: "fakehash",
- ChunkIndex: 0,
- ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
- Format: "mp4",
- },
- {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: notCalled as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk:
- renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- notCalled as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- },
- );
- const saved = process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE;
- const logged: string[] = [];
- const logSpy = spyOn(console, "log").mockImplementation((line: string) => {
- logged.push(line);
- });
- try {
- delete process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE;
- await renderOnce();
- process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = "10240";
- await renderOnce();
- } finally {
- if (saved === undefined) delete process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE;
- else process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = saved;
- logSpy.mockRestore();
- }
- const options = renderChunkMock.mock.calls.map((call) => (call as unknown[])[3]);
- expect(options).toEqual([
- { maxCaptureWorkers: undefined, interleaveCapture: true },
- {
- maxCaptureWorkers: Math.max(1, Math.min(5, availableParallelism() - 1)),
- interleaveCapture: true,
- },
- ]);
- const captures = logged
- .filter((line) => line.includes('"chunk_capture"'))
- .map((line) => JSON.parse(line));
- const capture = {
- event: "chunk_capture",
- chunkIndex: 0,
- workers: 5,
- captureMode: "beginframe",
- setupMs: expect.any(Number),
- chromeMs: expect.any(Number),
- planHashMs: 1,
- sessionBootMs: 1,
- captureStageMs: 1,
- encodeStageMs: 1,
- durationMs: 1,
- };
- expect(captures).toEqual([capture, capture]);
- });
-
- it("rejects renderChunk when event.PlanHash diverges from plan.json", async () => {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- // The fixture's plan.json has planHash="fakehash"; the event below
- // claims something else, so the handler must throw PLAN_HASH_MISMATCH
- // before invoking the primitive.
- s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
-
- const renderChunkMock = mock(async () => {
- throw new Error("primitive should not be called on a hash mismatch");
- });
- const planMock = mock(async () => {
- throw new Error("should not be called");
- });
- const assembleMock = mock(async () => {
- throw new Error("should not be called");
- });
+ } as unknown as PlanEvent;
- const event: RenderChunkEvent = {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
- PlanHash: "not-the-real-hash",
- ChunkIndex: 0,
- ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
- Format: "mp4",
- };
-
- let caught: unknown;
- try {
- await handler(event, {
+ await expect(
+ handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk:
- renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
skipChromeResolution: true,
- });
- } catch (err) {
- caught = err;
- }
- expect(caught).toBeInstanceOf(Error);
- expect((caught as Error).name).toBe("PLAN_HASH_MISMATCH");
- expect((caught as Error).message).toMatch(/not-the-real-hash/);
- expect(renderChunkMock).not.toHaveBeenCalled();
- });
-
- it("routes Action='assemble' to the assemble primitive", async () => {
- const tmpRoot = makeTmpRoot();
- const s3 = new FakeS3Client();
- s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
- s3.objects.set("s3://bucket/chunks/0001.mp4", Buffer.from("CHUNK-1"));
- s3.objects.set("s3://bucket/chunks/0002.mp4", Buffer.from("CHUNK-2"));
-
- const assembleMock = mock(
- async (
- _planDir: string,
- _chunkPaths: readonly string[],
- _audioPath: string | null,
- outputPath: string,
- ): Promise => {
- writeFileSync(outputPath, Buffer.from("FAKE-FINAL-MP4"));
- return {
- outputPath,
- durationMs: 7777,
- framesEncoded: 480,
- fileSize: 14,
- };
- },
- );
-
- const event: AssembleEvent = {
- Action: "assemble",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://bucket/plan.tar.gz",
- ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"],
- AudioS3Uri: null,
- OutputS3Uri: "s3://bucket/renders/abc/output.mp4",
- Format: "mp4",
- };
-
- const result = await handler(event, {
- s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
- primitives: {
- plan: mock(async () => {
- throw new Error("should not be called");
- }) as unknown as typeof import("@hyperframes/producer/distributed").plan,
- renderChunk: mock(async () => {
- throw new Error("should not be called");
- }) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
- assemble:
- assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
- },
- tmpRoot,
- skipChromeResolution: true,
- });
-
- expect(result.Action).toBe("assemble");
- if (result.Action !== "assemble") throw new Error("unreachable");
- expect(result.OutputS3Uri).toBe("s3://bucket/renders/abc/output.mp4");
- expect(result.FramesEncoded).toBe(480);
- expect(assembleMock).toHaveBeenCalledTimes(1);
+ }),
+ ).rejects.toMatchObject({ name: "PLAN_PROTOCOL_UNSUPPORTED" });
+ expect(s3.ops).toEqual([]);
});
it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
@@ -1616,8 +1001,10 @@ describe("handler — S3 URI allowlist (security: F-004)", () => {
const event: AssembleEvent = {
Action: "assemble",
- PlanProtocol: "v1",
- PlanS3Uri: "s3://good-bucket/plan.tar.gz",
+ PlanProtocol: "v2",
+ PlanV2ManifestS3Uri: "s3://good-bucket/plan/v2/manifest.json",
+ PlanV2ArtifactS3Prefix: "s3://good-bucket/plan/v2/artifacts/sha256",
+ PlanHash: "a".repeat(64),
ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"],
AudioS3Uri: null,
OutputS3Uri: "s3://good-bucket/renders/abc/output.mp4",
@@ -1750,29 +1137,6 @@ async function makeMinimalProjectTar(): Promise {
}
}
-/**
- * Build a minimal `.tar.gz` for a tiny planDir containing `plan.json` +
- * `meta/chunks.json`. Used by renderChunk/assemble tests where the handler
- * untars but the mock primitive doesn't inspect contents.
- */
-async function makeMinimalPlanTar(): Promise {
- const tar = await import("tar");
- const {
- mkdtempSync: mk,
- mkdirSync: md,
- readFileSync: rf,
- writeFileSync: wf,
- } = await import("node:fs");
- const dir = mk(join(tmpdir(), "hf-lambda-test-plan-"));
- tmpDirs.push(dir);
- md(join(dir, "meta"), { recursive: true });
- wf(join(dir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
- wf(join(dir, "meta", "chunks.json"), "[]");
- const tarPath = join(dir, "out.tar.gz");
- await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
- return rf(tarPath);
-}
-
function makeMinimalV1PlanDir(
dir: string,
withAudio: boolean,
diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts
index 684f4407bbb..eb58e987ed2 100644
--- a/packages/aws-lambda/src/handler.ts
+++ b/packages/aws-lambda/src/handler.ts
@@ -11,7 +11,7 @@
* primitive → S3 upload → return small JSON result.
*/
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { availableParallelism, tmpdir } from "node:os";
import { basename, join } from "node:path";
import { GetAccountSettingsCommand, InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
@@ -22,19 +22,16 @@ import {
chunksAreStitchedMp4,
plansSeparateAudio,
stitchesSeparateAudio,
- type AssembleResult,
type ChunkRenderer,
type ChunkResult,
type DistributedRenderConfig,
DEFAULT_MAX_PARALLEL_CHUNKS,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
- plan,
isPlanAudioArtifactPath,
PLAN_AUDIO_RELATIVE_PATH,
resolvePlanAudioPath,
planV2WithPublisher,
- type PlanResult,
isPlanV2DeferredSourceArtifact,
type PlanV2Artifact,
type PlanV2Manifest,
@@ -55,12 +52,9 @@ import type {
LambdaEvent,
LambdaResult,
PlanEvent,
- PlanV2Event,
PlanLambdaResult,
RenderChunkEvent,
- RenderChunkV2Event,
RenderChunkLambdaResult,
- AssembleV2Event,
StitchPartsEvent,
StitchPartsLambdaResult,
} from "./events.js";
@@ -100,7 +94,6 @@ export interface HandlerDeps {
/** Reads the account's concurrency when the render does not set one. */
lambda?: LambdaClient;
primitives?: {
- plan: typeof plan;
planV2WithPublisher?: typeof planV2WithPublisher;
buildPlanAudio?: typeof buildPlanAudio;
renderChunk: ChunkRenderer;
@@ -189,7 +182,6 @@ function normalizeTerminalErrorName(error: unknown): void {
const candidate = error as { code?: unknown; name?: string };
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
- candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "FONT_FETCH_FAILED" ||
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
@@ -251,26 +243,21 @@ function validatePlanProtocolShape(
): void {
const raw = event as unknown as Record;
const protocol = raw.PlanProtocol;
- if (protocol !== undefined && protocol !== "v1" && protocol !== "v2") {
+ if (protocol !== undefined && protocol !== "v2") {
const error = new Error(
- `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`,
+ `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v2" or absent`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
if (event.Action === "plan" || event.Action === "audio") 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) {
+ if (hasV1Locator || !hasV2Manifest || !hasV2Prefix) {
const error = new Error(
- `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
+ `[handler] v2 ${event.Action} event has mixed or missing plan locators`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
@@ -305,7 +292,7 @@ function summarizeEvent(
return {
projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix,
- planProtocol: event.PlanProtocol ?? "v2",
+ planProtocol: "v2",
format: event.Config.format,
fps: event.Config.fps,
};
@@ -318,19 +305,15 @@ function summarizeEvent(
};
case "renderChunk":
return {
- planProtocol: event.PlanProtocol ?? "v2",
- ...(event.PlanProtocol !== "v1"
- ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
- : { planS3Uri: event.PlanS3Uri }),
+ planProtocol: "v2",
+ planV2ManifestS3Uri: event.PlanV2ManifestS3Uri,
chunkIndex: event.ChunkIndex,
format: event.Format,
};
case "assemble":
return {
- planProtocol: event.PlanProtocol ?? "v2",
- ...(event.PlanProtocol !== "v1"
- ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
- : { planS3Uri: event.PlanS3Uri }),
+ planProtocol: "v2",
+ planV2ManifestS3Uri: event.PlanV2ManifestS3Uri,
chunkCount: event.ChunkS3Uris.length,
hasAudio: event.AudioS3Uri !== null,
outputS3Uri: event.OutputS3Uri,
@@ -414,71 +397,6 @@ async function planConfig(
return config;
}
-// The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle.
-// fallow-ignore-next-line complexity
-async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise {
- if (event.PlanProtocol !== "v1") {
- return handlePlanV2(event, deps);
- }
- const started = Date.now();
- const config = await planConfig(event, deps);
- const s3 = deps?.s3 ?? getS3Client();
- const primitive = deps?.primitives?.plan ?? plan;
- await ensureChromePath(deps);
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-"));
- const planDir = join(work, "plan");
-
- try {
- const { projectDir } = await fetchProject(s3, event.ProjectS3Uri, work);
-
- // Same chunk layout as v2, so both protocols produce the same chunks and output.
- const result: PlanResult = await primitive(projectDir, config, planDir, {
- chunksCarryAudio: true,
- });
-
- // Upload the planDir as a single tarball. Step Functions cannot pass
- // a directory-shaped artifact between states; we serialize and rely on
- // the consumer (renderChunk / assemble) to untar. Audio is co-located
- // alongside the plan so RenderChunk doesn't have to pull the whole
- // plan tarball when audio isn't relevant to the chunk.
- const planTar = join(work, "plan.tar.gz");
- await tarDirectory(planDir, planTar);
- const planTarUri = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/plan.tar.gz`;
- const audioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH);
- const hasAudio = existsSync(audioPath) && statSync(audioPath).size > 0;
- const audioUri = hasAudio
- ? `${trimTrailingSlash(event.PlanOutputS3Prefix)}/${PLAN_AUDIO_RELATIVE_PATH}`
- : null;
- // Plan and audio are independent S3 PUTs; run them in parallel so
- // the response returns as soon as the slower of the two completes.
- await Promise.all([
- uploadFileToS3(s3, planTar, planTarUri, "application/gzip"),
- hasAudio && audioUri ? uploadFileToS3(s3, audioPath, audioUri, "audio/aac") : null,
- ]);
-
- return {
- Action: "plan",
- PlanS3Uri: planTarUri,
- PlanHash: result.planHash,
- ChunkCount: result.chunkCount,
- MaxChunkConcurrency: Math.min(config.workerConcurrency, result.chunkCount),
- TotalFrames: result.totalFrames,
- Fps: result.fps,
- Width: result.width,
- Height: result.height,
- Format: result.format,
- HasAudio: audioUri !== null,
- AudioS3Uri: audioUri,
- FfmpegVersion: result.ffmpegVersion,
- ProducerVersion: result.producerVersion,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
/**
* `ProjectS3Uri` is a site manifest from `deploySite`, or a `.tar.gz` of the project
* (hand-built uploads such as `examples/aws-lambda/scripts/smoke.sh`).
@@ -507,10 +425,7 @@ async function fetchProject(
// Plan v2 orchestration is kept as one transactional boundary: stage, CAS upload,
// and manifest-last publication must stay ordered and fail together.
// fallow-ignore-next-line complexity
-async function handlePlanV2(
- event: PlanV2Event,
- deps?: HandlerDeps,
-): Promise> {
+async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise {
const started = Date.now();
const config = await planConfig(event, deps);
const s3 = deps?.s3 ?? getS3Client();
@@ -621,82 +536,11 @@ function lambdaMaxCaptureWorkers(): number | undefined {
return Math.max(1, Math.min(byMemory, availableParallelism() - 1));
}
-async function handleRenderChunk(
- event: RenderChunkEvent,
- deps?: HandlerDeps,
-): Promise {
- if (event.PlanProtocol !== "v1") {
- return handleRenderChunkV2(event, deps);
- }
- const started = Date.now();
- const s3 = deps?.s3 ?? getS3Client();
- const primitive = deps?.primitives?.renderChunk ?? renderChunk;
- const chromeStarted = Date.now();
- await ensureChromePath(deps);
- const chromeMs = Date.now() - chromeStarted;
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-"));
- const planTar = join(work, "plan.tar.gz");
- const planDir = join(work, "plan");
-
- try {
- await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
- await untarDirectory(planTar, planDir);
-
- // Verify the plan's hash matches what Step Functions told us to render.
- // The producer's renderChunk re-checks internally (defense-in-depth),
- // but doing it here at the handler boundary lets us fail before paying
- // the Chrome-launch + render cost on a misrouted chunk. Throws a
- // typed PLAN_HASH_MISMATCH that Step Functions can route as
- // non-retryable.
- verifyPlanHash(planDir, event.PlanHash);
-
- const chunkOutputBase = join(
- work,
- event.Format === "png-sequence"
- ? `chunk-${pad(event.ChunkIndex)}`
- : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`,
- );
-
- const primitiveStarted = Date.now();
- const result: ChunkResult = await primitive(planDir, event.ChunkIndex, chunkOutputBase, {
- maxCaptureWorkers: lambdaMaxCaptureWorkers(),
- interleaveCapture: true,
- });
- logEvent({
- event: "chunk_capture",
- chunkIndex: event.ChunkIndex,
- workers: result.workers,
- captureMode: result.captureMode,
- ...chunkStageTimings(result, primitiveStarted - started, chromeMs),
- });
-
- const chunkUri = await uploadChunkOutput(
- s3,
- result,
- event.ChunkOutputS3Prefix,
- event.ChunkIndex,
- );
-
- return {
- Action: "renderChunk",
- ChunkS3Uri: chunkUri,
- ChunkIndex: event.ChunkIndex,
- Sha256: result.sha256,
- FramesEncoded: result.framesEncoded,
- CaptureMode: result.captureMode,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
// The v2 chunk handler deliberately keeps download, verified materialization,
// render, and upload in one lifecycle so cleanup and errors remain atomic.
// fallow-ignore-next-line complexity
-async function handleRenderChunkV2(
- event: RenderChunkV2Event,
+async function handleRenderChunk(
+ event: RenderChunkEvent,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -789,67 +633,11 @@ async function uploadChunkOutput(
// ── Assemble ────────────────────────────────────────────────────────────────
-async function handleAssemble(
- event: AssembleEvent,
- deps?: HandlerDeps,
-): Promise {
- if (event.PlanProtocol !== "v1") {
- return handleAssembleV2(event, deps);
- }
- const started = Date.now();
- const s3 = deps?.s3 ?? getS3Client();
- const primitive = deps?.primitives?.assemble ?? assemble;
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-"));
- const planTar = join(work, "plan.tar.gz");
- const planDir = join(work, "plan");
-
- try {
- await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
- await untarDirectory(planTar, planDir);
-
- const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
-
- let audioPath: string | null = null;
- if (event.AudioS3Uri) {
- audioPath = resolvePlanAudioPath(planDir) ?? join(planDir, PLAN_AUDIO_RELATIVE_PATH);
- await downloadS3ObjectToFile(s3, event.AudioS3Uri, audioPath);
- }
-
- const finalOutput =
- event.Format === "png-sequence"
- ? join(work, "output-frames")
- : join(work, `output${formatExtension(event.Format)}`);
-
- const result: AssembleResult = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
- cfr: event.Cfr === true,
- });
-
- if (event.Format === "png-sequence") {
- const tarball = `${finalOutput}.tar.gz`;
- await tarDirectory(finalOutput, tarball);
- await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
- } else {
- await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
- }
-
- return {
- Action: "assemble",
- OutputS3Uri: event.OutputS3Uri,
- FramesEncoded: result.framesEncoded,
- FileSize: result.fileSize,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
// Assembly mirrors the chunk lifecycle while adding assembler-only artifacts;
// keeping the steps local makes its temporary-storage ownership explicit.
// fallow-ignore-next-line complexity
-async function handleAssembleV2(
- event: AssembleV2Event,
+async function handleAssemble(
+ event: AssembleEvent,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -873,7 +661,7 @@ async function handleAssembleV2(
return await stitchChunksInS3(s3, event, manifest, started, deps?.lambda);
}
// `downloadAndMaterializePlanV2` materializes atomically. Audio is
- // assembler-only and lives at the familiar v1-compatible location.
+ // assembler-only and lives at the producer's materialized audio location.
let audioPath = resolvePlanAudioPath(planDir);
if (event.AudioS3Uri !== null) {
audioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH);
@@ -915,10 +703,9 @@ async function handleAssembleV2(
cleanupDir(work);
}
}
-
async function stitchChunksInS3(
s3: S3Client,
- event: AssembleV2Event,
+ event: AssembleEvent,
plan: PlanV2Manifest,
started: number,
lambda?: LambdaClient,
@@ -1095,14 +882,11 @@ function getEventS3Uris(
case "audio":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk":
- return event.PlanProtocol !== "v1"
- ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
- : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
+ return [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix];
case "assemble":
return [
- ...(event.PlanProtocol !== "v1"
- ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
- : [event.PlanS3Uri]),
+ event.PlanV2ManifestS3Uri,
+ event.PlanV2ArtifactS3Prefix,
...event.ChunkS3Uris,
event.OutputS3Uri,
event.AudioS3Uri,
@@ -1204,34 +988,3 @@ function cleanupDir(dir: string): void {
// Best-effort — leak is preferable to crashing on success path.
}
}
-
-/**
- * Read the untarred planDir's `plan.json` and assert its `planHash`
- * matches what the Step Functions event claims. Throws on mismatch with
- * a typed `PLAN_HASH_MISMATCH` error name so the state machine's typed
- * non-retryable list routes it correctly.
- *
- * This is defense-in-depth — the producer's `renderChunk` does the same
- * check internally — but performing it here lets us fail before paying
- * the Chrome-launch + per-frame capture cost on a misrouted chunk.
- */
-function verifyPlanHash(planDir: string, expected: string): void {
- const planJsonPath = join(planDir, "plan.json");
- let parsed: { planHash?: unknown };
- try {
- parsed = JSON.parse(readFileSync(planJsonPath, "utf-8")) as { planHash?: unknown };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`);
- error.name = "PLAN_HASH_MISMATCH";
- throw error;
- }
- const actual = parsed.planHash;
- if (typeof actual !== "string" || actual !== expected) {
- const error = new Error(
- `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`,
- );
- error.name = "PLAN_HASH_MISMATCH";
- throw error;
- }
-}
diff --git a/packages/aws-lambda/src/index.ts b/packages/aws-lambda/src/index.ts
index 144e861a695..d3ec0aa03ad 100644
--- a/packages/aws-lambda/src/index.ts
+++ b/packages/aws-lambda/src/index.ts
@@ -28,19 +28,14 @@ export {
type AssembleLambdaResult,
type LambdaAction,
type LambdaEvent,
- type LambdaPlanProtocol,
type LambdaResult,
type PlanEvent,
type PlanLambdaResult,
- type PlanV1Event,
- type PlanV1LambdaResult,
type PlanV2Event,
type PlanV2LambdaResult,
type RenderChunkEvent,
type RenderChunkLambdaResult,
- type RenderChunkV1Event,
type RenderChunkV2Event,
- type AssembleV1Event,
type AssembleV2Event,
type SerializableDistributedRenderConfig,
} from "./events.js";
diff --git a/packages/aws-lambda/src/sdk/renderToLambda.test.ts b/packages/aws-lambda/src/sdk/renderToLambda.test.ts
index e0cd9fafc85..1a18b211816 100644
--- a/packages/aws-lambda/src/sdk/renderToLambda.test.ts
+++ b/packages/aws-lambda/src/sdk/renderToLambda.test.ts
@@ -22,10 +22,14 @@ const CURRENT_DEFINITION = JSON.stringify({
class FakeSFN {
starts: CapturedStart[] = [];
+ describes = 0;
definition = CURRENT_DEFINITION;
async send(command: unknown): Promise {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
- if (cmdName === "DescribeStateMachineCommand") return { definition: this.definition };
+ if (cmdName === "DescribeStateMachineCommand") {
+ this.describes++;
+ return { definition: this.definition };
+ }
if (cmdName === "StartExecutionCommand") {
const input = (command as { input: { stateMachineArn: string; name: string; input: string } })
.input;
@@ -66,6 +70,27 @@ afterEach(() => {
});
describe("renderToLambda", () => {
+ it("rejects a stale v1 SDK option before any AWS work", async () => {
+ const sfn = new FakeSFN();
+ const s3 = new FakeS3();
+ const staleOptions = {
+ projectDir,
+ bucketName: "test-bucket",
+ stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
+ config: baseConfig,
+ planProtocol: "v1",
+ sfn: asSFNClient(sfn),
+ s3: asS3Client(s3),
+ };
+
+ await expect(renderToLambda(staleOptions)).rejects.toMatchObject({
+ name: "PLAN_PROTOCOL_UNSUPPORTED",
+ });
+ expect(sfn.describes).toBe(0);
+ expect(sfn.starts).toHaveLength(0);
+ expect(s3.ops).toHaveLength(0);
+ });
+
it("returns a handle and starts a state-machine execution with the right input", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
@@ -107,29 +132,6 @@ describe("renderToLambda", () => {
});
});
- it("preserves explicit plan protocol v1 compatibility", async () => {
- const sfn = new FakeSFN();
- const s3 = new FakeS3();
- const handle = await renderToLambda({
- projectDir,
- bucketName: "test-bucket",
- stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
- config: baseConfig,
- executionName: "smoke-v1",
- planProtocol: "v1",
- sfn: asSFNClient(sfn),
- s3: asS3Client(s3),
- });
-
- expect(sfn.starts[0]?.input).toEqual({
- ProjectS3Uri: handle.projectS3Uri,
- PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v1/",
- OutputS3Uri: "s3://test-bucket/renders/smoke-v1/output.mp4",
- Config: baseConfig,
- PlanProtocol: "v1",
- });
- });
-
it("derives the file extension from config.format", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
diff --git a/packages/aws-lambda/src/sdk/renderToLambda.ts b/packages/aws-lambda/src/sdk/renderToLambda.ts
index d9136266571..6988f0291be 100644
--- a/packages/aws-lambda/src/sdk/renderToLambda.ts
+++ b/packages/aws-lambda/src/sdk/renderToLambda.ts
@@ -20,7 +20,7 @@
import { randomUUID } from "node:crypto";
import { DescribeStateMachineCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
import { S3Client } from "@aws-sdk/client-s3";
-import type { LambdaPlanProtocol, SerializableDistributedRenderConfig } from "../events.js";
+import type { SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatS3Uri } from "../s3Transport.js";
import { SITE_MANIFEST_INVALID } from "../siteManifest.js";
@@ -39,11 +39,6 @@ export interface RenderToLambdaOptions {
onNotice?: (message: string) => void;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
- /**
- * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
- * explicitly only for deprecated compatibility with the monolithic plan.
- */
- planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
bucketName: string;
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
@@ -80,8 +75,20 @@ export interface RenderHandle {
startedAt: string;
}
+function rejectUnsupportedLegacyPlanProtocol(opts: object): void {
+ const planProtocol = Reflect.get(opts, "planProtocol");
+ if (planProtocol === undefined || planProtocol === "v2") return;
+
+ const error = new Error(
+ `[renderToLambda] planProtocol is no longer configurable; expected "v2" or absent, got ${JSON.stringify(planProtocol)}`,
+ );
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
+ throw error;
+}
+
// fallow-ignore-next-line complexity
export async function renderToLambda(opts: RenderToLambdaOptions): Promise {
+ rejectUnsupportedLegacyPlanProtocol(opts);
validateDistributedRenderConfig(opts.config);
if (!opts.bucketName) {
@@ -125,7 +132,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise {
+ it("rejects a stale v1 SDK option before any GCP work", async () => {
+ const fake = new FakeExecutions();
+ const staleOptions = { ...opts(fake), planProtocol: "v1" };
+
+ await expect(renderToCloudRun(staleOptions)).rejects.toMatchObject({
+ name: "PLAN_PROTOCOL_UNSUPPORTED",
+ });
+ expect(fake.lastArgument).toBeNull();
+ expect(fake.lastParent).toBeNull();
+ });
+
it("starts an execution and returns a handle", async () => {
const fake = new FakeExecutions();
const handle = await renderToCloudRun(opts(fake));
@@ -95,21 +106,6 @@ describe("renderToCloudRun", () => {
);
});
- it("preserves explicit plan protocol v1 compatibility", async () => {
- const fake = new FakeExecutions();
- await renderToCloudRun({ ...opts(fake), planProtocol: "v1" });
- const arg = JSON.parse(fake.lastArgument ?? "{}");
- expect(arg).toEqual({
- RenderId: "hf-render-fixed",
- ProjectGcsUri: "gs://b/sites/abc/project.tar.gz",
- PlanOutputGcsPrefix: "gs://b/renders/hf-render-fixed/",
- OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4",
- ServiceUrl: "https://render-abc.run.app",
- Config: config,
- PlanProtocol: "v1",
- });
- });
-
it("derives the output extension from the format", async () => {
const fake = new FakeExecutions();
const handle = await renderToCloudRun({
diff --git a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
index 55cb20a3c18..ac02060a13d 100644
--- a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
+++ b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
@@ -26,7 +26,7 @@
import { randomUUID } from "node:crypto";
import type { Storage } from "@google-cloud/storage";
-import type { CloudRunPlanProtocol, SerializableDistributedRenderConfig } from "../events.js";
+import type { SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatGcsUri } from "../gcsTransport.js";
import { deploySite, type SiteHandle } from "./deploySite.js";
@@ -52,11 +52,6 @@ export interface RenderToCloudRunOptions {
siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
- /**
- * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
- * explicitly only for deprecated compatibility with the monolithic plan.
- */
- planProtocol?: CloudRunPlanProtocol;
/** GCS bucket from the Terraform output (`render_bucket_name`). */
bucketName: string;
/** GCP project id hosting the workflow. */
@@ -102,8 +97,20 @@ export interface RenderHandle {
startedAt: string;
}
+function rejectUnsupportedLegacyPlanProtocol(opts: object): void {
+ const planProtocol = Reflect.get(opts, "planProtocol");
+ if (planProtocol === undefined || planProtocol === "v2") return;
+
+ const error = new Error(
+ `[renderToCloudRun] planProtocol is no longer configurable; expected "v2" or absent, got ${JSON.stringify(planProtocol)}`,
+ );
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
+ throw error;
+}
+
// fallow-ignore-next-line complexity
export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise {
+ rejectUnsupportedLegacyPlanProtocol(opts);
validateDistributedRenderConfig(opts.config);
if (!opts.bucketName) throw new Error("[renderToCloudRun] bucketName is required");
@@ -149,7 +156,7 @@ export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise {
gcs.seedFromFile(uri, tarPath);
}
-/** Build a real plan tarball containing plan.json, seed it, return its URI. */
-async function seedPlanTar(gcs: FakeGcs, uri: string, planHash: string): Promise {
- const planDir = mkTmp("hf-plan-");
- writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash }));
- const tarPath = join(mkTmp("hf-plan-tar-"), "plan.tar.gz");
- await tarDirectory(planDir, tarPath);
- gcs.seedFromFile(uri, tarPath);
-}
-
function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
mkdirSync(join(dir, "meta"), { recursive: true });
mkdirSync(join(dir, "compiled"), { recursive: true });
@@ -92,29 +78,10 @@ function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
}
-const planResult: PlanResult = {
- planDir: "(set at call time)",
- planProtocol: CURRENT_PLAN_PROTOCOL,
- planHash: PLAN_HASH,
- chunkCount: 3,
- totalFrames: 90,
- fps: 30,
- width: 1920,
- height: 1080,
- format: "mp4",
- ffmpegVersion: "ffmpeg version 6.1.1",
- producerVersion: "0.6.79",
-};
-
function depsWith(
gcs: FakeGcs,
overrides: Partial> = {},
): HandlerDeps {
- const plan = async (_projectDir: string, _config: unknown, planDir: string) => {
- mkdirSync(planDir, { recursive: true });
- writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: PLAN_HASH }));
- return planResult;
- };
const renderChunk = async (_planDir: string, chunkIndex: number, outputBase: string) => {
writeFileSync(outputBase, Buffer.from(`chunk-${chunkIndex}`));
return {
@@ -144,9 +111,7 @@ function depsWith(
return {
storage: asStorage(gcs),
skipChromeResolution: true,
- primitives: { plan, renderChunk, assemble, ...overrides } as NonNullable<
- HandlerDeps["primitives"]
- >,
+ primitives: { renderChunk, assemble, ...overrides } as NonNullable,
};
}
@@ -178,83 +143,22 @@ describe("unwrapEvent", () => {
});
describe("dispatch", () => {
- it("preserves explicit v1 plan compatibility", async () => {
+ it("rejects explicit Plan v1 before touching GCS", async () => {
const gcs = new FakeGcs();
- await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz");
- const event: PlanEvent = {
+ const event = {
Action: "plan",
PlanProtocol: "v1",
- ProjectGcsUri: "gs://b/sites/x/project.tar.gz",
+ ProjectGcsUri: "gs://b/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/r1/",
- Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"],
- };
- const res = await dispatch(event, depsWith(gcs));
- expect(res.Action).toBe("plan");
- if (res.Action !== "plan") throw new Error("unreachable");
- expect(res.PlanHash).toBe(PLAN_HASH);
- expect(res.ChunkCount).toBe(3);
- expect(res.PlanGcsUri).toBe("gs://b/renders/r1/plan.tar.gz");
- expect(gcs.objects.has("gs://b/renders/r1/plan.tar.gz")).toBe(true);
- });
-
- it("routes renderChunk and uploads the chunk", async () => {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const event: RenderChunkEvent = {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 2,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- };
- const res = await dispatch(event, depsWith(gcs));
- if (res.Action !== "renderChunk") throw new Error("unreachable");
- expect(res.ChunkIndex).toBe(2);
- expect(res.CaptureMode).toBe("beginframe");
- expect(res.ChunkGcsUri).toBe("gs://b/renders/r1/chunks/0002.mp4");
- expect(gcs.objects.has("gs://b/renders/r1/chunks/0002.mp4")).toBe(true);
- });
+ Config: { fps: 30, width: 640, height: 360, format: "mp4" },
+ } as unknown as PlanEvent;
- it("throws PLAN_HASH_MISMATCH when the event hash disagrees", async () => {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const event: RenderChunkEvent = {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: "WRONG_HASH",
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- };
- await expect(dispatch(event, depsWith(gcs))).rejects.toThrow(/PLAN_HASH_MISMATCH/);
- });
-
- it("routes assemble and uploads the final output", async () => {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- gcs.seed("gs://b/renders/r1/chunks/0000.mp4", Buffer.from("c0"));
- gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1"));
- const event: AssembleEvent = {
- Action: "assemble",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"],
- AudioGcsUri: null,
- OutputGcsUri: "gs://b/renders/r1/output.mp4",
- Format: "mp4",
- };
- const res = await dispatch(event, depsWith(gcs));
- if (res.Action !== "assemble") throw new Error("unreachable");
- expect(res.FramesEncoded).toBe(90);
- expect(gcs.objects.has("gs://b/renders/r1/output.mp4")).toBe(true);
+ await expect(
+ dispatch(event, { storage: asStorage(gcs), skipChromeResolution: true }),
+ ).rejects.toMatchObject({ name: "PLAN_PROTOCOL_UNSUPPORTED" });
+ expect(gcs.ops).toEqual([]);
});
- // This end-to-end adapter contract is intentionally one narrative test: it
- // verifies ordering and target isolation across all three handler roles.
- // fallow-ignore-next-line complexity
it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/v2/project.tar.gz");
@@ -409,8 +313,9 @@ describe("bucket allowlist guard", () => {
try {
const event: RenderChunkEvent = {
Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://evil-bucket/plan.tar.gz",
+ PlanProtocol: "v2",
+ PlanV2ManifestGcsUri: "gs://evil-bucket/v2/manifest.json",
+ PlanV2ArtifactGcsPrefix: "gs://allowed-bucket/v2/artifacts/sha256",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
ChunkOutputGcsPrefix: "gs://allowed-bucket/renders/r1/",
@@ -446,21 +351,26 @@ describe("bucket allowlist guard", () => {
it('treats HYPERFRAMES_RENDER_BUCKET="*" as an explicit opt-out (off-bucket allowed)', async () => {
const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://any-bucket/renders/r1/plan.tar.gz", PLAN_HASH);
+ await seedProjectTar(gcs, "gs://any-bucket/project.tar.gz");
const prev = process.env.HYPERFRAMES_RENDER_BUCKET;
process.env.HYPERFRAMES_RENDER_BUCKET = "*";
try {
- const event: RenderChunkEvent = {
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://any-bucket/renders/r1/",
- Format: "mp4",
+ const event: PlanEvent = {
+ Action: "plan",
+ ProjectGcsUri: "gs://any-bucket/project.tar.gz",
+ PlanOutputGcsPrefix: "gs://any-bucket/renders/r1/",
+ Config: { fps: 30, width: 640, height: 360, format: "mp4" },
};
- const res = await dispatch(event, depsWith(gcs));
- expect(res.Action).toBe("renderChunk");
+ await expect(
+ dispatch(
+ event,
+ depsWith(gcs, {
+ planV2WithPublisher: async () => {
+ throw new Error("reached primitive");
+ },
+ }),
+ ),
+ ).rejects.toThrow("reached primitive");
} finally {
if (prev === undefined) delete process.env.HYPERFRAMES_RENDER_BUCKET;
else process.env.HYPERFRAMES_RENDER_BUCKET = prev;
@@ -469,205 +379,88 @@ describe("bucket allowlist guard", () => {
});
describe("createApp HTTP mapping", () => {
- it("returns 200 with the result body on success", async () => {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const app = createApp(depsWith(gcs));
- const res = await app.request("/", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { Action: string };
- expect(body.Action).toBe("renderChunk");
- });
+ const planBody = {
+ Action: "plan",
+ ProjectGcsUri: "gs://b/project.tar.gz",
+ PlanOutputGcsPrefix: "gs://b/renders/r1/",
+ Config: { fps: 30, width: 640, height: 360, format: "mp4" },
+ };
- it("returns 400 for a non-retryable error (plan-hash mismatch)", async () => {
+ it("returns 200 with a Plan v2 result", async () => {
const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const app = createApp(depsWith(gcs));
+ await seedProjectTar(gcs, planBody.ProjectGcsUri);
+ const manifest: PlanV2Manifest = {
+ protocol: {
+ schemaVersion: 2,
+ artifactLayout: "content-addressed-plan-v2",
+ hashSchema: "hyperframes-plan-manifest-hash-v2",
+ },
+ planHash: "a".repeat(64),
+ sourcePlanV1Hash: "b".repeat(64),
+ chunkCount: 1,
+ totalFrames: 30,
+ fps: 30,
+ width: 640,
+ height: 360,
+ format: "mp4",
+ ffmpegVersion: "6.0",
+ producerVersion: "test",
+ limitations: { videoDependencyMode: "source-extract" },
+ artifacts: [],
+ };
+ const app = createApp(depsWith(gcs, { planV2WithPublisher: async () => manifest }));
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: "WRONG",
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
+ body: JSON.stringify(planBody),
});
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("PLAN_HASH_MISMATCH");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ Action: "plan", PlanProtocol: "v2" });
});
- it("returns 400 for an unsupported plan protocol", async () => {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const app = createApp(
- depsWith(gcs, {
- renderChunk: async () => {
- throw new PlanProtocolUnsupportedError("unsupported test protocol");
- },
- }),
- );
+ it("returns 400 for explicit Plan v1", async () => {
+ const app = createApp(depsWith(new FakeGcs()));
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
+ body: JSON.stringify({ ...planBody, PlanProtocol: "v1" }),
});
-
expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("PLAN_PROTOCOL_UNSUPPORTED");
- });
-
- it("returns 400 for plan v2 integrity error names and code aliases", async () => {
- for (const error of [
- new PlanV2IntegrityError("corrupt test artifact"),
- Object.assign(new Error("corrupt test artifact"), {
- name: PLAN_V2_INTEGRITY_UNRECOVERABLE,
- }),
- Object.assign(new Error("corrupt test artifact"), {
- code: PLAN_V2_INTEGRITY_UNRECOVERABLE,
- }),
- ]) {
- const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
- const app = createApp(
- depsWith(gcs, {
- renderChunk: async () => {
- throw error;
- },
- }),
- );
- const res = await app.request("/", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe(PLAN_V2_INTEGRITY_UNRECOVERABLE);
- }
+ expect(await res.json()).toMatchObject({ error: "PLAN_PROTOCOL_UNSUPPORTED" });
});
it.each([
+ ["INVALID_VIDEO_METADATA", 400],
+ [PLAN_V2_INTEGRITY_UNRECOVERABLE, 400],
["VIDEO_SOURCE_UNRENDERABLE", 400],
["VIDEO_EXTRACTION_FAILED", 500],
["FONT_FETCH_FAILED", 400],
["FONT_FETCH_UNAVAILABLE", 500],
["CHUNK_EXCEEDS_WORKER_TIMEOUT", 400],
+ ["UNKNOWN_RETRYABLE", 500],
] as const)("routes producer code %s with HTTP %s", async (code, expectedStatus) => {
const gcs = new FakeGcs();
- await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
+ await seedProjectTar(gcs, planBody.ProjectGcsUri);
const app = createApp(
depsWith(gcs, {
- renderChunk: async () => {
- throw Object.assign(new Error(`test ${code}`), {
- name: "ProducerError",
- code,
- });
+ planV2WithPublisher: async () => {
+ throw Object.assign(new Error(`test ${code}`), { name: "ProducerError", code });
},
}),
);
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
+ body: JSON.stringify(planBody),
});
-
expect(res.status).toBe(expectedStatus);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe(code);
- });
-
- it("routes the real plan metadata error as non-retryable", async () => {
- const gcs = new FakeGcs();
- await seedProjectTar(gcs, "gs://b/sites/invalid-video-metadata/project.tar.gz");
- const app = createApp(
- depsWith(gcs, {
- plan: async () => {
- throw new PlanVideosMetadataError("test invalid plan video metadata");
- },
- }),
- );
- const res = await app.request("/", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "plan",
- PlanProtocol: "v1",
- ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
- PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
- Config: { fps: 30, width: 640, height: 360, format: "mp4" },
- }),
+ expect(await res.json()).toMatchObject({
+ error: code === "UNKNOWN_RETRYABLE" ? "ProducerError" : code,
});
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("INVALID_VIDEO_METADATA");
- });
-
- it("returns 500 for a retryable/unknown error", async () => {
- const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable)
- const app = createApp(depsWith(gcs));
- const res = await app.request("/", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- Action: "renderChunk",
- PlanProtocol: "v1",
- PlanGcsUri: "gs://b/renders/r1/missing.tar.gz",
- PlanHash: PLAN_HASH,
- ChunkIndex: 0,
- ChunkOutputGcsPrefix: "gs://b/renders/r1/",
- Format: "mp4",
- }),
- });
- expect(res.status).toBe(500);
});
it("returns 400 when the body is not JSON", async () => {
- const gcs = new FakeGcs();
- const app = createApp(depsWith(gcs));
+ const app = createApp(depsWith(new FakeGcs()));
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
diff --git a/packages/gcp-cloud-run/src/server.ts b/packages/gcp-cloud-run/src/server.ts
index 6ebe554085d..1eae3b220db 100644
--- a/packages/gcp-cloud-run/src/server.ts
+++ b/packages/gcp-cloud-run/src/server.ts
@@ -16,7 +16,7 @@
* adapters stay easy to diff.
*/
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
+import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, extname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -25,18 +25,13 @@ import { Storage } from "@google-cloud/storage";
import { Hono } from "hono";
import {
assemble,
- type AssembleResult,
type ChunkRenderer,
type ChunkResult,
- type DistributedRenderConfig,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
- plan,
isPlanAudioArtifactPath,
- PLAN_AUDIO_RELATIVE_PATH,
resolvePlanAudioPath,
planV2WithPublisher,
- type PlanResult,
type PlanV2Artifact,
type PlanV2Manifest,
type PlanV2MaterializationTarget,
@@ -46,16 +41,13 @@ import {
import { resolveChromeExecutablePath } from "./chromium.js";
import type {
AssembleEvent,
- AssembleV2Event,
AssembleResultBody,
CloudRunAction,
CloudRunEvent,
CloudRunResult,
PlanEvent,
- PlanV2Event,
PlanResultBody,
RenderChunkEvent,
- RenderChunkV2Event,
RenderChunkResultBody,
} from "./events.js";
import { type DistributedFormat, formatExtension } from "./formatExtension.js";
@@ -89,7 +81,6 @@ function getStorage(): Storage {
export interface HandlerDeps {
storage?: Storage;
primitives?: {
- plan: typeof plan;
planV2WithPublisher?: typeof planV2WithPublisher;
renderChunk: ChunkRenderer;
assemble: typeof assemble;
@@ -149,31 +140,27 @@ export async function dispatch(event: CloudRunEvent, deps?: HandlerDeps): Promis
function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | AssembleEvent): void {
const raw = event as unknown as Record;
const protocol = raw.PlanProtocol;
- if (protocol !== undefined && protocol !== "v1" && protocol !== "v2") {
+ if (protocol !== undefined && protocol !== "v2") {
const error = new Error(
- `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`,
+ `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v2" or absent`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
if (event.Action === "plan") return;
- const effectiveProtocol = protocol ?? "v2";
const hasV1Locator = typeof raw.PlanGcsUri === "string";
const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string";
const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string";
- const valid =
- effectiveProtocol === "v2"
- ? !hasV1Locator && hasV2Manifest && hasV2Prefix
- : hasV1Locator && !hasV2Manifest && !hasV2Prefix;
+ const valid = !hasV1Locator && hasV2Manifest && hasV2Prefix;
if (!valid) {
const error = new Error(
- `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
+ `[handler] v2 ${event.Action} event has mixed or missing plan locators`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
- if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) {
+ if (event.Action === "assemble" && event.AudioGcsUri !== null) {
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
@@ -188,7 +175,6 @@ function normalizeTerminalErrorName(error: unknown): void {
const candidate = error as { code?: unknown; name?: string };
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
- candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "FONT_FETCH_FAILED" ||
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
@@ -266,18 +252,14 @@ function summarizeEvent(
case "renderChunk":
return {
planProtocol: event.PlanProtocol ?? "v2",
- ...(event.PlanProtocol !== "v1"
- ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
- : { planGcsUri: event.PlanGcsUri }),
+ planV2ManifestGcsUri: event.PlanV2ManifestGcsUri,
chunkIndex: event.ChunkIndex,
format: event.Format,
};
case "assemble":
return {
planProtocol: event.PlanProtocol ?? "v2",
- ...(event.PlanProtocol !== "v1"
- ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
- : { planGcsUri: event.PlanGcsUri }),
+ planV2ManifestGcsUri: event.PlanV2ManifestGcsUri,
chunkCount: event.ChunkGcsUris.length,
hasAudio: event.AudioGcsUri !== null,
outputGcsUri: event.OutputGcsUri,
@@ -300,79 +282,7 @@ function primeChrome(deps?: HandlerDeps): void {
// ── Plan ────────────────────────────────────────────────────────────────────
-// fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise {
- if (event.PlanProtocol !== "v1") {
- return handlePlanV2(event, deps);
- }
- const started = Date.now();
- const storage = deps?.storage ?? getStorage();
- const primitive = deps?.primitives?.plan ?? plan;
-
- // The producer's probe stage launches Chromium whenever the composition
- // needs a runtime duration probe or has unresolved sub-compositions, so
- // plan has to resolve Chrome the same way renderChunk does.
- primeChrome(deps);
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-plan-"));
- const projectArchive = join(work, "project.tar.gz");
- const projectDir = join(work, "project");
- const planDir = join(work, "plan");
-
- try {
- await downloadGcsObjectToFile(storage, event.ProjectGcsUri, projectArchive);
- await untarDirectory(projectArchive, projectDir);
-
- const config: DistributedRenderConfig = {
- ...event.Config,
- };
- const result: PlanResult = await primitive(projectDir, config, planDir);
-
- // Upload the planDir as a single tarball. The workflow cannot pass a
- // directory-shaped artifact between steps; we serialize and rely on the
- // consumer (renderChunk / assemble) to untar. The audio artifact lives inside
- // planDir, so it already rides along in this tarball — every consumer
- // (including assemble) gets it from the untar. We deliberately do NOT
- // upload a separate audio object: it would duplicate the bytes on every
- // plan upload and be re-downloaded + overwritten by assemble. `AudioGcsUri`
- // stays in the result shape for wire compatibility but is null.
- const planTar = join(work, "plan.tar.gz");
- await tarDirectory(planDir, planTar);
- const planTarUri = `${trimTrailingSlash(event.PlanOutputGcsPrefix)}/plan.tar.gz`;
- const audioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH);
- const hasAudio = existsSync(audioPath) && statSync(audioPath).size > 0;
- await uploadFileToGcs(storage, planTar, planTarUri, "application/gzip");
-
- return {
- Action: "plan",
- PlanGcsUri: planTarUri,
- PlanHash: result.planHash,
- ChunkCount: result.chunkCount,
- TotalFrames: result.totalFrames,
- Fps: result.fps,
- Width: result.width,
- Height: result.height,
- Format: result.format,
- HasAudio: hasAudio,
- AudioGcsUri: null,
- FfmpegVersion: result.ffmpegVersion,
- ProducerVersion: result.producerVersion,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
-/**
- * Publish immutable v2 artifacts directly to GCS, with the manifest as the
- * final commit point. Planner-local paths never cross a worker boundary.
- */
-// fallow-ignore-next-line complexity
-async function handlePlanV2(
- event: PlanV2Event,
- deps?: HandlerDeps,
-): Promise> {
const started = Date.now();
const storage = deps?.storage ?? getStorage();
const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
@@ -422,66 +332,6 @@ async function handlePlanV2(
async function handleRenderChunk(
event: RenderChunkEvent,
deps?: HandlerDeps,
-): Promise {
- if (event.PlanProtocol !== "v1") {
- return handleRenderChunkV2(event, deps);
- }
- const started = Date.now();
- const storage = deps?.storage ?? getStorage();
- const primitive = deps?.primitives?.renderChunk ?? renderChunk;
-
- primeChrome(deps);
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-chunk-"));
- const planTar = join(work, "plan.tar.gz");
- const planDir = join(work, "plan");
-
- try {
- await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
- await untarDirectory(planTar, planDir);
-
- // Verify the plan's hash matches what the workflow told us to render.
- // The producer's renderChunk re-checks internally (defense-in-depth),
- // but doing it here at the handler boundary lets us fail before paying
- // the Chrome-launch + render cost on a misrouted chunk. Throws a typed
- // PLAN_HASH_MISMATCH the workflow can route as non-retryable.
- verifyPlanHash(planDir, event.PlanHash);
-
- const chunkOutputBase = join(
- work,
- event.Format === "png-sequence"
- ? `chunk-${pad(event.ChunkIndex)}`
- : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`,
- );
-
- const result: ChunkResult = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
-
- const chunkUri = await uploadChunkOutput(
- storage,
- result,
- event.ChunkOutputGcsPrefix,
- event.ChunkIndex,
- );
-
- return {
- Action: "renderChunk",
- ChunkGcsUri: chunkUri,
- ChunkIndex: event.ChunkIndex,
- Sha256: result.sha256,
- FramesEncoded: result.framesEncoded,
- CaptureMode: result.captureMode,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
-/** Materialize only this chunk's verified v2 dependencies before rendering. */
-// fallow-ignore-next-line complexity
-async function handleRenderChunkV2(
- event: RenderChunkV2Event,
- deps?: HandlerDeps,
): Promise {
const started = Date.now();
const storage = deps?.storage ?? getStorage();
@@ -552,74 +402,6 @@ async function uploadChunkOutput(
async function handleAssemble(
event: AssembleEvent,
deps?: HandlerDeps,
-): Promise {
- if (event.PlanProtocol !== "v1") {
- return handleAssembleV2(event, deps);
- }
- const started = Date.now();
- const storage = deps?.storage ?? getStorage();
- const primitive = deps?.primitives?.assemble ?? assemble;
-
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-assemble-"));
- const planTar = join(work, "plan.tar.gz");
- const planDir = join(work, "plan");
-
- try {
- await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
- await untarDirectory(planTar, planDir);
-
- const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format);
-
- // Audio rides inside the plan tarball, so it's already on disk after the
- // untar above — no separate download. Fall back to a supplied AudioGcsUri
- // only for backward compatibility with an older Plan that uploaded it
- // standalone.
- let audioPath: string | null = null;
- const planAudio = resolvePlanAudioPath(planDir) ?? join(planDir, PLAN_AUDIO_RELATIVE_PATH);
- if (existsSync(planAudio) && statSync(planAudio).size > 0) {
- audioPath = planAudio;
- } else if (event.AudioGcsUri) {
- audioPath = planAudio;
- await downloadGcsObjectToFile(storage, event.AudioGcsUri, audioPath);
- }
-
- const finalOutput =
- event.Format === "png-sequence"
- ? join(work, "output-frames")
- : join(work, `output${formatExtension(event.Format)}`);
-
- const result: AssembleResult = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
- cfr: event.Cfr === true,
- });
-
- if (event.Format === "png-sequence") {
- const tarball = `${finalOutput}.tar.gz`;
- await tarDirectory(finalOutput, tarball);
- await uploadFileToGcs(storage, tarball, event.OutputGcsUri, "application/gzip");
- } else {
- await uploadFileToGcs(storage, finalOutput, event.OutputGcsUri);
- }
-
- return {
- Action: "assemble",
- OutputGcsUri: event.OutputGcsUri,
- FramesEncoded: result.framesEncoded,
- FileSize: result.fileSize,
- DurationMs: Date.now() - started,
- };
- } finally {
- cleanupDir(work);
- }
-}
-
-/**
- * Materialize the assembler target. Audio is declared assembler-only by the
- * v2 manifest and therefore is never downloaded by chunk workers.
- */
-// fallow-ignore-next-line complexity
-async function handleAssembleV2(
- event: AssembleV2Event,
- deps?: HandlerDeps,
): Promise {
const started = Date.now();
const storage = deps?.storage ?? getStorage();
@@ -786,14 +568,15 @@ function getEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): s
case "plan":
return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
case "renderChunk":
- return event.PlanProtocol !== "v1"
- ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix]
- : [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
+ return [
+ event.PlanV2ManifestGcsUri,
+ event.PlanV2ArtifactGcsPrefix,
+ event.ChunkOutputGcsPrefix,
+ ];
case "assemble":
return [
- ...(event.PlanProtocol !== "v1"
- ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix]
- : [event.PlanGcsUri]),
+ event.PlanV2ManifestGcsUri,
+ event.PlanV2ArtifactGcsPrefix,
...event.ChunkGcsUris,
event.OutputGcsUri,
event.AudioGcsUri,
@@ -865,36 +648,6 @@ function cleanupDir(dir: string): void {
}
}
-/**
- * Read the untarred planDir's `plan.json` and assert its `planHash` matches
- * what the workflow event claims. Throws on mismatch with a typed
- * `PLAN_HASH_MISMATCH` error name so the workflow's non-retryable list
- * routes it correctly. Defense-in-depth — the producer's `renderChunk` does
- * the same check internally — but performing it here lets us fail before
- * paying the Chrome-launch + per-frame capture cost on a misrouted chunk.
- */
-// fallow-ignore-next-line complexity
-function verifyPlanHash(planDir: string, expected: string): void {
- const planJsonPath = join(planDir, "plan.json");
- let parsed: { planHash?: unknown };
- try {
- parsed = JSON.parse(readFileSync(planJsonPath, "utf-8")) as { planHash?: unknown };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`);
- error.name = "PLAN_HASH_MISMATCH";
- throw error;
- }
- const actual = parsed.planHash;
- if (typeof actual !== "string" || actual !== expected) {
- const error = new Error(
- `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`,
- );
- error.name = "PLAN_HASH_MISMATCH";
- throw error;
- }
-}
-
// ── HTTP shell ───────────────────────────────────────────────────────────────
/**
@@ -922,13 +675,11 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
// non-retryable list.
"FormatNotSupportedInDistributedError",
"NotMediaPayloadError",
- "PlanTooLargeError",
"PlanProtocolUnsupportedError",
"PlanV2IntegrityError",
"RenderChunkValidationError",
"FFMPEG_VERSION_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
- "PLAN_TOO_LARGE",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"ChromeBinaryUnavailableError",
diff --git a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
index faaa03b1d46..2b65e92e546 100644
--- a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
+++ b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
@@ -7,13 +7,11 @@ const smoke = readFileSync(smokePath, "utf-8");
const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8");
describe("GCP smoke ownership and protocol safety", () => {
- it("defaults to v2 and retains explicit v1/v2 protocol arguments", () => {
- expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v2}"');
- expect(smoke).toContain("--protocols)");
+ it("runs only the Plan v2 protocol", () => {
+ expect(smoke).toContain('PROTOCOL="v2"');
+ expect(smoke).not.toContain("--protocols)");
expect(smoke).toContain("PlanProtocol: $protocol");
- expect(smoke).toContain("decodedFramesEqual");
- expect(smoke).toContain("decodedAudioEqual");
- expect(smoke).toContain("normalizedMetadataEqual");
+ expect(smoke).not.toContain("PlanProtocol: v1");
expect(smoke).toContain('.CaptureMode == "beginframe"');
expect(smoke).toContain("expected every chunk to use beginframe");
});
diff --git a/packages/gcp-cloud-run/terraform/workflow.test.ts b/packages/gcp-cloud-run/terraform/workflow.test.ts
index e4c9f5d92d9..b1f2033a5df 100644
--- a/packages/gcp-cloud-run/terraform/workflow.test.ts
+++ b/packages/gcp-cloud-run/terraform/workflow.test.ts
@@ -6,19 +6,12 @@ import { parse } from "yaml";
type Step = Record;
const source = readFileSync(join(import.meta.dir, "workflow.yaml"), "utf-8");
-// Cloud Workflows expressions are valid to Google's parser but `${...}`
-// inside YAML flow collections is not valid generic YAML. Quote expressions
-// for structural parsing while preserving their text for contract assertions.
const parseableSource = source.replace(/\$\{([^}]*)\}/g, (_match, expression: string) =>
JSON.stringify(`\${${expression}}`),
);
const workflow = parse(parseableSource) as {
- main: {
- steps: Step[];
- };
- retryable: {
- steps: Step[];
- };
+ main: { steps: Step[] };
+ retryable: { steps: Step[] };
};
function namedStep(name: string, steps = workflow.main.steps): Record {
@@ -28,121 +21,54 @@ function namedStep(name: string, steps = workflow.main.steps): Record {
- const step = namedStep(stepName);
- const attempt = step.try as {
- args: {
- body: Record;
- };
- };
- return attempt.args.body;
+function requestBody(stepName: "plan" | "assemble"): Record {
+ return (namedStep(stepName).try as { args: { body: Record } }).args.body;
}
-function requestAuth(stepName: string): Record {
- const step = namedStep(stepName);
- const attempt = step.try as {
- args: {
- auth: Record;
- };
- };
- return attempt.args.auth;
+function chunkRequestBody(): Record {
+ const renderChunks = namedStep("renderChunks").parallel as { for: { steps: Step[] } };
+ const render = namedStep("renderOneChunk", renderChunks.for.steps);
+ return (render.try as { args: { body: Record } }).args.body;
}
-function chunkRequestBody(stepName: string): Record {
- const renderChunks = namedStep("renderChunks");
- const parallel = renderChunks.parallel as {
- for: {
- steps: Step[];
- };
- };
- const step = namedStep(stepName, parallel.for.steps);
- const attempt = step.try as {
- args: {
- body: Record;
- };
- };
- return attempt.args.body;
-}
-
-describe("Cloud Workflows plan protocol routing", () => {
- it("pins OIDC tokens to the Cloud Run root and retries IAM propagation", () => {
- for (const stepName of ["planV1", "planV2", "assembleV1", "assembleV2"]) {
- expect(requestAuth(stepName)).toEqual({
- type: "OIDC",
- audience: "${serviceUrl}",
- });
- }
- expect(JSON.stringify(workflow.retryable)).toContain("e.code == 403");
- expect(source.match(/max_retries: 6/g)).toHaveLength(2);
- expect(source.match(/max_retries: 4/g)).toHaveLength(4);
- });
-
- it("defaults omitted protocol to v2 and rejects unknown protocols before plan", () => {
+describe("Cloud Workflows Plan v2 orchestration", () => {
+ it("defaults an omitted protocol to v2 and rejects every explicit non-v2 value", () => {
expect(source).toContain('default(map.get(args, "PlanProtocol"), "v2")');
- expect(namedStep("selectPlanProtocol")).toMatchObject({
+ expect(namedStep("checkPlanProtocol")).toMatchObject({
+ switch: [{ condition: '${planProtocol == "v2"}', next: "plan" }],
next: "unsupportedPlanProtocol",
});
expect(namedStep("unsupportedPlanProtocol")).toMatchObject({
- raise: {
- code: "PLAN_PROTOCOL_UNSUPPORTED",
- },
+ raise: { code: "PLAN_PROTOCOL_UNSUPPORTED" },
});
});
- it("uses disjoint v1 and v2 plan request/response contracts", () => {
- expect(requestBody("planV1")).toMatchObject({
- Action: "plan",
- PlanProtocol: "v1",
- });
- expect(requestBody("planV2")).toMatchObject({
- Action: "plan",
- PlanProtocol: "v2",
- });
+ it("uses Plan v2 for every distributed action", () => {
+ for (const body of [requestBody("plan"), chunkRequestBody(), requestBody("assemble")]) {
+ expect(body.PlanProtocol).toBe("v2");
+ expect(body).not.toHaveProperty("PlanGcsUri");
+ }
+ expect(chunkRequestBody()).toHaveProperty("PlanV2ManifestGcsUri");
+ expect(chunkRequestBody()).toHaveProperty("PlanV2ArtifactGcsPrefix");
+ expect(requestBody("assemble")).toMatchObject({ AudioGcsUri: null });
+ expect(source).not.toContain("PlanProtocol: v1");
+ });
+
+ it("validates the v2 plan response before fan-out", () => {
const validation = JSON.stringify(namedStep("validatePlanResult"));
- expect(validation).toContain("PlanGcsUri");
expect(validation).toContain("PlanV2ManifestGcsUri");
expect(validation).toContain("PlanV2ArtifactGcsPrefix");
- expect(source).toContain('not("PlanGcsUri" in planResult)');
- expect(source).toContain(
- '(not("PlanProtocol" in planResult) or planResult.PlanProtocol == "v1")',
- );
- });
-
- it("never mixes v1 and v2 chunk locators", () => {
- const v1 = chunkRequestBody("renderOneChunkV1");
- expect(v1).toMatchObject({
- Action: "renderChunk",
- PlanProtocol: "v1",
- });
- expect(v1).toHaveProperty("PlanGcsUri");
- expect(v1).not.toHaveProperty("PlanV2ManifestGcsUri");
- expect(v1).not.toHaveProperty("PlanV2ArtifactGcsPrefix");
-
- const v2 = chunkRequestBody("renderOneChunkV2");
- expect(v2).toMatchObject({
- Action: "renderChunk",
- PlanProtocol: "v2",
+ expect(validation).toContain("PlanGcsUri");
+ expect(validation).toContain("chunkCount > 0");
+ expect(namedStep("invalidPlanResult")).toMatchObject({
+ raise: { code: "PLAN_RESULT_INVALID" },
});
- expect(v2).not.toHaveProperty("PlanGcsUri");
- expect(v2).toHaveProperty("PlanV2ManifestGcsUri");
- expect(v2).toHaveProperty("PlanV2ArtifactGcsPrefix");
- expect(v2).toHaveProperty("PlanHash");
});
- it("never mixes v1 and v2 assembler locators", () => {
- const v1 = requestBody("assembleV1");
- expect(v1).toHaveProperty("PlanGcsUri");
- expect(v1).not.toHaveProperty("PlanV2ManifestGcsUri");
- expect(v1).not.toHaveProperty("PlanV2ArtifactGcsPrefix");
-
- const v2 = requestBody("assembleV2");
- expect(v2).not.toHaveProperty("PlanGcsUri");
- expect(v2).toHaveProperty("PlanV2ManifestGcsUri");
- expect(v2).toHaveProperty("PlanV2ArtifactGcsPrefix");
- expect(v2).toHaveProperty("PlanHash");
- expect(v2).toMatchObject({
- PlanProtocol: "v2",
- AudioGcsUri: null,
- });
+ it("pins OIDC tokens to the service and retries transient failures", () => {
+ expect(source.match(/audience: \$\{serviceUrl\}/g)).toHaveLength(3);
+ expect(JSON.stringify(workflow.retryable)).toContain("e.code == 403");
+ expect(source.match(/max_retries: 6/g)).toHaveLength(1);
+ expect(source.match(/max_retries: 4/g)).toHaveLength(2);
});
});
diff --git a/packages/gcp-cloud-run/terraform/workflow.yaml b/packages/gcp-cloud-run/terraform/workflow.yaml
index 2e563a48924..39c357f8c77 100644
--- a/packages/gcp-cloud-run/terraform/workflow.yaml
+++ b/packages/gcp-cloud-run/terraform/workflow.yaml
@@ -1,20 +1,9 @@
-# HyperFrames distributed render orchestration on Cloud Workflows.
+# HyperFrames Plan v2 distributed render orchestration on Cloud Workflows.
#
-# Plan → BuildChunkList → AssertChunkCount → RenderChunks (parallel) → Assemble
+# Plan → BuildChunkList → RenderChunks (parallel) → Assemble
#
-# Mirrors the Step Functions state machine in
-# `examples/aws-lambda/template.yaml`. Every step POSTs to the same Cloud Run
-# service URL (passed in as `args.ServiceUrl`) and varies only the body's
-# `Action`. The service returns the step's small result body on 2xx; on a
-# non-retryable failure it returns HTTP 400, on a retryable failure HTTP 5xx —
-# the `retryable` predicate below keys off exactly that split.
-#
-# The final returned object accumulates every step's result body so
-# `getRenderProgress` can read frame totals + per-step durations on success:
-# { Plan: {...}, Chunks: [{...}, ...], Assemble: {...} }
-#
-# Deploy with `gcloud workflows deploy` (the Terraform module / the
-# `hyperframes cloudrun deploy` command do this for you).
+# Every step POSTs to the same Cloud Run service URL and varies only the
+# body's Action. Plan v2 is the only supported transport.
main:
params: [args]
@@ -26,51 +15,20 @@ main:
- planOutputGcsPrefix: ${args.PlanOutputGcsPrefix}
- outputGcsUri: ${args.OutputGcsUri}
- config: ${args.Config}
- # Plan v2 is the default. Explicit v1 remains available during the
- # deprecated monolithic-plan compatibility window.
- planProtocol: ${default(map.get(args, "PlanProtocol"), "v2")}
- # ── Plan (Activity A) ────────────────────────────────────────────────────
- - selectPlanProtocol:
+ - checkPlanProtocol:
switch:
- - condition: ${planProtocol == "v1"}
- next: planV1
- condition: ${planProtocol == "v2"}
- next: planV2
+ next: plan
next: unsupportedPlanProtocol
+
- unsupportedPlanProtocol:
raise:
code: PLAN_PROTOCOL_UNSUPPORTED
- message: ${"PlanProtocol must be v1 or v2; got " + string(planProtocol)}
- - planV1:
- try:
- call: http.post
- args:
- url: ${serviceUrl}
- timeout: 1800
- auth:
- type: OIDC
- audience: ${serviceUrl}
- body:
- Action: plan
- PlanProtocol: v1
- ProjectGcsUri: ${projectGcsUri}
- PlanOutputGcsPrefix: ${planOutputGcsPrefix}
- Config: ${config}
- result: planRespV1
- retry:
- predicate: ${retryable}
- max_retries: 6
- backoff:
- initial_delay: 2
- max_delay: 60
- multiplier: 2
- next: capturePlanV1
- - capturePlanV1:
- assign:
- - planResult: ${planRespV1.body}
- next: validatePlanResult
- - planV2:
+ message: ${"PlanProtocol must be v2 or absent; got " + string(planProtocol)}
+
+ - plan:
try:
call: http.post
args:
@@ -85,7 +43,7 @@ main:
ProjectGcsUri: ${projectGcsUri}
PlanOutputGcsPrefix: ${planOutputGcsPrefix}
Config: ${config}
- result: planRespV2
+ result: planResp
retry:
predicate: ${retryable}
max_retries: 6
@@ -93,46 +51,29 @@ main:
initial_delay: 2
max_delay: 60
multiplier: 2
- next: capturePlanV2
- - capturePlanV2:
- assign:
- - planResult: ${planRespV2.body}
- next: validatePlanResult
- - validatePlanResult:
- # Fail closed before fan-out. A v2 render may never fall back to a
- # v1 PlanGcsUri, and a v1 render may never consume v2 locators.
- switch:
- - condition: ${planProtocol == "v1" and (not("PlanProtocol" in planResult) or planResult.PlanProtocol == "v1") and ("PlanGcsUri" in planResult) and not("PlanV2ManifestGcsUri" in planResult) and not("PlanV2ArtifactGcsPrefix" in planResult)}
- next: captureChunkCount
- - condition: ${planProtocol == "v2" and ("PlanProtocol" in planResult) and planResult.PlanProtocol == "v2" and ("PlanV2ManifestGcsUri" in planResult) and ("PlanV2ArtifactGcsPrefix" in planResult) and not("PlanGcsUri" in planResult)}
- next: captureChunkCount
- next: planProtocolLocatorMismatch
- - planProtocolLocatorMismatch:
- raise:
- code: PLAN_PROTOCOL_LOCATOR_MISMATCH
- message: "Plan response did not match the selected protocol's disjoint locator contract."
- - captureChunkCount:
+
+ - capturePlan:
assign:
- - chunkCount: ${planResult.ChunkCount}
+ - planResult: ${planResp.body}
+ - chunkCount: ${planResp.body.ChunkCount}
- # ── BuildChunkList + AssertChunkCount ──────────────────────────────────────
- - assertChunkCount:
+ - validatePlanResult:
switch:
- - condition: ${chunkCount > 0}
+ - condition: ${planResult.PlanProtocol == "v2" and ("PlanV2ManifestGcsUri" in planResult) and ("PlanV2ArtifactGcsPrefix" in planResult) and not("PlanGcsUri" in planResult) and chunkCount > 0}
next: buildChunkList
- next: planProducedZeroChunks
- - planProducedZeroChunks:
+ next: invalidPlanResult
+
+ - invalidPlanResult:
raise:
- code: PLAN_PRODUCED_ZERO_CHUNKS
- message: "Plan returned ChunkCount=0 — the composition produced no frames. Non-retryable producer-side invariant violation."
+ code: PLAN_RESULT_INVALID
+ message: "Plan response did not satisfy the Plan v2 locator contract or produced no chunks."
+
- buildChunkList:
- # Pre-size the ordered chunk-URI + per-chunk result lists so the
- # parallel branches below assign by index (distinct indices, no
- # read-modify-write race on a shared accumulator).
assign:
- chunkIndexes: []
- chunkUris: []
- chunkResults: []
+
- fillLists:
for:
value: i
@@ -144,53 +85,15 @@ main:
- chunkUris: ${list.concat(chunkUris, "")}
- chunkResults: ${list.concat(chunkResults, "")}
- # ── RenderChunks (Activity B, fanned out) ──────────────────────────────────
- renderChunks:
parallel:
shared: [chunkUris, chunkResults]
- # Run up to chunkCount chunks at once, clamped to 20 — Cloud
- # Workflows hard-caps concurrent branches/iterations per execution
- # at 20 (https://cloud.google.com/workflows/quotas). Above that,
- # iterations queue regardless of concurrency_limit, so a config with
- # maxParallelChunks > 20 still renders correctly; the extra chunks
- # just wait. All chunkCount iterations always run.
concurrency_limit: ${math.min(chunkCount, 20)}
for:
value: idx
in: ${chunkIndexes}
steps:
- - selectChunkProtocol:
- switch:
- - condition: ${planProtocol == "v2"}
- next: renderOneChunkV2
- next: renderOneChunkV1
- - renderOneChunkV1:
- try:
- call: http.post
- args:
- url: ${serviceUrl}
- timeout: 1800
- auth:
- type: OIDC
- audience: ${serviceUrl}
- body:
- Action: renderChunk
- PlanProtocol: v1
- ChunkIndex: ${idx}
- PlanGcsUri: ${planResult.PlanGcsUri}
- PlanHash: ${planResult.PlanHash}
- ChunkOutputGcsPrefix: ${planOutputGcsPrefix}
- Format: ${planResult.Format}
- result: chunkResp
- retry:
- predicate: ${retryable}
- max_retries: 4
- backoff:
- initial_delay: 2
- max_delay: 60
- multiplier: 2
- next: storeChunk
- - renderOneChunkV2:
+ - renderOneChunk:
try:
call: http.post
args:
@@ -216,49 +119,12 @@ main:
initial_delay: 2
max_delay: 60
multiplier: 2
- next: storeChunk
- storeChunk:
assign:
- chunkUris[idx]: ${chunkResp.body.ChunkGcsUri}
- chunkResults[idx]: ${chunkResp.body}
- # ── Assemble (Activity C) ──────────────────────────────────────────────────
- - selectAssembleProtocol:
- switch:
- - condition: ${planProtocol == "v2"}
- next: assembleV2
- next: assembleV1
- - assembleV1:
- try:
- call: http.post
- args:
- url: ${serviceUrl}
- timeout: 1800
- auth:
- type: OIDC
- audience: ${serviceUrl}
- body:
- Action: assemble
- PlanProtocol: v1
- PlanGcsUri: ${planResult.PlanGcsUri}
- ChunkGcsUris: ${chunkUris}
- AudioGcsUri: ${planResult.AudioGcsUri}
- OutputGcsUri: ${outputGcsUri}
- Format: ${planResult.Format}
- # Forward the caller's exact-CFR request (Config.cfr) to assemble.
- # `"cfr" in config` guards the optional key; when unset this is
- # false, which the handler reads as the default -c copy path.
- Cfr: ${("cfr" in config) and config.cfr}
- result: assembleResp
- retry:
- predicate: ${retryable}
- max_retries: 4
- backoff:
- initial_delay: 2
- max_delay: 60
- multiplier: 2
- next: done
- - assembleV2:
+ - assemble:
try:
call: http.post
args:
@@ -274,8 +140,6 @@ main:
PlanV2ArtifactGcsPrefix: ${planResult.PlanV2ArtifactGcsPrefix}
PlanHash: ${planResult.PlanHash}
ChunkGcsUris: ${chunkUris}
- # Audio is an assembler-scoped v2 artifact and is materialized
- # from the manifest, never carried through a v1 AudioGcsUri.
AudioGcsUri: null
OutputGcsUri: ${outputGcsUri}
Format: ${planResult.Format}
@@ -288,7 +152,6 @@ main:
initial_delay: 2
max_delay: 60
multiplier: 2
- next: done
- done:
return:
@@ -296,11 +159,7 @@ main:
Chunks: ${chunkResults}
Assemble: ${assembleResp.body}
-# Retry predicate: retry transient/server failures (403 from Cloud Run IAM
-# propagation, 429, and 5xx), never the handler's non-retryable 400s (bad
-# input, plan-hash mismatch, unsupported format, …). The handler does not emit
-# 403, so that status is always from Cloud Run's authentication edge.
-# Connection / timeout errors carry no `.code`; retry those too.
+# Retry transient/server failures, never handler-classified HTTP 400s.
retryable:
params: [e]
steps:
diff --git a/packages/producer/fixtures/plan-parity-size-pressure/.plan-parity-fixture.json b/packages/producer/fixtures/plan-parity-size-pressure/.plan-parity-fixture.json
deleted file mode 100644
index 84c4f6d89de..00000000000
--- a/packages/producer/fixtures/plan-parity-size-pressure/.plan-parity-fixture.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "generatedAudio": {
- "path": "assets/tone.wav",
- "durationSeconds": 1,
- "frequencyHz": 330,
- "sampleRate": 48000
- },
- "generatedPressureFile": {
- "path": "unused-pressure.bin",
- "bytes": 65536,
- "seed": 20260725
- }
-}
diff --git a/packages/producer/fixtures/plan-parity-size-pressure/index.html b/packages/producer/fixtures/plan-parity-size-pressure/index.html
deleted file mode 100644
index 16acb402eba..00000000000
--- a/packages/producer/fixtures/plan-parity-size-pressure/index.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
- Plan protocol size pressure
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json b/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json
deleted file mode 100644
index 4dca04ada90..00000000000
--- a/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "generatedAudio": {
- "path": "assets/tone.wav",
- "durationSeconds": 2,
- "frequencyHz": 440,
- "sampleRate": 48000
- }
-}
diff --git a/packages/producer/fixtures/plan-parity-visual-audio/index.html b/packages/producer/fixtures/plan-parity-visual-audio/index.html
deleted file mode 100644
index 68415de236f..00000000000
--- a/packages/producer/fixtures/plan-parity-visual-audio/index.html
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
- Plan protocol visual and audio parity
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/producer/package.json b/packages/producer/package.json
index a4afb957f3f..82c016d990e 100644
--- a/packages/producer/package.json
+++ b/packages/producer/package.json
@@ -46,7 +46,6 @@
"build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts",
"typecheck": "tsc --noEmit",
"parity:check": "tsx src/parity-harness.ts",
- "parity:plans": "tsx src/plan-parity-harness.ts",
"parity:fixtures": "tsx src/parity-fixtures.ts",
"parity:fixtures:ci": "tsx src/parity-fixtures.ts",
"parity:check:ci": "tsx src/parity-harness.ts --preview-url \"http://127.0.0.1:4173/minimal-wysiwyg.html\" --producer-url \"http://127.0.0.1:4173/minimal-wysiwyg.html?mode=producer\" --checkpoints \"0,0.5,1,1.5\" --allow-mismatch-ratio 0 --emulate-producer-swap true --artifacts-dir \".debug/parity-harness-ci\"",
diff --git a/packages/producer/src/distributed.ts b/packages/producer/src/distributed.ts
index de262579112..c1129ac3b88 100644
--- a/packages/producer/src/distributed.ts
+++ b/packages/producer/src/distributed.ts
@@ -2,8 +2,9 @@
* `@hyperframes/producer/distributed` — the distributed render primitives.
*
* The distributed activities are pure functions over local file paths;
- * networking + orchestration live in adapters. New integrations should use
- * Plan v2; the v1 functions remain available for compatibility.
+ * networking + orchestration live in adapters. Plan v2 is the only public
+ * transport; chunk and assemble primitives consume its materialized execution
+ * directory.
*
* Adopters (AWS Lambda, Cloud Run Jobs, Temporal, K8s Jobs, plain SSH):
*
@@ -35,14 +36,12 @@ export {
// Functions
buildChunkSlices,
measurePlanDirBytes,
- plan,
rejectUnsupportedDistributedFormat,
resolveChunkPlan,
buildPlanAudio,
plansSeparateAudio,
// Types
type DistributedRenderConfig,
- type PlanResult,
type RemoteProjectFile,
// Constants
DEFAULT_CHUNK_SIZE,
@@ -55,14 +54,11 @@ export {
ChunkExceedsWorkerTimeoutError,
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
FormatNotSupportedInDistributedError,
- PLAN_TOO_LARGE,
- PlanTooLargeError,
} from "./services/distributed/plan.js";
// ── Plan v2 content-addressed transport ────────────────────────────────────
export {
createPlanV2FromExecutionPlan,
- createPlanV2FromV1,
getPlanV2ExecutionPlanHash,
isPlanV2DeferredSourceArtifact,
listPlanV2ArtifactsForTarget,
@@ -70,7 +66,6 @@ export {
planV2,
planV2WithPublisher,
publishPlanV2FromExecutionPlan,
- publishPlanV2FromV1,
readPlanV2Manifest,
validatePlanV2MaterializedTarget,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
@@ -152,31 +147,16 @@ export {
type ProbeFactsOutcome,
} from "./services/distributed/probeFacts.js";
-// ── Plan protocol compatibility ────────────────────────────────────────────
-// Workers validate this descriptor before consuming layout-specific
-// artifacts. Missing descriptors remain compatible with legacy v1 plans.
+// ── Plan protocol ──────────────────────────────────────────────────────────
export {
- CURRENT_PLAN_PROTOCOL,
- DISTRIBUTED_RENDER_CAPABILITIES,
- getDistributedRenderCapabilities,
- PLAN_ARTIFACT_LAYOUT,
- PLAN_HASH_SCHEMA,
- PLAN_PROTOCOL_V1,
PLAN_PROTOCOL_V2,
PLAN_PROTOCOL_UNSUPPORTED,
- PLAN_SCHEMA_VERSION,
PLAN_V2_ARTIFACT_LAYOUT,
PLAN_V2_HASH_SCHEMA,
PLAN_V2_SCHEMA_VERSION,
PlanProtocolUnsupportedError,
- readPlanProtocol,
- readPlanProtocolV1,
- type DistributedRenderCapabilities,
- type PlanProtocolConsumerCapabilities,
type PlanProtocolDescriptor,
- type PlanProtocolV1Descriptor,
type PlanProtocolV2Descriptor,
- type SupportedPlanProtocolDescriptor,
} from "./services/distributed/planProtocol.js";
// ── Format union ────────────────────────────────────────────────────────────
diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts
index 1af687c2939..ad0581b45b1 100644
--- a/packages/producer/src/index.ts
+++ b/packages/producer/src/index.ts
@@ -158,35 +158,23 @@ export {
export {
assemble,
assembleV2,
- CURRENT_PLAN_PROTOCOL,
- DISTRIBUTED_RENDER_CAPABILITIES,
- getDistributedRenderCapabilities,
- PLAN_ARTIFACT_LAYOUT,
- PLAN_HASH_SCHEMA,
- PLAN_PROTOCOL_V1,
PLAN_PROTOCOL_V2,
PLAN_PROTOCOL_UNSUPPORTED,
- PLAN_SCHEMA_VERSION,
PLAN_V2_ARTIFACT_LAYOUT,
PLAN_V2_HASH_SCHEMA,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PLAN_V2_MATERIALIZATION_MARKER,
PLAN_V2_SCHEMA_VERSION,
createPlanV2FromExecutionPlan,
- createPlanV2FromV1,
getPlanV2ExecutionPlanHash,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
- plan,
planV2,
planV2WithPublisher,
PlanV2IntegrityError,
PlanProtocolUnsupportedError,
- readPlanProtocol,
- readPlanProtocolV1,
readPlanV2Manifest,
publishPlanV2FromExecutionPlan,
- publishPlanV2FromV1,
renderChunk,
renderChunkV2,
validatePlanV2MaterializedTarget,
@@ -194,13 +182,9 @@ export {
type ChunkRenderer,
type ChunkResult,
type EffectiveChunkResult,
- type DistributedRenderCapabilities,
type DistributedRenderConfig,
- type PlanProtocolConsumerCapabilities,
type PlanProtocolDescriptor,
- type PlanProtocolV1Descriptor,
type PlanProtocolV2Descriptor,
- type PlanResult,
type PlanV2Artifact,
type PlanV2Limitations,
type PlanV2Manifest,
@@ -210,5 +194,4 @@ export {
type PlanV2WithPublisherOptions,
type PlanV2ArtifactPublisher,
type PlanV2PublishBlob,
- type SupportedPlanProtocolDescriptor,
} from "./distributed.js";
diff --git a/packages/producer/src/plan-parity-analysis.test.ts b/packages/producer/src/plan-parity-analysis.test.ts
deleted file mode 100644
index 0b3a17f43e8..00000000000
--- a/packages/producer/src/plan-parity-analysis.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import { normalizeFfprobeMetadata, parseCanonicalFrameHashes } from "./plan-parity-analysis.js";
-
-describe("parseCanonicalFrameHashes()", () => {
- it("returns ordered SHA-256 hashes from ffmpeg framemd5 output", () => {
- const first = "a".repeat(64);
- const second = "b".repeat(64);
- expect(
- parseCanonicalFrameHashes(
- [
- "#format: frame checksums",
- "#hash: SHA256",
- `0, 0, 0, 1, 16, ${first}`,
- `0, 1, 1, 1, 16, ${second}`,
- "",
- ].join("\n"),
- ),
- ).toEqual([first, second]);
- });
-
- it("refuses malformed or empty output", () => {
- expect(() => parseCanonicalFrameHashes("# only comments")).toThrow(/no decoded/);
- expect(() => parseCanonicalFrameHashes("0, 0, not-a-sha")).toThrow(/unexpected/);
- });
-});
-
-describe("normalizeFfprobeMetadata()", () => {
- it("normalizes relevant video, audio, and duration fields", () => {
- expect(
- normalizeFfprobeMetadata({
- streams: [
- {
- codec_type: "video",
- codec_name: "h264",
- width: 320,
- height: 180,
- pix_fmt: "yuv420p",
- avg_frame_rate: "30/1",
- r_frame_rate: "30/1",
- nb_frames: "60",
- color_space: "bt709",
- color_transfer: "bt709",
- color_primaries: "bt709",
- },
- {
- codec_type: "audio",
- codec_name: "aac",
- sample_rate: "48000",
- channels: 2,
- channel_layout: "stereo",
- },
- ],
- format: { duration: "2.000000" },
- }),
- ).toEqual({
- video: {
- codecName: "h264",
- width: 320,
- height: 180,
- pixelFormat: "yuv420p",
- averageFrameRate: "30/1",
- realFrameRate: "30/1",
- frameCount: 60,
- colorSpace: "bt709",
- colorTransfer: "bt709",
- colorPrimaries: "bt709",
- },
- audio: {
- codecName: "aac",
- sampleRate: 48_000,
- channels: 2,
- channelLayout: "stereo",
- },
- durationSeconds: 2,
- });
- });
-
- it("uses stream duration and maps N/A values to null", () => {
- expect(
- normalizeFfprobeMetadata({
- streams: [
- {
- codec_type: "video",
- codec_name: "rawvideo",
- width: 1,
- height: 1,
- nb_frames: "N/A",
- duration: "0.5",
- },
- ],
- format: {},
- }),
- ).toMatchObject({
- video: { frameCount: null },
- audio: null,
- durationSeconds: 0.5,
- });
- });
-});
diff --git a/packages/producer/src/plan-parity-analysis.ts b/packages/producer/src/plan-parity-analysis.ts
deleted file mode 100644
index b8061bbb823..00000000000
--- a/packages/producer/src/plan-parity-analysis.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import { createHash } from "node:crypto";
-import { createReadStream, readdirSync, statSync, type Dirent } from "node:fs";
-import { relative, resolve } from "node:path";
-import { spawn, spawnSync } from "node:child_process";
-import { ManagedChildProcess } from "@hyperframes/engine";
-import type {
- PlanParityDriverResult,
- PlanParityMediaMeasurement,
- PlanParityMeasurement,
- PlanParityStreamMetadata,
-} from "./plan-parity-contract.js";
-
-type JsonRecord = Record;
-
-function isRecord(value: unknown): value is JsonRecord {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function stringValue(value: unknown): string | null {
- return typeof value === "string" && value.length > 0 && value !== "N/A" ? value : null;
-}
-
-function numberValue(value: unknown): number | null {
- if (typeof value === "number" && Number.isFinite(value)) return value;
- if (typeof value !== "string" || value.length === 0 || value === "N/A") return null;
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : null;
-}
-
-function integerValue(value: unknown): number | null {
- const parsed = numberValue(value);
- return parsed === null || !Number.isInteger(parsed) ? null : parsed;
-}
-
-function requireCommandSuccess(
- command: string,
- args: string[],
- maxBuffer = 64 * 1024 * 1024,
-): Buffer {
- const result = spawnSync(command, args, {
- encoding: "buffer",
- maxBuffer,
- stdio: ["ignore", "pipe", "pipe"],
- windowsHide: true,
- });
- if (result.error) throw result.error;
- if (result.status !== 0) {
- throw new Error(
- `${command} exited ${result.status}: ${result.stderr.toString("utf-8").trim()}`,
- );
- }
- return result.stdout;
-}
-
-export function parseCanonicalFrameHashes(framemd5: string): string[] {
- const hashes: string[] = [];
- for (const line of framemd5.split(/\r?\n/u)) {
- const trimmed = line.trim();
- if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
- const columns = trimmed.split(",").map((column) => column.trim());
- const hash = columns.at(-1);
- if (!hash || !/^[a-f0-9]{64}$/iu.test(hash)) {
- throw new Error(`unexpected framemd5 row: ${line}`);
- }
- hashes.push(hash.toLowerCase());
- }
- if (hashes.length === 0) {
- throw new Error("ffmpeg produced no decoded video frame hashes");
- }
- return hashes;
-}
-
-export function normalizeFfprobeMetadata(value: unknown): PlanParityStreamMetadata {
- if (!isRecord(value)) throw new Error("ffprobe output must be a JSON object");
- const rawStreams = value.streams;
- if (!Array.isArray(rawStreams)) throw new Error("ffprobe output has no streams array");
- const streams = rawStreams.filter(isRecord);
- const video = streams.find((stream) => stream.codec_type === "video");
- const audio = streams.find((stream) => stream.codec_type === "audio");
- const format = isRecord(value.format) ? value.format : {};
- const durationSeconds =
- numberValue(format.duration) ??
- Math.max(0, ...streams.map((stream) => numberValue(stream.duration) ?? 0));
-
- return {
- video: video
- ? {
- codecName: stringValue(video.codec_name),
- width: integerValue(video.width) ?? 0,
- height: integerValue(video.height) ?? 0,
- pixelFormat: stringValue(video.pix_fmt),
- averageFrameRate: stringValue(video.avg_frame_rate),
- realFrameRate: stringValue(video.r_frame_rate),
- frameCount: integerValue(video.nb_frames),
- colorSpace: stringValue(video.color_space),
- colorTransfer: stringValue(video.color_transfer),
- colorPrimaries: stringValue(video.color_primaries),
- }
- : null,
- audio: audio
- ? {
- codecName: stringValue(audio.codec_name),
- sampleRate: integerValue(audio.sample_rate),
- channels: integerValue(audio.channels),
- channelLayout: stringValue(audio.channel_layout),
- }
- : null,
- durationSeconds,
- };
-}
-
-function probeMetadata(outputPath: string): PlanParityStreamMetadata {
- const bytes = requireCommandSuccess("ffprobe", [
- "-v",
- "error",
- "-show_entries",
- [
- "format=duration",
- "stream=codec_type,codec_name,width,height,pix_fmt,avg_frame_rate,r_frame_rate,nb_frames",
- "stream=color_space,color_transfer,color_primaries,sample_rate,channels,channel_layout,duration",
- ].join(":"),
- "-of",
- "json",
- "--",
- outputPath,
- ]);
- return normalizeFfprobeMetadata(JSON.parse(bytes.toString("utf-8")) as unknown);
-}
-
-function canonicalFrameHashes(outputPath: string): string[] {
- const bytes = requireCommandSuccess(
- "ffmpeg",
- [
- "-hide_banner",
- "-loglevel",
- "error",
- "-i",
- outputPath,
- "-map",
- "0:v:0",
- "-an",
- "-pix_fmt",
- "rgba",
- "-hash",
- "sha256",
- "-f",
- "framemd5",
- "-",
- ],
- 256 * 1024 * 1024,
- );
- return parseCanonicalFrameHashes(bytes.toString("utf-8"));
-}
-
-async function hashFile(path: string): Promise<{ sha256: string; bytes: number }> {
- const hash = createHash("sha256");
- let bytes = 0;
- for await (const chunk of createReadStream(path)) {
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
- hash.update(buffer);
- bytes += buffer.length;
- }
- return { sha256: hash.digest("hex"), bytes };
-}
-
-function walkFiles(root: string, current = root): Array<{ absolute: string; relative: string }> {
- const entries: Dirent[] = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
- left.name.localeCompare(right.name),
- );
- const files: Array<{ absolute: string; relative: string }> = [];
- for (const entry of entries) {
- const absolute = resolve(current, entry.name);
- if (entry.isDirectory()) {
- files.push(...walkFiles(root, absolute));
- } else if (entry.isFile()) {
- files.push({ absolute, relative: relative(root, absolute).replaceAll("\\", "/") });
- }
- }
- return files;
-}
-
-async function hashPath(path: string): Promise<{ sha256: string; bytes: number }> {
- const stat = statSync(path);
- if (stat.isFile()) return hashFile(path);
- if (!stat.isDirectory()) throw new Error(`cannot hash non-file artifact ${path}`);
-
- const hash = createHash("sha256");
- let bytes = 0;
- for (const file of walkFiles(path)) {
- const digest = await hashFile(file.absolute);
- hash.update(file.relative);
- hash.update("\0");
- hash.update(digest.sha256);
- hash.update("\0");
- bytes += digest.bytes;
- }
- return { sha256: hash.digest("hex"), bytes };
-}
-
-async function canonicalPcmAudio(
- outputPath: string,
-): Promise {
- const child = spawn(
- "ffmpeg",
- [
- "-hide_banner",
- "-loglevel",
- "error",
- "-i",
- outputPath,
- "-map",
- "0:a:0",
- "-vn",
- "-ac",
- "2",
- "-ar",
- "48000",
- "-f",
- "s16le",
- "-",
- ],
- { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
- );
- const hash = createHash("sha256");
- let bytes = 0;
- child.stdout.on("data", (chunk: Buffer) => {
- hash.update(chunk);
- bytes += chunk.length;
- });
- const outcome = await new ManagedChildProcess(child).wait();
- if (outcome.reason === "spawn_error") throw outcome.error;
- if (outcome.exitCode !== 0) {
- // A video-only fixture is a valid parity input. ffmpeg uses this
- // wording when the optional audio map has no match.
- if (/matches no streams|does not contain any stream/iu.test(outcome.stderr)) return null;
- throw new Error(`ffmpeg PCM decode exited ${outcome.exitCode}: ${outcome.stderr.trim()}`);
- }
- if (bytes % 4 !== 0) {
- throw new Error(`canonical stereo s16le byte count ${bytes} is not divisible by 4`);
- }
- return { sha256: hash.digest("hex"), sampleCount: bytes / 4, bytes };
-}
-
-export async function analyzePlanParityDriverResult(
- driverName: string,
- result: PlanParityDriverResult,
-): Promise {
- const output = await hashPath(result.outputPath);
- const metadata = probeMetadata(result.outputPath);
- const [pcmAudio, frameSha256] = await Promise.all([
- metadata.audio ? canonicalPcmAudio(result.outputPath) : Promise.resolve(null),
- Promise.resolve(canonicalFrameHashes(result.outputPath)),
- ]);
-
- const chunks = [];
- for (const chunk of [...result.chunks].sort((left, right) => left.index - right.index)) {
- const digest = await hashPath(chunk.path);
- if (
- chunk.reportedSha256 !== undefined &&
- chunk.reportedSha256.toLowerCase() !== digest.sha256
- ) {
- throw new Error(
- `chunk ${chunk.index} digest mismatch: adapter reported ${chunk.reportedSha256}, measured ${digest.sha256}`,
- );
- }
- chunks.push({
- index: chunk.index,
- sha256: digest.sha256,
- bytes: digest.bytes,
- reportedSha256: chunk.reportedSha256,
- });
- }
- const downloaded = result.transferBytes.downloaded;
- const uploaded = result.transferBytes.uploaded;
-
- return {
- protocol: result.protocol,
- driver: driverName,
- media: {
- outputSha256: output.sha256,
- outputBytes: output.bytes,
- frameSha256,
- pcmAudio,
- metadata,
- },
- chunks,
- transferBytes: {
- downloaded,
- uploaded,
- total: downloaded + uploaded,
- },
- peakMaterializedBytes: result.peakMaterializedBytes,
- };
-}
diff --git a/packages/producer/src/plan-parity-contract.test.ts b/packages/producer/src/plan-parity-contract.test.ts
deleted file mode 100644
index 516c3ae7dcb..00000000000
--- a/packages/producer/src/plan-parity-contract.test.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import {
- comparePlanParityMeasurements,
- type PlanParityMeasurement,
- type PlanParityProtocol,
-} from "./plan-parity-contract.js";
-
-function measurement(
- protocol: PlanParityProtocol,
- overrides: {
- frames?: string[];
- pcmSha?: string;
- duration?: number;
- outputSha?: string;
- outputBytes?: number;
- chunkSha?: string;
- transferBytes?: number;
- peakBytes?: number;
- } = {},
-): PlanParityMeasurement {
- const transferBytes = overrides.transferBytes ?? 100;
- return {
- protocol,
- driver: "test",
- media: {
- outputSha256: overrides.outputSha ?? "encoded",
- outputBytes: overrides.outputBytes ?? 1000,
- frameSha256: overrides.frames ?? ["frame-0", "frame-1"],
- pcmAudio: {
- sha256: overrides.pcmSha ?? "pcm",
- sampleCount: 96_000,
- bytes: 384_000,
- },
- metadata: {
- video: {
- codecName: "h264",
- width: 320,
- height: 180,
- pixelFormat: "yuv420p",
- averageFrameRate: "30/1",
- realFrameRate: "30/1",
- frameCount: 60,
- colorSpace: "bt709",
- colorTransfer: "bt709",
- colorPrimaries: "bt709",
- },
- audio: {
- codecName: "aac",
- sampleRate: 48_000,
- channels: 2,
- channelLayout: "stereo",
- },
- durationSeconds: overrides.duration ?? 2,
- },
- },
- chunks: [
- {
- index: 0,
- sha256: overrides.chunkSha ?? "chunk",
- bytes: 500,
- },
- ],
- transferBytes: {
- downloaded: Math.floor(transferBytes / 2),
- uploaded: Math.ceil(transferBytes / 2),
- total: transferBytes,
- },
- peakMaterializedBytes: overrides.peakBytes ?? 2000,
- };
-}
-
-describe("comparePlanParityMeasurements()", () => {
- it("accepts semantic parity while only reporting encoded and transport differences", () => {
- const result = comparePlanParityMeasurements(
- measurement("v1", {
- outputSha: "container-v1",
- outputBytes: 1000,
- transferBytes: 10_000,
- peakBytes: 20_000,
- }),
- measurement("v2", {
- outputSha: "container-v2",
- outputBytes: 900,
- transferBytes: 5000,
- peakBytes: 6000,
- }),
- );
-
- expect(result.passed).toBe(true);
- expect(result.checks.find((entry) => entry.name === "encoded-output")?.detail).toContain(
- "(reported)",
- );
- expect(result.checks.find((entry) => entry.name === "transfer-bytes")?.detail).toContain(
- "v1=10000",
- );
- });
-
- it("fails on decoded frame, PCM, metadata, duration, or chunk drift", () => {
- const cases: Array<[string, PlanParityMeasurement]> = [
- ["decoded-video-frames", measurement("v2", { frames: ["different"] })],
- ["canonical-pcm-audio", measurement("v2", { pcmSha: "different" })],
- [
- "ffprobe-stream-metadata",
- {
- ...measurement("v2"),
- media: {
- ...measurement("v2").media,
- metadata: {
- ...measurement("v2").media.metadata,
- video: {
- ...measurement("v2").media.metadata.video!,
- width: 640,
- },
- },
- },
- },
- ],
- ["ffprobe-duration", measurement("v2", { duration: 2.1 })],
- ["chunk-hashes", measurement("v2", { chunkSha: "different" })],
- ];
-
- for (const [expectedFailure, v2] of cases) {
- const result = comparePlanParityMeasurements(measurement("v1"), v2);
- expect(result.passed).toBe(false);
- expect(result.checks.find((entry) => entry.name === expectedFailure)?.passed).toBe(false);
- }
- });
-
- it("can enforce encoded equality and v2 resource ceilings", () => {
- const result = comparePlanParityMeasurements(
- measurement("v1"),
- measurement("v2", {
- outputSha: "different",
- transferBytes: 101,
- peakBytes: 201,
- }),
- {
- requireEncodedOutputEquality: true,
- maxV2TransferBytes: 100,
- maxV2PeakMaterializedBytes: 200,
- },
- );
-
- expect(result.passed).toBe(false);
- expect(result.checks.filter((entry) => !entry.passed).map((entry) => entry.name)).toEqual([
- "encoded-output",
- "transfer-bytes",
- "peak-materialized-working-set",
- ]);
- });
-
- it("rejects reversed or same-protocol comparisons", () => {
- expect(() => comparePlanParityMeasurements(measurement("v2"), measurement("v1"))).toThrow(
- /ordered v1\/v2/,
- );
- expect(() => comparePlanParityMeasurements(measurement("v1"), measurement("v1"))).toThrow(
- /ordered v1\/v2/,
- );
- });
-
- it("does not expose or compare planHash", () => {
- const result = comparePlanParityMeasurements(measurement("v1"), measurement("v2"));
- expect(JSON.stringify(result)).not.toContain("planHash");
- });
-});
diff --git a/packages/producer/src/plan-parity-contract.ts b/packages/producer/src/plan-parity-contract.ts
deleted file mode 100644
index 400f5367171..00000000000
--- a/packages/producer/src/plan-parity-contract.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-/**
- * Protocol-neutral contract for comparing distributed render plans.
- *
- * The comparator intentionally has no `planHash` field. v1 and v2 use
- * different artifact layouts and hash schemas, so their plan hashes are not
- * expected to match even when they render identical output.
- */
-
-import type { DistributedFps } from "@hyperframes/core";
-
-export type PlanParityProtocol = "v1" | "v2";
-
-export interface PlanParityRenderConfig {
- fps: DistributedFps;
- width: number;
- height: number;
- format: "mp4";
- chunkSize?: number;
- maxParallelChunks?: number;
-}
-
-export interface PlanParityDriverInput {
- protocol: PlanParityProtocol;
- projectDir: string;
- outputDir: string;
- renderConfig: PlanParityRenderConfig;
- /**
- * Test-only plan limit. The Lambda-local driver forwards this to v1 as
- * `planDirSizeLimitBytes`; v2 ignores it because it does not materialize a
- * monolithic plan directory.
- */
- planSizeCapBytes?: number;
-}
-
-export interface PlanParityChunkArtifact {
- index: number;
- path: string;
- /** Adapter-reported digest, retained for diagnostics. */
- reportedSha256?: string;
-}
-
-export interface PlanParityDriverResult {
- protocol: PlanParityProtocol;
- outputPath: string;
- chunks: PlanParityChunkArtifact[];
- transferBytes: {
- downloaded: number;
- uploaded: number;
- };
- /**
- * Maximum materialized bytes observed in the worker scratch directory.
- * This is distinct from S3 storage and from process RSS.
- */
- peakMaterializedBytes: number;
-}
-
-export interface PlanParityDriver {
- readonly name: string;
- render(input: PlanParityDriverInput): Promise;
-}
-
-export interface PlanParityStreamMetadata {
- video: {
- codecName: string | null;
- width: number;
- height: number;
- pixelFormat: string | null;
- averageFrameRate: string | null;
- realFrameRate: string | null;
- frameCount: number | null;
- colorSpace: string | null;
- colorTransfer: string | null;
- colorPrimaries: string | null;
- } | null;
- audio: {
- codecName: string | null;
- sampleRate: number | null;
- channels: number | null;
- channelLayout: string | null;
- } | null;
- durationSeconds: number;
-}
-
-export interface PlanParityMediaMeasurement {
- outputSha256: string;
- outputBytes: number;
- frameSha256: string[];
- pcmAudio: {
- sha256: string;
- /**
- * Interleaved audio frames after canonical decoding to signed 16-bit,
- * 48 kHz, stereo PCM. One sample frame contains two channel samples.
- */
- sampleCount: number;
- bytes: number;
- } | null;
- metadata: PlanParityStreamMetadata;
-}
-
-export interface PlanParityChunkMeasurement {
- index: number;
- sha256: string;
- bytes: number;
- reportedSha256?: string;
-}
-
-export interface PlanParityMeasurement {
- protocol: PlanParityProtocol;
- driver: string;
- media: PlanParityMediaMeasurement;
- chunks: PlanParityChunkMeasurement[];
- transferBytes: {
- downloaded: number;
- uploaded: number;
- total: number;
- };
- peakMaterializedBytes: number;
-}
-
-export interface PlanParityComparisonOptions {
- /** ffprobe duration tolerance. Defaults to 1 ms. */
- durationToleranceSeconds?: number;
- /**
- * Encoded containers can contain non-semantic metadata. Default false:
- * report output digest/size without requiring byte-identical containers.
- */
- requireEncodedOutputEquality?: boolean;
- /** Optional ceiling applied independently to the v2 run. */
- maxV2TransferBytes?: number;
- /** Optional ceiling applied independently to the v2 run. */
- maxV2PeakMaterializedBytes?: number;
-}
-
-export interface PlanParityCheck {
- name: string;
- passed: boolean;
- detail: string;
-}
-
-export interface PlanParityComparison {
- passed: boolean;
- checks: PlanParityCheck[];
- v1: PlanParityMeasurement;
- v2: PlanParityMeasurement;
-}
-
-function check(name: string, passed: boolean, detail: string): PlanParityCheck {
- return { name, passed, detail };
-}
-
-function equalJson(left: unknown, right: unknown): boolean {
- return JSON.stringify(left) === JSON.stringify(right);
-}
-
-function comparableMetadata(
- metadata: PlanParityStreamMetadata,
-): Omit {
- return {
- video: metadata.video,
- audio: metadata.audio,
- };
-}
-
-/**
- * Compare semantic render output plus transport/resource measurements.
- *
- * `outputBytes`, transfer bytes, and working-set bytes are always surfaced.
- * They are not equality gates by default: v2 is expected to change artifact
- * packaging, and encoded containers may carry non-semantic differences.
- */
-// This function is intentionally an exhaustive, flat contract checklist. Each
-// branch emits a distinct diagnostic needed to root-cause parity failures.
-// fallow-ignore-next-line complexity
-export function comparePlanParityMeasurements(
- v1: PlanParityMeasurement,
- v2: PlanParityMeasurement,
- options: PlanParityComparisonOptions = {},
-): PlanParityComparison {
- if (v1.protocol !== "v1" || v2.protocol !== "v2") {
- throw new Error(
- `plan parity requires ordered v1/v2 measurements (got ${v1.protocol}/${v2.protocol})`,
- );
- }
-
- const durationToleranceSeconds = options.durationToleranceSeconds ?? 0.001;
- const durationDelta = Math.abs(
- v1.media.metadata.durationSeconds - v2.media.metadata.durationSeconds,
- );
- const checks: PlanParityCheck[] = [
- check(
- "decoded-video-frames",
- equalJson(v1.media.frameSha256, v2.media.frameSha256),
- `v1=${v1.media.frameSha256.length} frames, v2=${v2.media.frameSha256.length} frames`,
- ),
- check(
- "canonical-pcm-audio",
- equalJson(v1.media.pcmAudio, v2.media.pcmAudio),
- v1.media.pcmAudio && v2.media.pcmAudio
- ? `v1=${v1.media.pcmAudio.sampleCount} samples, v2=${v2.media.pcmAudio.sampleCount} samples`
- : `v1=${v1.media.pcmAudio ? "present" : "none"}, v2=${v2.media.pcmAudio ? "present" : "none"}`,
- ),
- check(
- "ffprobe-stream-metadata",
- equalJson(comparableMetadata(v1.media.metadata), comparableMetadata(v2.media.metadata)),
- "normalized video/audio stream metadata",
- ),
- check(
- "ffprobe-duration",
- durationDelta <= durationToleranceSeconds,
- `delta=${durationDelta.toFixed(6)}s, tolerance=${durationToleranceSeconds.toFixed(6)}s`,
- ),
- check(
- "chunk-hashes",
- equalJson(
- v1.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
- v2.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
- ),
- `v1=${v1.chunks.length} chunks/${v1.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes, ` +
- `v2=${v2.chunks.length} chunks/${v2.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes`,
- ),
- check(
- "encoded-output",
- options.requireEncodedOutputEquality !== true ||
- (v1.media.outputSha256 === v2.media.outputSha256 &&
- v1.media.outputBytes === v2.media.outputBytes),
- `v1=${v1.media.outputBytes} bytes/${v1.media.outputSha256}, ` +
- `v2=${v2.media.outputBytes} bytes/${v2.media.outputSha256}` +
- (options.requireEncodedOutputEquality === true ? " (strict)" : " (reported)"),
- ),
- check(
- "transfer-bytes",
- options.maxV2TransferBytes === undefined ||
- v2.transferBytes.total <= options.maxV2TransferBytes,
- `v1=${v1.transferBytes.total} bytes, v2=${v2.transferBytes.total} bytes` +
- (options.maxV2TransferBytes === undefined
- ? " (reported)"
- : `, v2 limit=${options.maxV2TransferBytes}`),
- ),
- check(
- "peak-materialized-working-set",
- options.maxV2PeakMaterializedBytes === undefined ||
- v2.peakMaterializedBytes <= options.maxV2PeakMaterializedBytes,
- `v1=${v1.peakMaterializedBytes} bytes, v2=${v2.peakMaterializedBytes} bytes` +
- (options.maxV2PeakMaterializedBytes === undefined
- ? " (reported)"
- : `, v2 limit=${options.maxV2PeakMaterializedBytes}`),
- ),
- ];
-
- return {
- passed: checks.every((entry) => entry.passed),
- checks,
- v1,
- v2,
- };
-}
diff --git a/packages/producer/src/plan-parity-fixture.test.ts b/packages/producer/src/plan-parity-fixture.test.ts
deleted file mode 100644
index 1d9ae94ce47..00000000000
--- a/packages/producer/src/plan-parity-fixture.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { createHash } from "node:crypto";
-import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "bun:test";
-import {
- generatePressureBytes,
- generateToneWav,
- preparePlanParityFixture,
-} from "./plan-parity-fixture.js";
-
-const cleanup: string[] = [];
-afterEach(() => {
- for (const path of cleanup.splice(0)) {
- rmSync(path, { recursive: true, force: true });
- }
-});
-
-describe("plan parity generated fixtures", () => {
- it("generates a valid deterministic mono PCM WAV", () => {
- const config = { durationSeconds: 0.25, frequencyHz: 440, sampleRate: 48_000 };
- const first = generateToneWav(config);
- const second = generateToneWav(config);
- expect(first.equals(second)).toBe(true);
- expect(first.subarray(0, 4).toString("ascii")).toBe("RIFF");
- expect(first.subarray(8, 12).toString("ascii")).toBe("WAVE");
- expect(first.readUInt32LE(24)).toBe(48_000);
- expect(first.readUInt32LE(40)).toBe(24_000);
- expect(first.length).toBe(24_044);
- });
-
- it("generates deterministic size-pressure bytes that vary with the seed", () => {
- const first = generatePressureBytes(65_536, 123);
- const again = generatePressureBytes(65_536, 123);
- const other = generatePressureBytes(65_536, 124);
- expect(first.equals(again)).toBe(true);
- expect(first.equals(other)).toBe(false);
- expect(new Set(first).size).toBeGreaterThan(240);
- });
-
- it("materializes the checked-in visual/audio fixture", () => {
- const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-fixture-"));
- cleanup.push(target);
- preparePlanParityFixture(
- join(import.meta.dir, "..", "fixtures", "plan-parity-visual-audio"),
- target,
- );
- const tone = readFileSync(join(target, "assets", "tone.wav"));
- expect(tone.subarray(0, 4).toString("ascii")).toBe("RIFF");
- expect(readFileSync(join(target, "index.html"), "utf-8")).toContain("assets/tone.wav");
- });
-
- it("materializes a small compression-resistant pressure payload", () => {
- const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-pressure-"));
- cleanup.push(target);
- preparePlanParityFixture(
- join(import.meta.dir, "..", "fixtures", "plan-parity-size-pressure"),
- target,
- );
- const pressurePath = join(target, "unused-pressure.bin");
- expect(statSync(pressurePath).size).toBe(65_536);
- expect(createHash("sha256").update(readFileSync(pressurePath)).digest("hex")).toBe(
- "0e5f00e17597a73cf7a273d772456db0544959ed19a437954fd800a78447f468",
- );
- });
-});
diff --git a/packages/producer/src/plan-parity-fixture.ts b/packages/producer/src/plan-parity-fixture.ts
deleted file mode 100644
index 35687861cdd..00000000000
--- a/packages/producer/src/plan-parity-fixture.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-
-const FIXTURE_CONFIG_FILE = ".plan-parity-fixture.json";
-
-interface GeneratedAudioConfig {
- path: string;
- durationSeconds: number;
- frequencyHz: number;
- sampleRate: number;
-}
-
-interface GeneratedPressureFileConfig {
- path: string;
- bytes: number;
- seed: number;
-}
-
-interface PlanParityFixtureConfig {
- generatedAudio?: GeneratedAudioConfig;
- generatedPressureFile?: GeneratedPressureFileConfig;
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function positiveNumber(record: Record, key: string): number {
- const value = record[key];
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
- throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a positive number`);
- }
- return value;
-}
-
-function relativePath(record: Record, key: string): string {
- const value = record[key];
- if (
- typeof value !== "string" ||
- value.length === 0 ||
- value.startsWith("/") ||
- value.split(/[\\/]/u).includes("..")
- ) {
- throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a safe relative path`);
- }
- return value;
-}
-
-function parseGeneratedAudio(value: unknown): GeneratedAudioConfig | undefined {
- if (value === undefined) return undefined;
- if (!isRecord(value)) throw new Error(`${FIXTURE_CONFIG_FILE}: generatedAudio must be an object`);
- return {
- path: relativePath(value, "path"),
- durationSeconds: positiveNumber(value, "durationSeconds"),
- frequencyHz: positiveNumber(value, "frequencyHz"),
- sampleRate: positiveNumber(value, "sampleRate"),
- };
-}
-
-function parsePressureFile(value: unknown): GeneratedPressureFileConfig | undefined {
- if (value === undefined) return undefined;
- if (!isRecord(value)) {
- throw new Error(`${FIXTURE_CONFIG_FILE}: generatedPressureFile must be an object`);
- }
- const bytes = positiveNumber(value, "bytes");
- const seed = positiveNumber(value, "seed");
- if (!Number.isInteger(bytes) || !Number.isInteger(seed)) {
- throw new Error(`${FIXTURE_CONFIG_FILE}: pressure bytes and seed must be integers`);
- }
- return {
- path: relativePath(value, "path"),
- bytes,
- seed,
- };
-}
-
-function readFixtureConfig(projectDir: string): PlanParityFixtureConfig {
- const configPath = join(projectDir, FIXTURE_CONFIG_FILE);
- if (!existsSync(configPath)) return {};
- const raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
- if (!isRecord(raw)) throw new Error(`${FIXTURE_CONFIG_FILE}: root must be an object`);
- return {
- generatedAudio: parseGeneratedAudio(raw.generatedAudio),
- generatedPressureFile: parsePressureFile(raw.generatedPressureFile),
- };
-}
-
-function writeAscii(buffer: Buffer, offset: number, value: string): void {
- buffer.write(value, offset, value.length, "ascii");
-}
-
-/** Generate a deterministic mono PCM WAV without relying on ffmpeg. */
-export function generateToneWav(config: Omit): Buffer {
- const sampleCount = Math.round(config.durationSeconds * config.sampleRate);
- const pcmBytes = sampleCount * 2;
- const wav = Buffer.alloc(44 + pcmBytes);
- writeAscii(wav, 0, "RIFF");
- wav.writeUInt32LE(36 + pcmBytes, 4);
- writeAscii(wav, 8, "WAVE");
- writeAscii(wav, 12, "fmt ");
- wav.writeUInt32LE(16, 16);
- wav.writeUInt16LE(1, 20);
- wav.writeUInt16LE(1, 22);
- wav.writeUInt32LE(config.sampleRate, 24);
- wav.writeUInt32LE(config.sampleRate * 2, 28);
- wav.writeUInt16LE(2, 32);
- wav.writeUInt16LE(16, 34);
- writeAscii(wav, 36, "data");
- wav.writeUInt32LE(pcmBytes, 40);
- for (let sample = 0; sample < sampleCount; sample += 1) {
- const phase = (2 * Math.PI * config.frequencyHz * sample) / config.sampleRate;
- const value = Math.round(Math.sin(phase) * 0.25 * 32767);
- wav.writeInt16LE(value, 44 + sample * 2);
- }
- return wav;
-}
-
-/** Deterministic xorshift bytes that do not collapse into a tiny gzip. */
-export function generatePressureBytes(bytes: number, seed: number): Buffer {
- const output = Buffer.alloc(bytes);
- let state = seed >>> 0;
- for (let index = 0; index < bytes; index += 1) {
- state ^= state << 13;
- state ^= state >>> 17;
- state ^= state << 5;
- output[index] = state & 0xff;
- }
- return output;
-}
-
-/**
- * Copy a checked-in fixture into a driver-owned project directory and
- * materialize its generated binary assets. Source fixtures stay small and
- * reviewable; size-pressure tests can vary their v1 cap without allocating
- * multi-gigabyte files.
- */
-export function preparePlanParityFixture(sourceDir: string, projectDir: string): void {
- mkdirSync(projectDir, { recursive: true });
- cpSync(sourceDir, projectDir, { recursive: true });
- const config = readFixtureConfig(projectDir);
-
- if (config.generatedAudio) {
- const target = join(projectDir, config.generatedAudio.path);
- mkdirSync(dirname(target), { recursive: true });
- writeFileSync(target, generateToneWav(config.generatedAudio));
- }
- if (config.generatedPressureFile) {
- const target = join(projectDir, config.generatedPressureFile.path);
- mkdirSync(dirname(target), { recursive: true });
- writeFileSync(
- target,
- generatePressureBytes(config.generatedPressureFile.bytes, config.generatedPressureFile.seed),
- );
- }
-}
diff --git a/packages/producer/src/plan-parity-harness.test.ts b/packages/producer/src/plan-parity-harness.test.ts
deleted file mode 100644
index 4032ada6a29..00000000000
--- a/packages/producer/src/plan-parity-harness.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "bun:test";
-import type { PlanParityDriver, PlanParityMeasurement } from "./plan-parity-contract.js";
-import {
- classifyPlanTooLargeProbeFailure,
- parsePlanParityArgs,
- runPlanV2SizePressure,
-} from "./plan-parity-harness.js";
-
-const cleanup: string[] = [];
-afterEach(() => {
- for (const target of cleanup.splice(0)) {
- rmSync(target, { recursive: true, force: true });
- }
-});
-
-function fakeV2Measurement(): PlanParityMeasurement {
- return {
- protocol: "v2",
- driver: "fake-lambda-local",
- media: {
- outputSha256: "output",
- outputBytes: 123,
- frameSha256: ["frame"],
- pcmAudio: null,
- metadata: {
- video: null,
- audio: null,
- durationSeconds: 1,
- },
- },
- chunks: [],
- transferBytes: {
- downloaded: 10,
- uploaded: 20,
- total: 30,
- },
- peakMaterializedBytes: 40,
- };
-}
-
-describe("parsePlanParityArgs()", () => {
- it("defaults both explicit protocol runs to lambda-local", () => {
- const options = parsePlanParityArgs(["node", "plan-parity"]);
- expect(options.v1Target).toBe("lambda-local");
- expect(options.v2Target).toBe("lambda-local");
- expect(options.renderConfig).toMatchObject({
- fps: 30,
- width: 320,
- height: 180,
- format: "mp4",
- });
- expect(options.expectV1PlanTooLarge).toBe(false);
- });
-
- it("parses resource gates and a low v1 size cap", () => {
- const options = parsePlanParityArgs([
- "node",
- "plan-parity",
- "--v1-plan-size-cap-bytes=32768",
- "--max-v2-transfer-bytes",
- "1048576",
- "--max-v2-peak-materialized-bytes",
- "524288",
- "--strict-encoded-output=false",
- "--chunk-size",
- "15",
- "--expect-v1-plan-too-large",
- ]);
- expect(options.v1PlanSizeCapBytes).toBe(32_768);
- expect(options.comparison.maxV2TransferBytes).toBe(1_048_576);
- expect(options.comparison.maxV2PeakMaterializedBytes).toBe(524_288);
- expect(options.comparison.requireEncodedOutputEquality).toBe(false);
- expect(options.renderConfig.chunkSize).toBe(15);
- expect(options.expectV1PlanTooLarge).toBe(true);
- });
-
- it("reserves an explicit deployed-AWS target grammar", () => {
- const options = parsePlanParityArgs([
- "node",
- "plan-parity",
- "--v1-target",
- "aws:hf-plan-v1-test",
- "--v2-target",
- "aws:hf-plan-v2-test",
- ]);
- expect(options.v1Target).toBe("aws:hf-plan-v1-test");
- expect(options.v2Target).toBe("aws:hf-plan-v2-test");
- });
-
- it("rejects bad targets and numeric flags", () => {
- expect(() => parsePlanParityArgs(["node", "plan-parity", "--v2-target", "production"])).toThrow(
- /lambda-local or aws/,
- );
- expect(() =>
- parsePlanParityArgs(["node", "plan-parity", "--v1-plan-size-cap-bytes", "-1"]),
- ).toThrow(/positive integer/);
- expect(() => parsePlanParityArgs(["node", "plan-parity", "--fps", "50"])).toThrow(
- /must be one of 24, 25, 30, 60/,
- );
- expect(() =>
- parsePlanParityArgs(["node", "plan-parity", "--duration-tolerance-seconds", "not-a-number"]),
- ).toThrow(/non-negative finite number/);
- });
-});
-
-describe("runPlanV2SizePressure()", () => {
- it("records typed v1 PLAN_TOO_LARGE and still completes explicit v2", async () => {
- const fixtureDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-fixture-"));
- const artifactsDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-report-"));
- cleanup.push(fixtureDir, artifactsDir);
- writeFileSync(join(fixtureDir, "index.html"), "", "utf-8");
- const calls: string[] = [];
- const driver: PlanParityDriver = {
- name: "fake-lambda-local",
- async render(input) {
- calls.push(input.protocol);
- if (input.protocol === "v1") {
- const error = new Error("synthetic cap exceeded") as Error & {
- code: "PLAN_TOO_LARGE";
- sizeBytes: number;
- limitBytes: number;
- };
- error.code = "PLAN_TOO_LARGE";
- error.sizeBytes = 65_536;
- error.limitBytes = 32_768;
- throw error;
- }
- return {
- protocol: "v2",
- outputPath: join(input.outputDir, "output.mp4"),
- chunks: [],
- transferBytes: { downloaded: 10, uploaded: 20 },
- peakMaterializedBytes: 40,
- };
- },
- };
-
- const report = await runPlanV2SizePressure({
- fixtureDir,
- artifactsDir,
- driver,
- renderConfig: {
- fps: 30,
- width: 320,
- height: 180,
- format: "mp4",
- },
- v1PlanSizeCapBytes: 32_768,
- analyzeDriverResult: async () => fakeV2Measurement(),
- });
-
- expect(calls).toEqual(["v1", "v2"]);
- expect(report.passed).toBe(true);
- expect(report.v1).toEqual({
- status: "expected-failure",
- code: "PLAN_TOO_LARGE",
- message: "synthetic cap exceeded",
- sizeBytes: 65_536,
- limitBytes: 32_768,
- });
- expect(report.v2.status).toBe("success");
- const written = JSON.parse(
- readFileSync(join(artifactsDir, "plan-too-large-v2-report.json"), "utf-8"),
- ) as {
- passed: boolean;
- v1: { code: string };
- v2: { status: string };
- };
- expect(written).toMatchObject({
- passed: true,
- v1: { code: "PLAN_TOO_LARGE" },
- v2: { status: "success" },
- });
- });
-
- it("distinguishes non-PLAN_TOO_LARGE failures", () => {
- const error = new Error("network down") as Error & { code: string };
- error.code = "S3_UNAVAILABLE";
- expect(classifyPlanTooLargeProbeFailure(error)).toEqual({
- status: "unexpected-failure",
- code: "S3_UNAVAILABLE",
- message: "network down",
- });
- });
-});
diff --git a/packages/producer/src/plan-parity-harness.ts b/packages/producer/src/plan-parity-harness.ts
deleted file mode 100644
index c73ad5f8dbf..00000000000
--- a/packages/producer/src/plan-parity-harness.ts
+++ /dev/null
@@ -1,453 +0,0 @@
-import { existsSync, mkdirSync, writeFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import process from "node:process";
-import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
-import { analyzePlanParityDriverResult } from "./plan-parity-analysis.js";
-import {
- comparePlanParityMeasurements,
- type PlanParityComparison,
- type PlanParityComparisonOptions,
- type PlanParityDriver,
- type PlanParityDriverResult,
- type PlanParityMeasurement,
- type PlanParityRenderConfig,
-} from "./plan-parity-contract.js";
-import { preparePlanParityFixture } from "./plan-parity-fixture.js";
-
-export type PlanParityTarget = "lambda-local" | `aws:${string}`;
-
-export interface RunPlanProtocolParityOptions {
- fixtureDir: string;
- artifactsDir: string;
- v1Driver: PlanParityDriver;
- v2Driver: PlanParityDriver;
- renderConfig: PlanParityRenderConfig;
- v1PlanSizeCapBytes?: number;
- comparison?: PlanParityComparisonOptions;
-}
-
-export interface PlanParityCliOptions {
- fixtureDir: string;
- artifactsDir: string;
- v1Target: PlanParityTarget;
- v2Target: PlanParityTarget;
- renderConfig: PlanParityRenderConfig;
- v1PlanSizeCapBytes?: number;
- expectV1PlanTooLarge: boolean;
- comparison: PlanParityComparisonOptions;
-}
-
-export interface RunPlanV2SizePressureOptions {
- fixtureDir: string;
- artifactsDir: string;
- driver: PlanParityDriver;
- renderConfig: PlanParityRenderConfig;
- v1PlanSizeCapBytes: number;
- /**
- * Test seam for the post-render media analyzer. Production callers use
- * the canonical ffmpeg/ffprobe analyzer.
- */
- analyzeDriverResult?: (
- driverName: string,
- result: PlanParityDriverResult,
- ) => Promise;
-}
-
-export type PlanTooLargeProbeOutcome =
- | {
- status: "expected-failure";
- code: "PLAN_TOO_LARGE";
- message: string;
- sizeBytes: number | null;
- limitBytes: number | null;
- }
- | {
- status: "unexpected-success";
- message: string;
- }
- | {
- status: "unexpected-failure";
- code: string | null;
- message: string;
- };
-
-export type PlanV2PressureOutcome =
- | {
- status: "success";
- measurement: PlanParityMeasurement;
- }
- | {
- status: "failure";
- message: string;
- };
-
-export interface PlanV2SizePressureReport {
- passed: boolean;
- checks: Array<{
- name: "v1-plan-too-large" | "v2-render-success";
- passed: boolean;
- detail: string;
- }>;
- v1: PlanTooLargeProbeOutcome;
- v2: PlanV2PressureOutcome;
-}
-
-function parsePositiveInteger(name: string, value: string | undefined): number | undefined {
- if (value === undefined) return undefined;
- const parsed = Number(value);
- if (!Number.isInteger(parsed) || parsed <= 0) {
- throw new Error(`plan parity: --${name} must be a positive integer`);
- }
- return parsed;
-}
-
-function parseNonNegativeNumber(name: string, value: string | undefined): number | undefined {
- if (value === undefined) return undefined;
- const parsed = Number(value);
- if (!Number.isFinite(parsed) || parsed < 0) {
- throw new Error(`plan parity: --${name} must be a non-negative finite number`);
- }
- return parsed;
-}
-
-function parseBoolean(name: string, value: string | undefined): boolean {
- if (value === undefined || value === "true") return true;
- if (value === "false") return false;
- throw new Error(`plan parity: --${name} must be true or false`);
-}
-
-function parseTarget(name: string, value: string | undefined): PlanParityTarget {
- const target = value ?? "lambda-local";
- if (target === "lambda-local") return target;
- if (target.startsWith("aws:") && target.length > "aws:".length) {
- return target as `aws:${string}`;
- }
- throw new Error(
- `plan parity: --${name} must be lambda-local or aws: (got ${JSON.stringify(target)})`,
- );
-}
-
-function collectArgs(argv: string[]): Map {
- const args = new Map();
- for (let index = 2; index < argv.length; index += 1) {
- const token = argv[index];
- if (!token?.startsWith("--")) {
- throw new Error(`plan parity: unexpected positional argument ${JSON.stringify(token)}`);
- }
- const equals = token.indexOf("=");
- if (equals > 2) {
- args.set(token.slice(2, equals), token.slice(equals + 1));
- continue;
- }
- const key = token.slice(2);
- const next = argv[index + 1];
- if (!next || next.startsWith("--")) {
- args.set(key, "true");
- continue;
- }
- args.set(key, next);
- index += 1;
- }
- return args;
-}
-
-export function parsePlanParityArgs(argv: string[]): PlanParityCliOptions {
- const args = collectArgs(argv);
- const fixtureDir = resolve(args.get("fixture") ?? "fixtures/plan-parity-visual-audio");
- const artifactsDir = resolve(args.get("artifacts-dir") ?? ".debug/plan-protocol-parity");
- const fps = parsePositiveInteger("fps", args.get("fps")) ?? 30;
- if (!isDistributedFps(fps)) {
- throw new Error(`plan parity: --fps must be one of ${DISTRIBUTED_FPS.join(", ")}`);
- }
-
- return {
- fixtureDir,
- artifactsDir,
- v1Target: parseTarget("v1-target", args.get("v1-target")),
- v2Target: parseTarget("v2-target", args.get("v2-target")),
- renderConfig: {
- fps,
- width: parsePositiveInteger("width", args.get("width")) ?? 320,
- height: parsePositiveInteger("height", args.get("height")) ?? 180,
- format: "mp4",
- chunkSize: parsePositiveInteger("chunk-size", args.get("chunk-size")),
- maxParallelChunks: parsePositiveInteger(
- "max-parallel-chunks",
- args.get("max-parallel-chunks"),
- ),
- },
- v1PlanSizeCapBytes: parsePositiveInteger(
- "v1-plan-size-cap-bytes",
- args.get("v1-plan-size-cap-bytes"),
- ),
- expectV1PlanTooLarge:
- args.get("expect-v1-plan-too-large") === undefined
- ? false
- : parseBoolean("expect-v1-plan-too-large", args.get("expect-v1-plan-too-large")),
- comparison: {
- durationToleranceSeconds: parseNonNegativeNumber(
- "duration-tolerance-seconds",
- args.get("duration-tolerance-seconds"),
- ),
- requireEncodedOutputEquality:
- args.get("strict-encoded-output") === undefined
- ? false
- : parseBoolean("strict-encoded-output", args.get("strict-encoded-output")),
- maxV2TransferBytes: parsePositiveInteger(
- "max-v2-transfer-bytes",
- args.get("max-v2-transfer-bytes"),
- ),
- maxV2PeakMaterializedBytes: parsePositiveInteger(
- "max-v2-peak-materialized-bytes",
- args.get("max-v2-peak-materialized-bytes"),
- ),
- },
- };
-}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null;
-}
-
-function errorCode(error: unknown): string | null {
- if (isRecord(error) && typeof error.code === "string") {
- return error.code;
- }
- return null;
-}
-
-function errorNumber(error: unknown, field: "sizeBytes" | "limitBytes"): number | null {
- if (isRecord(error)) {
- const value = error[field];
- if (typeof value === "number" && Number.isFinite(value)) return value;
- }
- return null;
-}
-
-export function classifyPlanTooLargeProbeFailure(error: unknown): PlanTooLargeProbeOutcome {
- const code = errorCode(error);
- if (code === "PLAN_TOO_LARGE") {
- return {
- status: "expected-failure",
- code,
- message: errorMessage(error),
- sizeBytes: errorNumber(error, "sizeBytes"),
- limitBytes: errorNumber(error, "limitBytes"),
- };
- }
- return {
- status: "unexpected-failure",
- code,
- message: errorMessage(error),
- };
-}
-
-/**
- * Prove the migration boundary directly:
- *
- * 1. explicit v1 with a deliberately low cap must fail PLAN_TOO_LARGE;
- * 2. explicit v2 must still run to completion for the same prepared fixture.
- *
- * Unlike semantic parity mode, this API does not compare v1/v2 output because
- * the expected v1 run never produces output. Its durable report records both
- * the typed failure and the fully analyzed v2 success.
- */
-export async function runPlanV2SizePressure(
- options: RunPlanV2SizePressureOptions,
-): Promise {
- if (!existsSync(options.fixtureDir)) {
- throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
- }
- mkdirSync(options.artifactsDir, { recursive: true });
- const v1ProjectDir = resolve(options.artifactsDir, "v1-low-cap", "project");
- const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
- preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
- preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
-
- let v1: PlanTooLargeProbeOutcome;
- try {
- await options.driver.render({
- protocol: "v1",
- projectDir: v1ProjectDir,
- outputDir: resolve(options.artifactsDir, "v1-low-cap", "run"),
- renderConfig: options.renderConfig,
- planSizeCapBytes: options.v1PlanSizeCapBytes,
- });
- v1 = {
- status: "unexpected-success",
- message: `v1 rendered despite plan cap ${options.v1PlanSizeCapBytes}`,
- };
- } catch (error) {
- v1 = classifyPlanTooLargeProbeFailure(error);
- }
-
- let v2: PlanV2PressureOutcome;
- try {
- const result = await options.driver.render({
- protocol: "v2",
- projectDir: v2ProjectDir,
- outputDir: resolve(options.artifactsDir, "v2", "run"),
- renderConfig: options.renderConfig,
- });
- const analyze = options.analyzeDriverResult ?? analyzePlanParityDriverResult;
- v2 = {
- status: "success",
- measurement: await analyze(options.driver.name, result),
- };
- } catch (error) {
- v2 = {
- status: "failure",
- message: errorMessage(error),
- };
- }
-
- const checks: PlanV2SizePressureReport["checks"] = [
- {
- name: "v1-plan-too-large",
- passed: v1.status === "expected-failure",
- detail:
- v1.status === "expected-failure"
- ? `PLAN_TOO_LARGE size=${v1.sizeBytes ?? "unknown"} limit=${v1.limitBytes ?? "unknown"}`
- : v1.message,
- },
- {
- name: "v2-render-success",
- passed: v2.status === "success",
- detail:
- v2.status === "success"
- ? `${v2.measurement.media.frameSha256.length} frames, ${v2.measurement.media.outputBytes} output bytes`
- : v2.message,
- },
- ];
- const report: PlanV2SizePressureReport = {
- passed: checks.every((entry) => entry.passed),
- checks,
- v1,
- v2,
- };
- writeFileSync(
- resolve(options.artifactsDir, "plan-too-large-v2-report.json"),
- `${JSON.stringify(report, null, 2)}\n`,
- "utf-8",
- );
- return report;
-}
-
-/**
- * Run the same prepared project through explicit v1 and v2 drivers, analyze
- * their artifacts, and persist a machine-readable comparison report.
- */
-export async function runPlanProtocolParity(
- options: RunPlanProtocolParityOptions,
-): Promise {
- if (!existsSync(options.fixtureDir)) {
- throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
- }
- mkdirSync(options.artifactsDir, { recursive: true });
-
- const v1ProjectDir = resolve(options.artifactsDir, "v1", "project");
- const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
- preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
- preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
-
- const v1Result = await options.v1Driver.render({
- protocol: "v1",
- projectDir: v1ProjectDir,
- outputDir: resolve(options.artifactsDir, "v1", "run"),
- renderConfig: options.renderConfig,
- planSizeCapBytes: options.v1PlanSizeCapBytes,
- });
- const v2Result = await options.v2Driver.render({
- protocol: "v2",
- projectDir: v2ProjectDir,
- outputDir: resolve(options.artifactsDir, "v2", "run"),
- renderConfig: options.renderConfig,
- });
- const [v1, v2] = await Promise.all([
- analyzePlanParityDriverResult(options.v1Driver.name, v1Result),
- analyzePlanParityDriverResult(options.v2Driver.name, v2Result),
- ]);
- const comparison = comparePlanParityMeasurements(v1, v2, options.comparison);
- writeFileSync(
- resolve(options.artifactsDir, "comparison.json"),
- `${JSON.stringify(comparison, null, 2)}\n`,
- "utf-8",
- );
- return comparison;
-}
-
-interface LambdaLocalDriverModule {
- createLambdaLocalPlanParityDriver(): PlanParityDriver;
-}
-
-function isLambdaLocalDriverModule(value: unknown): value is LambdaLocalDriverModule {
- return (
- typeof value === "object" &&
- value !== null &&
- "createLambdaLocalPlanParityDriver" in value &&
- typeof value.createLambdaLocalPlanParityDriver === "function"
- );
-}
-
-async function loadDriver(target: PlanParityTarget): Promise {
- if (target.startsWith("aws:")) {
- throw new Error(
- `plan parity target ${target} requires the deployed-AWS driver package; ` +
- "use the library API to inject that driver until it is installed",
- );
- }
- // Indirect path keeps producer's build from pulling @hyperframes/aws-lambda
- // into its declaration emit before that workspace package has built.
- const modulePath = "./plan-parity-lambda-local-driver.js";
- const loaded: unknown = await import(modulePath);
- if (!isLambdaLocalDriverModule(loaded)) {
- throw new Error("lambda-local parity driver module has an invalid shape");
- }
- return loaded.createLambdaLocalPlanParityDriver();
-}
-
-async function main(): Promise {
- const options = parsePlanParityArgs(process.argv);
- if (options.expectV1PlanTooLarge) {
- if (options.v1PlanSizeCapBytes === undefined) {
- throw new Error("plan parity: --expect-v1-plan-too-large requires --v1-plan-size-cap-bytes");
- }
- if (options.v1Target !== options.v2Target) {
- throw new Error(
- "plan parity: pressure mode requires the same target for its v1 probe and v2 proof",
- );
- }
- const report = await runPlanV2SizePressure({
- fixtureDir: options.fixtureDir,
- artifactsDir: options.artifactsDir,
- driver: await loadDriver(options.v1Target),
- renderConfig: options.renderConfig,
- v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
- });
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
- if (!report.passed) process.exitCode = 1;
- return;
- }
- const comparison = await runPlanProtocolParity({
- fixtureDir: options.fixtureDir,
- artifactsDir: options.artifactsDir,
- v1Driver: await loadDriver(options.v1Target),
- v2Driver: await loadDriver(options.v2Target),
- renderConfig: options.renderConfig,
- v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
- comparison: options.comparison,
- });
- process.stdout.write(`${JSON.stringify(comparison, null, 2)}\n`);
- if (!comparison.passed) process.exitCode = 1;
-}
-
-const isDirectRun =
- process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
-if (isDirectRun) {
- await main();
-}
diff --git a/packages/producer/src/plan-parity-lambda-local-driver.ts b/packages/producer/src/plan-parity-lambda-local-driver.ts
deleted file mode 100644
index 62a4038f03f..00000000000
--- a/packages/producer/src/plan-parity-lambda-local-driver.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-// fallow-ignore-file unused-file
-// Loaded by a runtime-only dynamic import so producer declaration emit does
-// not eagerly resolve the AWS workspace package.
-/**
- * Lambda-local adapter for the generic v1/v2 parity runner.
- *
- * Kept separate from the protocol-neutral harness because it imports
- * `@hyperframes/aws-lambda`; producer's declaration emit runs before the
- * Lambda workspace package is built in some CI stages.
- */
-
-import { existsSync, mkdirSync, rmSync } from "node:fs";
-import { join } from "node:path";
-import type { PlanParityDriver } from "./plan-parity-contract.js";
-import { runLambdaLocalRender } from "./regression-harness-lambda-local.js";
-
-export function createLambdaLocalPlanParityDriver(): PlanParityDriver {
- return {
- name: "lambda-local",
- async render(input) {
- if (existsSync(input.outputDir)) {
- rmSync(input.outputDir, { recursive: true, force: true });
- }
- mkdirSync(input.outputDir, { recursive: true });
- return runLambdaLocalRender({
- protocol: input.protocol,
- projectDir: input.projectDir,
- tempRoot: input.outputDir,
- renderedOutputPath: join(input.outputDir, "output.mp4"),
- fps: input.renderConfig.fps,
- width: input.renderConfig.width,
- height: input.renderConfig.height,
- format: input.renderConfig.format,
- chunkSize: input.renderConfig.chunkSize,
- maxParallelChunks: input.renderConfig.maxParallelChunks,
- planDirSizeLimitBytes: input.planSizeCapBytes,
- });
- },
- };
-}
diff --git a/packages/producer/src/regression-harness-distributed.ts b/packages/producer/src/regression-harness-distributed.ts
index a5774420af7..fb9c043ab4f 100644
--- a/packages/producer/src/regression-harness-distributed.ts
+++ b/packages/producer/src/regression-harness-distributed.ts
@@ -39,7 +39,8 @@ import {
type Fps,
isDistributedFps,
} from "@hyperframes/core";
-import { assemble, plan, renderChunk } from "./distributed.js";
+import { assemble, renderChunk } from "./distributed.js";
+import { plan } from "./services/distributed/plan.js";
import { PLAN_AUDIO_RELATIVE_PATH, type DistributedFormat } from "./services/distributed/shared.js";
/**
@@ -80,7 +81,7 @@ export type DistributedSupportResult = { supported: true } | { supported: false;
* `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call
* site. We surface this gate in code rather than only in TS so the
* harness can skip the fixture cleanly instead of throwing.
- * - hdr must not be `true`. Distributed mode is SDR-only at v1.
+ * - hdr must not be `true`. Distributed mode is SDR-only.
*
* Callers that want the structured reason can read it off the returned
* `reason` field; the message is intended to be log-friendly.
diff --git a/packages/producer/src/regression-harness-lambda-local-types.ts b/packages/producer/src/regression-harness-lambda-local-types.ts
index ffb25a95b7b..5a502b7fa7b 100644
--- a/packages/producer/src/regression-harness-lambda-local-types.ts
+++ b/packages/producer/src/regression-harness-lambda-local-types.ts
@@ -14,8 +14,6 @@ import type { DistributedFormat } from "./services/distributed/shared.js";
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
export interface RunLambdaLocalInput {
- /** Explicit plan transport. Omitted only for legacy regression callers, where it defaults to v1. */
- protocol?: "v1" | "v2";
projectDir: string;
tempRoot: string;
renderedOutputPath: string;
@@ -35,13 +33,11 @@ export interface RunLambdaLocalInput {
codec?: "h264" | "h265";
chunkSize?: number;
maxParallelChunks?: number;
- /** Test-only low cap for exercising typed v1 PLAN_TOO_LARGE behavior. */
- planDirSizeLimitBytes?: number;
variables?: Record;
}
export interface LambdaLocalRenderResult {
- protocol: "v1" | "v2";
+ protocol: "v2";
outputPath: string;
chunks: Array<{
index: number;
diff --git a/packages/producer/src/regression-harness-lambda-local.ts b/packages/producer/src/regression-harness-lambda-local.ts
index c475fc704e0..c971aebeceb 100644
--- a/packages/producer/src/regression-harness-lambda-local.ts
+++ b/packages/producer/src/regression-harness-lambda-local.ts
@@ -4,7 +4,7 @@
* Drives the OSS `@hyperframes/aws-lambda` handler through the exact
* sequence Step Functions invokes in production:
*
- * handler({ Action: "plan" }) → planDir tarball on S3
+ * handler({ Action: "plan" }) → Plan v2 manifest + CAS artifacts on S3
* handler({ Action: "renderChunk" }) × N → chunk artifacts on S3
* handler({ Action: "assemble" }) → final mp4 / mov / png-seq
*
@@ -71,7 +71,6 @@ function uri(key: string): string {
export async function runLambdaLocalRender(
input: RunLambdaLocalInput,
): Promise {
- const protocol = input.protocol ?? "v1";
const s3Root = join(input.tempRoot, "s3");
mkdirSync(s3Root, { recursive: true });
@@ -121,7 +120,6 @@ export async function runLambdaLocalRender(
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
- planDirSizeLimitBytes: input.planDirSizeLimitBytes,
hdrMode: "force-sdr",
// Forward `variables` through the event boundary so lambda-local mode
// exercises the same variables-in-encoder.json path that real Lambda
@@ -132,22 +130,13 @@ export async function runLambdaLocalRender(
// STEP A: plan
const planPrefix = `renders/harness/${Date.now()}/`;
- const planEvent: PlanEvent =
- protocol === "v2"
- ? {
- Action: "plan",
- PlanProtocol: "v2",
- ProjectS3Uri: uri(projectKey),
- PlanOutputS3Prefix: uri(planPrefix),
- Config: config,
- }
- : {
- Action: "plan",
- PlanProtocol: "v1",
- ProjectS3Uri: uri(projectKey),
- PlanOutputS3Prefix: uri(planPrefix),
- Config: config,
- };
+ const planEvent: PlanEvent = {
+ Action: "plan",
+ PlanProtocol: "v2",
+ ProjectS3Uri: uri(projectKey),
+ PlanOutputS3Prefix: uri(planPrefix),
+ Config: config,
+ };
const planResponse = await observe(() => handler(planEvent, deps));
if (planResponse.Action !== "plan") {
throw new Error(`lambda-local: plan action returned ${planResponse.Action}`);
@@ -165,19 +154,12 @@ export async function runLambdaLocalRender(
ChunkOutputS3Prefix: uri(planPrefix),
Format: input.format,
};
- const chunkEvent: RenderChunkEvent =
- protocol === "v2"
- ? {
- ...shared,
- PlanProtocol: "v2",
- PlanV2ManifestS3Uri: requireV2PlanResult(planResult).PlanV2ManifestS3Uri,
- PlanV2ArtifactS3Prefix: requireV2PlanResult(planResult).PlanV2ArtifactS3Prefix,
- }
- : {
- ...shared,
- PlanProtocol: "v1",
- PlanS3Uri: requireV1PlanResult(planResult).PlanS3Uri,
- };
+ const chunkEvent: RenderChunkEvent = {
+ ...shared,
+ PlanProtocol: "v2",
+ PlanV2ManifestS3Uri: planResult.PlanV2ManifestS3Uri,
+ PlanV2ArtifactS3Prefix: planResult.PlanV2ArtifactS3Prefix,
+ };
const chunkResponse = await observe(() => handler(chunkEvent, deps));
if (chunkResponse.Action !== "renderChunk") {
throw new Error(`lambda-local: renderChunk action returned ${chunkResponse.Action}`);
@@ -202,20 +184,13 @@ export async function runLambdaLocalRender(
OutputS3Uri: finalUri,
Format: input.format,
};
- const assembleEvent: AssembleEvent =
- protocol === "v2"
- ? {
- ...assembleShared,
- PlanProtocol: "v2",
- PlanV2ManifestS3Uri: requireV2PlanResult(planResult).PlanV2ManifestS3Uri,
- PlanV2ArtifactS3Prefix: requireV2PlanResult(planResult).PlanV2ArtifactS3Prefix,
- PlanHash: planResult.PlanHash,
- }
- : {
- ...assembleShared,
- PlanProtocol: "v1",
- PlanS3Uri: requireV1PlanResult(planResult).PlanS3Uri,
- };
+ const assembleEvent: AssembleEvent = {
+ ...assembleShared,
+ PlanProtocol: "v2",
+ PlanV2ManifestS3Uri: planResult.PlanV2ManifestS3Uri,
+ PlanV2ArtifactS3Prefix: planResult.PlanV2ArtifactS3Prefix,
+ PlanHash: planResult.PlanHash,
+ };
const assembleResponse = await observe(() => handler(assembleEvent, deps));
if (assembleResponse.Action !== "assemble") {
throw new Error(`lambda-local: assemble action returned ${assembleResponse.Action}`);
@@ -238,7 +213,7 @@ export async function runLambdaLocalRender(
}
return {
- protocol,
+ protocol: "v2",
outputPath: input.renderedOutputPath,
chunks,
transferBytes,
@@ -254,24 +229,6 @@ function fakeS3Path(s3Root: string, s3Uri: string): string {
return join(s3Root, s3Uri.slice(prefix.length));
}
-function requireV1PlanResult(
- result: PlanLambdaResult,
-): Extract {
- if (!("PlanS3Uri" in result)) {
- throw new Error("lambda-local: v1 plan returned v2 locators");
- }
- return result;
-}
-
-function requireV2PlanResult(
- result: PlanLambdaResult,
-): Extract {
- if (!("PlanProtocol" in result) || result.PlanProtocol !== "v2") {
- throw new Error("lambda-local: v2 plan did not return explicit v2 locators");
- }
- return result;
-}
-
// The recursive walk is the measurement itself; extracting its two filesystem
// branches would make this small test-harness utility harder to audit.
// fallow-ignore-next-line complexity
diff --git a/packages/producer/src/services/distributed/publicExports.test.ts b/packages/producer/src/services/distributed/publicExports.test.ts
index cce59624fb5..bd81020ab8c 100644
--- a/packages/producer/src/services/distributed/publicExports.test.ts
+++ b/packages/producer/src/services/distributed/publicExports.test.ts
@@ -1,152 +1,52 @@
-/**
- * Unit tests for the public-export surface of the distributed primitives.
- *
- * Two import paths must work for adopters:
- *
- * 1. `import { plan, renderChunk, assemble } from "@hyperframes/producer"`
- * — the canonical package entry. Includes the three activity functions
- * and their result types.
- *
- * 2. `import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed"`
- * — the focused subpath, suitable for Lambda chunk-runner images that
- * don't pull in the in-process renderer's transitive deps.
- *
- * These tests are surface-only: they assert the symbols exist and have the
- * expected shapes. The functional behaviour is covered by `plan.test.ts` /
- * `renderChunk.test.ts` / `assemble.test.ts`.
- *
- * We import via the workspace-relative `../../distributed.js` /
- * `../../index.js` paths rather than `"@hyperframes/producer"` because the
- * package resolver inside the workspace points back at `src/index.ts` —
- * either form exercises the same surface.
- */
-
import { describe, expect, it } from "bun:test";
import * as distributedSubpath from "../../distributed.js";
import * as producerIndex from "../../index.js";
-describe("@hyperframes/producer/distributed (subpath)", () => {
- it("exports the three activity functions", () => {
- expect(typeof distributedSubpath.plan).toBe("function");
- expect(typeof distributedSubpath.renderChunk).toBe("function");
- expect(typeof distributedSubpath.assemble).toBe("function");
- });
-
- it("exports the chunking helpers + constants", () => {
- expect(typeof distributedSubpath.resolveChunkPlan).toBe("function");
- expect(typeof distributedSubpath.buildChunkSlices).toBe("function");
- expect(typeof distributedSubpath.measurePlanDirBytes).toBe("function");
- expect(distributedSubpath.DEFAULT_CHUNK_SIZE).toBe(240);
- expect(distributedSubpath.DEFAULT_MAX_PARALLEL_CHUNKS).toBe(16);
- expect(distributedSubpath.PLAN_DIR_SIZE_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024);
- });
-
- it("exports the non-retryable error codes + classes", () => {
- expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE");
- expect(distributedSubpath.DISTRIBUTED_DURATION_OUT_OF_RANGE).toBe(
- "DISTRIBUTED_DURATION_OUT_OF_RANGE",
- );
- expect(distributedSubpath.MAX_DISTRIBUTED_DURATION_SECONDS).toBe(24 * 60 * 60);
- expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe(
- "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
- );
- expect(distributedSubpath.FFMPEG_VERSION_MISMATCH).toBe("FFMPEG_VERSION_MISMATCH");
- expect(distributedSubpath.INVALID_VIDEO_METADATA).toBe("INVALID_VIDEO_METADATA");
- expect(distributedSubpath.PLAN_HASH_MISMATCH).toBe("PLAN_HASH_MISMATCH");
- expect(distributedSubpath.PLAN_V2_INTEGRITY_UNRECOVERABLE).toBe(
- "PLAN_V2_INTEGRITY_UNRECOVERABLE",
- );
-
- expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
- expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
- expect(typeof distributedSubpath.PlanValidationError).toBe("function");
- expect(typeof distributedSubpath.PlanV2IntegrityError).toBe("function");
- expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
- });
-
- it("exports the input-validation helpers", () => {
- expect(typeof distributedSubpath.rejectUnsupportedDistributedFormat).toBe("function");
- expect(typeof distributedSubpath.applyRuntimeEnvSnapshot).toBe("function");
- expect(typeof distributedSubpath.readWebGlVendorInfoFromCanvas).toBe("function");
- });
-
- it("exports the plan protocol contract", () => {
- expect(distributedSubpath.PLAN_SCHEMA_VERSION).toBe(1);
- expect(distributedSubpath.PLAN_ARTIFACT_LAYOUT).toBe("plan-dir-v1");
- expect(distributedSubpath.PLAN_HASH_SCHEMA).toBe("hyperframes-plan-hash-v1");
- expect(distributedSubpath.PLAN_V2_SCHEMA_VERSION).toBe(2);
- expect(distributedSubpath.PLAN_V2_ARTIFACT_LAYOUT).toBe("content-addressed-plan-v2");
- expect(distributedSubpath.PLAN_V2_HASH_SCHEMA).toBe("hyperframes-plan-manifest-hash-v2");
- expect(distributedSubpath.PLAN_PROTOCOL_UNSUPPORTED).toBe("PLAN_PROTOCOL_UNSUPPORTED");
- expect(distributedSubpath.CURRENT_PLAN_PROTOCOL).toEqual({
- schemaVersion: 1,
- artifactLayout: "plan-dir-v1",
- hashSchema: "hyperframes-plan-hash-v1",
- });
- expect(distributedSubpath.PLAN_PROTOCOL_V1).toBe(distributedSubpath.CURRENT_PLAN_PROTOCOL);
- expect(distributedSubpath.DISTRIBUTED_RENDER_CAPABILITIES.roles).toEqual({
- planner: {
- produces: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
- },
- chunk: {
- accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
- acceptsLegacyV1WithoutDescriptor: true,
- },
- assembler: {
- accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
- acceptsLegacyV1WithoutDescriptor: true,
- },
+const REMOVED_PLAN_V1_EXPORTS = [
+ "plan",
+ "PLAN_TOO_LARGE",
+ "PlanTooLargeError",
+ "CURRENT_PLAN_PROTOCOL",
+ "DISTRIBUTED_RENDER_CAPABILITIES",
+ "getDistributedRenderCapabilities",
+ "PLAN_ARTIFACT_LAYOUT",
+ "PLAN_HASH_SCHEMA",
+ "PLAN_PROTOCOL_V1",
+ "PLAN_SCHEMA_VERSION",
+ "readPlanProtocol",
+ "readPlanProtocolV1",
+ "createPlanV2FromV1",
+ "publishPlanV2FromV1",
+] as const;
+
+describe("distributed public exports", () => {
+ it("exposes Plan v2 as the only distributed plan transport", () => {
+ for (const name of REMOVED_PLAN_V1_EXPORTS) {
+ expect(name in distributedSubpath).toBe(false);
+ expect(name in producerIndex).toBe(false);
+ }
+
+ expect(distributedSubpath.PLAN_PROTOCOL_V2).toEqual({
+ schemaVersion: 2,
+ artifactLayout: "content-addressed-plan-v2",
+ hashSchema: "hyperframes-plan-manifest-hash-v2",
});
- expect(typeof distributedSubpath.getDistributedRenderCapabilities).toBe("function");
- expect(typeof distributedSubpath.readPlanProtocol).toBe("function");
expect(typeof distributedSubpath.planV2).toBe("function");
expect(typeof distributedSubpath.planV2WithPublisher).toBe("function");
expect(typeof distributedSubpath.createPlanV2FromExecutionPlan).toBe("function");
expect(typeof distributedSubpath.publishPlanV2FromExecutionPlan).toBe("function");
- expect(typeof distributedSubpath.getPlanV2ExecutionPlanHash).toBe("function");
- // Deprecated compatibility aliases remain available.
- expect(typeof distributedSubpath.createPlanV2FromV1).toBe("function");
- expect(typeof distributedSubpath.publishPlanV2FromV1).toBe("function");
- expect(typeof distributedSubpath.LocalPlanV2ArtifactPublisher).toBe("function");
expect(typeof distributedSubpath.renderChunkV2).toBe("function");
expect(typeof distributedSubpath.assembleV2).toBe("function");
- expect(typeof distributedSubpath.readPlanV2Manifest).toBe("function");
- expect(typeof distributedSubpath.materializePlanV2Target).toBe("function");
- expect(typeof distributedSubpath.PlanProtocolUnsupportedError).toBe("function");
});
-});
-describe("@hyperframes/producer (main entry)", () => {
- it("re-exports the three activity functions", () => {
- expect(typeof producerIndex.plan).toBe("function");
- expect(typeof producerIndex.renderChunk).toBe("function");
- expect(typeof producerIndex.assemble).toBe("function");
- });
-
- it("re-exports the plan protocol contract", () => {
- expect(producerIndex.CURRENT_PLAN_PROTOCOL).toBe(distributedSubpath.CURRENT_PLAN_PROTOCOL);
- expect(producerIndex.PLAN_PROTOCOL_V1).toBe(distributedSubpath.PLAN_PROTOCOL_V1);
- expect(producerIndex.DISTRIBUTED_RENDER_CAPABILITIES).toBe(
- distributedSubpath.DISTRIBUTED_RENDER_CAPABILITIES,
- );
- expect(typeof producerIndex.getDistributedRenderCapabilities).toBe("function");
- expect(producerIndex.PLAN_PROTOCOL_UNSUPPORTED).toBe("PLAN_PROTOCOL_UNSUPPORTED");
- expect(producerIndex.PLAN_V2_INTEGRITY_UNRECOVERABLE).toBe("PLAN_V2_INTEGRITY_UNRECOVERABLE");
- expect(typeof producerIndex.readPlanProtocol).toBe("function");
- expect(typeof producerIndex.planV2WithPublisher).toBe("function");
- expect(typeof producerIndex.createPlanV2FromExecutionPlan).toBe("function");
- expect(typeof producerIndex.publishPlanV2FromExecutionPlan).toBe("function");
- expect(typeof producerIndex.getPlanV2ExecutionPlanHash).toBe("function");
- // Deprecated compatibility aliases remain available.
- expect(typeof producerIndex.createPlanV2FromV1).toBe("function");
- expect(typeof producerIndex.publishPlanV2FromV1).toBe("function");
- expect(typeof producerIndex.PlanV2IntegrityError).toBe("function");
- expect(typeof producerIndex.PlanProtocolUnsupportedError).toBe("function");
+ it("keeps execution primitives used after Plan v2 materialization", () => {
+ expect(typeof distributedSubpath.renderChunk).toBe("function");
+ expect(typeof distributedSubpath.assemble).toBe("function");
+ expect(typeof distributedSubpath.materializePlanV2Target).toBe("function");
+ expect(typeof distributedSubpath.readPlanV2Manifest).toBe("function");
});
- it("preserves the existing in-process exports (executeRenderJob unchanged)", () => {
- // The distributed primitives must NOT break the in-process surface;
- // spot-check the load-bearing exports the in-process callers rely on.
+ it("keeps the in-process renderer public surface unchanged", () => {
expect(typeof producerIndex.executeRenderJob).toBe("function");
expect(typeof producerIndex.createRenderJob).toBe("function");
expect(typeof producerIndex.createCaptureSession).toBe("function");
diff --git a/packages/producer/src/utils/ffprobeArgvContract.test.ts b/packages/producer/src/utils/ffprobeArgvContract.test.ts
index 27912cc8b14..a388c9dffb1 100644
--- a/packages/producer/src/utils/ffprobeArgvContract.test.ts
+++ b/packages/producer/src/utils/ffprobeArgvContract.test.ts
@@ -64,7 +64,6 @@ const MANIFEST = [
"packages/core/src/mediaGradeAnalyzer.ts",
"packages/engine/src/utils/ffprobe.ts",
"packages/lint/src/hevcPreviewLint.ts",
- "packages/producer/src/plan-parity-analysis.ts",
"packages/producer/src/services/render/audioPadTrim.ts",
"packages/producer/src/utils/audioRegression.ts",
"packages/studio-server/src/helpers/mediaMetadata.ts",
diff --git a/packages/producer/tests/README.md b/packages/producer/tests/README.md
index 57809e998d7..dbe9f8874ce 100644
--- a/packages/producer/tests/README.md
+++ b/packages/producer/tests/README.md
@@ -94,7 +94,7 @@ passing in the summary):
- `fps.den !== 1` — distributed mode is integer-fps only (no NTSC).
- `fps.num` not in `DISTRIBUTED_FPS` (`@hyperframes/core`) — closed set per `DistributedRenderConfig`.
- `format === "webm"` — `plan()` refuses webm.
-- `hdr === true` — distributed mode is SDR-only at v1.
+- `hdr === true` — distributed mode is SDR-only.
Both modes use the fixture's authored `minPsnr` as the per-test
threshold — distributed must clear the same quality bar in-process
diff --git a/packages/producer/tsconfig.json b/packages/producer/tsconfig.json
index f63915cad51..4e876a30f22 100644
--- a/packages/producer/tsconfig.json
+++ b/packages/producer/tsconfig.json
@@ -25,7 +25,6 @@
"dist",
"src/**/*.test.ts",
"src/**/__test_utils__/**",
- "src/regression-harness-lambda-local.ts",
- "src/plan-parity-lambda-local-driver.ts"
+ "src/regression-harness-lambda-local.ts"
]
}
diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs
index 1b373186383..1199e124e3d 100644
--- a/scripts/check-no-main-deletions.mjs
+++ b/scripts/check-no-main-deletions.mjs
@@ -740,6 +740,39 @@ const RETIRED_ROUTING_FILES = [
const FALLOW_PRUNE_REASON =
"fallow 3 reuses one audit snapshot per checkout and the hook prunes with `fallow audit-cache prune`, replacing this script";
+const PLAN_V1_SAMPLE_REASON =
+ "Plan v1 is no longer a supported cloud transport; the adapters, workflows and smoke tests now accept Plan v2 only";
+
+const PLAN_V1_SAMPLE_FILES = [
+ "examples/aws-lambda/sample-events/assemble-v1.json",
+ "examples/aws-lambda/sample-events/plan-v1.json",
+ "examples/aws-lambda/sample-events/render-chunk-v1.json",
+ "examples/gcp-cloud-run/sample-events/assemble-v1.json",
+ "examples/gcp-cloud-run/sample-events/plan-v1.json",
+ "examples/gcp-cloud-run/sample-events/render-chunk-v1.json",
+];
+
+const PLAN_PARITY_HARNESS_REASON =
+ "the completed Plan v1-to-v2 rollout harness has no supported v1 cloud path left to exercise";
+
+const PLAN_PARITY_HARNESS_FILES = [
+ "examples/aws-lambda/scripts/_semantic-compare.sh",
+ "examples/aws-lambda/scripts/semantic-compare.test.sh",
+ "packages/producer/fixtures/plan-parity-size-pressure/.plan-parity-fixture.json",
+ "packages/producer/fixtures/plan-parity-size-pressure/index.html",
+ "packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json",
+ "packages/producer/fixtures/plan-parity-visual-audio/index.html",
+ "packages/producer/src/plan-parity-analysis.test.ts",
+ "packages/producer/src/plan-parity-analysis.ts",
+ "packages/producer/src/plan-parity-contract.test.ts",
+ "packages/producer/src/plan-parity-contract.ts",
+ "packages/producer/src/plan-parity-fixture.test.ts",
+ "packages/producer/src/plan-parity-fixture.ts",
+ "packages/producer/src/plan-parity-harness.test.ts",
+ "packages/producer/src/plan-parity-harness.ts",
+ "packages/producer/src/plan-parity-lambda-local-driver.ts",
+];
+
export const ALLOWED_DELETIONS = new Map([
// Prompt Guide as six topic pages
[
@@ -2072,6 +2105,8 @@ export const ALLOWED_DELETIONS = new Map([
],
["scripts/prune-fallow-audit-caches.mjs", FALLOW_PRUNE_REASON],
["scripts/prune-fallow-audit-caches.test.mjs", FALLOW_PRUNE_REASON],
+ ...PLAN_V1_SAMPLE_FILES.map((path) => [path, PLAN_V1_SAMPLE_REASON]),
+ ...PLAN_PARITY_HARNESS_FILES.map((path) => [path, PLAN_PARITY_HARNESS_REASON]),
[
"packages/engine/src/services/frameCapture-releaseInjectedVideoMedia.browser.test.ts",
"the revert of #5795 removes releaseInjectedVideoMedia, the only code this file tested",
diff --git a/scripts/check-no-main-deletions.test.mjs b/scripts/check-no-main-deletions.test.mjs
index 7682b97cf29..ff4914f7e1d 100644
--- a/scripts/check-no-main-deletions.test.mjs
+++ b/scripts/check-no-main-deletions.test.mjs
@@ -68,3 +68,21 @@ test("the Chrome beat analyzer removal is explicitly scoped", () => {
assert.match(ALLOWED_DELETIONS.get(path) ?? "", /TypeScript beat tracker/);
assert.equal(ALLOWED_DELETIONS.has("packages/core/src/beats/beatDetection.ts"), false);
});
+
+test("the Plan v1 cleanup allowlist is limited to samples and the rollout harness", () => {
+ for (const path of [
+ "examples/aws-lambda/sample-events/plan-v1.json",
+ "examples/gcp-cloud-run/sample-events/plan-v1.json",
+ ])
+ assert.match(ALLOWED_DELETIONS.get(path) ?? "", /no longer a supported cloud transport/);
+
+ assert.match(
+ ALLOWED_DELETIONS.get("packages/producer/src/plan-parity-harness.ts") ?? "",
+ /completed Plan v1-to-v2 rollout harness/,
+ );
+ assert.match(
+ ALLOWED_DELETIONS.get("examples/aws-lambda/scripts/_semantic-compare.sh") ?? "",
+ /completed Plan v1-to-v2 rollout harness/,
+ );
+ assert.equal(ALLOWED_DELETIONS.has("packages/producer/src/services/distributed/plan.ts"), false);
+});
diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json
index 93701119c89..b43dee148ee 100644
--- a/scripts/package-size-budgets.json
+++ b/scripts/package-size-budgets.json
@@ -17,7 +17,7 @@
},
"@hyperframes/gcp-cloud-run": {
"packed": 49412,
- "unpacked": 192672
+ "unpacked": 170124
},
"@hyperframes/inspector": {
"packed": 858046,