feat(aws-lambda): support plan protocol v2 (#2789)

* feat(aws-lambda): support plan protocol v2

* fix(aws-lambda): align SAM v2 terminal errors
This commit is contained in:
James Russo
2026-07-25 23:42:51 -04:00
committed by GitHub
parent c6fdd9c015
commit 5bf61d6df0
46 changed files with 4687 additions and 114 deletions
+6
View File
@@ -397,6 +397,12 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
// extracting a shared cloud abstraction would couple independent packages.
"packages/aws-lambda/src/handler.ts",
"packages/aws-lambda/src/s3Transport.ts",
// sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
+1
View File
@@ -43,6 +43,7 @@
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
"yaml": "^2.9.0",
},
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
+26 -5
View File
@@ -75,6 +75,7 @@ aws stepfunctions start-execution \
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
"PlanProtocol": "v1",
"Config": {
"fps": 30,
"width": 1920,
@@ -91,6 +92,9 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
`PlanProtocol` may be `"v1"` or `"v2"`; absent defaults to v1. V2 uses
separate manifest and content-addressed artifact locators throughout the
workflow and never places a v2 object in `PlanS3Uri`.
## Local invocation
@@ -119,13 +123,14 @@ the architecture works on a deployed Lambda — use the local smoke
script:
```bash
# All defaults (mp4-h264-sdr fixture, chunk counts 2/4/8, PSNR >= 40 dB).
# Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
./scripts/smoke.sh
# Customised:
./scripts/smoke.sh \
--fixture mp4-h264-sdr \
--chunk-counts 2,4,8,16 \
--plan-protocol both \
--psnr-threshold 40 \
--reserved-concurrency 8
@@ -141,7 +146,21 @@ 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/<fixture>/output/`), captures
per-execution Step Functions history, and tears the stack down.
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
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
on teardown. Suspended-version buckets are purged in 1,000-entry batches,
including concrete versions, null versions, and delete markers. It then
verifies that the stack, both buckets, Lambda, state-machine, and both log
groups are absent; an otherwise-successful run fails if cleanup cannot be
proven.
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
local-vs-Lambda "speedup" column. The local timing includes `bun` +
@@ -162,9 +181,11 @@ spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
- `results.json``chunkCount × wallClockMs × psnrAvgDb`
- `renders/N<N>-output.mp4` — each rendered chunk count
- `renders/N<N>-history.json` — full Step Functions execution history
- `results.json``planProtocol × chunkCount × wallClockMs × psnrAvgDb`
- `semantic-comparisons.json` — direct v1/v2 semantic gate results
- `renders/<protocol>-N<N>-output.mp4` — each rendered variant
- `renders/<protocol>-N<N>-history.json` — full Step Functions execution history
- `renders/v1-v2-N<N>.*` — normalized frame hashes, ffprobe metadata, and comparison JSON
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
`jq`, `zip`. AWS credentials come from the standard resolution chain
@@ -0,0 +1,11 @@
{
"Action": "assemble",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkS3Uris": ["s3://example-bucket/renders/sample/chunks/0000.mp4"],
"AudioS3Uri": null,
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
"Format": "mp4"
}
@@ -0,0 +1,15 @@
{
"Action": "plan",
"PlanProtocol": "v2",
"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"
}
}
@@ -0,0 +1,10 @@
{
"Action": "renderChunk",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Format": "mp4"
}
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# AWS resource-name isolation and failed-deploy discovery helpers.
hf_new_smoke_run_id() {
local seconds seed digest
seconds=$(date +%s)
seed="${seconds}:$$:${RANDOM}:${BASHPID:-$$}"
digest=$(printf '%s' "$seed" | sha256sum | awk '{print substr($1,1,16)}')
printf '%s-%s\n' "$seconds" "$digest"
}
hf_sam_deploy_bucket_name() {
local account_id="$1" region="$2" run_id="$3" digest
digest=$(printf '%s' "$run_id" | sha256sum | awk '{print substr($1,1,20)}')
printf 'hf-sam-%s-%s-%s\n' "$account_id" "$region" "$digest"
}
hf_derive_project_name() {
local stack_name="$1" prefix digest
prefix=$(printf '%s' "$stack_name" |
tr -c '[:alnum:]-' '-' |
sed -E 's/^-+//; s/-+$//' |
cut -c1-36)
[ -n "$prefix" ] || prefix="hf-smoke"
digest=$(printf '%s' "$stack_name" | sha256sum | awk '{print substr($1,1,12)}')
printf '%s-%s\n' "$prefix" "$digest"
}
hf_known_absent() {
local pattern="$1" output_file="$2"
grep -Eiq "$pattern" "$output_file"
}
hf_assert_command_absent() {
local label="$1" absent_pattern="$2"
shift 2
local output_file status detail
output_file=$(mktemp)
if "$@" >"$output_file" 2>&1; then
echo "ERROR: destructive-isolation collision: $label already exists" >&2
rm -f "$output_file"
return 1
else
status=$?
fi
if ! hf_known_absent "$absent_pattern" "$output_file"; then
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
echo "ERROR: could not prove $label absent (exit=$status): $detail" >&2
rm -f "$output_file"
return 2
fi
rm -f "$output_file"
}
hf_assert_named_list_absent() {
local label="$1"
shift
local output_file output status detail
output_file=$(mktemp)
if output=$("$@" 2>"$output_file"); then
if [ -n "$output" ]; then
echo "ERROR: destructive-isolation collision: $label already exists ($output)" >&2
rm -f "$output_file"
return 1
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
echo "ERROR: could not verify $label absence (exit=$status): $detail" >&2
rm -f "$output_file"
return 2
fi
rm -f "$output_file"
}
# Fail closed unless every exact name this smoke run can destructively clean
# is absent. Call before arming cleanup or creating any AWS resource.
hf_assert_deploy_isolation() {
local stack_name="$1" project_name="$2"
local function_name="${project_name}-render"
local lambda_log="/aws/lambda/${function_name}"
local states_log="/aws/states/${function_name}"
hf_assert_command_absent "CloudFormation stack $stack_name" "does not exist" \
aws cloudformation describe-stacks --stack-name "$stack_name" &&
hf_assert_command_absent "Lambda function $function_name" \
"ResourceNotFoundException|Function not found" \
aws lambda get-function --function-name "$function_name" &&
hf_assert_named_list_absent "Step Functions state machine $function_name" \
aws stepfunctions list-state-machines \
--query "stateMachines[?name=='$function_name'].stateMachineArn" --output text &&
hf_assert_named_list_absent "log group $lambda_log" \
aws logs describe-log-groups --log-group-name-prefix "$lambda_log" \
--query "logGroups[?logGroupName=='$lambda_log'].logGroupName" --output text &&
hf_assert_named_list_absent "log group $states_log" \
aws logs describe-log-groups --log-group-name-prefix "$states_log" \
--query "logGroups[?logGroupName=='$states_log'].logGroupName" --output text
}
# Atomically reserve the exact stack name before SAM can create or update it.
# CloudFormation's create-stack call is the compare-and-set: only one concurrent
# smoke run can acquire a name that both preflight checks observed as absent.
hf_reserve_smoke_stack() {
local stack_name="$1" run_id="$2"
aws cloudformation create-stack \
--stack-name "$stack_name" \
--template-body \
'{"Resources":{"SmokeOwnershipHandle":{"Type":"AWS::CloudFormation::WaitConditionHandle"}}}' \
--tags "Key=HyperframesSmokeRun,Value=$run_id" >/dev/null &&
aws cloudformation wait stack-create-complete --stack-name "$stack_name"
}
# Print "owned" when the stack has this run's ownership tag and
# "absent" when there is no stack. Any foreign/missing tag or AWS API error
# fails closed so a cleanup trap cannot delete a concurrent run's resources.
hf_stack_ownership_status() {
local stack_name="$1" run_id="$2" output_file error_file owner status detail
output_file=$(mktemp)
error_file=$(mktemp)
if aws cloudformation describe-stacks \
--stack-name "$stack_name" \
--query "Stacks[0].Tags[?Key=='HyperframesSmokeRun'].Value | [0]" \
--output text >"$output_file" 2>"$error_file"; then
owner=$(tr -d '\r\n' <"$output_file")
rm -f "$output_file" "$error_file"
if [ "$owner" != "$run_id" ]; then
echo "ERROR: refusing cleanup: stack ownership is '${owner:-missing}', expected '$run_id'" >&2
return 3
fi
printf 'owned\n'
return
else
status=$?
fi
if hf_known_absent "does not exist" "$error_file"; then
rm -f "$output_file" "$error_file"
printf 'absent\n'
return
fi
detail=$(tr '\n' ' ' <"$error_file" | cut -c1-240)
echo "ERROR: could not verify stack ownership (exit=$status): $detail" >&2
rm -f "$output_file" "$error_file"
return 2
}
# Return a JSON object with any physical resources CloudFormation managed to
# create, even when stack outputs were never populated. A genuinely absent
# stack is an empty result; auth/network/query failures are errors.
hf_discover_stack_resources() {
local stack_name="$1" output_file error_file status detail
output_file=$(mktemp)
error_file=$(mktemp)
if aws cloudformation list-stack-resources \
--stack-name "$stack_name" --output json >"$output_file" 2>"$error_file"; then
jq '{
renderBucket: (
[.StackResourceSummaries[]?
| select(.LogicalResourceId == "RenderBucket")
| .PhysicalResourceId][0] // ""
),
stateMachineArn: (
[.StackResourceSummaries[]?
| select(.LogicalResourceId == "RenderStateMachine")
| .PhysicalResourceId][0] // ""
)
}' "$output_file"
rm -f "$output_file" "$error_file"
return
else
status=$?
fi
if hf_known_absent "does not exist" "$error_file"; then
printf '{"renderBucket":"","stateMachineArn":""}\n'
rm -f "$output_file" "$error_file"
return
fi
detail=$(tr '\n' ' ' < "$error_file" | cut -c1-240)
echo "ERROR: failed to discover physical stack resources (exit=$status): $detail" >&2
rm -f "$output_file" "$error_file"
return 2
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# S3 bucket cleanup helpers. Sourcing this file has no side effects.
# Delete every concrete object version and delete marker from a bucket.
#
# We intentionally re-list the first 1,000 entries after every delete batch
# instead of advancing markers through a mutating result set. This handles
# arbitrary pagination depth while avoiding skipped keys when the page being
# used as a cursor has just been removed. It is also required for buckets with
# versioning Suspended: `aws s3 rm` only creates null-version delete markers
# and leaves the historical/null versions behind.
hf_purge_s3_bucket_versions() {
local bucket="$1" work page delete_request delete_response count errors rounds=0
work=$(mktemp -d)
page="$work/page.json"
delete_request="$work/delete.json"
delete_response="$work/delete-response.json"
while true; do
rounds=$((rounds + 1))
if [ "$rounds" -gt 100000 ]; then
echo "ERROR: S3 purge exceeded 100000 batches for s3://$bucket" >&2
rm -rf "$work"
return 1
fi
if ! aws s3api list-object-versions \
--bucket "$bucket" \
--max-keys 1000 \
--no-paginate \
--output json > "$page"; then
echo "ERROR: failed to list object versions for s3://$bucket" >&2
rm -rf "$work"
return 1
fi
jq '{
Objects: [
(.Versions // [])[],
(.DeleteMarkers // [])[]
] | map({Key, VersionId}),
Quiet: true
}' "$page" > "$delete_request"
count=$(jq '.Objects | length' "$delete_request")
if [ "$count" -eq 0 ]; then
break
fi
if ! aws s3api delete-objects \
--bucket "$bucket" \
--delete "file://$delete_request" \
--output json > "$delete_response"; then
echo "ERROR: failed to delete a version batch from s3://$bucket" >&2
rm -rf "$work"
return 1
fi
# Successful Quiet=true deletes may produce a zero-byte response body.
# Slurp mode treats that as an empty input set and therefore zero errors,
# while still counting per-object Errors when AWS returns a JSON object.
errors=$(jq -s '[.[] | (.Errors // [])[]] | length' "$delete_response")
if [ "$errors" -ne 0 ]; then
echo "ERROR: S3 returned per-object deletion errors for s3://$bucket:" >&2
jq -c '.Errors[]' "$delete_response" >&2
rm -rf "$work"
return 1
fi
echo " purged $count object versions/delete markers from s3://$bucket"
done
rm -rf "$work"
}
hf_delete_s3_bucket_completely() {
local bucket="$1"
hf_purge_s3_bucket_versions "$bucket" &&
aws s3api delete-bucket --bucket "$bucket"
}
+239
View File
@@ -0,0 +1,239 @@
#!/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"
}
hf_extract_chunk_hashes() {
local history="$1" output="$2"
jq '[
.events[] |
.taskSucceededEventDetails.output? |
select(type == "string") |
(try fromjson catch empty) |
.Payload |
select(type == "object" and .Action == "renderChunk") |
{ChunkIndex, Sha256, FramesEncoded}
] | sort_by(.ChunkIndex)' "$history" > "$output"
}
# Compare two rendered outputs. Writes durable evidence at <prefix>.* and a
# machine-readable <prefix>.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 ]
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Small configuration helpers shared by smoke.sh and shell tests.
hf_resolve_psnr_threshold() {
local explicit="$1" fixture_meta="$2"
if [ -n "$explicit" ]; then
printf '%s\n' "$explicit"
return
fi
jq -er '(.minPsnr // 40) | numbers' "$fixture_meta"
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_aws-isolation.sh
source "$SCRIPT_DIR/_aws-isolation.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
cat > "$WORK/bin/aws" <<'MOCK_AWS'
#!/usr/bin/env bash
set -euo pipefail
operation="${1:-} ${2:-}"
case "$MOCK_MODE:$operation" in
absent:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
absent:"lambda get-function")
echo "ResourceNotFoundException: Function not found" >&2
exit 254
;;
absent:"stepfunctions list-state-machines"|absent:"logs describe-log-groups")
exit 0
;;
collision:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
collision:"lambda get-function")
printf '{"Configuration":{"FunctionName":"collision"}}\n'
;;
auth:"cloudformation describe-stacks")
echo "AccessDenied: credentials expired" >&2
exit 253
;;
owned:"cloudformation describe-stacks")
printf 'run-a\n'
;;
foreign:"cloudformation describe-stacks")
printf 'run-b\n'
;;
ownership-missing:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
reserve:"cloudformation create-stack"|reserve:"cloudformation wait")
exit 0
;;
discovery:"cloudformation list-stack-resources")
cat <<'JSON'
{"StackResourceSummaries":[
{"LogicalResourceId":"RenderBucket","PhysicalResourceId":"physical-render-bucket"},
{"LogicalResourceId":"RenderStateMachine","PhysicalResourceId":"arn:aws:states:us-east-2:1:stateMachine:physical"}
]}
JSON
;;
missing:"cloudformation list-stack-resources")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
*)
echo "unexpected mock request: $MOCK_MODE $operation" >&2
exit 2
;;
esac
MOCK_AWS
chmod 755 "$WORK/bin/aws"
name_a=$(hf_derive_project_name "hyperframes-lambda-smoke-a-very-long-shared-prefix-111")
name_b=$(hf_derive_project_name "hyperframes-lambda-smoke-a-very-long-shared-prefix-222")
[ "$name_a" != "$name_b" ]
[ "${#name_a}" -le 49 ]
[ "${#name_b}" -le 49 ]
run_a=$(hf_new_smoke_run_id)
run_b=$(hf_new_smoke_run_id)
[ "$run_a" != "$run_b" ]
bucket_a=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_a")
bucket_b=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_b")
[ "$bucket_a" != "$bucket_b" ]
[ "${#bucket_a}" -le 63 ]
[ "${#bucket_b}" -le 63 ]
MOCK_MODE=absent PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a"
if MOCK_MODE=collision PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a" 2>"$WORK/collision-error"; then
echo "expected exact-name collision to fail closed" >&2
exit 1
fi
grep -q "collision" "$WORK/collision-error"
if MOCK_MODE=auth PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a" 2>"$WORK/auth-error"; then
echo "expected verification API error to fail closed" >&2
exit 1
fi
grep -q "could not prove" "$WORK/auth-error"
[ "$(MOCK_MODE=owned PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a")" = "owned" ]
[ "$(MOCK_MODE=ownership-missing PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a")" = "absent" ]
if MOCK_MODE=foreign PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a" 2>"$WORK/foreign-error"; then
echo "expected foreign stack ownership to fail closed" >&2
exit 1
fi
grep -q "refusing cleanup" "$WORK/foreign-error"
MOCK_MODE=reserve PATH="$WORK/bin:$PATH" \
hf_reserve_smoke_stack "smoke" "run-a"
discovered=$(MOCK_MODE=discovery PATH="$WORK/bin:$PATH" \
hf_discover_stack_resources "smoke")
[ "$(jq -r .renderBucket <<<"$discovered")" = "physical-render-bucket" ]
[ "$(jq -r .stateMachineArn <<<"$discovered")" = \
"arn:aws:states:us-east-2:1:stateMachine:physical" ]
missing=$(MOCK_MODE=missing PATH="$WORK/bin:$PATH" \
hf_discover_stack_resources "smoke")
[ "$(jq -r .renderBucket <<<"$missing")" = "" ]
[ "$(jq -r .stateMachineArn <<<"$missing")" = "" ]
echo "aws isolation shell test passed"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
printf '1001\n' > "$WORK/remaining"
: > "$WORK/delete-batches"
cat > "$WORK/bin/aws" <<'MOCK_AWS'
#!/usr/bin/env bash
set -euo pipefail
operation="${1:-} ${2:-}"
shift 2
bucket=""
delete_file=""
while [ $# -gt 0 ]; do
case "$1" in
--bucket) bucket="$2"; shift 2 ;;
--delete) delete_file="${2#file://}"; shift 2 ;;
*) shift ;;
esac
done
[ "$bucket" = "mock-bucket" ]
case "$operation" in
"s3api list-object-versions")
remaining=$(cat "$MOCK_WORK/remaining")
if [ "$remaining" -gt 1000 ]; then
jq -n '{
IsTruncated: true,
Versions: [range(0;999) | {Key:("version-" + tostring),VersionId:("v-" + tostring)}],
DeleteMarkers: [{Key:"deleted-null-object",VersionId:"null"}]
}'
elif [ "$remaining" -gt 0 ]; then
jq -n --argjson remaining "$remaining" '{
IsTruncated: false,
Versions: [range(0;$remaining) | {Key:("tail-" + tostring),VersionId:"null"}],
DeleteMarkers: []
}'
else
jq -n '{IsTruncated:false,Versions:[],DeleteMarkers:[]}'
fi
;;
"s3api delete-objects")
count=$(jq '.Objects | length' "$delete_file")
jq -e '
if (.Objects | length) == 1000
then any(.Objects[]; .Key == "deleted-null-object" and .VersionId == "null")
else true
end
' "$delete_file" >/dev/null
remaining=$(cat "$MOCK_WORK/remaining")
printf '%s\n' "$((remaining - count))" > "$MOCK_WORK/remaining"
printf '%s\n' "$count" >> "$MOCK_WORK/delete-batches"
# Real AWS returns a blank body for a successful Quiet=true delete.
:
;;
"s3api delete-bucket")
[ "$(cat "$MOCK_WORK/remaining")" -eq 0 ]
touch "$MOCK_WORK/bucket-deleted"
;;
*)
echo "unexpected mock operation: $operation" >&2
exit 2
;;
esac
MOCK_AWS
chmod 755 "$WORK/bin/aws"
MOCK_WORK="$WORK" PATH="$WORK/bin:$PATH" \
hf_delete_s3_bucket_completely "mock-bucket" 2>"$WORK/stderr"
[ "$(cat "$WORK/remaining")" -eq 0 ]
[ "$(paste -sd, "$WORK/delete-batches")" = "1000,1" ]
[ -f "$WORK/bucket-deleted" ]
[ ! -s "$WORK/stderr" ]
echo "s3 purge shell test passed"
+121
View File
@@ -0,0 +1,121 @@
#!/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
cat > "$WORK/v1-history.json" <<'JSON'
{"events":[
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":1,\"Sha256\":\"bbb\",\"FramesEncoded\":12}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"plan\",\"ChunkCount\":2}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"aaa\",\"FramesEncoded\":12}}"}}
]}
JSON
cat > "$WORK/v2-history.json" <<'JSON'
{"events":[
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"aaa\",\"FramesEncoded\":12}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":1,\"Sha256\":\"bbb\",\"FramesEncoded\":12}}"}}
]}
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 '(.events[0].taskSucceededEventDetails.output) =
"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"different\",\"FramesEncoded\":12}}"' \
"$WORK/v2-history.json" > "$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"
+336 -43
View File
@@ -18,17 +18,20 @@
# - sam (AWS SAM CLI, >= 1.100)
# - bun (>= 1.3, to build the handler ZIP)
# - ffmpeg (system or built-in; PSNR computation)
# - ffprobe (normalized stream metadata + duration)
# - jq
# - sha256sum + cmp
# - zip
#
# Inputs (flags or env vars):
# --fixture <name> (default: mp4-h264-sdr)
# --chunk-counts <list> (default: 2,4,8)
# --psnr-threshold <db> (default: 40)
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>)
# --psnr-threshold <db> (default: fixture meta.json minPsnr)
# --stack-name <name> (default: hyperframes-lambda-smoke-<unique-run-id>)
# --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
# --plan-protocol <v1|v2|both> (default: v1)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
@@ -51,6 +54,14 @@ 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
source "$SCRIPT_DIR/_smoke-config.sh"
# shellcheck source=./_aws-isolation.sh
source "$SCRIPT_DIR/_aws-isolation.sh"
# ── Defaults ──────────────────────────────────────────────────────────────
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
@@ -63,19 +74,25 @@ CHUNK_COUNTS="${CHUNK_COUNTS:-2,4,8}"
# than the in-process baseline (Debian-bookworm-slim's apt ffmpeg +
# Puppeteer-managed chrome-headless-shell). Expected drift across those
# environments is ~3 dB on simple fixtures, more on font-heavy ones.
# The gate defaults to 40 dB to absorb that drift; tighten it via
# --psnr-threshold for a stricter check.
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}"
# The gate defaults to the fixture's own `meta.json.minPsnr`, which is
# calibrated for that content/runtime boundary. Override it via
# --psnr-threshold (or PSNR_THRESHOLD) for a stricter experiment.
PSNR_THRESHOLD="${PSNR_THRESHOLD-}"
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:-v1}"
KEEP_STACK="false"
SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
# Lambda Map-state concurrency cap. 16 fans out the chunks aggressively
# at the cost of a higher peak Lambda bill. Drop to 2-4 for cheaper runs;
# raise as far as your AWS account's regional concurrency quota allows.
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
PROJECT_NAME=""
SAM_DEPLOY_BUCKET=""
usage() {
cat <<'EOF'
@@ -89,12 +106,14 @@ output against the in-process baseline, and tears the stack down.
Flags:
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>)
--psnr-threshold <db> PSNR floor (default: fixture meta.json minPsnr)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
--region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE)
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1)
--reserved-concurrency <N> 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
--skip-build reuse existing dist/handler.zip
-h, --help show this help and exit
@@ -105,7 +124,8 @@ Cost notes:
per run before S3 PUT/GET. Set --reserved-concurrency lower for
cost-conscious accounts.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3), ffmpeg, jq, zip.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3),
ffmpeg, ffprobe, jq, sha256sum, cmp, zip.
EOF
}
@@ -118,7 +138,9 @@ 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 ;;
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
@@ -126,6 +148,12 @@ 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
# standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's
# default resolution (env → ~/.aws/config → IMDS) take over without us
@@ -143,6 +171,62 @@ fi
# ── Cleanup helper (defined early so the failure paths below can call it) ─
BUCKET=""
STATE_MACHINE_ARN=""
verify_absent_api() {
local label="$1" absent_pattern="$2"
shift 2
local output_file status
output_file=$(mktemp)
if "$@" >"$output_file" 2>&1; then
leaks+=("$label")
rm -f "$output_file"
return
else
status=$?
fi
if ! grep -Eiq "$absent_pattern" "$output_file"; then
local detail
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:$label:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_log_group_absent() {
local log_group="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws logs describe-log-groups \
--log-group-name-prefix "$log_group" \
--query "logGroups[?logGroupName=='$log_group'].logGroupName" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("log-group:$log_group")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:log-group:$log_group:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_state_machine_name_absent() {
local state_machine_name="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws stepfunctions list-state-machines \
--query "stateMachines[?name=='$state_machine_name'].stateMachineArn" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("state-machine-name:$state_machine_name:$output")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:state-machine-name:$state_machine_name:exit=$status:$detail")
fi
rm -f "$output_file"
}
cleanup_and_exit() {
local exit_code="${1:-0}"
@@ -157,24 +241,135 @@ cleanup_and_exit() {
fi
else
echo "→ Tearing down stack $STACK_NAME"
if [ -n "$BUCKET" ]; then
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true
local cleanup_identity_ok=true
local stack_cleanup_allowed=false
local ownership_status=""
local discovery_errors=()
if ! aws sts get-caller-identity >/dev/null; then
cleanup_identity_ok=false
echo "ERROR: AWS identity check failed before cleanup; absence cannot be trusted" >&2
fi
(cd "$SAM_DIR" && sam delete \
if [ "$cleanup_identity_ok" = true ]; then
if ownership_status=$(hf_stack_ownership_status "$STACK_NAME" "$SMOKE_RUN_ID"); then
if [ "$ownership_status" = "owned" ]; then
stack_cleanup_allowed=true
else
echo "→ Stack is absent; skipping stack-scoped destructive cleanup"
fi
else
discovery_errors+=("verification-error:cloudformation-stack-ownership")
fi
fi
if [ "$stack_cleanup_allowed" = true ]; then
local discovered
if discovered=$(hf_discover_stack_resources "$STACK_NAME"); then
if [ -z "$BUCKET" ]; then
BUCKET=$(jq -r '.renderBucket' <<<"$discovered")
fi
if [ -z "$STATE_MACHINE_ARN" ]; then
STATE_MACHINE_ARN=$(jq -r '.stateMachineArn' <<<"$discovered")
fi
else
discovery_errors+=("verification-error:cloudformation-resource-discovery")
fi
if [ -n "$BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$BUCKET"; then
echo "WARN: failed to purge/delete retained render bucket s3://$BUCKET" >&2
fi
fi
if ! (cd "$SAM_DIR" && sam delete \
--stack-name "$STACK_NAME" \
--no-prompts) >/dev/null 2>&1 || true
--region "$AWS_REGION" \
--no-prompts); then
echo "WARN: sam delete failed for $STACK_NAME" >&2
fi
if ! aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"; then
echo "WARN: CloudFormation did not confirm stack deletion for $STACK_NAME" >&2
fi
if aws logs describe-log-groups \
--log-group-name-prefix "/aws/lambda/${PROJECT_NAME}-render" \
--query "logGroups[?logGroupName=='/aws/lambda/${PROJECT_NAME}-render'].logGroupName" \
--output text | grep -q .; then
if ! aws logs delete-log-group --log-group-name "/aws/lambda/${PROJECT_NAME}-render"; then
echo "WARN: failed to delete Lambda log group" >&2
fi
fi
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$SAM_DEPLOY_BUCKET"; then
echo "WARN: failed to purge/delete SAM deployment bucket s3://$SAM_DEPLOY_BUCKET" >&2
fi
fi
local leaks=()
if [ "${#discovery_errors[@]}" -gt 0 ]; then
leaks+=("${discovery_errors[@]}")
fi
if [ "$cleanup_identity_ok" != true ]; then
leaks+=("verification-error:aws-identity-unavailable")
fi
verify_absent_api "cloudformation-stack:$STACK_NAME" \
"does not exist" \
aws cloudformation describe-stacks --stack-name "$STACK_NAME"
if [ -n "$BUCKET" ]; then
verify_absent_api "render-bucket:s3://$BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$BUCKET"
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
verify_absent_api "sam-bucket:s3://$SAM_DEPLOY_BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$SAM_DEPLOY_BUCKET"
fi
verify_absent_api "lambda-function:${PROJECT_NAME}-render" \
"ResourceNotFoundException|Function not found" \
aws lambda get-function --function-name "${PROJECT_NAME}-render"
if [ -n "$STATE_MACHINE_ARN" ]; then
verify_absent_api "state-machine:$STATE_MACHINE_ARN" \
"StateMachineDoesNotExist|does not exist" \
aws stepfunctions describe-state-machine --state-machine-arn "$STATE_MACHINE_ARN"
fi
verify_state_machine_name_absent "${PROJECT_NAME}-render"
local lambda_log="/aws/lambda/${PROJECT_NAME}-render"
local states_log="/aws/states/${PROJECT_NAME}-render"
verify_log_group_absent "$lambda_log"
verify_log_group_absent "$states_log"
if [ "${#leaks[@]}" -gt 0 ]; then
echo "ERROR: AWS cleanup verification found leaked resources:" >&2
printf ' - %s\n' "${leaks[@]}" >&2
if [ "$exit_code" -eq 0 ]; then
exit_code=7
fi
else
echo "→ Cleanup verified: no scoped AWS resources remain"
fi
mkdir -p "$ARTIFACT_DIR"
local cleanup_lines
cleanup_lines=$(mktemp)
if [ "${#leaks[@]}" -gt 0 ]; then
printf '%s\n' "${leaks[@]}" > "$cleanup_lines"
fi
jq -Rn \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg checkedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson originalExitCode "${1:-0}" \
'{
stackName: $stackName,
projectName: $projectName,
checkedAt: $checkedAt,
originalExitCode: $originalExitCode,
leaks: [inputs | select(length > 0)],
cleanupVerified: false
} | .cleanupVerified = (.leaks | length == 0)' \
< "$cleanup_lines" > "$ARTIFACT_DIR/cleanup-verification.json"
rm -f "$cleanup_lines"
fi
exit "$exit_code"
}
# Trap unexpected failures (set -e trips, SIGINT, etc.) so we don't leak
# the deployed stack + bucket on a non-routed error path. Explicit
# cleanup_and_exit calls disarm the trap first so teardown runs once.
trap 'cleanup_and_exit $?' EXIT
# ── Pre-flight checks ─────────────────────────────────────────────────────
for cmd in aws sam bun ffmpeg jq zip; do
for cmd in aws sam bun ffmpeg ffprobe jq zip sha256sum cmp; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: '$cmd' not found on PATH." >&2
exit 1
@@ -203,6 +398,25 @@ if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
exit 1
fi
# This check runs before cleanup is armed or any resource is created.
echo "→ Pre-flight: proving exact AWS resource names are unused"
if ! hf_assert_deploy_isolation "$STACK_NAME" "$PROJECT_NAME"; then
echo "ERROR: refusing to reuse or clean resources not created by this run." >&2
exit 1
fi
# Arm cleanup before atomically reserving the stack name. If another smoke run
# wins the create-stack race, ownership verification prevents this run from
# touching it. If this run wins, every later destructive action requires the
# same ownership tag.
trap 'cleanup_and_exit $?' EXIT
echo "→ Pre-flight: atomically reserving stack name for this smoke run"
if ! hf_reserve_smoke_stack "$STACK_NAME" "$SMOKE_RUN_ID"; then
echo "ERROR: could not reserve stack name; another run may have won the race." >&2
cleanup_and_exit 1
fi
mkdir -p "$ARTIFACT_DIR/renders"
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
@@ -223,21 +437,29 @@ echo "→ SAM validate"
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
# ProjectName is intentionally NOT set to $STACK_NAME — the template
# uses ProjectName only for the function/state-machine human-facing
# names, and forcing it long here doesn't help. The BucketName is
# auto-generated by CloudFormation per stack so concurrent smoke runs
# don't collide. Pass --region explicitly here even though
# AWS_DEFAULT_REGION is set, so a stray samconfig.toml in the working
# directory can't override the script's choice.
# Use a per-run resource prefix and deployment bucket. The template has
# explicit FunctionName/StateMachineName properties, so leaving ProjectName
# at its default makes concurrent smoke stacks overwrite/collide. A dedicated
# SAM bucket also lets teardown remove every object created by this run.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
SAM_DEPLOY_BUCKET=$(hf_sam_deploy_bucket_name "$ACCOUNT_ID" "$AWS_REGION" "$SMOKE_RUN_ID")
if [ "$AWS_REGION" = "us-east-1" ]; then
aws s3api create-bucket --bucket "$SAM_DEPLOY_BUCKET" >/dev/null
else
aws s3api create-bucket \
--bucket "$SAM_DEPLOY_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_REGION" >/dev/null
fi
if ! (cd "$SAM_DIR" && sam deploy \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--resolve-s3 \
--s3-bucket "$SAM_DEPLOY_BUCKET" \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--tags "HyperframesSmokeRun=$SMOKE_RUN_ID" \
--parameter-overrides \
"ProjectName=$PROJECT_NAME" \
ChromeSource=sparticuz \
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then
echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2
@@ -254,6 +476,27 @@ STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
--output text)
echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN"
jq -n \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg region "$AWS_REGION" \
--arg renderBucket "$BUCKET" \
--arg samDeployBucket "$SAM_DEPLOY_BUCKET" \
--arg lambdaFunction "${PROJECT_NAME}-render" \
--arg stateMachineArn "$STATE_MACHINE_ARN" \
--arg lambdaLogGroup "/aws/lambda/${PROJECT_NAME}-render" \
--arg statesLogGroup "/aws/states/${PROJECT_NAME}-render" \
'{
stackName: $stackName,
projectName: $projectName,
region: $region,
renderBucket: $renderBucket,
samDeployBucket: $samDeployBucket,
lambdaFunction: $lambdaFunction,
stateMachineArn: $stateMachineArn,
lambdaLogGroup: $lambdaLogGroup,
statesLogGroup: $statesLogGroup
}' > "$ARTIFACT_DIR/aws-resource-scope.json"
# ── 4. Upload fixture as a project tarball ────────────────────────────────
# tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not
@@ -267,6 +510,7 @@ rm -rf "$TMP_ARCHIVE"
# ── 5. Render at each chunk count ─────────────────────────────────────────
FIXTURE_META="$FIXTURE_DIR/meta.json"
PSNR_THRESHOLD=$(hf_resolve_psnr_threshold "$PSNR_THRESHOLD" "$FIXTURE_META")
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
@@ -275,13 +519,20 @@ 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
for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-N$N-$(date +%s)"
EXEC_NAME="smoke-$PROTOCOL-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
INPUT_JSON=$(jq -n \
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
--arg protocol "$PROTOCOL" \
--argjson n "$N" \
--argjson fps "$BASE_FPS" \
--argjson w "$BASE_W" \
@@ -290,6 +541,7 @@ for N in "${COUNTS[@]}"; do
ProjectS3Uri: $project,
PlanOutputS3Prefix: $prefix,
OutputS3Uri: $output,
PlanProtocol: $protocol,
Config: {
fps: $fps,
width: $w,
@@ -301,7 +553,7 @@ for N in "${COUNTS[@]}"; do
}')
echo
echo "================== N=$N =================="
echo "================== protocol=$PROTOCOL N=$N =================="
echo "$INPUT_JSON" | jq .
START_MS=$(date +%s%3N)
@@ -323,21 +575,21 @@ for N in "${COUNTS[@]}"; do
WALL_MS=$((END_MS - START_MS))
if [ "$STATUS" != "SUCCEEDED" ]; then
echo "ERROR: N=$N execution did not succeed ($STATUS)." >&2
echo "ERROR: protocol=$PROTOCOL N=$N execution did not succeed ($STATUS)." >&2
aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" \
> "$ARTIFACT_DIR/renders/N$N-execution.json"
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-execution.json"
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 200 \
> "$ARTIFACT_DIR/renders/N$N-history.json" || true
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json" || true
cleanup_and_exit 4
fi
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 1000 --output json \
> "$ARTIFACT_DIR/renders/N$N-history.json"
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/N$N-output.mp4"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/$PROTOCOL-N$N-output.mp4"
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL"
PSNR_LOG=$(mktemp)
@@ -363,34 +615,75 @@ for N in "${COUNTS[@]}"; do
' "$PSNR_LOG")
rm -f "$PSNR_LOG"
echo "N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
echo "protocol=$PROTOCOL N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
jq --argjson n "$N" \
--argjson wall "$WALL_MS" \
--arg psnr "$PSNR_AVG" \
'. += [{chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/N\($n)-output.mp4", history: "renders/N\($n)-history.json"}]' \
--arg protocol "$PROTOCOL" \
'. += [{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. Gate on PSNR threshold ─────────────────────────────────────────────
# ── 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
while read -r row; do
N=$(echo "$row" | jq -r .chunkCount)
PROTOCOL=$(echo "$row" | jq -r .planProtocol)
P=$(echo "$row" | jq -r .psnrAvgDb)
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
echo "FAIL: N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
echo "FAIL: protocol=$PROTOCOL N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
FAILED=$((FAILED + 1))
fi
done < <(jq -c '.[]' "$RESULTS_JSON")
# ── 7. Summary ────────────────────────────────────────────────────────────
# ── 8. Summary ────────────────────────────────────────────────────────────
echo
echo "================ RESULTS ================"
printf '%-10s %-12s %-10s\n' "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-12s %-10s\n", $1, $2, $3}'
printf '%-10s %-10s %-12s %-10s\n' "Protocol" "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-10s %-12s %-10s\n", $1, $2, $3, $4}'
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
+189 -2
View File
@@ -158,6 +158,7 @@ Resources:
# Lambda's Node 22 runtime sets these by default; explicit for
# clarity + so users can override during local SAM invoke.
TMPDIR: /tmp
HYPERFRAMES_RENDER_BUCKET: !Ref RenderBucket
Policies:
- S3CrudPolicy:
BucketName: !Ref RenderBucket
@@ -207,8 +208,27 @@ Resources:
# compound into a multi-hour execution. The longest legitimate
# render observed in PR 880's eval was ~3 minutes.
TimeoutSeconds: 3600
StartAt: Plan
StartAt: SelectPlanProtocol
States:
SelectPlanProtocol:
Type: Choice
Choices:
- Variable: $.PlanProtocol
StringEquals: v2
Next: PlanV2
- Variable: $.PlanProtocol
StringEquals: v1
Next: Plan
- Variable: $.PlanProtocol
IsPresent: true
Next: UnsupportedPlanProtocol
Default: Plan
UnsupportedPlanProtocol:
Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED
Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1).
Plan:
Type: Task
Resource: arn:aws:states:::lambda:invoke
@@ -220,6 +240,7 @@ Resources:
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanProtocol: v1
PlanS3Uri.$: "$.Payload.PlanS3Uri"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
@@ -239,6 +260,10 @@ Resources:
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
- ErrorEquals: [States.ALL]
@@ -248,6 +273,50 @@ Resources:
MaxDelaySeconds: 60
Next: BuildChunkList
PlanV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: plan
PlanProtocol: v2
ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanProtocol: v2
PlanV2ManifestS3Uri.$: "$.Payload.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Payload.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
Format.$: "$.Payload.Format"
HasAudio.$: "$.Payload.HasAudio"
ResultPath: $.Plan
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
Next: BuildChunkList
BuildChunkList:
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
# Map state below has something to iterate. Range is the
@@ -269,7 +338,7 @@ Resources:
Choices:
- Variable: $.Plan.ChunkCount
NumericGreaterThan: 0
Next: RenderChunks
Next: SelectWorkerProtocol
Default: PlanProducedZeroChunks
PlanProducedZeroChunks:
@@ -277,6 +346,14 @@ Resources:
Error: PLAN_TOO_LARGE
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
SelectWorkerProtocol:
Type: Choice
Choices:
- Variable: $.Plan.PlanProtocol
StringEquals: v2
Next: RenderChunksV2
Default: RenderChunks
RenderChunks:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
@@ -320,6 +397,11 @@ Resources:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
@@ -356,6 +438,111 @@ Resources:
- 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: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
RenderChunksV2:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
ItemSelector:
ChunkIndex.$: "$$.Map.Item.Value"
PlanV2ManifestS3Uri.$: "$.Plan.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Plan.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Plan.PlanHash"
ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Format.$: "$.Plan.Format"
MaxConcurrencyPath: $.Plan.ChunkCount
ResultPath: $.Chunks
ItemProcessor:
ProcessorConfig:
Mode: INLINE
StartAt: RenderChunkV2
States:
RenderChunkV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: renderChunk
PlanProtocol: v2
ChunkIndex.$: "$.ChunkIndex"
PlanV2ManifestS3Uri.$: "$.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.PlanHash"
ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
Format.$: "$.Format"
ResultSelector:
ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
ChunkIndex.$: "$.Payload.ChunkIndex"
Sha256.$: "$.Payload.Sha256"
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
Next: AssembleV2
AssembleV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: assemble
PlanProtocol: v2
PlanV2ManifestS3Uri.$: "$.Plan.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Plan.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Plan.PlanHash"
ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
AudioS3Uri: null
OutputS3Uri.$: "$.OutputS3Uri"
Format.$: "$.Plan.Format"
ResultSelector:
OutputS3Uri.$: "$.Payload.OutputS3Uri"
FramesEncoded.$: "$.Payload.FramesEncoded"
FileSize.$: "$.Payload.FileSize"
ResultPath: $.Output
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
+21 -1
View File
@@ -37,7 +37,7 @@ smoke flow; the SDK + CDK are the supported public surface for adopters.
│ pure functions over local paths
┌──────────────────────────────────────────────────────────────────┐
│ S3 bucket — plan tarball + per-chunk outputs + final mp4
│ S3 bucket — v1 plan tar or v2 manifest/blobs + chunks + output │
└──────────────────────────────────────────────────────────────────┘
```
@@ -45,6 +45,26 @@ 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
`renderToLambda` defaults to the existing monolithic v1 plan transport.
Plan v2 is an explicit whole-render opt-in:
```ts
await renderToLambda({
// ...bucket, state machine, project, and config...
planProtocol: "v2",
});
```
V2 never overloads `PlanS3Uri`. The planner returns
`PlanV2ManifestS3Uri` and `PlanV2ArtifactS3Prefix`; chunk workers fetch
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. Omit the selector—or use `"v1"`—to retain the prior wire contract.
## Chrome runtime
The package supports two Chromium sources:
+2 -1
View File
@@ -86,7 +86,8 @@
"constructs": "^10.3.0",
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"yaml": "^2.9.0"
},
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
@@ -20,11 +20,12 @@
*/
import { beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { parse as parseYaml } from "yaml";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
@@ -50,22 +51,33 @@ const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
// not at this level — we cover it separately in the contract test.
const EXPECTED_STATE_NAMES = [
"SelectPlanProtocol",
"Plan",
"PlanV2",
"BuildChunkList",
"AssertChunkCount",
"SelectWorkerProtocol",
"RenderChunks",
"RenderChunksV2",
"Assemble",
"AssembleV2",
"PlanProducedZeroChunks",
"UnsupportedPlanProtocol",
];
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
]);
@@ -130,7 +142,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("declares the state machine with the expected state names", () => {
const { definition } = SYNTHED;
expect(definition.StartAt).toBe("Plan");
expect(definition.StartAt).toBe("SelectPlanProtocol");
const actualStates = Object.keys(definition.States);
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
});
@@ -140,7 +152,7 @@ describe("HyperframesRenderStack — snapshot", () => {
const collected = new Set<string>();
// Plan + Assemble are top-level states; RenderChunk is nested inside
// the Map's Iterator definition.
const topLevelStates = ["Plan", "Assemble"] as const;
const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected);
}
@@ -152,6 +164,15 @@ describe("HyperframesRenderStack — snapshot", () => {
| undefined;
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStates.RenderChunk, collected);
const renderChunksV2 = definition.States.RenderChunksV2 as
| {
Iterator?: { States?: Record<string, unknown> };
ItemProcessor?: { States?: Record<string, unknown> };
}
| undefined;
const innerStatesV2 =
renderChunksV2?.Iterator?.States ?? renderChunksV2?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStatesV2.RenderChunkV2, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
expect({ error: expected, present: collected.has(expected) }).toEqual({
@@ -160,6 +181,52 @@ describe("HyperframesRenderStack — snapshot", () => {
});
}
});
it("classifies plan v2 integrity failures as terminal in every v2 Lambda task", () => {
const v2TaskStates = Object.values(getV2TaskStates(SYNTHED.definition));
for (const state of v2TaskStates) {
const errors = new Set<string>();
collectNonRetryableErrors(state, errors);
expect(errors.has("PLAN_V2_INTEGRITY_UNRECOVERABLE")).toBe(true);
expect(errors.has("PlanV2IntegrityError")).toBe(true);
}
});
it("keeps SAM and CDK terminal classifiers identical for every v2 Lambda task", () => {
const cdkTasks = getV2TaskStates(SYNTHED.definition);
const samTasks = getV2TaskStates(readSamDefinition());
for (const taskName of ["PlanV2", "RenderChunkV2", "AssembleV2"] as const) {
const cdkErrors = new Set<string>();
const samErrors = new Set<string>();
collectNonRetryableErrors(cdkTasks[taskName], cdkErrors);
collectNonRetryableErrors(samTasks[taskName], samErrors);
expect({ taskName, errors: [...samErrors].sort() }).toEqual({
taskName,
errors: [...cdkErrors].sort(),
});
}
});
it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
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: definition.States.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");
});
});
function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
@@ -171,3 +238,66 @@ function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
return value;
}
function requireRecordProperty(
record: Record<string, unknown>,
property: string,
label: string,
): Record<string, unknown> {
return requireRecord(record[property], label);
}
function getV2TaskStates(definition: {
States: Record<string, unknown>;
}): Record<"PlanV2" | "RenderChunkV2" | "AssembleV2", unknown> {
const renderChunksV2 = requireRecord(definition.States.RenderChunksV2, "RenderChunksV2 state");
const processor = isRecord(renderChunksV2.Iterator)
? renderChunksV2.Iterator
: requireRecord(renderChunksV2.ItemProcessor, "RenderChunksV2 processor");
const innerStates = requireRecord(processor.States, "RenderChunksV2 processor states");
return {
PlanV2: definition.States.PlanV2,
RenderChunkV2: innerStates.RenderChunkV2,
AssembleV2: definition.States.AssembleV2,
};
}
function readSamDefinition(): { States: Record<string, unknown> } {
const source = readFileSync(
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),
"utf8",
);
// CloudFormation intrinsic tags are irrelevant to classifier parity. The
// YAML parser preserves their scalar values while this option suppresses
// warnings for the intentionally unresolved `!Ref`/`!GetAtt` tags.
const parsed: unknown = parseYaml(source, { logLevel: "silent" });
const root = requireRecord(parsed, "SAM template");
const resources = requireRecordProperty(root, "Resources", "SAM resources");
const stateMachine = requireRecordProperty(
resources,
"RenderStateMachine",
"SAM RenderStateMachine",
);
const properties = requireRecordProperty(
stateMachine,
"Properties",
"SAM state-machine properties",
);
const definition = requireRecordProperty(
properties,
"Definition",
"SAM state-machine definition",
);
return {
States: requireRecordProperty(definition, "States", "SAM state-machine states"),
};
}
@@ -200,8 +200,12 @@ export class HyperframesRenderStack extends Construct {
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
];
@@ -210,8 +214,13 @@ 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",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
const NON_RETRYABLE_ASSEMBLE = [
@@ -220,7 +229,12 @@ export class HyperframesRenderStack extends Construct {
"S3_URI_NOT_ALLOWED",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
@@ -233,6 +247,7 @@ export class HyperframesRenderStack extends Construct {
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
@@ -254,6 +269,35 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
PlanProtocol: "v2",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Payload.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
},
resultPath: "$.Plan",
});
planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
planV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
parameters: {
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
@@ -337,11 +381,100 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
PlanProtocol: "v2",
"ChunkIndex.$": "$.ChunkIndex",
"PlanV2ManifestS3Uri.$": "$.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
renderChunkV2Task.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunksV2.itemProcessor(renderChunkV2Task);
const assembleV2 = new tasks.LambdaInvoke(this, "AssembleV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
AudioS3Uri: null,
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
assembleV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
.when(
sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
renderChunksV2.next(assembleV2),
)
.otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderChunks.next(assemble))
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
.otherwise(planProducedZero);
return plan.next(buildChunkList).next(assertChunkCount);
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 v1).',
});
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(plan);
}
}
+74 -15
View File
@@ -25,6 +25,8 @@ export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/
/** Discriminator for the three roles the one Lambda image fulfills. */
export type LambdaAction = "plan" | "renderChunk" | "assemble";
/** Transport protocol selected for one complete distributed render. */
export type LambdaPlanProtocol = "v1" | "v2";
/**
* Top-level shape of any event the handler may receive.
@@ -42,7 +44,7 @@ export type LambdaEvent =
| { Input: LambdaEvent };
/** Activity A: produce a planDir, upload to S3. */
export interface PlanEvent {
interface PlanEventBase {
Action: "plan";
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
ProjectS3Uri: string;
@@ -52,17 +54,27 @@ export interface PlanEvent {
Config: SerializableDistributedRenderConfig;
}
/** Legacy/default plan transport. Absence is deliberately interpreted as v1. */
export interface PlanV1Event extends PlanEventBase {
PlanProtocol?: "v1";
}
/** Explicit opt-in to the content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase {
PlanProtocol: "v2";
}
export type PlanEvent = PlanV1Event | PlanV2Event;
/** Activity B: fetch planDir, render one chunk, upload result. */
export interface RenderChunkEvent {
interface RenderChunkEventBase {
Action: "renderChunk";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
/**
* `PlanResult.planHash` from the Plan invocation. The handler verifies
* this against the untarred planDir's `plan.json` before invoking the
* producer, throwing a typed `PLAN_HASH_MISMATCH` on divergence so the
* state machine routes it as non-retryable. Defense-in-depth the
* producer also re-checks internally.
* `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.
*/
PlanHash: string;
/** 0-based chunk index this invocation should render. */
@@ -73,11 +85,28 @@ export interface RenderChunkEvent {
Format: DistributedFormat;
}
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
export interface AssembleEvent {
Action: "assemble";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
/** Legacy/default chunk event. */
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;
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
interface AssembleEventBase {
Action: "assemble";
/** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */
ChunkS3Uris: string[];
/** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */
@@ -98,12 +127,28 @@ export interface AssembleEvent {
Cfr?: boolean;
}
/** Legacy/default assemble event. */
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;
// ── Result types — kept small to fit Step Functions history budgets ─────────
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
export interface PlanLambdaResult {
interface PlanLambdaResultBase {
Action: "plan";
PlanS3Uri: string;
PlanHash: string;
ChunkCount: number;
TotalFrames: number;
@@ -118,6 +163,20 @@ export interface PlanLambdaResult {
DurationMs: number;
}
/** Existing v1 result. Kept unchanged for wire compatibility. */
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;
}
export type PlanLambdaResult = PlanV1LambdaResult | PlanV2LambdaResult;
/** Result of a `renderChunk` invocation. Sized ≤200 bytes per §2.4. */
export interface RenderChunkLambdaResult {
Action: "renderChunk";
+212 -6
View File
@@ -15,15 +15,19 @@
*/
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
createPlanV2FromV1,
type AssembleResult,
type ChunkResult,
type PlanResult,
type PlanV2Result,
} from "@hyperframes/producer/distributed";
import { recomputePlanHashFromPlanDir } from "../../producer/src/services/render/stages/freezePlan.js";
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
import { handler, unwrapEvent } from "./handler.js";
@@ -42,9 +46,13 @@ class FakeS3Client {
ops: FakeS3Op[] = [];
// Map S3 URIs → byte buffers the fake serves.
objects = new Map<string, Buffer>();
metadata = new Map<string, Record<string, string>>();
// Methods called by the real S3 transport — minimal surface so the
// handler's call sites don't need rewriting under test.
// This fake intentionally implements the complete S3 command matrix inline so
// handler tests exercise realistic state transitions without AWS.
// fallow-ignore-next-line complexity
async send(command: unknown): Promise<unknown> {
const op = command as { input: { Bucket: string; Key: string } } & {
constructor: { name: string };
@@ -59,20 +67,38 @@ class FakeS3Client {
const { Readable } = await import("node:stream");
return { Body: Readable.from([bytes]) };
}
if (cmdName === "HeadObjectCommand") {
const bytes = this.objects.get(uri);
if (!bytes) {
const error = new Error("not found") as Error & {
$metadata: { httpStatusCode: number };
};
error.name = "NotFound";
error.$metadata = { httpStatusCode: 404 };
throw error;
}
return {
ContentLength: bytes.length,
Metadata: this.metadata.get(uri),
};
}
if (cmdName === "PutObjectCommand") {
// Buffer the body so we can record how many bytes were uploaded; the
// handler's hot path streams from disk, but tests pin the count.
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
let bytes = 0;
const chunks: Buffer[] = [];
if (Buffer.isBuffer(body)) {
bytes = body.length;
chunks.push(body);
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
for await (const chunk of body as NodeJS.ReadableStream) {
bytes += (chunk as Buffer).length;
chunks.push(Buffer.from(chunk as Buffer));
}
}
this.ops.push({ kind: "upload", uri, bytes });
this.objects.set(uri, Buffer.alloc(bytes));
const bytes = Buffer.concat(chunks);
this.ops.push({ kind: "upload", uri, bytes: bytes.length });
this.objects.set(uri, bytes);
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
if (metadata) this.metadata.set(uri, metadata);
return {};
}
return {};
@@ -215,6 +241,49 @@ describe("handler dispatch", () => {
).toBe(true);
});
it("normalizes producer terminal codes to Step Functions error names", async () => {
for (const code of [
"PLAN_TOO_LARGE",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
] as const) {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const terminal = Object.assign(new Error(`terminal: ${code}`), {
code,
name: "ProducerError",
});
await expect(
handler(
{
Action: "plan",
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.
@@ -454,6 +523,119 @@ describe("handler dispatch", () => {
expect(assembleMock).toHaveBeenCalledTimes(1);
});
it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const planV2Mock = mock(
async (_projectDir: string, _config: unknown, planV2Dir: string): Promise<PlanV2Result> => {
const v1Dir = join(tmpRoot, `v1-${Date.now()}`);
makeMinimalV1PlanDir(v1Dir, true);
return createPlanV2FromV1(v1Dir, planV2Dir);
},
);
const renderChunkMock = mock(
async (planDir: string, _chunkIndex: number, outputPath: string): Promise<ChunkResult> => {
expect(existsSync(join(planDir, "audio.aac"))).toBe(false);
writeFileSync(outputPath, "V2-CHUNK");
return {
outputPath,
outputKind: "file",
framesEncoded: 30,
sha256: "b".repeat(64),
durationMs: 1,
perfPath: `${outputPath}.perf.json`,
};
},
);
const assembleMock = mock(
async (
_planDir: string,
_chunks: readonly string[],
audioPath: string | null,
outputPath: string,
): Promise<AssembleResult> => {
expect(audioPath).not.toBeNull();
expect(readFileSync(audioPath as string, "utf-8")).toBe("AAC");
writeFileSync(outputPath, "V2-OUTPUT");
return { outputPath, durationMs: 1, framesEncoded: 30, fileSize: 9 };
},
);
const deps = {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: mock(async () => {
throw new Error("v1 plan should not be called");
}) as unknown as typeof import("@hyperframes/producer/distributed").plan,
planV2: planV2Mock as unknown as typeof import("@hyperframes/producer/distributed").planV2,
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,
};
const planned = await handler(
{
Action: "plan",
PlanProtocol: "v2",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
},
deps,
);
expect(planned).toMatchObject({
PlanProtocol: "v2",
PlanV2ManifestS3Uri: "s3://bucket/renders/v2/v2/manifest.json",
PlanV2ArtifactS3Prefix: "s3://bucket/renders/v2/v2/artifacts/sha256",
});
expect("PlanS3Uri" in planned).toBe(false);
if (!("PlanProtocol" in planned) || planned.PlanProtocol !== "v2") {
throw new Error("expected v2 plan result");
}
const beforeChunk = s3.ops.length;
const chunk = await handler(
{
Action: "renderChunk",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash,
ChunkIndex: 0,
ChunkOutputS3Prefix: "s3://bucket/renders/v2/",
Format: "mp4",
},
deps,
);
if (chunk.Action !== "renderChunk") throw new Error("expected chunk result");
const audioDigest = createHash("sha256").update("AAC").digest("hex");
const audioUri = `${planned.PlanV2ArtifactS3Prefix}/${audioDigest.slice(0, 2)}/${audioDigest}`;
expect(s3.ops.slice(beforeChunk).some((operation) => operation.uri === audioUri)).toBe(false);
await handler(
{
Action: "assemble",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash,
ChunkS3Uris: [chunk.ChunkS3Uri],
AudioS3Uri: null,
OutputS3Uri: "s3://bucket/renders/v2/output.mp4",
Format: "mp4",
},
deps,
);
expect(
s3.ops.some((operation) => operation.kind === "download" && operation.uri === audioUri),
).toBe(true);
});
it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot();
await expect(
@@ -575,3 +757,27 @@ async function makeMinimalPlanTar(): Promise<Buffer> {
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
return rf(tarPath);
}
function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
mkdirSync(join(dir, "meta"), { recursive: true });
mkdirSync(join(dir, "compiled"), { recursive: true });
writeFileSync(join(dir, "compiled", "index.html"), "<html>aws v2 fixture</html>");
const planJson = {
planHash: "a".repeat(64),
chunkCount: 1,
totalFrames: 30,
dimensions: { fpsNum: 30, fpsDen: 1, width: 640, height: 360, format: "mp4" },
ffmpegVersion: "6.0",
producerVersion: "test",
fontSnapshotSha: "font-snapshot-test",
};
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
writeFileSync(
join(dir, "meta", "chunks.json"),
JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]),
);
writeFileSync(join(dir, "meta", "encoder.json"), "{}");
if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC");
planJson.planHash = recomputePlanHashFromPlanDir(dir);
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
}
+304 -6
View File
@@ -20,8 +20,15 @@ import {
type AssembleResult,
type ChunkResult,
type DistributedRenderConfig,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
plan,
planV2,
type PlanResult,
type PlanV2Artifact,
type PlanV2MaterializationTarget,
type PlanV2Result,
readPlanV2Manifest,
renderChunk,
} from "@hyperframes/producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js";
@@ -39,9 +46,12 @@ import type {
} from "./events.js";
import {
downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
parseS3Uri,
sha256File,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
uploadFileToS3,
} from "./s3Transport.js";
@@ -67,6 +77,7 @@ export interface HandlerDeps {
s3?: S3Client;
primitives?: {
plan: typeof plan;
planV2?: typeof planV2;
renderChunk: typeof renderChunk;
assemble: typeof assemble;
};
@@ -109,6 +120,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
}
}
} catch (err) {
normalizeTerminalErrorName(err);
// Log before re-throwing so CloudWatch captures the structured
// error context alongside Lambda's default stack trace. Otherwise
// ops only sees the trace and has to correlate with execution
@@ -116,6 +128,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
logEvent({
event: "handler_error",
action: unwrapped.Action,
input: summarizeEvent(unwrapped),
message: err instanceof Error ? err.message : String(err),
name: err instanceof Error ? err.name : undefined,
});
@@ -123,6 +136,25 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
}
}
/**
* AWS Lambda reports `Error.name` to Step Functions, while producer errors
* expose stable machine codes separately. Normalize the terminal codes
* whose historical class names differ from their orchestration contracts.
*/
// The explicit error-name mapping is the public Step Functions failure contract.
// fallow-ignore-next-line complexity
function normalizeTerminalErrorName(error: unknown): void {
if (!error || typeof error !== "object") return;
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.name = candidate.code;
}
}
/**
* Walk through Step Functions' Map-state and Task-state envelopes until
* the discriminated event is found.
@@ -178,6 +210,8 @@ function logEvent(payload: Record<string, unknown>): void {
* the routable fields (S3 URIs, chunk index, format) needed to triage
* a failure from CloudWatch.
*/
// Keep event variants together so logs share one redaction and summarization boundary.
// fallow-ignore-next-line complexity
function summarizeEvent(
event: PlanEvent | RenderChunkEvent | AssembleEvent,
): Record<string, unknown> {
@@ -186,18 +220,25 @@ function summarizeEvent(
return {
projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix,
planProtocol: event.PlanProtocol ?? "v1",
format: event.Config.format,
fps: event.Config.fps,
};
case "renderChunk":
return {
planS3Uri: event.PlanS3Uri,
planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkIndex: event.ChunkIndex,
format: event.Format,
};
case "assemble":
return {
planS3Uri: event.PlanS3Uri,
planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkCount: event.ChunkS3Uris.length,
hasAudio: event.AudioS3Uri !== null,
outputS3Uri: event.OutputS3Uri,
@@ -226,6 +267,9 @@ function primeRuntimeEnv(): void {
// ── Plan ────────────────────────────────────────────────────────────────────
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
if (event.PlanProtocol === "v2") {
return handlePlanV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.plan ?? plan;
@@ -297,12 +341,90 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLam
}
}
// 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: Extract<PlanEvent, { PlanProtocol: "v2" }>,
deps?: HandlerDeps,
): Promise<Extract<PlanLambdaResult, { PlanProtocol: "v2" }>> {
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.planV2 ?? planV2;
if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
}
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-v2-"));
const projectArchive = join(work, "project.tar.gz");
const projectDir = join(work, "project");
const planV2Dir = join(work, "plan-v2");
try {
await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
await untarDirectory(projectArchive, projectDir);
const result: PlanV2Result = await primitive(projectDir, { ...event.Config }, planV2Dir);
const manifest = readPlanV2Manifest(planV2Dir);
if (manifest.planHash !== result.planHash) {
throwPlanHashMismatch(result.planHash, manifest.planHash);
}
const outputPrefix = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/v2`;
const artifactPrefix = `${outputPrefix}/artifacts/sha256`;
const uniqueArtifacts = [
...new Map(manifest.artifacts.map((artifact) => [artifact.sha256, artifact])).values(),
];
await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
const localPath = planV2BlobPath(planV2Dir, artifact.sha256);
await uploadContentAddressedFileToS3(
s3,
localPath,
planV2BlobUri(artifactPrefix, artifact.sha256),
artifact.sha256,
);
});
// Publish the manifest only after every referenced blob is durable.
const manifestUri = `${outputPrefix}/manifest.json`;
await uploadContentAddressedFileToS3(
s3,
result.manifestPath,
manifestUri,
await sha256File(result.manifestPath),
"application/json",
);
return {
Action: "plan",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: manifestUri,
PlanV2ArtifactS3Prefix: artifactPrefix,
PlanHash: result.planHash,
ChunkCount: result.chunkCount,
TotalFrames: result.totalFrames,
Fps: result.fps,
Width: result.width,
Height: result.height,
Format: result.format,
HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
AudioS3Uri: null,
FfmpegVersion: result.ffmpegVersion,
ProducerVersion: result.producerVersion,
DurationMs: Date.now() - started,
};
} finally {
cleanupDir(work);
}
}
// ── RenderChunk ─────────────────────────────────────────────────────────────
async function handleRenderChunk(
event: RenderChunkEvent,
deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleRenderChunkV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -364,6 +486,53 @@ async function handleRenderChunk(
}
}
// 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: Extract<RenderChunkEvent, { PlanProtocol: "v2" }>,
deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> {
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
}
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-v2-"));
try {
const planDir = await downloadAndMaterializePlanV2(
s3,
event,
{ role: "chunk", chunkIndex: event.ChunkIndex },
work,
);
const chunkOutputBase = join(
work,
event.Format === "png-sequence"
? `chunk-${pad(event.ChunkIndex)}`
: `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`,
);
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
const chunkUri = await uploadChunkOutput(
s3,
result,
event.ChunkOutputS3Prefix,
event.ChunkIndex,
);
return {
Action: "renderChunk",
ChunkS3Uri: chunkUri,
ChunkIndex: event.ChunkIndex,
Sha256: result.sha256,
FramesEncoded: result.framesEncoded,
DurationMs: Date.now() - started,
};
} finally {
cleanupDir(work);
}
}
async function uploadChunkOutput(
s3: S3Client,
result: ChunkResult,
@@ -393,6 +562,9 @@ async function handleAssemble(
event: AssembleEvent,
deps?: HandlerDeps,
): Promise<AssembleLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleAssembleV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.assemble ?? assemble;
@@ -442,6 +614,123 @@ async function handleAssemble(
}
}
// 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: Extract<AssembleEvent, { PlanProtocol: "v2" }>,
deps?: HandlerDeps,
): Promise<AssembleLambdaResult> {
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-v2-"));
try {
const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
// `downloadAndMaterializePlanV2` materializes atomically. Audio is
// assembler-only and lives at the familiar v1-compatible location.
const audioPath = existsSync(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null;
const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
const finalOutput =
event.Format === "png-sequence"
? join(work, "output-frames")
: join(work, `output${formatExtension(event.Format)}`);
const result = 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);
}
}
async function downloadAndMaterializePlanV2(
s3: S3Client,
event: {
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
PlanHash: string;
},
target: PlanV2MaterializationTarget,
work: string,
): Promise<string> {
const transportDir = join(work, "plan-v2");
mkdirSync(transportDir, { recursive: true });
await downloadS3ObjectToFile(s3, event.PlanV2ManifestS3Uri, join(transportDir, "plan.json"));
const manifest = readPlanV2Manifest(transportDir);
if (manifest.planHash !== event.PlanHash) {
throwPlanHashMismatch(event.PlanHash, manifest.planHash);
}
const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
const uniqueArtifacts = [
...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values(),
];
await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
await downloadPlanV2Artifact(s3, event.PlanV2ArtifactS3Prefix, transportDir, artifact);
});
const planDir = join(work, "plan");
materializePlanV2Target(transportDir, target, planDir);
return planDir;
}
async function downloadPlanV2Artifact(
s3: S3Client,
artifactPrefix: string,
planV2Dir: string,
artifact: Readonly<PlanV2Artifact>,
): Promise<void> {
await downloadS3ObjectToFileVerified(
s3,
planV2BlobUri(artifactPrefix, artifact.sha256),
planV2BlobPath(planV2Dir, artifact.sha256),
artifact.sha256,
);
}
function planV2BlobPath(planV2Dir: string, digest: string): string {
return join(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
}
function planV2BlobUri(prefix: string, digest: string): string {
return `${trimTrailingSlash(prefix)}/${digest.slice(0, 2)}/${digest}`;
}
function throwPlanHashMismatch(expected: string, actual: string): never {
const error = new Error(
`PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`,
);
error.name = "PLAN_HASH_MISMATCH";
throw error;
}
async function mapConcurrent<T>(
values: readonly T[],
concurrency: number,
fn: (value: T) => Promise<void>,
): Promise<void> {
let cursor = 0;
async function worker(): Promise<void> {
while (cursor < values.length) {
const index = cursor++;
await fn(values[index]!);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
}
async function downloadChunkObjects(
s3: S3Client,
uris: string[],
@@ -479,16 +768,25 @@ async function downloadChunkObjects(
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Collect every S3 URI that the handler will touch for a given event. */
// This is an exhaustive event-union projection used only for safe log summaries.
// fallow-ignore-next-line complexity
function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] {
switch (event.Action) {
case "plan":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk":
return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
return event.PlanProtocol === "v2"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
: [event.PlanS3Uri, event.ChunkOutputS3Prefix];
case "assemble":
return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
(u): u is string => u != null,
);
return [
...(event.PlanProtocol === "v2"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
: [event.PlanS3Uri]),
...event.ChunkS3Uris,
event.OutputS3Uri,
event.AudioS3Uri,
].filter((u): u is string => u != null);
}
}
+11
View File
@@ -28,11 +28,20 @@ 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";
// `_setSparticuzChromiumForTests` is intentionally NOT re-exported from
@@ -47,11 +56,13 @@ export {
} from "./chromium.js";
export {
downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
formatS3Uri,
parseS3Uri,
type S3Location,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
uploadFileToS3,
} from "./s3Transport.js";
+116 -1
View File
@@ -8,7 +8,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatS3Uri, parseS3Uri, tarDirectory, untarDirectory } from "./s3Transport.js";
import {
downloadS3ObjectToFileVerified,
formatS3Uri,
parseS3Uri,
sha256File,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
} from "./s3Transport.js";
let scratchRoot: string;
@@ -91,3 +99,110 @@ describe("tar round-trip", () => {
expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
});
});
describe("content-addressed v2 artifacts", () => {
it("uploads once and reuses an object with matching digest metadata", async () => {
const source = join(scratchRoot, "artifact-upload.bin");
writeFileSync(source, "immutable bytes");
const digest = await sha256File(source);
const s3 = new ContentAddressedFakeS3();
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe(
"uploaded",
);
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe("reused");
expect(s3.putCount).toBe(1);
});
it("refuses to overwrite an immutable key with conflicting metadata", async () => {
const source = join(scratchRoot, "artifact-conflict.bin");
writeFileSync(source, "expected bytes");
const digest = await sha256File(source);
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
const s3 = new ContentAddressedFakeS3();
s3.objects.set(uri, {
bytes: Buffer.from("same length!!"),
sha256: "0".repeat(64),
});
await expect(
uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
expect(s3.putCount).toBe(0);
});
it("deletes a downloaded artifact when digest verification fails", async () => {
const expectedSource = join(scratchRoot, "artifact-expected.bin");
const destination = join(scratchRoot, "artifact-download.bin");
writeFileSync(expectedSource, "expected");
const expected = await sha256File(expectedSource);
const uri = "s3://bucket/v2/artifacts/corrupt";
const s3 = new ContentAddressedFakeS3();
s3.objects.set(uri, { bytes: Buffer.from("corrupt"), sha256: "f".repeat(64) });
await expect(
downloadS3ObjectToFileVerified(s3.asClient(), uri, destination, expected),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
const { existsSync } = await import("node:fs");
expect(existsSync(destination)).toBe(false);
});
});
class ContentAddressedFakeS3 {
readonly objects = new Map<string, { bytes: Buffer; sha256: string }>();
putCount = 0;
asClient(): import("@aws-sdk/client-s3").S3Client {
return this as unknown as import("@aws-sdk/client-s3").S3Client;
}
// This fake intentionally keeps the S3 command matrix in one stateful boundary;
// splitting commands across helpers would obscure the transport test behavior.
// fallow-ignore-next-line complexity
async send(command: unknown): Promise<unknown> {
const value = command as {
constructor: { name: string };
input: {
Bucket: string;
Key: string;
Body?: NodeJS.ReadableStream;
Metadata?: Record<string, string>;
};
};
const uri = `s3://${value.input.Bucket}/${value.input.Key}`;
if (value.constructor.name === "HeadObjectCommand") {
const object = this.objects.get(uri);
if (!object) {
const error = new Error("not found") as Error & {
$metadata: { httpStatusCode: number };
};
error.name = "NotFound";
error.$metadata = { httpStatusCode: 404 };
throw error;
}
return {
ContentLength: object.bytes.length,
Metadata: { sha256: object.sha256 },
};
}
if (value.constructor.name === "GetObjectCommand") {
const object = this.objects.get(uri);
if (!object) throw new Error("missing fake object");
const { Readable } = await import("node:stream");
return { Body: Readable.from([object.bytes]) };
}
if (value.constructor.name === "PutObjectCommand") {
const chunks: Buffer[] = [];
for await (const chunk of value.input.Body ?? []) chunks.push(Buffer.from(chunk));
const bytes = Buffer.concat(chunks);
this.objects.set(uri, {
bytes,
sha256: value.input.Metadata?.sha256 ?? "",
});
this.putCount += 1;
return {};
}
throw new Error(`unexpected command ${value.constructor.name}`);
}
}
+115 -1
View File
@@ -25,9 +25,15 @@ import {
rmSync,
statSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import { GetObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import * as tar from "tar";
/** Parsed `s3://bucket/key` URI. */
@@ -75,6 +81,26 @@ export async function downloadS3ObjectToFile(
await pipeline(body, createWriteStream(destPath));
}
/** Download and verify an immutable plan-v2 artifact before materialization. */
export async function downloadS3ObjectToFileVerified(
client: S3Client,
uri: string,
destPath: string,
expectedSha256: string,
): Promise<void> {
assertSha256(expectedSha256);
await downloadS3ObjectToFile(client, uri, destPath);
const actual = await sha256File(destPath);
if (actual !== expectedSha256) {
rmSync(destPath, { force: true });
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
}
}
/**
* Upload a local file's contents to an S3 URI using a streaming
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
@@ -104,6 +130,94 @@ export async function uploadFileToS3(
);
}
/**
* Upload one content-addressed plan-v2 artifact exactly once.
*
* Existing objects are reused only when their immutable digest metadata and
* byte length agree. A conflicting object is never overwritten: doing so
* could change a plan already being consumed by another chunk invocation.
*/
export async function uploadContentAddressedFileToS3(
client: S3Client,
localPath: string,
uri: string,
expectedSha256: string,
contentType?: string,
): Promise<"uploaded" | "reused"> {
assertSha256(expectedSha256);
if (!existsSync(localPath)) {
throw new Error(`[s3Transport] upload source missing: ${localPath}`);
}
const actualSha256 = await sha256File(localPath);
if (actualSha256 !== expectedSha256) {
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
}
const { bucket, key } = parseS3Uri(uri);
const size = statSync(localPath).size;
try {
const existing = await client.send(
new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" }),
);
if (existing.ContentLength === size && existing.Metadata?.sha256 === expectedSha256) {
return "reused";
}
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: immutable object ${uri} already exists with different digest metadata or size`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
} catch (error) {
if (!isS3NotFound(error)) throw error;
}
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(localPath),
ContentType: contentType,
ContentLength: size,
Metadata: { sha256: expectedSha256 },
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
}),
);
return "uploaded";
}
export async function sha256File(path: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) {
hash.update(chunk as Buffer);
}
return hash.digest("hex");
}
function assertSha256(value: string): void {
if (!/^[a-f0-9]{64}$/.test(value)) {
throw new Error(
`[s3Transport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`,
);
}
}
function isS3NotFound(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const candidate = error as {
name?: string;
$metadata?: { httpStatusCode?: number };
};
return (
candidate.name === "NotFound" ||
candidate.name === "NoSuchKey" ||
candidate.$metadata?.httpStatusCode === 404
);
}
/**
* Pack a directory into a `.tar.gz` at `destTarball`. Uses the `tar` npm
* package (pure JS over `node:zlib`) rather than spawning a system tar
@@ -201,6 +201,30 @@ describe("getRenderProgress", () => {
expect(progress.endedAt).not.toBeNull();
});
it("recognizes v2 chunk and assemble state names", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("PlanV2"),
lambdaSucceeded({ Action: "plan", TotalFrames: 30 }),
stateEntered("RenderChunkV2"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 30 }),
stateEntered("AssembleV2"),
lambdaSucceeded({ Action: "assemble", FramesEncoded: 30 }),
stateExited("AssembleV2", {
Output: { OutputS3Uri: "s3://b/v2.mp4", FileSize: 321 },
}),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(30);
expect(progress.overallProgress).toBe(1);
expect(progress.outputFile).toEqual({ s3Uri: "s3://b/v2.mp4", bytes: 321 });
});
it("computes cost from observed billed duration", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
@@ -251,7 +251,7 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda
// result, so we re-extract them here from the state exit's
// own output rather than relying on the Lambda payload.
if (ev.stateExitedEventDetails?.name === "Assemble") {
if (isAssembleState(ev.stateExitedEventDetails?.name)) {
assembleComplete = true;
const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
if (exitPayload && typeof exitPayload === "object") {
@@ -350,12 +350,20 @@ function applyPayloadFrameCounts(
currentLambdaState: string | null,
bump: (delta: number) => void,
): void {
if (currentLambdaState !== "RenderChunk") return;
if (!isRenderChunkState(currentLambdaState)) return;
if (!payload || typeof payload !== "object") return;
const obj = payload as Record<string, unknown>;
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
}
function isRenderChunkState(name: string | null | undefined): boolean {
return name === "RenderChunk" || name === "RenderChunkV2";
}
function isAssembleState(name: string | null | undefined): boolean {
return name === "Assemble" || name === "AssembleV2";
}
/**
* Lambda success payloads from our handler include `DurationMs` the
* wall-clock the handler observed. We use it as a best-effort proxy
@@ -89,9 +89,27 @@ describe("renderToLambda", () => {
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig,
PlanProtocol: "v1",
});
});
it("opts the complete execution into plan protocol v2 explicitly", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
executionName: "smoke-v2",
planProtocol: "v2",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(sfn.starts[0]?.input).toMatchObject({ PlanProtocol: "v2" });
});
it("derives the file extension from config.format", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
@@ -20,7 +20,7 @@
import { randomUUID } from "node:crypto";
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
import type { S3Client } from "@aws-sdk/client-s3";
import type { SerializableDistributedRenderConfig } from "../events.js";
import type { LambdaPlanProtocol, SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatS3Uri } from "../s3Transport.js";
import { deploySite, type SiteHandle } from "./deploySite.js";
@@ -37,6 +37,11 @@ export interface RenderToLambdaOptions {
siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/**
* Distributed plan transport. Defaults to `"v1"` for backwards
* compatibility; v2 is always an explicit whole-render opt-in.
*/
planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
bucketName: string;
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
@@ -110,6 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
PlanOutputS3Prefix: planOutputS3Prefix,
OutputS3Uri: outputS3Uri,
Config: opts.config,
PlanProtocol: opts.planProtocol ?? "v1",
};
// Reject oversize input client-side. Step Functions Standard caps the
@@ -0,0 +1,13 @@
{
"generatedAudio": {
"path": "assets/tone.wav",
"durationSeconds": 1,
"frequencyHz": 330,
"sampleRate": 48000
},
"generatedPressureFile": {
"path": "unused-pressure.bin",
"bytes": 65536,
"seed": 20260725
}
}
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=320, height=180" />
<title>Plan protocol size pressure</title>
<style>
html,
body {
width: 320px;
height: 180px;
margin: 0;
overflow: hidden;
background: #020617;
}
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 40%, #22d3ee 0 18px, transparent 19px),
radial-gradient(circle at 68% 60%, #f472b6 0 26px, transparent 27px), #0f172a;
}
</style>
</head>
<body>
<main
id="root"
data-composition-id="plan-protocol-size-pressure"
data-start="0"
data-duration="1"
data-width="320"
data-height="180"
data-fps="30"
data-no-timeline
>
<audio
id="tone"
src="assets/tone.wav"
data-start="0"
data-duration="1"
data-volume="0.5"
></audio>
<section class="scene clip" data-start="0" data-duration="1"></section>
</main>
<script>
window.__timelines = window.__timelines || {};
</script>
</body>
</html>
@@ -0,0 +1,8 @@
{
"generatedAudio": {
"path": "assets/tone.wav",
"durationSeconds": 2,
"frequencyHz": 440,
"sampleRate": 48000
}
}
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=320, height=180" />
<title>Plan protocol visual and audio parity</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
width: 320px;
height: 180px;
margin: 0;
overflow: hidden;
background: #07111f;
}
.scene {
position: absolute;
inset: 0;
}
.first {
background: linear-gradient(135deg, #07111f, #0e7490);
}
.second {
background: linear-gradient(135deg, #312e81, #be185d);
}
.shape {
position: absolute;
width: 72px;
height: 72px;
border-radius: 18px;
background: #f8fafc;
box-shadow: 0 12px 30px rgb(0 0 0 / 35%);
}
.first .shape {
top: 38px;
left: 48px;
transform: rotate(12deg);
}
.second .shape {
right: 48px;
bottom: 38px;
transform: rotate(-12deg);
}
</style>
</head>
<body>
<main
id="root"
data-composition-id="plan-protocol-visual-audio"
data-start="0"
data-duration="2"
data-width="320"
data-height="180"
data-fps="30"
data-no-timeline
>
<audio
id="tone"
src="assets/tone.wav"
data-start="0"
data-duration="2"
data-volume="0.5"
></audio>
<section class="scene first clip" data-start="0" data-duration="1">
<div class="shape"></div>
</section>
<section class="scene second clip" data-start="1" data-duration="1">
<div class="shape"></div>
</section>
</main>
<script>
window.__timelines = window.__timelines || {};
</script>
</body>
</html>
+1
View File
@@ -47,6 +47,7 @@
"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\"",
@@ -0,0 +1,99 @@
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,
});
});
});
@@ -0,0 +1,306 @@
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 type {
PlanParityDriverResult,
PlanParityMediaMeasurement,
PlanParityMeasurement,
PlanParityStreamMetadata,
} from "./plan-parity-contract.js";
type JsonRecord = Record<string, unknown>;
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"],
});
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<PlanParityMediaMeasurement["pcmAudio"]> {
return new Promise((resolvePromise, reject) => {
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"] },
);
const hash = createHash("sha256");
const stderr: Buffer[] = [];
let bytes = 0;
child.stdout.on("data", (chunk: Buffer) => {
hash.update(chunk);
bytes += chunk.length;
});
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
const message = Buffer.concat(stderr).toString("utf-8");
// 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(message)) {
resolvePromise(null);
return;
}
reject(new Error(`ffmpeg PCM decode exited ${code}: ${message.trim()}`));
return;
}
if (bytes % 4 !== 0) {
reject(new Error(`canonical stereo s16le byte count ${bytes} is not divisible by 4`));
return;
}
resolvePromise({
sha256: hash.digest("hex"),
sampleCount: bytes / 4,
bytes,
});
});
});
}
export async function analyzePlanParityDriverResult(
driverName: string,
result: PlanParityDriverResult,
): Promise<PlanParityMeasurement> {
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,
};
}
@@ -0,0 +1,165 @@
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");
});
});
@@ -0,0 +1,254 @@
/**
* 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.
*/
export type PlanParityProtocol = "v1" | "v2";
export interface PlanParityRenderConfig {
fps: 24 | 30 | 60;
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<PlanParityDriverResult>;
}
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<PlanParityStreamMetadata, "durationSeconds"> {
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,
};
}
@@ -0,0 +1,66 @@
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",
);
});
});
@@ -0,0 +1,155 @@
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function positiveNumber(record: Record<string, unknown>, 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<string, unknown>, 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<GeneratedAudioConfig, "path">): 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),
);
}
}
@@ -0,0 +1,188 @@
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", "25"])).toThrow(
/24, 30, or 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"), "<html></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",
});
});
});
@@ -0,0 +1,452 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import process from "node:process";
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<PlanParityMeasurement>;
}
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:<isolated-stack> (got ${JSON.stringify(target)})`,
);
}
function collectArgs(argv: string[]): Map<string, string> {
const args = new Map<string, string>();
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 (fps !== 24 && fps !== 30 && fps !== 60) {
throw new Error("plan parity: --fps must be 24, 30, or 60");
}
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<string, unknown> {
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<PlanV2SizePressureReport> {
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<PlanParityComparison> {
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<PlanParityDriver> {
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<void> {
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]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
await main();
}
@@ -0,0 +1,40 @@
// 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,
});
},
};
}
@@ -13,6 +13,8 @@ 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;
@@ -32,8 +34,25 @@ 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<string, unknown>;
}
export interface LambdaLocalRenderResult {
protocol: "v1" | "v2";
outputPath: string;
chunks: Array<{
index: number;
path: string;
reportedSha256: string;
}>;
transferBytes: {
downloaded: number;
uploaded: number;
};
peakMaterializedBytes: number;
}
/** Public signature of the dynamically-loaded `runLambdaLocalRender`. */
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<void>;
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<LambdaLocalRenderResult>;
@@ -26,6 +26,7 @@ import {
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
@@ -37,7 +38,6 @@ import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperfram
import { handler } from "@hyperframes/aws-lambda/handler";
import type {
AssembleEvent,
AssembleLambdaResult,
HandlerDeps,
PlanEvent,
PlanLambdaResult,
@@ -46,8 +46,14 @@ import type {
SerializableDistributedRenderConfig,
} from "@hyperframes/aws-lambda";
export type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
import type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
export type {
LambdaLocalRenderResult,
RunLambdaLocalInput,
} from "./regression-harness-lambda-local-types.js";
import type {
LambdaLocalRenderResult,
RunLambdaLocalInput,
} from "./regression-harness-lambda-local-types.js";
const FAKE_BUCKET = "harness-lambda-local";
@@ -60,7 +66,10 @@ function uri(key: string): string {
* Run plan renderChunk × N assemble through the OSS handler with a
* filesystem-backed fake S3. Output lands at `input.renderedOutputPath`.
*/
export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<void> {
export async function runLambdaLocalRender(
input: RunLambdaLocalInput,
): Promise<LambdaLocalRenderResult> {
const protocol = input.protocol ?? "v1";
const s3Root = join(input.tempRoot, "s3");
mkdirSync(s3Root, { recursive: true });
@@ -81,7 +90,26 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
skipChromeResolution: true,
tmpRoot: join(input.tempRoot, "lambda-tmp"),
};
mkdirSync(deps.tmpRoot as string, { recursive: true });
const lambdaTmpRoot = join(input.tempRoot, "lambda-tmp");
mkdirSync(lambdaTmpRoot, { recursive: true });
let peakObservedMaterializedBytes = measureDirectoryBytes(lambdaTmpRoot);
const observe = async <Result>(operation: () => Promise<Result>): Promise<Result> => {
const sample = (): void => {
peakObservedMaterializedBytes = Math.max(
peakObservedMaterializedBytes,
measureDirectoryBytes(lambdaTmpRoot),
);
};
sample();
const timer = setInterval(sample, 2);
timer.unref();
try {
return await operation();
} finally {
clearInterval(timer);
sample();
}
};
const config: SerializableDistributedRenderConfig = {
fps: input.fps,
@@ -91,6 +119,7 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
...(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
@@ -101,42 +130,95 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
// STEP A: plan
const planPrefix = `renders/harness/${Date.now()}/`;
const planEvent: PlanEvent = {
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 planResult = (await handler(planEvent, deps)) as PlanLambdaResult;
const planResponse = await observe(() => handler(planEvent, deps));
if (planResponse.Action !== "plan") {
throw new Error(`lambda-local: plan action returned ${planResponse.Action}`);
}
const planResult: PlanLambdaResult = planResponse;
// STEP B: render every chunk through the handler.
const chunkUris: string[] = [];
const chunks: LambdaLocalRenderResult["chunks"] = [];
for (let i = 0; i < planResult.ChunkCount; i++) {
const chunkEvent: RenderChunkEvent = {
Action: "renderChunk",
PlanS3Uri: planResult.PlanS3Uri,
const shared = {
Action: "renderChunk" as const,
PlanHash: planResult.PlanHash,
ChunkIndex: i,
ChunkOutputS3Prefix: uri(planPrefix),
Format: input.format,
};
const chunkResult = (await handler(chunkEvent, deps)) as RenderChunkLambdaResult;
const chunkEvent: RenderChunkEvent =
protocol === "v2"
? {
...shared,
PlanProtocol: "v2",
PlanV2ManifestS3Uri: requireV2PlanResult(planResult).PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: requireV2PlanResult(planResult).PlanV2ArtifactS3Prefix,
}
: {
...shared,
PlanProtocol: "v1",
PlanS3Uri: requireV1PlanResult(planResult).PlanS3Uri,
};
const chunkResponse = await observe(() => handler(chunkEvent, deps));
if (chunkResponse.Action !== "renderChunk") {
throw new Error(`lambda-local: renderChunk action returned ${chunkResponse.Action}`);
}
const chunkResult: RenderChunkLambdaResult = chunkResponse;
chunkUris.push(chunkResult.ChunkS3Uri);
chunks.push({
index: i,
path: fakeS3Path(s3Root, chunkResult.ChunkS3Uri),
reportedSha256: chunkResult.Sha256,
});
}
// STEP C: assemble
const finalUri = uri(
`${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`,
);
const assembleEvent: AssembleEvent = {
Action: "assemble",
PlanS3Uri: planResult.PlanS3Uri,
const assembleShared = {
Action: "assemble" as const,
ChunkS3Uris: chunkUris,
AudioS3Uri: planResult.AudioS3Uri,
OutputS3Uri: finalUri,
Format: input.format,
};
(await handler(assembleEvent, deps)) as AssembleLambdaResult;
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 assembleResponse = await observe(() => handler(assembleEvent, deps));
if (assembleResponse.Action !== "assemble") {
throw new Error(`lambda-local: assemble action returned ${assembleResponse.Action}`);
}
const transferBytes = fakeS3.transferBytes;
// Copy the final output from fake-S3 land back out to the path the
// harness expects. For png-sequence, untar into the dir.
@@ -152,6 +234,57 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
input.renderedOutputPath,
);
}
return {
protocol,
outputPath: input.renderedOutputPath,
chunks,
transferBytes,
peakMaterializedBytes: peakObservedMaterializedBytes,
};
}
function fakeS3Path(s3Root: string, s3Uri: string): string {
const prefix = `s3://${FAKE_BUCKET}/`;
if (!s3Uri.startsWith(prefix)) {
throw new Error(`lambda-local: unexpected fake S3 URI ${s3Uri}`);
}
return join(s3Root, s3Uri.slice(prefix.length));
}
function requireV1PlanResult(
result: PlanLambdaResult,
): Extract<PlanLambdaResult, { PlanS3Uri: string }> {
if (!("PlanS3Uri" in result)) {
throw new Error("lambda-local: v1 plan returned v2 locators");
}
return result;
}
function requireV2PlanResult(
result: PlanLambdaResult,
): Extract<PlanLambdaResult, { PlanProtocol: "v2" }> {
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
function measureDirectoryBytes(path: string): number {
if (!existsSync(path)) return 0;
let bytes = 0;
for (const entry of readdirSync(path, { withFileTypes: true })) {
const child = join(path, entry.name);
if (entry.isDirectory()) {
bytes += measureDirectoryBytes(child);
} else if (entry.isFile()) {
bytes += statSync(child).size;
}
}
return bytes;
}
/**
@@ -162,8 +295,19 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
* without going through a real S3 endpoint.
*/
class FilesystemBackedFakeS3 {
private downloadedBytes = 0;
private uploadedBytes = 0;
private readonly metadata = new Map<string, Record<string, string>>();
constructor(private readonly root: string) {}
get transferBytes(): { downloaded: number; uploaded: number } {
return {
downloaded: this.downloadedBytes,
uploaded: this.uploadedBytes,
};
}
async send(command: unknown): Promise<unknown> {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input;
@@ -180,6 +324,7 @@ class FilesystemBackedFakeS3 {
throw err;
}
const bytes = readFileSync(fsPath);
this.downloadedBytes += bytes.length;
return { Body: Readable.from([bytes]) };
}
if (cmdName === "PutObjectCommand") {
@@ -192,7 +337,11 @@ class FilesystemBackedFakeS3 {
} else {
throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`);
}
return { ETag: `"fake-${statSync(fsPath).size}"` };
const size = statSync(fsPath).size;
this.uploadedBytes += size;
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
if (metadata) this.metadata.set(input.Key, metadata);
return { ETag: `"fake-${size}"` };
}
if (cmdName === "HeadObjectCommand") {
if (!existsSync(fsPath)) {
@@ -204,7 +353,11 @@ class FilesystemBackedFakeS3 {
err.$metadata = { httpStatusCode: 404 };
throw err;
}
return { ContentLength: statSync(fsPath).size, LastModified: new Date() };
return {
ContentLength: statSync(fsPath).size,
LastModified: new Date(),
Metadata: this.metadata.get(input.Key),
};
}
throw new Error(`FakeS3: unexpected command ${cmdName}`);
}
+2 -1
View File
@@ -25,6 +25,7 @@
"dist",
"src/**/*.test.ts",
"src/**/__test_utils__/**",
"src/regression-harness-lambda-local.ts"
"src/regression-harness-lambda-local.ts",
"src/plan-parity-lambda-local-driver.ts"
]
}