diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 1bcd56387..41ba72dbe 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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. diff --git a/bun.lock b/bun.lock index f277c1224..9643719f3 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/examples/aws-lambda/README.md b/examples/aws-lambda/README.md index 65c8ed1da..7300ac141 100644 --- a/examples/aws-lambda/README.md +++ b/examples/aws-lambda/README.md @@ -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//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 `/lambda-smoke-artifacts/`: -- `results.json` — `chunkCount × wallClockMs × psnrAvgDb` -- `renders/N-output.mp4` — each rendered chunk count -- `renders/N-history.json` — full Step Functions execution history +- `results.json` — `planProtocol × chunkCount × wallClockMs × psnrAvgDb` +- `semantic-comparisons.json` — direct v1/v2 semantic gate results +- `renders/-N-output.mp4` — each rendered variant +- `renders/-N-history.json` — full Step Functions execution history +- `renders/v1-v2-N.*` — normalized frame hashes, ffprobe metadata, and comparison JSON Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`, `jq`, `zip`. AWS credentials come from the standard resolution chain diff --git a/examples/aws-lambda/sample-events/assemble-v2.json b/examples/aws-lambda/sample-events/assemble-v2.json new file mode 100644 index 000000000..bbb3bd2c2 --- /dev/null +++ b/examples/aws-lambda/sample-events/assemble-v2.json @@ -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" +} diff --git a/examples/aws-lambda/sample-events/plan-v2.json b/examples/aws-lambda/sample-events/plan-v2.json new file mode 100644 index 000000000..7b7330b09 --- /dev/null +++ b/examples/aws-lambda/sample-events/plan-v2.json @@ -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" + } +} diff --git a/examples/aws-lambda/sample-events/render-chunk-v2.json b/examples/aws-lambda/sample-events/render-chunk-v2.json new file mode 100644 index 000000000..065ac4083 --- /dev/null +++ b/examples/aws-lambda/sample-events/render-chunk-v2.json @@ -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" +} diff --git a/examples/aws-lambda/scripts/_aws-isolation.sh b/examples/aws-lambda/scripts/_aws-isolation.sh new file mode 100755 index 000000000..36718d13a --- /dev/null +++ b/examples/aws-lambda/scripts/_aws-isolation.sh @@ -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 +} diff --git a/examples/aws-lambda/scripts/_s3-purge.sh b/examples/aws-lambda/scripts/_s3-purge.sh new file mode 100755 index 000000000..8b794434a --- /dev/null +++ b/examples/aws-lambda/scripts/_s3-purge.sh @@ -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" +} diff --git a/examples/aws-lambda/scripts/_semantic-compare.sh b/examples/aws-lambda/scripts/_semantic-compare.sh new file mode 100755 index 000000000..d90be6292 --- /dev/null +++ b/examples/aws-lambda/scripts/_semantic-compare.sh @@ -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 .* and a +# machine-readable .json. Returns 0 only for semantic equivalence. +hf_compare_render_semantics() { + local v1="$1" v2="$2" prefix="$3" + local v1_history="${4:-}" v2_history="${5:-}" + local tolerance="${SEMANTIC_DURATION_TOLERANCE_SECONDS:-0.001}" + local work + work=$(mktemp -d) + + local v1_frames="${prefix}.v1.framemd5" + local v2_frames="${prefix}.v2.framemd5" + local v1_meta="${prefix}.v1.ffprobe.json" + local v2_meta="${prefix}.v2.ffprobe.json" + local v1_duration v2_duration duration_delta + local v1_encoded_sha v2_encoded_sha encoded_equal + local video_equal metadata_equal duration_equal + local audio_state audio_equal v1_audio_sha="" v2_audio_sha="" + local v1_audio_bytes=0 v2_audio_bytes=0 + local encoded_gated=false + local chunks_checked=false chunks_equal=true v1_chunk_count=0 v2_chunk_count=0 + if [ "${REQUIRE_ENCODED_SHA_EQUAL:-false}" = true ]; then encoded_gated=true; fi + + if ! hf_canonical_video_framemd5 "$v1" "$v1_frames" || + ! hf_canonical_video_framemd5 "$v2" "$v2_frames" || + ! hf_normalized_ffprobe_metadata "$v1" "$v1_meta" || + ! hf_normalized_ffprobe_metadata "$v2" "$v2_meta"; then + rm -rf "$work" + return 2 + fi + + if cmp -s "$v1_frames" "$v2_frames"; then video_equal=true; else video_equal=false; fi + if cmp -s "$v1_meta" "$v2_meta"; then metadata_equal=true; else metadata_equal=false; fi + + if ! v1_duration=$(hf_duration_seconds "$v1") || + ! v2_duration=$(hf_duration_seconds "$v2"); then + rm -rf "$work" + return 2 + fi + duration_delta=$(awk -v a="$v1_duration" -v b="$v2_duration" \ + 'BEGIN { d=a-b; if (d<0) d=-d; printf("%.9f", d) }') + if awk -v d="$duration_delta" -v t="$tolerance" 'BEGIN { exit !(d <= t) }'; then + duration_equal=true + else + duration_equal=false + fi + + local v1_has_audio=false v2_has_audio=false + if hf_has_audio "$v1"; then v1_has_audio=true; fi + if hf_has_audio "$v2"; then v2_has_audio=true; fi + if [ "$v1_has_audio" = false ] && [ "$v2_has_audio" = false ]; then + audio_state="no-audio-on-either" + audio_equal=true + elif [ "$v1_has_audio" != "$v2_has_audio" ]; then + audio_state="audio-stream-mismatch" + audio_equal=false + else + audio_state="decoded-pcm" + if ! hf_decode_pcm "$v1" "$work/v1.pcm" || + ! hf_decode_pcm "$v2" "$work/v2.pcm"; then + rm -rf "$work" + return 2 + fi + v1_audio_sha=$(hf_sha256 "$work/v1.pcm") + v2_audio_sha=$(hf_sha256 "$work/v2.pcm") + v1_audio_bytes=$(wc -c < "$work/v1.pcm" | tr -d '[:space:]') + v2_audio_bytes=$(wc -c < "$work/v2.pcm" | tr -d '[:space:]') + if [ "$v1_audio_sha" = "$v2_audio_sha" ] && [ "$v1_audio_bytes" = "$v2_audio_bytes" ]; then + audio_equal=true + else + audio_equal=false + fi + fi + + v1_encoded_sha=$(hf_sha256 "$v1") + v2_encoded_sha=$(hf_sha256 "$v2") + if [ "$v1_encoded_sha" = "$v2_encoded_sha" ]; then encoded_equal=true; else encoded_equal=false; fi + + local semantic_equal=false + if [ "$video_equal" = true ] && + [ "$metadata_equal" = true ] && + [ "$duration_equal" = true ] && + [ "$audio_equal" = true ]; then + semantic_equal=true + fi + if [ "$encoded_gated" = true ] && [ "$encoded_equal" != true ]; then + semantic_equal=false + fi + + if [ -n "$v1_history" ] || [ -n "$v2_history" ]; then + chunks_checked=true + if [ -z "$v1_history" ] || [ -z "$v2_history" ] || + ! hf_extract_chunk_hashes "$v1_history" "${prefix}.v1.chunk-hashes.json" || + ! hf_extract_chunk_hashes "$v2_history" "${prefix}.v2.chunk-hashes.json"; then + rm -rf "$work" + return 2 + fi + v1_chunk_count=$(jq 'length' "${prefix}.v1.chunk-hashes.json") + v2_chunk_count=$(jq 'length' "${prefix}.v2.chunk-hashes.json") + if [ "$v1_chunk_count" -eq 0 ] || + [ "$v2_chunk_count" -eq 0 ] || + ! cmp -s "${prefix}.v1.chunk-hashes.json" "${prefix}.v2.chunk-hashes.json"; then + chunks_equal=false + semantic_equal=false + fi + fi + + jq -n \ + --arg v1 "$v1" --arg v2 "$v2" \ + --argjson semanticEqual "$semantic_equal" \ + --argjson videoEqual "$video_equal" \ + --argjson v1VideoFrameCount "$(wc -l < "$v1_frames" | tr -d '[:space:]')" \ + --argjson v2VideoFrameCount "$(wc -l < "$v2_frames" | tr -d '[:space:]')" \ + --argjson metadataEqual "$metadata_equal" \ + --arg v1Duration "$v1_duration" --arg v2Duration "$v2_duration" \ + --arg durationDelta "$duration_delta" --arg durationTolerance "$tolerance" \ + --arg audioState "$audio_state" --argjson audioEqual "$audio_equal" \ + --arg v1AudioSha256 "$v1_audio_sha" --arg v2AudioSha256 "$v2_audio_sha" \ + --argjson v1AudioBytes "$v1_audio_bytes" --argjson v2AudioBytes "$v2_audio_bytes" \ + --arg v1EncodedSha256 "$v1_encoded_sha" --arg v2EncodedSha256 "$v2_encoded_sha" \ + --argjson encodedShaEqual "$encoded_equal" \ + --argjson encodedShaGated "$encoded_gated" \ + --argjson chunksChecked "$chunks_checked" \ + --argjson chunksEqual "$chunks_equal" \ + --argjson v1ChunkCount "$v1_chunk_count" \ + --argjson v2ChunkCount "$v2_chunk_count" \ + '{ + v1: $v1, + v2: $v2, + semanticEqual: $semanticEqual, + video: { + equal: $videoEqual, + v1FrameCount: $v1VideoFrameCount, + v2FrameCount: $v2VideoFrameCount + }, + metadata: {equal: $metadataEqual}, + chunks: { + checked: $chunksChecked, + equal: $chunksEqual, + v1Count: $v1ChunkCount, + v2Count: $v2ChunkCount + }, + duration: { + v1Seconds: ($v1Duration | tonumber), + v2Seconds: ($v2Duration | tonumber), + deltaSeconds: ($durationDelta | tonumber), + toleranceSeconds: ($durationTolerance | tonumber), + equal: (($durationDelta | tonumber) <= ($durationTolerance | tonumber)) + }, + audio: { + state: $audioState, + equal: $audioEqual, + v1Sha256: $v1AudioSha256, + v2Sha256: $v2AudioSha256, + v1Bytes: $v1AudioBytes, + v2Bytes: $v2AudioBytes + }, + encoded: { + equal: $encodedShaEqual, + gated: $encodedShaGated, + v1Sha256: $v1EncodedSha256, + v2Sha256: $v2EncodedSha256 + } + }' > "${prefix}.json" + + rm -rf "$work" + [ "$semantic_equal" = true ] +} diff --git a/examples/aws-lambda/scripts/_smoke-config.sh b/examples/aws-lambda/scripts/_smoke-config.sh new file mode 100755 index 000000000..54f3e500a --- /dev/null +++ b/examples/aws-lambda/scripts/_smoke-config.sh @@ -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" +} diff --git a/examples/aws-lambda/scripts/aws-isolation.test.sh b/examples/aws-lambda/scripts/aws-isolation.test.sh new file mode 100755 index 000000000..5f132b7f3 --- /dev/null +++ b/examples/aws-lambda/scripts/aws-isolation.test.sh @@ -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" diff --git a/examples/aws-lambda/scripts/s3-purge.test.sh b/examples/aws-lambda/scripts/s3-purge.test.sh new file mode 100755 index 000000000..bfaf00bbd --- /dev/null +++ b/examples/aws-lambda/scripts/s3-purge.test.sh @@ -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" diff --git a/examples/aws-lambda/scripts/semantic-compare.test.sh b/examples/aws-lambda/scripts/semantic-compare.test.sh new file mode 100755 index 000000000..99de4c5f5 --- /dev/null +++ b/examples/aws-lambda/scripts/semantic-compare.test.sh @@ -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" diff --git a/examples/aws-lambda/scripts/smoke.sh b/examples/aws-lambda/scripts/smoke.sh index 2d0d0cf3a..d90d46f71 100755 --- a/examples/aws-lambda/scripts/smoke.sh +++ b/examples/aws-lambda/scripts/smoke.sh @@ -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 (default: mp4-h264-sdr) # --chunk-counts (default: 2,4,8) -# --psnr-threshold (default: 40) -# --stack-name (default: hyperframes-lambda-smoke-) +# --psnr-threshold (default: fixture meta.json minPsnr) +# --stack-name (default: hyperframes-lambda-smoke-) # --region (default: $AWS_REGION or us-east-1) # --profile (default: $AWS_PROFILE, otherwise the AWS # default profile resolution chain) +# --plan-protocol (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 fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr) --chunk-counts comma-separated chunk counts to benchmark (default: 2,4,8) - --psnr-threshold PSNR floor in dB for visual equivalence (default: 40) - --stack-name SAM stack name (default: hyperframes-lambda-smoke-) + --psnr-threshold PSNR floor (default: fixture meta.json minPsnr) + --stack-name SAM stack name (default: hyperframes-lambda-smoke-) --region AWS region (default: $AWS_REGION or us-east-1) --profile AWS profile (default: $AWS_PROFILE) + --plan-protocol plan transport(s) to compare (default: v1) --reserved-concurrency Lambda Map MaxConcurrency cap (default: 16) --keep-stack skip `sam delete` at the end (manual teardown later) + --require-encoded-sha-equal also gate byte-identical encoded MP4 output --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 \ - --stack-name "$STACK_NAME" \ - --no-prompts) >/dev/null 2>&1 || true + 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" \ + --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&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 diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml index 86921eb89..fb2a15973 100644 --- a/examples/aws-lambda/template.yaml +++ b/examples/aws-lambda/template.yaml @@ -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 diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index 492637d18..8f462ad19 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -37,7 +37,7 @@ smoke flow; the SDK + CDK are the supported public surface for adopters. │ pure functions over local paths ▼ ┌──────────────────────────────────────────────────────────────────┐ -│ S3 bucket — 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: diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index d9773e20b..2a189e2df 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -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", diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts index b1d08f853..e5ef15d74 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts @@ -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 = { // `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(); // 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 }; + ItemProcessor?: { States?: Record }; + } + | 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(); + 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(); + const samErrors = new Set(); + 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): void { @@ -171,3 +238,66 @@ function collectNonRetryableErrors(state: unknown, out: Set): void { } } } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`${label} must be an object`); + return value; +} + +function requireRecordProperty( + record: Record, + property: string, + label: string, +): Record { + return requireRecord(record[property], label); +} + +function getV2TaskStates(definition: { + States: Record; +}): 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 } { + 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"), + }; +} diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts index 86d626d3f..9cbbb1a2f 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -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); } } diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts index 738ef0188..e49da1d80 100644 --- a/packages/aws-lambda/src/events.ts +++ b/packages/aws-lambda/src/events.ts @@ -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"; diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts index c3d13ee73..f67314eb3 100644 --- a/packages/aws-lambda/src/handler.test.ts +++ b/packages/aws-lambda/src/handler.test.ts @@ -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(); + metadata = new Map>(); // 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 { 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 } }).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 => { + 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 => { + 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 => { + 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 { 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"), "aws v2 fixture"); + 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)); +} diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index f5bf8ed6f..62476255d 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -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): 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 { @@ -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 { + 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, + deps?: HandlerDeps, +): Promise> { + 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 { + 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, + deps?: HandlerDeps, +): Promise { + 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 { + 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, + deps?: HandlerDeps, +): Promise { + 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 { + 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, +): Promise { + 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( + values: readonly T[], + concurrency: number, + fn: (value: T) => Promise, +): Promise { + let cursor = 0; + async function worker(): Promise { + 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); } } diff --git a/packages/aws-lambda/src/index.ts b/packages/aws-lambda/src/index.ts index eba39a530..eb035249d 100644 --- a/packages/aws-lambda/src/index.ts +++ b/packages/aws-lambda/src/index.ts @@ -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"; diff --git a/packages/aws-lambda/src/s3Transport.test.ts b/packages/aws-lambda/src/s3Transport.test.ts index dcaa9c549..ea03aea09 100644 --- a/packages/aws-lambda/src/s3Transport.test.ts +++ b/packages/aws-lambda/src/s3Transport.test.ts @@ -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(); + 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 { + const value = command as { + constructor: { name: string }; + input: { + Bucket: string; + Key: string; + Body?: NodeJS.ReadableStream; + Metadata?: Record; + }; + }; + 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}`); + } +} diff --git a/packages/aws-lambda/src/s3Transport.ts b/packages/aws-lambda/src/s3Transport.ts index 0c74d378f..becfe3828 100644 --- a/packages/aws-lambda/src/s3Transport.ts +++ b/packages/aws-lambda/src/s3Transport.ts @@ -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 { + 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 { + 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 diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.test.ts b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts index fd572c88f..7f0982511 100644 --- a/packages/aws-lambda/src/sdk/getRenderProgress.test.ts +++ b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts @@ -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 })]]; diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.ts b/packages/aws-lambda/src/sdk/getRenderProgress.ts index 9442fc495..6d17736ab 100644 --- a/packages/aws-lambda/src/sdk/getRenderProgress.ts +++ b/packages/aws-lambda/src/sdk/getRenderProgress.ts @@ -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; 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 diff --git a/packages/aws-lambda/src/sdk/renderToLambda.test.ts b/packages/aws-lambda/src/sdk/renderToLambda.test.ts index 8beb409ec..9410936b9 100644 --- a/packages/aws-lambda/src/sdk/renderToLambda.test.ts +++ b/packages/aws-lambda/src/sdk/renderToLambda.test.ts @@ -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(); diff --git a/packages/aws-lambda/src/sdk/renderToLambda.ts b/packages/aws-lambda/src/sdk/renderToLambda.ts index 7b96f2e07..5e83482b0 100644 --- a/packages/aws-lambda/src/sdk/renderToLambda.ts +++ b/packages/aws-lambda/src/sdk/renderToLambda.ts @@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto"; import { 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 + + + + + Plan protocol size pressure + + + +
+ +
+
+ + + diff --git a/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json b/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json new file mode 100644 index 000000000..4dca04ada --- /dev/null +++ b/packages/producer/fixtures/plan-parity-visual-audio/.plan-parity-fixture.json @@ -0,0 +1,8 @@ +{ + "generatedAudio": { + "path": "assets/tone.wav", + "durationSeconds": 2, + "frequencyHz": 440, + "sampleRate": 48000 + } +} diff --git a/packages/producer/fixtures/plan-parity-visual-audio/index.html b/packages/producer/fixtures/plan-parity-visual-audio/index.html new file mode 100644 index 000000000..68415de23 --- /dev/null +++ b/packages/producer/fixtures/plan-parity-visual-audio/index.html @@ -0,0 +1,78 @@ + + + + + + Plan protocol visual and audio parity + + + +
+ +
+
+
+
+
+
+
+ + + diff --git a/packages/producer/package.json b/packages/producer/package.json index a493e8cdd..5ecd684e3 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -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\"", diff --git a/packages/producer/src/plan-parity-analysis.test.ts b/packages/producer/src/plan-parity-analysis.test.ts new file mode 100644 index 000000000..0b3a17f43 --- /dev/null +++ b/packages/producer/src/plan-parity-analysis.test.ts @@ -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, + }); + }); +}); diff --git a/packages/producer/src/plan-parity-analysis.ts b/packages/producer/src/plan-parity-analysis.ts new file mode 100644 index 000000000..3ab31650d --- /dev/null +++ b/packages/producer/src/plan-parity-analysis.ts @@ -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; + +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 { + 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 { + const output = await hashPath(result.outputPath); + const metadata = probeMetadata(result.outputPath); + const [pcmAudio, frameSha256] = await Promise.all([ + metadata.audio ? canonicalPcmAudio(result.outputPath) : Promise.resolve(null), + Promise.resolve(canonicalFrameHashes(result.outputPath)), + ]); + + const chunks = []; + for (const chunk of [...result.chunks].sort((left, right) => left.index - right.index)) { + const digest = await hashPath(chunk.path); + if ( + chunk.reportedSha256 !== undefined && + chunk.reportedSha256.toLowerCase() !== digest.sha256 + ) { + throw new Error( + `chunk ${chunk.index} digest mismatch: adapter reported ${chunk.reportedSha256}, measured ${digest.sha256}`, + ); + } + chunks.push({ + index: chunk.index, + sha256: digest.sha256, + bytes: digest.bytes, + reportedSha256: chunk.reportedSha256, + }); + } + const downloaded = result.transferBytes.downloaded; + const uploaded = result.transferBytes.uploaded; + + return { + protocol: result.protocol, + driver: driverName, + media: { + outputSha256: output.sha256, + outputBytes: output.bytes, + frameSha256, + pcmAudio, + metadata, + }, + chunks, + transferBytes: { + downloaded, + uploaded, + total: downloaded + uploaded, + }, + peakMaterializedBytes: result.peakMaterializedBytes, + }; +} diff --git a/packages/producer/src/plan-parity-contract.test.ts b/packages/producer/src/plan-parity-contract.test.ts new file mode 100644 index 000000000..516c3ae7d --- /dev/null +++ b/packages/producer/src/plan-parity-contract.test.ts @@ -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"); + }); +}); diff --git a/packages/producer/src/plan-parity-contract.ts b/packages/producer/src/plan-parity-contract.ts new file mode 100644 index 000000000..0b783a114 --- /dev/null +++ b/packages/producer/src/plan-parity-contract.ts @@ -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; +} + +export interface PlanParityStreamMetadata { + video: { + codecName: string | null; + width: number; + height: number; + pixelFormat: string | null; + averageFrameRate: string | null; + realFrameRate: string | null; + frameCount: number | null; + colorSpace: string | null; + colorTransfer: string | null; + colorPrimaries: string | null; + } | null; + audio: { + codecName: string | null; + sampleRate: number | null; + channels: number | null; + channelLayout: string | null; + } | null; + durationSeconds: number; +} + +export interface PlanParityMediaMeasurement { + outputSha256: string; + outputBytes: number; + frameSha256: string[]; + pcmAudio: { + sha256: string; + /** + * Interleaved audio frames after canonical decoding to signed 16-bit, + * 48 kHz, stereo PCM. One sample frame contains two channel samples. + */ + sampleCount: number; + bytes: number; + } | null; + metadata: PlanParityStreamMetadata; +} + +export interface PlanParityChunkMeasurement { + index: number; + sha256: string; + bytes: number; + reportedSha256?: string; +} + +export interface PlanParityMeasurement { + protocol: PlanParityProtocol; + driver: string; + media: PlanParityMediaMeasurement; + chunks: PlanParityChunkMeasurement[]; + transferBytes: { + downloaded: number; + uploaded: number; + total: number; + }; + peakMaterializedBytes: number; +} + +export interface PlanParityComparisonOptions { + /** ffprobe duration tolerance. Defaults to 1 ms. */ + durationToleranceSeconds?: number; + /** + * Encoded containers can contain non-semantic metadata. Default false: + * report output digest/size without requiring byte-identical containers. + */ + requireEncodedOutputEquality?: boolean; + /** Optional ceiling applied independently to the v2 run. */ + maxV2TransferBytes?: number; + /** Optional ceiling applied independently to the v2 run. */ + maxV2PeakMaterializedBytes?: number; +} + +export interface PlanParityCheck { + name: string; + passed: boolean; + detail: string; +} + +export interface PlanParityComparison { + passed: boolean; + checks: PlanParityCheck[]; + v1: PlanParityMeasurement; + v2: PlanParityMeasurement; +} + +function check(name: string, passed: boolean, detail: string): PlanParityCheck { + return { name, passed, detail }; +} + +function equalJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function comparableMetadata( + metadata: PlanParityStreamMetadata, +): Omit { + return { + video: metadata.video, + audio: metadata.audio, + }; +} + +/** + * Compare semantic render output plus transport/resource measurements. + * + * `outputBytes`, transfer bytes, and working-set bytes are always surfaced. + * They are not equality gates by default: v2 is expected to change artifact + * packaging, and encoded containers may carry non-semantic differences. + */ +// This function is intentionally an exhaustive, flat contract checklist. Each +// branch emits a distinct diagnostic needed to root-cause parity failures. +// fallow-ignore-next-line complexity +export function comparePlanParityMeasurements( + v1: PlanParityMeasurement, + v2: PlanParityMeasurement, + options: PlanParityComparisonOptions = {}, +): PlanParityComparison { + if (v1.protocol !== "v1" || v2.protocol !== "v2") { + throw new Error( + `plan parity requires ordered v1/v2 measurements (got ${v1.protocol}/${v2.protocol})`, + ); + } + + const durationToleranceSeconds = options.durationToleranceSeconds ?? 0.001; + const durationDelta = Math.abs( + v1.media.metadata.durationSeconds - v2.media.metadata.durationSeconds, + ); + const checks: PlanParityCheck[] = [ + check( + "decoded-video-frames", + equalJson(v1.media.frameSha256, v2.media.frameSha256), + `v1=${v1.media.frameSha256.length} frames, v2=${v2.media.frameSha256.length} frames`, + ), + check( + "canonical-pcm-audio", + equalJson(v1.media.pcmAudio, v2.media.pcmAudio), + v1.media.pcmAudio && v2.media.pcmAudio + ? `v1=${v1.media.pcmAudio.sampleCount} samples, v2=${v2.media.pcmAudio.sampleCount} samples` + : `v1=${v1.media.pcmAudio ? "present" : "none"}, v2=${v2.media.pcmAudio ? "present" : "none"}`, + ), + check( + "ffprobe-stream-metadata", + equalJson(comparableMetadata(v1.media.metadata), comparableMetadata(v2.media.metadata)), + "normalized video/audio stream metadata", + ), + check( + "ffprobe-duration", + durationDelta <= durationToleranceSeconds, + `delta=${durationDelta.toFixed(6)}s, tolerance=${durationToleranceSeconds.toFixed(6)}s`, + ), + check( + "chunk-hashes", + equalJson( + v1.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })), + v2.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })), + ), + `v1=${v1.chunks.length} chunks/${v1.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes, ` + + `v2=${v2.chunks.length} chunks/${v2.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes`, + ), + check( + "encoded-output", + options.requireEncodedOutputEquality !== true || + (v1.media.outputSha256 === v2.media.outputSha256 && + v1.media.outputBytes === v2.media.outputBytes), + `v1=${v1.media.outputBytes} bytes/${v1.media.outputSha256}, ` + + `v2=${v2.media.outputBytes} bytes/${v2.media.outputSha256}` + + (options.requireEncodedOutputEquality === true ? " (strict)" : " (reported)"), + ), + check( + "transfer-bytes", + options.maxV2TransferBytes === undefined || + v2.transferBytes.total <= options.maxV2TransferBytes, + `v1=${v1.transferBytes.total} bytes, v2=${v2.transferBytes.total} bytes` + + (options.maxV2TransferBytes === undefined + ? " (reported)" + : `, v2 limit=${options.maxV2TransferBytes}`), + ), + check( + "peak-materialized-working-set", + options.maxV2PeakMaterializedBytes === undefined || + v2.peakMaterializedBytes <= options.maxV2PeakMaterializedBytes, + `v1=${v1.peakMaterializedBytes} bytes, v2=${v2.peakMaterializedBytes} bytes` + + (options.maxV2PeakMaterializedBytes === undefined + ? " (reported)" + : `, v2 limit=${options.maxV2PeakMaterializedBytes}`), + ), + ]; + + return { + passed: checks.every((entry) => entry.passed), + checks, + v1, + v2, + }; +} diff --git a/packages/producer/src/plan-parity-fixture.test.ts b/packages/producer/src/plan-parity-fixture.test.ts new file mode 100644 index 000000000..1d9ae94ce --- /dev/null +++ b/packages/producer/src/plan-parity-fixture.test.ts @@ -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", + ); + }); +}); diff --git a/packages/producer/src/plan-parity-fixture.ts b/packages/producer/src/plan-parity-fixture.ts new file mode 100644 index 000000000..35687861c --- /dev/null +++ b/packages/producer/src/plan-parity-fixture.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function positiveNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a positive number`); + } + return value; +} + +function relativePath(record: Record, key: string): string { + const value = record[key]; + if ( + typeof value !== "string" || + value.length === 0 || + value.startsWith("/") || + value.split(/[\\/]/u).includes("..") + ) { + throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a safe relative path`); + } + return value; +} + +function parseGeneratedAudio(value: unknown): GeneratedAudioConfig | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error(`${FIXTURE_CONFIG_FILE}: generatedAudio must be an object`); + return { + path: relativePath(value, "path"), + durationSeconds: positiveNumber(value, "durationSeconds"), + frequencyHz: positiveNumber(value, "frequencyHz"), + sampleRate: positiveNumber(value, "sampleRate"), + }; +} + +function parsePressureFile(value: unknown): GeneratedPressureFileConfig | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) { + throw new Error(`${FIXTURE_CONFIG_FILE}: generatedPressureFile must be an object`); + } + const bytes = positiveNumber(value, "bytes"); + const seed = positiveNumber(value, "seed"); + if (!Number.isInteger(bytes) || !Number.isInteger(seed)) { + throw new Error(`${FIXTURE_CONFIG_FILE}: pressure bytes and seed must be integers`); + } + return { + path: relativePath(value, "path"), + bytes, + seed, + }; +} + +function readFixtureConfig(projectDir: string): PlanParityFixtureConfig { + const configPath = join(projectDir, FIXTURE_CONFIG_FILE); + if (!existsSync(configPath)) return {}; + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + if (!isRecord(raw)) throw new Error(`${FIXTURE_CONFIG_FILE}: root must be an object`); + return { + generatedAudio: parseGeneratedAudio(raw.generatedAudio), + generatedPressureFile: parsePressureFile(raw.generatedPressureFile), + }; +} + +function writeAscii(buffer: Buffer, offset: number, value: string): void { + buffer.write(value, offset, value.length, "ascii"); +} + +/** Generate a deterministic mono PCM WAV without relying on ffmpeg. */ +export function generateToneWav(config: Omit): Buffer { + const sampleCount = Math.round(config.durationSeconds * config.sampleRate); + const pcmBytes = sampleCount * 2; + const wav = Buffer.alloc(44 + pcmBytes); + writeAscii(wav, 0, "RIFF"); + wav.writeUInt32LE(36 + pcmBytes, 4); + writeAscii(wav, 8, "WAVE"); + writeAscii(wav, 12, "fmt "); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(config.sampleRate, 24); + wav.writeUInt32LE(config.sampleRate * 2, 28); + wav.writeUInt16LE(2, 32); + wav.writeUInt16LE(16, 34); + writeAscii(wav, 36, "data"); + wav.writeUInt32LE(pcmBytes, 40); + for (let sample = 0; sample < sampleCount; sample += 1) { + const phase = (2 * Math.PI * config.frequencyHz * sample) / config.sampleRate; + const value = Math.round(Math.sin(phase) * 0.25 * 32767); + wav.writeInt16LE(value, 44 + sample * 2); + } + return wav; +} + +/** Deterministic xorshift bytes that do not collapse into a tiny gzip. */ +export function generatePressureBytes(bytes: number, seed: number): Buffer { + const output = Buffer.alloc(bytes); + let state = seed >>> 0; + for (let index = 0; index < bytes; index += 1) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + output[index] = state & 0xff; + } + return output; +} + +/** + * Copy a checked-in fixture into a driver-owned project directory and + * materialize its generated binary assets. Source fixtures stay small and + * reviewable; size-pressure tests can vary their v1 cap without allocating + * multi-gigabyte files. + */ +export function preparePlanParityFixture(sourceDir: string, projectDir: string): void { + mkdirSync(projectDir, { recursive: true }); + cpSync(sourceDir, projectDir, { recursive: true }); + const config = readFixtureConfig(projectDir); + + if (config.generatedAudio) { + const target = join(projectDir, config.generatedAudio.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, generateToneWav(config.generatedAudio)); + } + if (config.generatedPressureFile) { + const target = join(projectDir, config.generatedPressureFile.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync( + target, + generatePressureBytes(config.generatedPressureFile.bytes, config.generatedPressureFile.seed), + ); + } +} diff --git a/packages/producer/src/plan-parity-harness.test.ts b/packages/producer/src/plan-parity-harness.test.ts new file mode 100644 index 000000000..aae449507 --- /dev/null +++ b/packages/producer/src/plan-parity-harness.test.ts @@ -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"), "", "utf-8"); + const calls: string[] = []; + const driver: PlanParityDriver = { + name: "fake-lambda-local", + async render(input) { + calls.push(input.protocol); + if (input.protocol === "v1") { + const error = new Error("synthetic cap exceeded") as Error & { + code: "PLAN_TOO_LARGE"; + sizeBytes: number; + limitBytes: number; + }; + error.code = "PLAN_TOO_LARGE"; + error.sizeBytes = 65_536; + error.limitBytes = 32_768; + throw error; + } + return { + protocol: "v2", + outputPath: join(input.outputDir, "output.mp4"), + chunks: [], + transferBytes: { downloaded: 10, uploaded: 20 }, + peakMaterializedBytes: 40, + }; + }, + }; + + const report = await runPlanV2SizePressure({ + fixtureDir, + artifactsDir, + driver, + renderConfig: { + fps: 30, + width: 320, + height: 180, + format: "mp4", + }, + v1PlanSizeCapBytes: 32_768, + analyzeDriverResult: async () => fakeV2Measurement(), + }); + + expect(calls).toEqual(["v1", "v2"]); + expect(report.passed).toBe(true); + expect(report.v1).toEqual({ + status: "expected-failure", + code: "PLAN_TOO_LARGE", + message: "synthetic cap exceeded", + sizeBytes: 65_536, + limitBytes: 32_768, + }); + expect(report.v2.status).toBe("success"); + const written = JSON.parse( + readFileSync(join(artifactsDir, "plan-too-large-v2-report.json"), "utf-8"), + ) as { + passed: boolean; + v1: { code: string }; + v2: { status: string }; + }; + expect(written).toMatchObject({ + passed: true, + v1: { code: "PLAN_TOO_LARGE" }, + v2: { status: "success" }, + }); + }); + + it("distinguishes non-PLAN_TOO_LARGE failures", () => { + const error = new Error("network down") as Error & { code: string }; + error.code = "S3_UNAVAILABLE"; + expect(classifyPlanTooLargeProbeFailure(error)).toEqual({ + status: "unexpected-failure", + code: "S3_UNAVAILABLE", + message: "network down", + }); + }); +}); diff --git a/packages/producer/src/plan-parity-harness.ts b/packages/producer/src/plan-parity-harness.ts new file mode 100644 index 000000000..12a7cda80 --- /dev/null +++ b/packages/producer/src/plan-parity-harness.ts @@ -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; +} + +export type PlanTooLargeProbeOutcome = + | { + status: "expected-failure"; + code: "PLAN_TOO_LARGE"; + message: string; + sizeBytes: number | null; + limitBytes: number | null; + } + | { + status: "unexpected-success"; + message: string; + } + | { + status: "unexpected-failure"; + code: string | null; + message: string; + }; + +export type PlanV2PressureOutcome = + | { + status: "success"; + measurement: PlanParityMeasurement; + } + | { + status: "failure"; + message: string; + }; + +export interface PlanV2SizePressureReport { + passed: boolean; + checks: Array<{ + name: "v1-plan-too-large" | "v2-render-success"; + passed: boolean; + detail: string; + }>; + v1: PlanTooLargeProbeOutcome; + v2: PlanV2PressureOutcome; +} + +function parsePositiveInteger(name: string, value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`plan parity: --${name} must be a positive integer`); + } + return parsed; +} + +function parseNonNegativeNumber(name: string, value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`plan parity: --${name} must be a non-negative finite number`); + } + return parsed; +} + +function parseBoolean(name: string, value: string | undefined): boolean { + if (value === undefined || value === "true") return true; + if (value === "false") return false; + throw new Error(`plan parity: --${name} must be true or false`); +} + +function parseTarget(name: string, value: string | undefined): PlanParityTarget { + const target = value ?? "lambda-local"; + if (target === "lambda-local") return target; + if (target.startsWith("aws:") && target.length > "aws:".length) { + return target as `aws:${string}`; + } + throw new Error( + `plan parity: --${name} must be lambda-local or aws: (got ${JSON.stringify(target)})`, + ); +} + +function collectArgs(argv: string[]): Map { + const args = new Map(); + for (let index = 2; index < argv.length; index += 1) { + const token = argv[index]; + if (!token?.startsWith("--")) { + throw new Error(`plan parity: unexpected positional argument ${JSON.stringify(token)}`); + } + const equals = token.indexOf("="); + if (equals > 2) { + args.set(token.slice(2, equals), token.slice(equals + 1)); + continue; + } + const key = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args.set(key, "true"); + continue; + } + args.set(key, next); + index += 1; + } + return args; +} + +export function parsePlanParityArgs(argv: string[]): PlanParityCliOptions { + const args = collectArgs(argv); + const fixtureDir = resolve(args.get("fixture") ?? "fixtures/plan-parity-visual-audio"); + const artifactsDir = resolve(args.get("artifacts-dir") ?? ".debug/plan-protocol-parity"); + const fps = parsePositiveInteger("fps", args.get("fps")) ?? 30; + if (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 { + return typeof value === "object" && value !== null; +} + +function errorCode(error: unknown): string | null { + if (isRecord(error) && typeof error.code === "string") { + return error.code; + } + return null; +} + +function errorNumber(error: unknown, field: "sizeBytes" | "limitBytes"): number | null { + if (isRecord(error)) { + const value = error[field]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +export function classifyPlanTooLargeProbeFailure(error: unknown): PlanTooLargeProbeOutcome { + const code = errorCode(error); + if (code === "PLAN_TOO_LARGE") { + return { + status: "expected-failure", + code, + message: errorMessage(error), + sizeBytes: errorNumber(error, "sizeBytes"), + limitBytes: errorNumber(error, "limitBytes"), + }; + } + return { + status: "unexpected-failure", + code, + message: errorMessage(error), + }; +} + +/** + * Prove the migration boundary directly: + * + * 1. explicit v1 with a deliberately low cap must fail PLAN_TOO_LARGE; + * 2. explicit v2 must still run to completion for the same prepared fixture. + * + * Unlike semantic parity mode, this API does not compare v1/v2 output because + * the expected v1 run never produces output. Its durable report records both + * the typed failure and the fully analyzed v2 success. + */ +export async function runPlanV2SizePressure( + options: RunPlanV2SizePressureOptions, +): Promise { + if (!existsSync(options.fixtureDir)) { + throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`); + } + mkdirSync(options.artifactsDir, { recursive: true }); + const v1ProjectDir = resolve(options.artifactsDir, "v1-low-cap", "project"); + const v2ProjectDir = resolve(options.artifactsDir, "v2", "project"); + preparePlanParityFixture(options.fixtureDir, v1ProjectDir); + preparePlanParityFixture(options.fixtureDir, v2ProjectDir); + + let v1: PlanTooLargeProbeOutcome; + try { + await options.driver.render({ + protocol: "v1", + projectDir: v1ProjectDir, + outputDir: resolve(options.artifactsDir, "v1-low-cap", "run"), + renderConfig: options.renderConfig, + planSizeCapBytes: options.v1PlanSizeCapBytes, + }); + v1 = { + status: "unexpected-success", + message: `v1 rendered despite plan cap ${options.v1PlanSizeCapBytes}`, + }; + } catch (error) { + v1 = classifyPlanTooLargeProbeFailure(error); + } + + let v2: PlanV2PressureOutcome; + try { + const result = await options.driver.render({ + protocol: "v2", + projectDir: v2ProjectDir, + outputDir: resolve(options.artifactsDir, "v2", "run"), + renderConfig: options.renderConfig, + }); + const analyze = options.analyzeDriverResult ?? analyzePlanParityDriverResult; + v2 = { + status: "success", + measurement: await analyze(options.driver.name, result), + }; + } catch (error) { + v2 = { + status: "failure", + message: errorMessage(error), + }; + } + + const checks: PlanV2SizePressureReport["checks"] = [ + { + name: "v1-plan-too-large", + passed: v1.status === "expected-failure", + detail: + v1.status === "expected-failure" + ? `PLAN_TOO_LARGE size=${v1.sizeBytes ?? "unknown"} limit=${v1.limitBytes ?? "unknown"}` + : v1.message, + }, + { + name: "v2-render-success", + passed: v2.status === "success", + detail: + v2.status === "success" + ? `${v2.measurement.media.frameSha256.length} frames, ${v2.measurement.media.outputBytes} output bytes` + : v2.message, + }, + ]; + const report: PlanV2SizePressureReport = { + passed: checks.every((entry) => entry.passed), + checks, + v1, + v2, + }; + writeFileSync( + resolve(options.artifactsDir, "plan-too-large-v2-report.json"), + `${JSON.stringify(report, null, 2)}\n`, + "utf-8", + ); + return report; +} + +/** + * Run the same prepared project through explicit v1 and v2 drivers, analyze + * their artifacts, and persist a machine-readable comparison report. + */ +export async function runPlanProtocolParity( + options: RunPlanProtocolParityOptions, +): Promise { + if (!existsSync(options.fixtureDir)) { + throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`); + } + mkdirSync(options.artifactsDir, { recursive: true }); + + const v1ProjectDir = resolve(options.artifactsDir, "v1", "project"); + const v2ProjectDir = resolve(options.artifactsDir, "v2", "project"); + preparePlanParityFixture(options.fixtureDir, v1ProjectDir); + preparePlanParityFixture(options.fixtureDir, v2ProjectDir); + + const v1Result = await options.v1Driver.render({ + protocol: "v1", + projectDir: v1ProjectDir, + outputDir: resolve(options.artifactsDir, "v1", "run"), + renderConfig: options.renderConfig, + planSizeCapBytes: options.v1PlanSizeCapBytes, + }); + const v2Result = await options.v2Driver.render({ + protocol: "v2", + projectDir: v2ProjectDir, + outputDir: resolve(options.artifactsDir, "v2", "run"), + renderConfig: options.renderConfig, + }); + const [v1, v2] = await Promise.all([ + analyzePlanParityDriverResult(options.v1Driver.name, v1Result), + analyzePlanParityDriverResult(options.v2Driver.name, v2Result), + ]); + const comparison = comparePlanParityMeasurements(v1, v2, options.comparison); + writeFileSync( + resolve(options.artifactsDir, "comparison.json"), + `${JSON.stringify(comparison, null, 2)}\n`, + "utf-8", + ); + return comparison; +} + +interface LambdaLocalDriverModule { + createLambdaLocalPlanParityDriver(): PlanParityDriver; +} + +function isLambdaLocalDriverModule(value: unknown): value is LambdaLocalDriverModule { + return ( + typeof value === "object" && + value !== null && + "createLambdaLocalPlanParityDriver" in value && + typeof value.createLambdaLocalPlanParityDriver === "function" + ); +} + +async function loadDriver(target: PlanParityTarget): Promise { + if (target.startsWith("aws:")) { + throw new Error( + `plan parity target ${target} requires the deployed-AWS driver package; ` + + "use the library API to inject that driver until it is installed", + ); + } + // Indirect path keeps producer's build from pulling @hyperframes/aws-lambda + // into its declaration emit before that workspace package has built. + const modulePath = "./plan-parity-lambda-local-driver.js"; + const loaded: unknown = await import(modulePath); + if (!isLambdaLocalDriverModule(loaded)) { + throw new Error("lambda-local parity driver module has an invalid shape"); + } + return loaded.createLambdaLocalPlanParityDriver(); +} + +async function main(): Promise { + const options = parsePlanParityArgs(process.argv); + if (options.expectV1PlanTooLarge) { + if (options.v1PlanSizeCapBytes === undefined) { + throw new Error("plan parity: --expect-v1-plan-too-large requires --v1-plan-size-cap-bytes"); + } + if (options.v1Target !== options.v2Target) { + throw new Error( + "plan parity: pressure mode requires the same target for its v1 probe and v2 proof", + ); + } + const report = await runPlanV2SizePressure({ + fixtureDir: options.fixtureDir, + artifactsDir: options.artifactsDir, + driver: await loadDriver(options.v1Target), + renderConfig: options.renderConfig, + v1PlanSizeCapBytes: options.v1PlanSizeCapBytes, + }); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (!report.passed) process.exitCode = 1; + return; + } + const comparison = await runPlanProtocolParity({ + fixtureDir: options.fixtureDir, + artifactsDir: options.artifactsDir, + v1Driver: await loadDriver(options.v1Target), + v2Driver: await loadDriver(options.v2Target), + renderConfig: options.renderConfig, + v1PlanSizeCapBytes: options.v1PlanSizeCapBytes, + comparison: options.comparison, + }); + process.stdout.write(`${JSON.stringify(comparison, null, 2)}\n`); + if (!comparison.passed) process.exitCode = 1; +} + +const isDirectRun = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname); +if (isDirectRun) { + await main(); +} diff --git a/packages/producer/src/plan-parity-lambda-local-driver.ts b/packages/producer/src/plan-parity-lambda-local-driver.ts new file mode 100644 index 000000000..62a4038f0 --- /dev/null +++ b/packages/producer/src/plan-parity-lambda-local-driver.ts @@ -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, + }); + }, + }; +} diff --git a/packages/producer/src/regression-harness-lambda-local-types.ts b/packages/producer/src/regression-harness-lambda-local-types.ts index 528bfb9d4..157954715 100644 --- a/packages/producer/src/regression-harness-lambda-local-types.ts +++ b/packages/producer/src/regression-harness-lambda-local-types.ts @@ -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; } +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; +export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise; diff --git a/packages/producer/src/regression-harness-lambda-local.ts b/packages/producer/src/regression-harness-lambda-local.ts index caf124a43..88a2da412 100644 --- a/packages/producer/src/regression-harness-lambda-local.ts +++ b/packages/producer/src/regression-harness-lambda-local.ts @@ -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 { +export async function runLambdaLocalRender( + input: RunLambdaLocalInput, +): Promise { + 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 (operation: () => Promise): Promise => { + 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 = { - Action: "plan", - ProjectS3Uri: uri(projectKey), - PlanOutputS3Prefix: uri(planPrefix), - Config: config, - }; - const planResult = (await handler(planEvent, deps)) as PlanLambdaResult; + 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 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 { + if (!("PlanS3Uri" in result)) { + throw new Error("lambda-local: v1 plan returned v2 locators"); + } + return result; +} + +function requireV2PlanResult( + result: PlanLambdaResult, +): Extract { + if (!("PlanProtocol" in result) || result.PlanProtocol !== "v2") { + throw new Error("lambda-local: v2 plan did not return explicit v2 locators"); + } + return result; +} + +// The recursive walk is the measurement itself; extracting its two filesystem +// branches would make this small test-harness utility harder to audit. +// fallow-ignore-next-line complexity +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>(); + constructor(private readonly root: string) {} + get transferBytes(): { downloaded: number; uploaded: number } { + return { + downloaded: this.downloadedBytes, + uploaded: this.uploadedBytes, + }; + } + async send(command: unknown): Promise { 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 } }).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}`); } diff --git a/packages/producer/tsconfig.json b/packages/producer/tsconfig.json index 4e876a30f..f63915cad 100644 --- a/packages/producer/tsconfig.json +++ b/packages/producer/tsconfig.json @@ -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" ] }