mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(aws-lambda): support plan protocol v2 (#2789)
* feat(aws-lambda): support plan protocol v2 * fix(aws-lambda): align SAM v2 terminal errors
This commit is contained in:
@@ -397,6 +397,12 @@
|
|||||||
// require intrusive middleware changes beyond this PR's scope.
|
// require intrusive middleware changes beyond this PR's scope.
|
||||||
"minLines": 6,
|
"minLines": 6,
|
||||||
"ignore": [
|
"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
|
// sourcePatcher.ts: pre-existing internal clones between the inline-style
|
||||||
// and attribute tag-patchers; only the PatchOperation type gained two
|
// and attribute tag-patchers; only the PatchOperation type gained two
|
||||||
// optional fields here, but the line shift makes fallow re-flag them.
|
// optional fields here, but the line shift makes fallow re-flag them.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
"yaml": "^2.9.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"aws-cdk-lib": "^2.130.0",
|
"aws-cdk-lib": "^2.130.0",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ aws stepfunctions start-execution \
|
|||||||
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
|
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
|
||||||
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
|
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
|
||||||
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
|
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
|
||||||
|
"PlanProtocol": "v1",
|
||||||
"Config": {
|
"Config": {
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
@@ -91,6 +92,9 @@ EOF
|
|||||||
|
|
||||||
The Step Functions execution kicks off Plan, fans out RenderChunk via
|
The Step Functions execution kicks off Plan, fans out RenderChunk via
|
||||||
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
|
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
|
||||||
|
`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
|
## Local invocation
|
||||||
|
|
||||||
@@ -119,13 +123,14 @@ the architecture works on a deployed Lambda — use the local smoke
|
|||||||
script:
|
script:
|
||||||
|
|
||||||
```bash
|
```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
|
./scripts/smoke.sh
|
||||||
|
|
||||||
# Customised:
|
# Customised:
|
||||||
./scripts/smoke.sh \
|
./scripts/smoke.sh \
|
||||||
--fixture mp4-h264-sdr \
|
--fixture mp4-h264-sdr \
|
||||||
--chunk-counts 2,4,8,16 \
|
--chunk-counts 2,4,8,16 \
|
||||||
|
--plan-protocol both \
|
||||||
--psnr-threshold 40 \
|
--psnr-threshold 40 \
|
||||||
--reserved-concurrency 8
|
--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
|
Step Functions state machine, PSNR-compares against the in-process
|
||||||
baseline (which is git-LFS tracked under
|
baseline (which is git-LFS tracked under
|
||||||
`packages/producer/tests/distributed/<fixture>/output/`), captures
|
`packages/producer/tests/distributed/<fixture>/output/`), captures
|
||||||
per-execution Step Functions history, and tears the stack down.
|
per-execution Step Functions history, and tears the stack down. Use
|
||||||
|
`--plan-protocol both` to run v1 and v2 through the same deployed Lambda
|
||||||
|
package and baseline. Each v1/v2 pair is also gated directly on per-chunk
|
||||||
|
hashes from Step Functions history, normalized decoded RGBA frame hashes,
|
||||||
|
decoded 48 kHz stereo s16le PCM hashes and byte counts, normalized stream
|
||||||
|
metadata, and duration. Encoded MP4 SHA equality is reported but is
|
||||||
|
informational unless `--require-encoded-sha-equal` is set. The script
|
||||||
|
assigns unique function/state-machine names, uses a
|
||||||
|
dedicated temporary SAM artifact bucket, and removes render objects,
|
||||||
|
retained buckets, the implicit Lambda log group, and deployment artifacts
|
||||||
|
on teardown. Suspended-version buckets are purged in 1,000-entry batches,
|
||||||
|
including concrete versions, null versions, and delete markers. It then
|
||||||
|
verifies that the stack, both buckets, Lambda, state-machine, and both log
|
||||||
|
groups are absent; an otherwise-successful run fails if cleanup cannot be
|
||||||
|
proven.
|
||||||
|
|
||||||
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
|
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
|
||||||
local-vs-Lambda "speedup" column. The local timing includes `bun` +
|
local-vs-Lambda "speedup" column. The local timing includes `bun` +
|
||||||
@@ -162,9 +181,11 @@ spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
|
|||||||
|
|
||||||
Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
|
Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
|
||||||
|
|
||||||
- `results.json` — `chunkCount × wallClockMs × psnrAvgDb`
|
- `results.json` — `planProtocol × chunkCount × wallClockMs × psnrAvgDb`
|
||||||
- `renders/N<N>-output.mp4` — each rendered chunk count
|
- `semantic-comparisons.json` — direct v1/v2 semantic gate results
|
||||||
- `renders/N<N>-history.json` — full Step Functions execution history
|
- `renders/<protocol>-N<N>-output.mp4` — each rendered variant
|
||||||
|
- `renders/<protocol>-N<N>-history.json` — full Step Functions execution history
|
||||||
|
- `renders/v1-v2-N<N>.*` — normalized frame hashes, ffprobe metadata, and comparison JSON
|
||||||
|
|
||||||
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
|
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
|
||||||
`jq`, `zip`. AWS credentials come from the standard resolution chain
|
`jq`, `zip`. AWS credentials come from the standard resolution chain
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Action": "assemble",
|
||||||
|
"PlanProtocol": "v2",
|
||||||
|
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
|
||||||
|
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
|
||||||
|
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"ChunkS3Uris": ["s3://example-bucket/renders/sample/chunks/0000.mp4"],
|
||||||
|
"AudioS3Uri": null,
|
||||||
|
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
|
||||||
|
"Format": "mp4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"Action": "plan",
|
||||||
|
"PlanProtocol": "v2",
|
||||||
|
"ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
|
||||||
|
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
|
||||||
|
"Config": {
|
||||||
|
"fps": 30,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"format": "mp4",
|
||||||
|
"chunkSize": 240,
|
||||||
|
"maxParallelChunks": 8,
|
||||||
|
"runtimeCap": "lambda"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Action": "renderChunk",
|
||||||
|
"PlanProtocol": "v2",
|
||||||
|
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
|
||||||
|
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
|
||||||
|
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"ChunkIndex": 0,
|
||||||
|
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
|
||||||
|
"Format": "mp4"
|
||||||
|
}
|
||||||
+181
@@ -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
|
||||||
|
}
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# S3 bucket cleanup helpers. Sourcing this file has no side effects.
|
||||||
|
|
||||||
|
# Delete every concrete object version and delete marker from a bucket.
|
||||||
|
#
|
||||||
|
# We intentionally re-list the first 1,000 entries after every delete batch
|
||||||
|
# instead of advancing markers through a mutating result set. This handles
|
||||||
|
# arbitrary pagination depth while avoiding skipped keys when the page being
|
||||||
|
# used as a cursor has just been removed. It is also required for buckets with
|
||||||
|
# versioning Suspended: `aws s3 rm` only creates null-version delete markers
|
||||||
|
# and leaves the historical/null versions behind.
|
||||||
|
hf_purge_s3_bucket_versions() {
|
||||||
|
local bucket="$1" work page delete_request delete_response count errors rounds=0
|
||||||
|
work=$(mktemp -d)
|
||||||
|
page="$work/page.json"
|
||||||
|
delete_request="$work/delete.json"
|
||||||
|
delete_response="$work/delete-response.json"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
rounds=$((rounds + 1))
|
||||||
|
if [ "$rounds" -gt 100000 ]; then
|
||||||
|
echo "ERROR: S3 purge exceeded 100000 batches for s3://$bucket" >&2
|
||||||
|
rm -rf "$work"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! aws s3api list-object-versions \
|
||||||
|
--bucket "$bucket" \
|
||||||
|
--max-keys 1000 \
|
||||||
|
--no-paginate \
|
||||||
|
--output json > "$page"; then
|
||||||
|
echo "ERROR: failed to list object versions for s3://$bucket" >&2
|
||||||
|
rm -rf "$work"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq '{
|
||||||
|
Objects: [
|
||||||
|
(.Versions // [])[],
|
||||||
|
(.DeleteMarkers // [])[]
|
||||||
|
] | map({Key, VersionId}),
|
||||||
|
Quiet: true
|
||||||
|
}' "$page" > "$delete_request"
|
||||||
|
count=$(jq '.Objects | length' "$delete_request")
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! aws s3api delete-objects \
|
||||||
|
--bucket "$bucket" \
|
||||||
|
--delete "file://$delete_request" \
|
||||||
|
--output json > "$delete_response"; then
|
||||||
|
echo "ERROR: failed to delete a version batch from s3://$bucket" >&2
|
||||||
|
rm -rf "$work"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Successful Quiet=true deletes may produce a zero-byte response body.
|
||||||
|
# Slurp mode treats that as an empty input set and therefore zero errors,
|
||||||
|
# while still counting per-object Errors when AWS returns a JSON object.
|
||||||
|
errors=$(jq -s '[.[] | (.Errors // [])[]] | length' "$delete_response")
|
||||||
|
if [ "$errors" -ne 0 ]; then
|
||||||
|
echo "ERROR: S3 returned per-object deletion errors for s3://$bucket:" >&2
|
||||||
|
jq -c '.Errors[]' "$delete_response" >&2
|
||||||
|
rm -rf "$work"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " purged $count object versions/delete markers from s3://$bucket"
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_delete_s3_bucket_completely() {
|
||||||
|
local bucket="$1"
|
||||||
|
hf_purge_s3_bucket_versions "$bucket" &&
|
||||||
|
aws s3api delete-bucket --bucket "$bucket"
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Canonical decoded-output comparison shared by the real-AWS smoke test and
|
||||||
|
# its local unit test. This file defines functions only; sourcing it has no
|
||||||
|
# side effects.
|
||||||
|
|
||||||
|
hf_sha256() {
|
||||||
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_canonical_video_framemd5() {
|
||||||
|
local input="$1" output="$2" raw
|
||||||
|
raw=$(mktemp)
|
||||||
|
if ! ffmpeg -nostdin -v error -y -i "$input" \
|
||||||
|
-map 0:v:0 -an -vf format=rgba -fps_mode passthrough \
|
||||||
|
-f framemd5 "$raw"; then
|
||||||
|
rm -f "$raw"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Ignore container timestamp/header differences here: normalized ffprobe
|
||||||
|
# metadata and duration are gated separately. This file pins decoded pixel
|
||||||
|
# bytes, frame order, frame count, and per-frame byte size.
|
||||||
|
awk -F',' '
|
||||||
|
!/^#/ && NF >= 6 {
|
||||||
|
size=$5; hash=$6
|
||||||
|
gsub(/[[:space:]]/, "", size)
|
||||||
|
gsub(/[[:space:]]/, "", hash)
|
||||||
|
print size "," hash
|
||||||
|
}
|
||||||
|
' "$raw" > "$output"
|
||||||
|
rm -f "$raw"
|
||||||
|
[ -s "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_normalized_ffprobe_metadata() {
|
||||||
|
local input="$1" output="$2"
|
||||||
|
ffprobe -v error \
|
||||||
|
-show_entries \
|
||||||
|
stream=index,codec_type,codec_name,profile,pix_fmt,width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate,avg_frame_rate,time_base,color_range,color_space,color_transfer,color_primaries,chroma_location,field_order,sample_fmt,sample_rate,channels,channel_layout \
|
||||||
|
-of json "$input" |
|
||||||
|
jq -S '{
|
||||||
|
streams: ((.streams // [])
|
||||||
|
| sort_by(.codec_type, .index)
|
||||||
|
| map(del(.index)))
|
||||||
|
}' > "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_duration_seconds() {
|
||||||
|
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_has_audio() {
|
||||||
|
[ -n "$(ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$1" 2>/dev/null | head -1)" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_decode_pcm() {
|
||||||
|
ffmpeg -nostdin -v error -i "$1" -map 0:a:0 -vn \
|
||||||
|
-ac 2 -ar 48000 -c:a pcm_s16le -f s16le "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
hf_extract_chunk_hashes() {
|
||||||
|
local history="$1" output="$2"
|
||||||
|
jq '[
|
||||||
|
.events[] |
|
||||||
|
.taskSucceededEventDetails.output? |
|
||||||
|
select(type == "string") |
|
||||||
|
(try fromjson catch empty) |
|
||||||
|
.Payload |
|
||||||
|
select(type == "object" and .Action == "renderChunk") |
|
||||||
|
{ChunkIndex, Sha256, FramesEncoded}
|
||||||
|
] | sort_by(.ChunkIndex)' "$history" > "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare two rendered outputs. Writes durable evidence at <prefix>.* and a
|
||||||
|
# machine-readable <prefix>.json. Returns 0 only for semantic equivalence.
|
||||||
|
hf_compare_render_semantics() {
|
||||||
|
local v1="$1" v2="$2" prefix="$3"
|
||||||
|
local v1_history="${4:-}" v2_history="${5:-}"
|
||||||
|
local tolerance="${SEMANTIC_DURATION_TOLERANCE_SECONDS:-0.001}"
|
||||||
|
local work
|
||||||
|
work=$(mktemp -d)
|
||||||
|
|
||||||
|
local v1_frames="${prefix}.v1.framemd5"
|
||||||
|
local v2_frames="${prefix}.v2.framemd5"
|
||||||
|
local v1_meta="${prefix}.v1.ffprobe.json"
|
||||||
|
local v2_meta="${prefix}.v2.ffprobe.json"
|
||||||
|
local v1_duration v2_duration duration_delta
|
||||||
|
local v1_encoded_sha v2_encoded_sha encoded_equal
|
||||||
|
local video_equal metadata_equal duration_equal
|
||||||
|
local audio_state audio_equal v1_audio_sha="" v2_audio_sha=""
|
||||||
|
local v1_audio_bytes=0 v2_audio_bytes=0
|
||||||
|
local encoded_gated=false
|
||||||
|
local chunks_checked=false chunks_equal=true v1_chunk_count=0 v2_chunk_count=0
|
||||||
|
if [ "${REQUIRE_ENCODED_SHA_EQUAL:-false}" = true ]; then encoded_gated=true; fi
|
||||||
|
|
||||||
|
if ! hf_canonical_video_framemd5 "$v1" "$v1_frames" ||
|
||||||
|
! hf_canonical_video_framemd5 "$v2" "$v2_frames" ||
|
||||||
|
! hf_normalized_ffprobe_metadata "$v1" "$v1_meta" ||
|
||||||
|
! hf_normalized_ffprobe_metadata "$v2" "$v2_meta"; then
|
||||||
|
rm -rf "$work"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if cmp -s "$v1_frames" "$v2_frames"; then video_equal=true; else video_equal=false; fi
|
||||||
|
if cmp -s "$v1_meta" "$v2_meta"; then metadata_equal=true; else metadata_equal=false; fi
|
||||||
|
|
||||||
|
if ! v1_duration=$(hf_duration_seconds "$v1") ||
|
||||||
|
! v2_duration=$(hf_duration_seconds "$v2"); then
|
||||||
|
rm -rf "$work"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
duration_delta=$(awk -v a="$v1_duration" -v b="$v2_duration" \
|
||||||
|
'BEGIN { d=a-b; if (d<0) d=-d; printf("%.9f", d) }')
|
||||||
|
if awk -v d="$duration_delta" -v t="$tolerance" 'BEGIN { exit !(d <= t) }'; then
|
||||||
|
duration_equal=true
|
||||||
|
else
|
||||||
|
duration_equal=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
local v1_has_audio=false v2_has_audio=false
|
||||||
|
if hf_has_audio "$v1"; then v1_has_audio=true; fi
|
||||||
|
if hf_has_audio "$v2"; then v2_has_audio=true; fi
|
||||||
|
if [ "$v1_has_audio" = false ] && [ "$v2_has_audio" = false ]; then
|
||||||
|
audio_state="no-audio-on-either"
|
||||||
|
audio_equal=true
|
||||||
|
elif [ "$v1_has_audio" != "$v2_has_audio" ]; then
|
||||||
|
audio_state="audio-stream-mismatch"
|
||||||
|
audio_equal=false
|
||||||
|
else
|
||||||
|
audio_state="decoded-pcm"
|
||||||
|
if ! hf_decode_pcm "$v1" "$work/v1.pcm" ||
|
||||||
|
! hf_decode_pcm "$v2" "$work/v2.pcm"; then
|
||||||
|
rm -rf "$work"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
v1_audio_sha=$(hf_sha256 "$work/v1.pcm")
|
||||||
|
v2_audio_sha=$(hf_sha256 "$work/v2.pcm")
|
||||||
|
v1_audio_bytes=$(wc -c < "$work/v1.pcm" | tr -d '[:space:]')
|
||||||
|
v2_audio_bytes=$(wc -c < "$work/v2.pcm" | tr -d '[:space:]')
|
||||||
|
if [ "$v1_audio_sha" = "$v2_audio_sha" ] && [ "$v1_audio_bytes" = "$v2_audio_bytes" ]; then
|
||||||
|
audio_equal=true
|
||||||
|
else
|
||||||
|
audio_equal=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
v1_encoded_sha=$(hf_sha256 "$v1")
|
||||||
|
v2_encoded_sha=$(hf_sha256 "$v2")
|
||||||
|
if [ "$v1_encoded_sha" = "$v2_encoded_sha" ]; then encoded_equal=true; else encoded_equal=false; fi
|
||||||
|
|
||||||
|
local semantic_equal=false
|
||||||
|
if [ "$video_equal" = true ] &&
|
||||||
|
[ "$metadata_equal" = true ] &&
|
||||||
|
[ "$duration_equal" = true ] &&
|
||||||
|
[ "$audio_equal" = true ]; then
|
||||||
|
semantic_equal=true
|
||||||
|
fi
|
||||||
|
if [ "$encoded_gated" = true ] && [ "$encoded_equal" != true ]; then
|
||||||
|
semantic_equal=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$v1_history" ] || [ -n "$v2_history" ]; then
|
||||||
|
chunks_checked=true
|
||||||
|
if [ -z "$v1_history" ] || [ -z "$v2_history" ] ||
|
||||||
|
! hf_extract_chunk_hashes "$v1_history" "${prefix}.v1.chunk-hashes.json" ||
|
||||||
|
! hf_extract_chunk_hashes "$v2_history" "${prefix}.v2.chunk-hashes.json"; then
|
||||||
|
rm -rf "$work"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
v1_chunk_count=$(jq 'length' "${prefix}.v1.chunk-hashes.json")
|
||||||
|
v2_chunk_count=$(jq 'length' "${prefix}.v2.chunk-hashes.json")
|
||||||
|
if [ "$v1_chunk_count" -eq 0 ] ||
|
||||||
|
[ "$v2_chunk_count" -eq 0 ] ||
|
||||||
|
! cmp -s "${prefix}.v1.chunk-hashes.json" "${prefix}.v2.chunk-hashes.json"; then
|
||||||
|
chunks_equal=false
|
||||||
|
semantic_equal=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg v1 "$v1" --arg v2 "$v2" \
|
||||||
|
--argjson semanticEqual "$semantic_equal" \
|
||||||
|
--argjson videoEqual "$video_equal" \
|
||||||
|
--argjson v1VideoFrameCount "$(wc -l < "$v1_frames" | tr -d '[:space:]')" \
|
||||||
|
--argjson v2VideoFrameCount "$(wc -l < "$v2_frames" | tr -d '[:space:]')" \
|
||||||
|
--argjson metadataEqual "$metadata_equal" \
|
||||||
|
--arg v1Duration "$v1_duration" --arg v2Duration "$v2_duration" \
|
||||||
|
--arg durationDelta "$duration_delta" --arg durationTolerance "$tolerance" \
|
||||||
|
--arg audioState "$audio_state" --argjson audioEqual "$audio_equal" \
|
||||||
|
--arg v1AudioSha256 "$v1_audio_sha" --arg v2AudioSha256 "$v2_audio_sha" \
|
||||||
|
--argjson v1AudioBytes "$v1_audio_bytes" --argjson v2AudioBytes "$v2_audio_bytes" \
|
||||||
|
--arg v1EncodedSha256 "$v1_encoded_sha" --arg v2EncodedSha256 "$v2_encoded_sha" \
|
||||||
|
--argjson encodedShaEqual "$encoded_equal" \
|
||||||
|
--argjson encodedShaGated "$encoded_gated" \
|
||||||
|
--argjson chunksChecked "$chunks_checked" \
|
||||||
|
--argjson chunksEqual "$chunks_equal" \
|
||||||
|
--argjson v1ChunkCount "$v1_chunk_count" \
|
||||||
|
--argjson v2ChunkCount "$v2_chunk_count" \
|
||||||
|
'{
|
||||||
|
v1: $v1,
|
||||||
|
v2: $v2,
|
||||||
|
semanticEqual: $semanticEqual,
|
||||||
|
video: {
|
||||||
|
equal: $videoEqual,
|
||||||
|
v1FrameCount: $v1VideoFrameCount,
|
||||||
|
v2FrameCount: $v2VideoFrameCount
|
||||||
|
},
|
||||||
|
metadata: {equal: $metadataEqual},
|
||||||
|
chunks: {
|
||||||
|
checked: $chunksChecked,
|
||||||
|
equal: $chunksEqual,
|
||||||
|
v1Count: $v1ChunkCount,
|
||||||
|
v2Count: $v2ChunkCount
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
v1Seconds: ($v1Duration | tonumber),
|
||||||
|
v2Seconds: ($v2Duration | tonumber),
|
||||||
|
deltaSeconds: ($durationDelta | tonumber),
|
||||||
|
toleranceSeconds: ($durationTolerance | tonumber),
|
||||||
|
equal: (($durationDelta | tonumber) <= ($durationTolerance | tonumber))
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
state: $audioState,
|
||||||
|
equal: $audioEqual,
|
||||||
|
v1Sha256: $v1AudioSha256,
|
||||||
|
v2Sha256: $v2AudioSha256,
|
||||||
|
v1Bytes: $v1AudioBytes,
|
||||||
|
v2Bytes: $v2AudioBytes
|
||||||
|
},
|
||||||
|
encoded: {
|
||||||
|
equal: $encodedShaEqual,
|
||||||
|
gated: $encodedShaGated,
|
||||||
|
v1Sha256: $v1EncodedSha256,
|
||||||
|
v2Sha256: $v2EncodedSha256
|
||||||
|
}
|
||||||
|
}' > "${prefix}.json"
|
||||||
|
|
||||||
|
rm -rf "$work"
|
||||||
|
[ "$semantic_equal" = true ]
|
||||||
|
}
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Small configuration helpers shared by smoke.sh and shell tests.
|
||||||
|
|
||||||
|
hf_resolve_psnr_threshold() {
|
||||||
|
local explicit="$1" fixture_meta="$2"
|
||||||
|
if [ -n "$explicit" ]; then
|
||||||
|
printf '%s\n' "$explicit"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
jq -er '(.minPsnr // 40) | numbers' "$fixture_meta"
|
||||||
|
}
|
||||||
+127
@@ -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"
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./_s3-purge.sh
|
||||||
|
source "$SCRIPT_DIR/_s3-purge.sh"
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
mkdir -p "$WORK/bin"
|
||||||
|
printf '1001\n' > "$WORK/remaining"
|
||||||
|
: > "$WORK/delete-batches"
|
||||||
|
|
||||||
|
cat > "$WORK/bin/aws" <<'MOCK_AWS'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
operation="${1:-} ${2:-}"
|
||||||
|
shift 2
|
||||||
|
bucket=""
|
||||||
|
delete_file=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--bucket) bucket="$2"; shift 2 ;;
|
||||||
|
--delete) delete_file="${2#file://}"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$bucket" = "mock-bucket" ]
|
||||||
|
|
||||||
|
case "$operation" in
|
||||||
|
"s3api list-object-versions")
|
||||||
|
remaining=$(cat "$MOCK_WORK/remaining")
|
||||||
|
if [ "$remaining" -gt 1000 ]; then
|
||||||
|
jq -n '{
|
||||||
|
IsTruncated: true,
|
||||||
|
Versions: [range(0;999) | {Key:("version-" + tostring),VersionId:("v-" + tostring)}],
|
||||||
|
DeleteMarkers: [{Key:"deleted-null-object",VersionId:"null"}]
|
||||||
|
}'
|
||||||
|
elif [ "$remaining" -gt 0 ]; then
|
||||||
|
jq -n --argjson remaining "$remaining" '{
|
||||||
|
IsTruncated: false,
|
||||||
|
Versions: [range(0;$remaining) | {Key:("tail-" + tostring),VersionId:"null"}],
|
||||||
|
DeleteMarkers: []
|
||||||
|
}'
|
||||||
|
else
|
||||||
|
jq -n '{IsTruncated:false,Versions:[],DeleteMarkers:[]}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"s3api delete-objects")
|
||||||
|
count=$(jq '.Objects | length' "$delete_file")
|
||||||
|
jq -e '
|
||||||
|
if (.Objects | length) == 1000
|
||||||
|
then any(.Objects[]; .Key == "deleted-null-object" and .VersionId == "null")
|
||||||
|
else true
|
||||||
|
end
|
||||||
|
' "$delete_file" >/dev/null
|
||||||
|
remaining=$(cat "$MOCK_WORK/remaining")
|
||||||
|
printf '%s\n' "$((remaining - count))" > "$MOCK_WORK/remaining"
|
||||||
|
printf '%s\n' "$count" >> "$MOCK_WORK/delete-batches"
|
||||||
|
# Real AWS returns a blank body for a successful Quiet=true delete.
|
||||||
|
:
|
||||||
|
;;
|
||||||
|
"s3api delete-bucket")
|
||||||
|
[ "$(cat "$MOCK_WORK/remaining")" -eq 0 ]
|
||||||
|
touch "$MOCK_WORK/bucket-deleted"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unexpected mock operation: $operation" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
MOCK_AWS
|
||||||
|
chmod 755 "$WORK/bin/aws"
|
||||||
|
|
||||||
|
MOCK_WORK="$WORK" PATH="$WORK/bin:$PATH" \
|
||||||
|
hf_delete_s3_bucket_completely "mock-bucket" 2>"$WORK/stderr"
|
||||||
|
|
||||||
|
[ "$(cat "$WORK/remaining")" -eq 0 ]
|
||||||
|
[ "$(paste -sd, "$WORK/delete-batches")" = "1000,1" ]
|
||||||
|
[ -f "$WORK/bucket-deleted" ]
|
||||||
|
[ ! -s "$WORK/stderr" ]
|
||||||
|
echo "s3 purge shell test passed"
|
||||||
+121
@@ -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"
|
||||||
@@ -18,17 +18,20 @@
|
|||||||
# - sam (AWS SAM CLI, >= 1.100)
|
# - sam (AWS SAM CLI, >= 1.100)
|
||||||
# - bun (>= 1.3, to build the handler ZIP)
|
# - bun (>= 1.3, to build the handler ZIP)
|
||||||
# - ffmpeg (system or built-in; PSNR computation)
|
# - ffmpeg (system or built-in; PSNR computation)
|
||||||
|
# - ffprobe (normalized stream metadata + duration)
|
||||||
# - jq
|
# - jq
|
||||||
|
# - sha256sum + cmp
|
||||||
# - zip
|
# - zip
|
||||||
#
|
#
|
||||||
# Inputs (flags or env vars):
|
# Inputs (flags or env vars):
|
||||||
# --fixture <name> (default: mp4-h264-sdr)
|
# --fixture <name> (default: mp4-h264-sdr)
|
||||||
# --chunk-counts <list> (default: 2,4,8)
|
# --chunk-counts <list> (default: 2,4,8)
|
||||||
# --psnr-threshold <db> (default: 40)
|
# --psnr-threshold <db> (default: fixture meta.json minPsnr)
|
||||||
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>)
|
# --stack-name <name> (default: hyperframes-lambda-smoke-<unique-run-id>)
|
||||||
# --region <region> (default: $AWS_REGION or us-east-1)
|
# --region <region> (default: $AWS_REGION or us-east-1)
|
||||||
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
|
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
|
||||||
# default profile resolution chain)
|
# default profile resolution chain)
|
||||||
|
# --plan-protocol <v1|v2|both> (default: v1)
|
||||||
# --keep-stack (skip `sam delete` at the end)
|
# --keep-stack (skip `sam delete` at the end)
|
||||||
# --skip-build (skip the ZIP rebuild; use the existing one)
|
# --skip-build (skip the ZIP rebuild; use the existing one)
|
||||||
#
|
#
|
||||||
@@ -51,6 +54,14 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
SAM_DIR="$SCRIPT_DIR/.."
|
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 ──────────────────────────────────────────────────────────────
|
# ── Defaults ──────────────────────────────────────────────────────────────
|
||||||
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
|
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 +
|
# than the in-process baseline (Debian-bookworm-slim's apt ffmpeg +
|
||||||
# Puppeteer-managed chrome-headless-shell). Expected drift across those
|
# Puppeteer-managed chrome-headless-shell). Expected drift across those
|
||||||
# environments is ~3 dB on simple fixtures, more on font-heavy ones.
|
# 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
|
# The gate defaults to the fixture's own `meta.json.minPsnr`, which is
|
||||||
# --psnr-threshold for a stricter check.
|
# calibrated for that content/runtime boundary. Override it via
|
||||||
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}"
|
# --psnr-threshold (or PSNR_THRESHOLD) for a stricter experiment.
|
||||||
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}"
|
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_REGION="${AWS_REGION:-us-east-1}"
|
||||||
AWS_PROFILE="${AWS_PROFILE:-}"
|
AWS_PROFILE="${AWS_PROFILE:-}"
|
||||||
|
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}"
|
||||||
KEEP_STACK="false"
|
KEEP_STACK="false"
|
||||||
SKIP_BUILD="false"
|
SKIP_BUILD="false"
|
||||||
|
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
|
||||||
# Lambda Map-state concurrency cap. 16 fans out the chunks aggressively
|
# 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;
|
# 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.
|
# raise as far as your AWS account's regional concurrency quota allows.
|
||||||
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
|
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
|
||||||
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
|
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
|
||||||
|
PROJECT_NAME=""
|
||||||
|
SAM_DEPLOY_BUCKET=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -89,12 +106,14 @@ output against the in-process baseline, and tears the stack down.
|
|||||||
Flags:
|
Flags:
|
||||||
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
|
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
|
||||||
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
|
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
|
||||||
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40)
|
--psnr-threshold <db> PSNR floor (default: fixture meta.json minPsnr)
|
||||||
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>)
|
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
|
||||||
--region <region> AWS region (default: $AWS_REGION or us-east-1)
|
--region <region> AWS region (default: $AWS_REGION or us-east-1)
|
||||||
--profile <name> AWS profile (default: $AWS_PROFILE)
|
--profile <name> AWS profile (default: $AWS_PROFILE)
|
||||||
|
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1)
|
||||||
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
|
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
|
||||||
--keep-stack skip `sam delete` at the end (manual teardown later)
|
--keep-stack skip `sam delete` at the end (manual teardown later)
|
||||||
|
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
|
||||||
--skip-build reuse existing dist/handler.zip
|
--skip-build reuse existing dist/handler.zip
|
||||||
-h, --help show this help and exit
|
-h, --help show this help and exit
|
||||||
|
|
||||||
@@ -105,7 +124,8 @@ Cost notes:
|
|||||||
per run before S3 PUT/GET. Set --reserved-concurrency lower for
|
per run before S3 PUT/GET. Set --reserved-concurrency lower for
|
||||||
cost-conscious accounts.
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +138,9 @@ while [ $# -gt 0 ]; do
|
|||||||
--stack-name) STACK_NAME="$2"; shift 2 ;;
|
--stack-name) STACK_NAME="$2"; shift 2 ;;
|
||||||
--region) AWS_REGION="$2"; shift 2 ;;
|
--region) AWS_REGION="$2"; shift 2 ;;
|
||||||
--profile) AWS_PROFILE="$2"; shift 2 ;;
|
--profile) AWS_PROFILE="$2"; shift 2 ;;
|
||||||
|
--plan-protocol) PLAN_PROTOCOL="$2"; shift 2 ;;
|
||||||
--keep-stack) KEEP_STACK="true"; shift ;;
|
--keep-stack) KEEP_STACK="true"; shift ;;
|
||||||
|
--require-encoded-sha-equal) REQUIRE_ENCODED_SHA_EQUAL="true"; shift ;;
|
||||||
--skip-build) SKIP_BUILD="true"; shift ;;
|
--skip-build) SKIP_BUILD="true"; shift ;;
|
||||||
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
|
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
@@ -126,6 +148,12 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
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
|
# 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
|
# standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's
|
||||||
# default resolution (env → ~/.aws/config → IMDS) take over without us
|
# 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) ─
|
# ── Cleanup helper (defined early so the failure paths below can call it) ─
|
||||||
BUCKET=""
|
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() {
|
cleanup_and_exit() {
|
||||||
local exit_code="${1:-0}"
|
local exit_code="${1:-0}"
|
||||||
@@ -157,24 +241,135 @@ cleanup_and_exit() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "→ Tearing down stack $STACK_NAME"
|
echo "→ Tearing down stack $STACK_NAME"
|
||||||
if [ -n "$BUCKET" ]; then
|
local cleanup_identity_ok=true
|
||||||
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
|
local stack_cleanup_allowed=false
|
||||||
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true
|
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
|
fi
|
||||||
(cd "$SAM_DIR" && sam delete \
|
if [ "$cleanup_identity_ok" = true ]; then
|
||||||
--stack-name "$STACK_NAME" \
|
if ownership_status=$(hf_stack_ownership_status "$STACK_NAME" "$SMOKE_RUN_ID"); then
|
||||||
--no-prompts) >/dev/null 2>&1 || true
|
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
|
fi
|
||||||
exit "$exit_code"
|
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 ─────────────────────────────────────────────────────
|
# ── 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
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
echo "ERROR: '$cmd' not found on PATH." >&2
|
echo "ERROR: '$cmd' not found on PATH." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -203,6 +398,25 @@ if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
mkdir -p "$ARTIFACT_DIR/renders"
|
||||||
|
|
||||||
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
|
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
|
||||||
@@ -223,21 +437,29 @@ echo "→ SAM validate"
|
|||||||
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
|
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
|
||||||
|
|
||||||
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
|
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
|
||||||
# ProjectName is intentionally NOT set to $STACK_NAME — the template
|
# Use a per-run resource prefix and deployment bucket. The template has
|
||||||
# uses ProjectName only for the function/state-machine human-facing
|
# explicit FunctionName/StateMachineName properties, so leaving ProjectName
|
||||||
# names, and forcing it long here doesn't help. The BucketName is
|
# at its default makes concurrent smoke stacks overwrite/collide. A dedicated
|
||||||
# auto-generated by CloudFormation per stack so concurrent smoke runs
|
# SAM bucket also lets teardown remove every object created by this run.
|
||||||
# don't collide. Pass --region explicitly here even though
|
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
|
||||||
# AWS_DEFAULT_REGION is set, so a stray samconfig.toml in the working
|
SAM_DEPLOY_BUCKET=$(hf_sam_deploy_bucket_name "$ACCOUNT_ID" "$AWS_REGION" "$SMOKE_RUN_ID")
|
||||||
# directory can't override the script's choice.
|
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 \
|
if ! (cd "$SAM_DIR" && sam deploy \
|
||||||
--stack-name "$STACK_NAME" \
|
--stack-name "$STACK_NAME" \
|
||||||
--region "$AWS_REGION" \
|
--region "$AWS_REGION" \
|
||||||
--resolve-s3 \
|
--s3-bucket "$SAM_DEPLOY_BUCKET" \
|
||||||
--capabilities CAPABILITY_IAM \
|
--capabilities CAPABILITY_IAM \
|
||||||
--no-confirm-changeset \
|
--no-confirm-changeset \
|
||||||
--no-fail-on-empty-changeset \
|
--no-fail-on-empty-changeset \
|
||||||
|
--tags "HyperframesSmokeRun=$SMOKE_RUN_ID" \
|
||||||
--parameter-overrides \
|
--parameter-overrides \
|
||||||
|
"ProjectName=$PROJECT_NAME" \
|
||||||
ChromeSource=sparticuz \
|
ChromeSource=sparticuz \
|
||||||
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then
|
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then
|
||||||
echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2
|
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" \
|
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
|
||||||
--output text)
|
--output text)
|
||||||
echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN"
|
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 ────────────────────────────────
|
# ── 4. Upload fixture as a project tarball ────────────────────────────────
|
||||||
# tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not
|
# 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 ─────────────────────────────────────────
|
# ── 5. Render at each chunk count ─────────────────────────────────────────
|
||||||
FIXTURE_META="$FIXTURE_DIR/meta.json"
|
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_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
|
||||||
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
|
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
|
||||||
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
|
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
|
||||||
@@ -275,13 +519,20 @@ RESULTS_JSON="$ARTIFACT_DIR/results.json"
|
|||||||
echo "[]" > "$RESULTS_JSON"
|
echo "[]" > "$RESULTS_JSON"
|
||||||
|
|
||||||
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
|
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
|
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"
|
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
|
||||||
INPUT_JSON=$(jq -n \
|
INPUT_JSON=$(jq -n \
|
||||||
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
|
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
|
||||||
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
|
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
|
||||||
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
|
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
|
||||||
|
--arg protocol "$PROTOCOL" \
|
||||||
--argjson n "$N" \
|
--argjson n "$N" \
|
||||||
--argjson fps "$BASE_FPS" \
|
--argjson fps "$BASE_FPS" \
|
||||||
--argjson w "$BASE_W" \
|
--argjson w "$BASE_W" \
|
||||||
@@ -290,6 +541,7 @@ for N in "${COUNTS[@]}"; do
|
|||||||
ProjectS3Uri: $project,
|
ProjectS3Uri: $project,
|
||||||
PlanOutputS3Prefix: $prefix,
|
PlanOutputS3Prefix: $prefix,
|
||||||
OutputS3Uri: $output,
|
OutputS3Uri: $output,
|
||||||
|
PlanProtocol: $protocol,
|
||||||
Config: {
|
Config: {
|
||||||
fps: $fps,
|
fps: $fps,
|
||||||
width: $w,
|
width: $w,
|
||||||
@@ -301,7 +553,7 @@ for N in "${COUNTS[@]}"; do
|
|||||||
}')
|
}')
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "================== N=$N =================="
|
echo "================== protocol=$PROTOCOL N=$N =================="
|
||||||
echo "$INPUT_JSON" | jq .
|
echo "$INPUT_JSON" | jq .
|
||||||
|
|
||||||
START_MS=$(date +%s%3N)
|
START_MS=$(date +%s%3N)
|
||||||
@@ -323,21 +575,21 @@ for N in "${COUNTS[@]}"; do
|
|||||||
WALL_MS=$((END_MS - START_MS))
|
WALL_MS=$((END_MS - START_MS))
|
||||||
|
|
||||||
if [ "$STATUS" != "SUCCEEDED" ]; then
|
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 \
|
aws stepfunctions describe-execution \
|
||||||
--execution-arn "$EXEC_ARN" \
|
--execution-arn "$EXEC_ARN" \
|
||||||
> "$ARTIFACT_DIR/renders/N$N-execution.json"
|
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-execution.json"
|
||||||
aws stepfunctions get-execution-history \
|
aws stepfunctions get-execution-history \
|
||||||
--execution-arn "$EXEC_ARN" --max-results 200 \
|
--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
|
cleanup_and_exit 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
aws stepfunctions get-execution-history \
|
aws stepfunctions get-execution-history \
|
||||||
--execution-arn "$EXEC_ARN" --max-results 1000 --output json \
|
--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"
|
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL"
|
||||||
|
|
||||||
PSNR_LOG=$(mktemp)
|
PSNR_LOG=$(mktemp)
|
||||||
@@ -363,34 +615,75 @@ for N in "${COUNTS[@]}"; do
|
|||||||
' "$PSNR_LOG")
|
' "$PSNR_LOG")
|
||||||
rm -f "$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" \
|
jq --argjson n "$N" \
|
||||||
--argjson wall "$WALL_MS" \
|
--argjson wall "$WALL_MS" \
|
||||||
--arg psnr "$PSNR_AVG" \
|
--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"
|
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
|
||||||
done
|
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
|
FAILED=0
|
||||||
while read -r row; do
|
while read -r row; do
|
||||||
N=$(echo "$row" | jq -r .chunkCount)
|
N=$(echo "$row" | jq -r .chunkCount)
|
||||||
|
PROTOCOL=$(echo "$row" | jq -r .planProtocol)
|
||||||
P=$(echo "$row" | jq -r .psnrAvgDb)
|
P=$(echo "$row" | jq -r .psnrAvgDb)
|
||||||
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
|
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
|
||||||
echo "FAIL: N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
|
echo "FAIL: protocol=$PROTOCOL N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
|
||||||
FAILED=$((FAILED + 1))
|
FAILED=$((FAILED + 1))
|
||||||
fi
|
fi
|
||||||
done < <(jq -c '.[]' "$RESULTS_JSON")
|
done < <(jq -c '.[]' "$RESULTS_JSON")
|
||||||
|
|
||||||
# ── 7. Summary ────────────────────────────────────────────────────────────
|
# ── 8. Summary ────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo "================ RESULTS ================"
|
echo "================ RESULTS ================"
|
||||||
printf '%-10s %-12s %-10s\n' "ChunkCount" "WallMs" "PSNR (dB)"
|
printf '%-10s %-10s %-12s %-10s\n' "Protocol" "ChunkCount" "WallMs" "PSNR (dB)"
|
||||||
jq -r '.[] | [.chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
|
jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
|
||||||
| awk -F'\t' '{printf "%-10s %-12s %-10s\n", $1, $2, $3}'
|
| awk -F'\t' '{printf "%-10s %-10s %-12s %-10s\n", $1, $2, $3, $4}'
|
||||||
echo
|
echo
|
||||||
echo "Artifacts: $ARTIFACT_DIR"
|
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
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
|
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
|
||||||
cleanup_and_exit 5
|
cleanup_and_exit 5
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ Resources:
|
|||||||
# Lambda's Node 22 runtime sets these by default; explicit for
|
# Lambda's Node 22 runtime sets these by default; explicit for
|
||||||
# clarity + so users can override during local SAM invoke.
|
# clarity + so users can override during local SAM invoke.
|
||||||
TMPDIR: /tmp
|
TMPDIR: /tmp
|
||||||
|
HYPERFRAMES_RENDER_BUCKET: !Ref RenderBucket
|
||||||
Policies:
|
Policies:
|
||||||
- S3CrudPolicy:
|
- S3CrudPolicy:
|
||||||
BucketName: !Ref RenderBucket
|
BucketName: !Ref RenderBucket
|
||||||
@@ -207,8 +208,27 @@ Resources:
|
|||||||
# compound into a multi-hour execution. The longest legitimate
|
# compound into a multi-hour execution. The longest legitimate
|
||||||
# render observed in PR 880's eval was ~3 minutes.
|
# render observed in PR 880's eval was ~3 minutes.
|
||||||
TimeoutSeconds: 3600
|
TimeoutSeconds: 3600
|
||||||
StartAt: Plan
|
StartAt: SelectPlanProtocol
|
||||||
States:
|
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:
|
Plan:
|
||||||
Type: Task
|
Type: Task
|
||||||
Resource: arn:aws:states:::lambda:invoke
|
Resource: arn:aws:states:::lambda:invoke
|
||||||
@@ -220,6 +240,7 @@ Resources:
|
|||||||
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
|
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
|
||||||
Config.$: "$.Config"
|
Config.$: "$.Config"
|
||||||
ResultSelector:
|
ResultSelector:
|
||||||
|
PlanProtocol: v1
|
||||||
PlanS3Uri.$: "$.Payload.PlanS3Uri"
|
PlanS3Uri.$: "$.Payload.PlanS3Uri"
|
||||||
PlanHash.$: "$.Payload.PlanHash"
|
PlanHash.$: "$.Payload.PlanHash"
|
||||||
ChunkCount.$: "$.Payload.ChunkCount"
|
ChunkCount.$: "$.Payload.ChunkCount"
|
||||||
@@ -239,6 +260,10 @@ Resources:
|
|||||||
- BROWSER_GPU_NOT_SOFTWARE
|
- BROWSER_GPU_NOT_SOFTWARE
|
||||||
- FONT_FETCH_FAILED
|
- FONT_FETCH_FAILED
|
||||||
- PLAN_TOO_LARGE
|
- PLAN_TOO_LARGE
|
||||||
|
- PlanTooLargeError
|
||||||
|
- PLAN_PROTOCOL_UNSUPPORTED
|
||||||
|
- PlanProtocolUnsupportedError
|
||||||
|
- PLAN_ARTIFACT_DIGEST_MISMATCH
|
||||||
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
||||||
MaxAttempts: 0
|
MaxAttempts: 0
|
||||||
- ErrorEquals: [States.ALL]
|
- ErrorEquals: [States.ALL]
|
||||||
@@ -248,6 +273,50 @@ Resources:
|
|||||||
MaxDelaySeconds: 60
|
MaxDelaySeconds: 60
|
||||||
Next: BuildChunkList
|
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:
|
BuildChunkList:
|
||||||
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
|
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
|
||||||
# Map state below has something to iterate. Range is the
|
# Map state below has something to iterate. Range is the
|
||||||
@@ -269,7 +338,7 @@ Resources:
|
|||||||
Choices:
|
Choices:
|
||||||
- Variable: $.Plan.ChunkCount
|
- Variable: $.Plan.ChunkCount
|
||||||
NumericGreaterThan: 0
|
NumericGreaterThan: 0
|
||||||
Next: RenderChunks
|
Next: SelectWorkerProtocol
|
||||||
Default: PlanProducedZeroChunks
|
Default: PlanProducedZeroChunks
|
||||||
|
|
||||||
PlanProducedZeroChunks:
|
PlanProducedZeroChunks:
|
||||||
@@ -277,6 +346,14 @@ Resources:
|
|||||||
Error: PLAN_TOO_LARGE
|
Error: PLAN_TOO_LARGE
|
||||||
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
|
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:
|
RenderChunks:
|
||||||
Type: Map
|
Type: Map
|
||||||
ItemsPath: $.Iterator.ChunkIndexes
|
ItemsPath: $.Iterator.ChunkIndexes
|
||||||
@@ -320,6 +397,11 @@ Resources:
|
|||||||
- FFMPEG_VERSION_MISMATCH
|
- FFMPEG_VERSION_MISMATCH
|
||||||
- PLAN_HASH_MISMATCH
|
- PLAN_HASH_MISMATCH
|
||||||
- BROWSER_GPU_NOT_SOFTWARE
|
- BROWSER_GPU_NOT_SOFTWARE
|
||||||
|
- PLAN_TOO_LARGE
|
||||||
|
- PlanTooLargeError
|
||||||
|
- PLAN_PROTOCOL_UNSUPPORTED
|
||||||
|
- PlanProtocolUnsupportedError
|
||||||
|
- PLAN_ARTIFACT_DIGEST_MISMATCH
|
||||||
MaxAttempts: 0
|
MaxAttempts: 0
|
||||||
- ErrorEquals: [States.ALL]
|
- ErrorEquals: [States.ALL]
|
||||||
IntervalSeconds: 2
|
IntervalSeconds: 2
|
||||||
@@ -356,6 +438,111 @@ Resources:
|
|||||||
- FFMPEG_VERSION_MISMATCH
|
- FFMPEG_VERSION_MISMATCH
|
||||||
- PLAN_HASH_MISMATCH
|
- PLAN_HASH_MISMATCH
|
||||||
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
- 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
|
MaxAttempts: 0
|
||||||
- ErrorEquals: [States.ALL]
|
- ErrorEquals: [States.ALL]
|
||||||
IntervalSeconds: 2
|
IntervalSeconds: 2
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ smoke flow; the SDK + CDK are the supported public surface for adopters.
|
|||||||
│ pure functions over local paths
|
│ 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
|
uploads outputs back to S3, and returns a small JSON result that fits
|
||||||
inside Step Functions' history budget (under 200 bytes per chunk).
|
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
|
## Chrome runtime
|
||||||
|
|
||||||
The package supports two Chromium sources:
|
The package supports two Chromium sources:
|
||||||
|
|||||||
@@ -86,7 +86,8 @@
|
|||||||
"constructs": "^10.3.0",
|
"constructs": "^10.3.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"aws-cdk-lib": "^2.130.0",
|
"aws-cdk-lib": "^2.130.0",
|
||||||
|
|||||||
@@ -20,11 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "bun:test";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { App, Stack } from "aws-cdk-lib";
|
import { App, Stack } from "aws-cdk-lib";
|
||||||
import { Template } from "aws-cdk-lib/assertions";
|
import { Template } from "aws-cdk-lib/assertions";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
|
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
|
||||||
|
|
||||||
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
|
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
|
||||||
@@ -50,22 +51,33 @@ const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
|
|||||||
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
|
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
|
||||||
// not at this level — we cover it separately in the contract test.
|
// not at this level — we cover it separately in the contract test.
|
||||||
const EXPECTED_STATE_NAMES = [
|
const EXPECTED_STATE_NAMES = [
|
||||||
|
"SelectPlanProtocol",
|
||||||
"Plan",
|
"Plan",
|
||||||
|
"PlanV2",
|
||||||
"BuildChunkList",
|
"BuildChunkList",
|
||||||
"AssertChunkCount",
|
"AssertChunkCount",
|
||||||
|
"SelectWorkerProtocol",
|
||||||
"RenderChunks",
|
"RenderChunks",
|
||||||
|
"RenderChunksV2",
|
||||||
"Assemble",
|
"Assemble",
|
||||||
|
"AssembleV2",
|
||||||
"PlanProducedZeroChunks",
|
"PlanProducedZeroChunks",
|
||||||
|
"UnsupportedPlanProtocol",
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
|
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
|
||||||
"FFMPEG_VERSION_MISMATCH",
|
"FFMPEG_VERSION_MISMATCH",
|
||||||
"PLAN_HASH_MISMATCH",
|
"PLAN_HASH_MISMATCH",
|
||||||
|
"S3_URI_NOT_ALLOWED",
|
||||||
"BROWSER_GPU_NOT_SOFTWARE",
|
"BROWSER_GPU_NOT_SOFTWARE",
|
||||||
"FONT_FETCH_FAILED",
|
"FONT_FETCH_FAILED",
|
||||||
"PLAN_TOO_LARGE",
|
"PLAN_TOO_LARGE",
|
||||||
|
"PlanTooLargeError",
|
||||||
"PLAN_PROTOCOL_UNSUPPORTED",
|
"PLAN_PROTOCOL_UNSUPPORTED",
|
||||||
"PlanProtocolUnsupportedError",
|
"PlanProtocolUnsupportedError",
|
||||||
|
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||||
|
"PlanV2IntegrityError",
|
||||||
|
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||||
"ChromeBinaryUnavailableError",
|
"ChromeBinaryUnavailableError",
|
||||||
]);
|
]);
|
||||||
@@ -130,7 +142,7 @@ describe("HyperframesRenderStack — snapshot", () => {
|
|||||||
|
|
||||||
it("declares the state machine with the expected state names", () => {
|
it("declares the state machine with the expected state names", () => {
|
||||||
const { definition } = SYNTHED;
|
const { definition } = SYNTHED;
|
||||||
expect(definition.StartAt).toBe("Plan");
|
expect(definition.StartAt).toBe("SelectPlanProtocol");
|
||||||
const actualStates = Object.keys(definition.States);
|
const actualStates = Object.keys(definition.States);
|
||||||
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
|
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
|
||||||
});
|
});
|
||||||
@@ -140,7 +152,7 @@ describe("HyperframesRenderStack — snapshot", () => {
|
|||||||
const collected = new Set<string>();
|
const collected = new Set<string>();
|
||||||
// Plan + Assemble are top-level states; RenderChunk is nested inside
|
// Plan + Assemble are top-level states; RenderChunk is nested inside
|
||||||
// the Map's Iterator definition.
|
// the Map's Iterator definition.
|
||||||
const topLevelStates = ["Plan", "Assemble"] as const;
|
const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
|
||||||
for (const stateName of topLevelStates) {
|
for (const stateName of topLevelStates) {
|
||||||
collectNonRetryableErrors(definition.States[stateName], collected);
|
collectNonRetryableErrors(definition.States[stateName], collected);
|
||||||
}
|
}
|
||||||
@@ -152,6 +164,15 @@ describe("HyperframesRenderStack — snapshot", () => {
|
|||||||
| undefined;
|
| undefined;
|
||||||
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
|
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
|
||||||
collectNonRetryableErrors(innerStates.RenderChunk, collected);
|
collectNonRetryableErrors(innerStates.RenderChunk, collected);
|
||||||
|
const renderChunksV2 = definition.States.RenderChunksV2 as
|
||||||
|
| {
|
||||||
|
Iterator?: { States?: Record<string, unknown> };
|
||||||
|
ItemProcessor?: { States?: Record<string, unknown> };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const innerStatesV2 =
|
||||||
|
renderChunksV2?.Iterator?.States ?? renderChunksV2?.ItemProcessor?.States ?? {};
|
||||||
|
collectNonRetryableErrors(innerStatesV2.RenderChunkV2, collected);
|
||||||
|
|
||||||
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
|
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
|
||||||
expect({ error: expected, present: collected.has(expected) }).toEqual({
|
expect({ error: expected, present: collected.has(expected) }).toEqual({
|
||||||
@@ -160,6 +181,52 @@ describe("HyperframesRenderStack — snapshot", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies plan v2 integrity failures as terminal in every v2 Lambda task", () => {
|
||||||
|
const v2TaskStates = Object.values(getV2TaskStates(SYNTHED.definition));
|
||||||
|
|
||||||
|
for (const state of v2TaskStates) {
|
||||||
|
const errors = new Set<string>();
|
||||||
|
collectNonRetryableErrors(state, errors);
|
||||||
|
expect(errors.has("PLAN_V2_INTEGRITY_UNRECOVERABLE")).toBe(true);
|
||||||
|
expect(errors.has("PlanV2IntegrityError")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps SAM and CDK terminal classifiers identical for every v2 Lambda task", () => {
|
||||||
|
const cdkTasks = getV2TaskStates(SYNTHED.definition);
|
||||||
|
const samTasks = getV2TaskStates(readSamDefinition());
|
||||||
|
|
||||||
|
for (const taskName of ["PlanV2", "RenderChunkV2", "AssembleV2"] as const) {
|
||||||
|
const cdkErrors = new Set<string>();
|
||||||
|
const samErrors = new Set<string>();
|
||||||
|
collectNonRetryableErrors(cdkTasks[taskName], cdkErrors);
|
||||||
|
collectNonRetryableErrors(samTasks[taskName], samErrors);
|
||||||
|
expect({ taskName, errors: [...samErrors].sort() }).toEqual({
|
||||||
|
taskName,
|
||||||
|
errors: [...cdkErrors].sort(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
|
||||||
|
const { definition } = SYNTHED;
|
||||||
|
const v1 = JSON.stringify({
|
||||||
|
plan: definition.States.Plan,
|
||||||
|
chunks: definition.States.RenderChunks,
|
||||||
|
assemble: definition.States.Assemble,
|
||||||
|
});
|
||||||
|
const v2 = JSON.stringify({
|
||||||
|
plan: definition.States.PlanV2,
|
||||||
|
chunks: definition.States.RenderChunksV2,
|
||||||
|
assemble: definition.States.AssembleV2,
|
||||||
|
});
|
||||||
|
expect(v1).toContain("PlanS3Uri");
|
||||||
|
expect(v1).not.toContain("PlanV2ManifestS3Uri");
|
||||||
|
expect(v2).toContain("PlanV2ManifestS3Uri");
|
||||||
|
expect(v2).toContain("PlanV2ArtifactS3Prefix");
|
||||||
|
expect(v2).not.toContain("PlanS3Uri");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
|
function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
|
||||||
@@ -171,3 +238,66 @@ function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRecordProperty(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
property: string,
|
||||||
|
label: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return requireRecord(record[property], label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getV2TaskStates(definition: {
|
||||||
|
States: Record<string, unknown>;
|
||||||
|
}): Record<"PlanV2" | "RenderChunkV2" | "AssembleV2", unknown> {
|
||||||
|
const renderChunksV2 = requireRecord(definition.States.RenderChunksV2, "RenderChunksV2 state");
|
||||||
|
const processor = isRecord(renderChunksV2.Iterator)
|
||||||
|
? renderChunksV2.Iterator
|
||||||
|
: requireRecord(renderChunksV2.ItemProcessor, "RenderChunksV2 processor");
|
||||||
|
const innerStates = requireRecord(processor.States, "RenderChunksV2 processor states");
|
||||||
|
return {
|
||||||
|
PlanV2: definition.States.PlanV2,
|
||||||
|
RenderChunkV2: innerStates.RenderChunkV2,
|
||||||
|
AssembleV2: definition.States.AssembleV2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSamDefinition(): { States: Record<string, unknown> } {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
// CloudFormation intrinsic tags are irrelevant to classifier parity. The
|
||||||
|
// YAML parser preserves their scalar values while this option suppresses
|
||||||
|
// warnings for the intentionally unresolved `!Ref`/`!GetAtt` tags.
|
||||||
|
const parsed: unknown = parseYaml(source, { logLevel: "silent" });
|
||||||
|
const root = requireRecord(parsed, "SAM template");
|
||||||
|
const resources = requireRecordProperty(root, "Resources", "SAM resources");
|
||||||
|
const stateMachine = requireRecordProperty(
|
||||||
|
resources,
|
||||||
|
"RenderStateMachine",
|
||||||
|
"SAM RenderStateMachine",
|
||||||
|
);
|
||||||
|
const properties = requireRecordProperty(
|
||||||
|
stateMachine,
|
||||||
|
"Properties",
|
||||||
|
"SAM state-machine properties",
|
||||||
|
);
|
||||||
|
const definition = requireRecordProperty(
|
||||||
|
properties,
|
||||||
|
"Definition",
|
||||||
|
"SAM state-machine definition",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
States: requireRecordProperty(definition, "States", "SAM state-machine states"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,8 +200,12 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
"BROWSER_GPU_NOT_SOFTWARE",
|
"BROWSER_GPU_NOT_SOFTWARE",
|
||||||
"FONT_FETCH_FAILED",
|
"FONT_FETCH_FAILED",
|
||||||
"PLAN_TOO_LARGE",
|
"PLAN_TOO_LARGE",
|
||||||
|
"PlanTooLargeError",
|
||||||
"PLAN_PROTOCOL_UNSUPPORTED",
|
"PLAN_PROTOCOL_UNSUPPORTED",
|
||||||
"PlanProtocolUnsupportedError",
|
"PlanProtocolUnsupportedError",
|
||||||
|
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||||
|
"PlanV2IntegrityError",
|
||||||
|
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||||
"ChromeBinaryUnavailableError",
|
"ChromeBinaryUnavailableError",
|
||||||
];
|
];
|
||||||
@@ -210,8 +214,13 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
"PLAN_HASH_MISMATCH",
|
"PLAN_HASH_MISMATCH",
|
||||||
"S3_URI_NOT_ALLOWED",
|
"S3_URI_NOT_ALLOWED",
|
||||||
"BROWSER_GPU_NOT_SOFTWARE",
|
"BROWSER_GPU_NOT_SOFTWARE",
|
||||||
|
"PLAN_TOO_LARGE",
|
||||||
|
"PlanTooLargeError",
|
||||||
"PLAN_PROTOCOL_UNSUPPORTED",
|
"PLAN_PROTOCOL_UNSUPPORTED",
|
||||||
"PlanProtocolUnsupportedError",
|
"PlanProtocolUnsupportedError",
|
||||||
|
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||||
|
"PlanV2IntegrityError",
|
||||||
|
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||||
"ChromeBinaryUnavailableError",
|
"ChromeBinaryUnavailableError",
|
||||||
];
|
];
|
||||||
const NON_RETRYABLE_ASSEMBLE = [
|
const NON_RETRYABLE_ASSEMBLE = [
|
||||||
@@ -220,7 +229,12 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
"S3_URI_NOT_ALLOWED",
|
"S3_URI_NOT_ALLOWED",
|
||||||
"PLAN_PROTOCOL_UNSUPPORTED",
|
"PLAN_PROTOCOL_UNSUPPORTED",
|
||||||
"PlanProtocolUnsupportedError",
|
"PlanProtocolUnsupportedError",
|
||||||
|
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||||
|
"PlanV2IntegrityError",
|
||||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||||
|
"PLAN_TOO_LARGE",
|
||||||
|
"PlanTooLargeError",
|
||||||
|
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||||
"ChromeBinaryUnavailableError",
|
"ChromeBinaryUnavailableError",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -233,6 +247,7 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
"Config.$": "$.Config",
|
"Config.$": "$.Config",
|
||||||
}),
|
}),
|
||||||
resultSelector: {
|
resultSelector: {
|
||||||
|
PlanProtocol: "v1",
|
||||||
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
|
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
|
||||||
"PlanHash.$": "$.Payload.PlanHash",
|
"PlanHash.$": "$.Payload.PlanHash",
|
||||||
"ChunkCount.$": "$.Payload.ChunkCount",
|
"ChunkCount.$": "$.Payload.ChunkCount",
|
||||||
@@ -254,6 +269,35 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
maxDelay: Duration.seconds(60),
|
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", {
|
const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
|
||||||
parameters: {
|
parameters: {
|
||||||
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
|
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
|
||||||
@@ -337,11 +381,100 @@ export class HyperframesRenderStack extends Construct {
|
|||||||
maxDelay: Duration.seconds(60),
|
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")
|
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);
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/
|
|||||||
|
|
||||||
/** Discriminator for the three roles the one Lambda image fulfills. */
|
/** Discriminator for the three roles the one Lambda image fulfills. */
|
||||||
export type LambdaAction = "plan" | "renderChunk" | "assemble";
|
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.
|
* Top-level shape of any event the handler may receive.
|
||||||
@@ -42,7 +44,7 @@ export type LambdaEvent =
|
|||||||
| { Input: LambdaEvent };
|
| { Input: LambdaEvent };
|
||||||
|
|
||||||
/** Activity A: produce a planDir, upload to S3. */
|
/** Activity A: produce a planDir, upload to S3. */
|
||||||
export interface PlanEvent {
|
interface PlanEventBase {
|
||||||
Action: "plan";
|
Action: "plan";
|
||||||
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
|
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
|
||||||
ProjectS3Uri: string;
|
ProjectS3Uri: string;
|
||||||
@@ -52,17 +54,27 @@ export interface PlanEvent {
|
|||||||
Config: SerializableDistributedRenderConfig;
|
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. */
|
/** Activity B: fetch planDir, render one chunk, upload result. */
|
||||||
export interface RenderChunkEvent {
|
interface RenderChunkEventBase {
|
||||||
Action: "renderChunk";
|
Action: "renderChunk";
|
||||||
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
|
|
||||||
PlanS3Uri: string;
|
|
||||||
/**
|
/**
|
||||||
* `PlanResult.planHash` from the Plan invocation. The handler verifies
|
* `PlanResult.planHash` from the Plan invocation. For v1, the handler
|
||||||
* this against the untarred planDir's `plan.json` before invoking the
|
* verifies it against the untarred planDir's `plan.json`; for v2, it
|
||||||
* producer, throwing a typed `PLAN_HASH_MISMATCH` on divergence so the
|
* verifies it against the content-addressed manifest before invoking the
|
||||||
* state machine routes it as non-retryable. Defense-in-depth — the
|
* producer. Divergence throws a typed `PLAN_HASH_MISMATCH` so the state
|
||||||
* producer also re-checks internally.
|
* machine routes it as non-retryable.
|
||||||
*/
|
*/
|
||||||
PlanHash: string;
|
PlanHash: string;
|
||||||
/** 0-based chunk index this invocation should render. */
|
/** 0-based chunk index this invocation should render. */
|
||||||
@@ -73,11 +85,28 @@ export interface RenderChunkEvent {
|
|||||||
Format: DistributedFormat;
|
Format: DistributedFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
|
/** Legacy/default chunk event. */
|
||||||
export interface AssembleEvent {
|
export interface RenderChunkV1Event extends RenderChunkEventBase {
|
||||||
Action: "assemble";
|
PlanProtocol?: "v1";
|
||||||
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
|
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
|
||||||
PlanS3Uri: string;
|
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`. */
|
/** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */
|
||||||
ChunkS3Uris: string[];
|
ChunkS3Uris: string[];
|
||||||
/** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */
|
/** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */
|
||||||
@@ -98,12 +127,28 @@ export interface AssembleEvent {
|
|||||||
Cfr?: boolean;
|
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 types — kept small to fit Step Functions history budgets ─────────
|
||||||
|
|
||||||
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
|
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
|
||||||
export interface PlanLambdaResult {
|
interface PlanLambdaResultBase {
|
||||||
Action: "plan";
|
Action: "plan";
|
||||||
PlanS3Uri: string;
|
|
||||||
PlanHash: string;
|
PlanHash: string;
|
||||||
ChunkCount: number;
|
ChunkCount: number;
|
||||||
TotalFrames: number;
|
TotalFrames: number;
|
||||||
@@ -118,6 +163,20 @@ export interface PlanLambdaResult {
|
|||||||
DurationMs: number;
|
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. */
|
/** Result of a `renderChunk` invocation. Sized ≤200 bytes per §2.4. */
|
||||||
export interface RenderChunkLambdaResult {
|
export interface RenderChunkLambdaResult {
|
||||||
Action: "renderChunk";
|
Action: "renderChunk";
|
||||||
|
|||||||
@@ -15,15 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
CURRENT_PLAN_PROTOCOL,
|
CURRENT_PLAN_PROTOCOL,
|
||||||
|
createPlanV2FromV1,
|
||||||
type AssembleResult,
|
type AssembleResult,
|
||||||
type ChunkResult,
|
type ChunkResult,
|
||||||
type PlanResult,
|
type PlanResult,
|
||||||
|
type PlanV2Result,
|
||||||
} from "@hyperframes/producer/distributed";
|
} from "@hyperframes/producer/distributed";
|
||||||
|
import { recomputePlanHashFromPlanDir } from "../../producer/src/services/render/stages/freezePlan.js";
|
||||||
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
|
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
|
||||||
import { handler, unwrapEvent } from "./handler.js";
|
import { handler, unwrapEvent } from "./handler.js";
|
||||||
|
|
||||||
@@ -42,9 +46,13 @@ class FakeS3Client {
|
|||||||
ops: FakeS3Op[] = [];
|
ops: FakeS3Op[] = [];
|
||||||
// Map S3 URIs → byte buffers the fake serves.
|
// Map S3 URIs → byte buffers the fake serves.
|
||||||
objects = new Map<string, Buffer>();
|
objects = new Map<string, Buffer>();
|
||||||
|
metadata = new Map<string, Record<string, string>>();
|
||||||
|
|
||||||
// Methods called by the real S3 transport — minimal surface so the
|
// Methods called by the real S3 transport — minimal surface so the
|
||||||
// handler's call sites don't need rewriting under test.
|
// handler's call sites don't need rewriting under test.
|
||||||
|
// This fake intentionally implements the complete S3 command matrix inline so
|
||||||
|
// handler tests exercise realistic state transitions without AWS.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
async send(command: unknown): Promise<unknown> {
|
async send(command: unknown): Promise<unknown> {
|
||||||
const op = command as { input: { Bucket: string; Key: string } } & {
|
const op = command as { input: { Bucket: string; Key: string } } & {
|
||||||
constructor: { name: string };
|
constructor: { name: string };
|
||||||
@@ -59,20 +67,38 @@ class FakeS3Client {
|
|||||||
const { Readable } = await import("node:stream");
|
const { Readable } = await import("node:stream");
|
||||||
return { Body: Readable.from([bytes]) };
|
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") {
|
if (cmdName === "PutObjectCommand") {
|
||||||
// Buffer the body so we can record how many bytes were uploaded; the
|
// 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.
|
// handler's hot path streams from disk, but tests pin the count.
|
||||||
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
|
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
|
||||||
let bytes = 0;
|
const chunks: Buffer[] = [];
|
||||||
if (Buffer.isBuffer(body)) {
|
if (Buffer.isBuffer(body)) {
|
||||||
bytes = body.length;
|
chunks.push(body);
|
||||||
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
|
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
|
||||||
for await (const chunk of body as NodeJS.ReadableStream) {
|
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 });
|
const bytes = Buffer.concat(chunks);
|
||||||
this.objects.set(uri, Buffer.alloc(bytes));
|
this.ops.push({ kind: "upload", uri, bytes: bytes.length });
|
||||||
|
this.objects.set(uri, bytes);
|
||||||
|
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
|
||||||
|
if (metadata) this.metadata.set(uri, metadata);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
@@ -215,6 +241,49 @@ describe("handler dispatch", () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
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
|
// Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
|
||||||
// RIE smoke) seeds the path, handlePlan must not overwrite it.
|
// RIE smoke) seeds the path, handlePlan must not overwrite it.
|
||||||
@@ -454,6 +523,119 @@ describe("handler dispatch", () => {
|
|||||||
expect(assembleMock).toHaveBeenCalledTimes(1);
|
expect(assembleMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => {
|
||||||
|
const tmpRoot = makeTmpRoot();
|
||||||
|
const s3 = new FakeS3Client();
|
||||||
|
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
|
||||||
|
|
||||||
|
const planV2Mock = mock(
|
||||||
|
async (_projectDir: string, _config: unknown, planV2Dir: string): Promise<PlanV2Result> => {
|
||||||
|
const v1Dir = join(tmpRoot, `v1-${Date.now()}`);
|
||||||
|
makeMinimalV1PlanDir(v1Dir, true);
|
||||||
|
return createPlanV2FromV1(v1Dir, planV2Dir);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const renderChunkMock = mock(
|
||||||
|
async (planDir: string, _chunkIndex: number, outputPath: string): Promise<ChunkResult> => {
|
||||||
|
expect(existsSync(join(planDir, "audio.aac"))).toBe(false);
|
||||||
|
writeFileSync(outputPath, "V2-CHUNK");
|
||||||
|
return {
|
||||||
|
outputPath,
|
||||||
|
outputKind: "file",
|
||||||
|
framesEncoded: 30,
|
||||||
|
sha256: "b".repeat(64),
|
||||||
|
durationMs: 1,
|
||||||
|
perfPath: `${outputPath}.perf.json`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const assembleMock = mock(
|
||||||
|
async (
|
||||||
|
_planDir: string,
|
||||||
|
_chunks: readonly string[],
|
||||||
|
audioPath: string | null,
|
||||||
|
outputPath: string,
|
||||||
|
): Promise<AssembleResult> => {
|
||||||
|
expect(audioPath).not.toBeNull();
|
||||||
|
expect(readFileSync(audioPath as string, "utf-8")).toBe("AAC");
|
||||||
|
writeFileSync(outputPath, "V2-OUTPUT");
|
||||||
|
return { outputPath, durationMs: 1, framesEncoded: 30, fileSize: 9 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const deps = {
|
||||||
|
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
|
||||||
|
primitives: {
|
||||||
|
plan: mock(async () => {
|
||||||
|
throw new Error("v1 plan should not be called");
|
||||||
|
}) as unknown as typeof import("@hyperframes/producer/distributed").plan,
|
||||||
|
planV2: planV2Mock as unknown as typeof import("@hyperframes/producer/distributed").planV2,
|
||||||
|
renderChunk:
|
||||||
|
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
|
||||||
|
assemble:
|
||||||
|
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
|
||||||
|
},
|
||||||
|
tmpRoot,
|
||||||
|
skipChromeResolution: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const planned = await handler(
|
||||||
|
{
|
||||||
|
Action: "plan",
|
||||||
|
PlanProtocol: "v2",
|
||||||
|
ProjectS3Uri: "s3://bucket/project.tar.gz",
|
||||||
|
PlanOutputS3Prefix: "s3://bucket/renders/v2/",
|
||||||
|
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
expect(planned).toMatchObject({
|
||||||
|
PlanProtocol: "v2",
|
||||||
|
PlanV2ManifestS3Uri: "s3://bucket/renders/v2/v2/manifest.json",
|
||||||
|
PlanV2ArtifactS3Prefix: "s3://bucket/renders/v2/v2/artifacts/sha256",
|
||||||
|
});
|
||||||
|
expect("PlanS3Uri" in planned).toBe(false);
|
||||||
|
if (!("PlanProtocol" in planned) || planned.PlanProtocol !== "v2") {
|
||||||
|
throw new Error("expected v2 plan result");
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeChunk = s3.ops.length;
|
||||||
|
const chunk = await handler(
|
||||||
|
{
|
||||||
|
Action: "renderChunk",
|
||||||
|
PlanProtocol: "v2",
|
||||||
|
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
|
||||||
|
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
|
||||||
|
PlanHash: planned.PlanHash,
|
||||||
|
ChunkIndex: 0,
|
||||||
|
ChunkOutputS3Prefix: "s3://bucket/renders/v2/",
|
||||||
|
Format: "mp4",
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
if (chunk.Action !== "renderChunk") throw new Error("expected chunk result");
|
||||||
|
const audioDigest = createHash("sha256").update("AAC").digest("hex");
|
||||||
|
const audioUri = `${planned.PlanV2ArtifactS3Prefix}/${audioDigest.slice(0, 2)}/${audioDigest}`;
|
||||||
|
expect(s3.ops.slice(beforeChunk).some((operation) => operation.uri === audioUri)).toBe(false);
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
Action: "assemble",
|
||||||
|
PlanProtocol: "v2",
|
||||||
|
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
|
||||||
|
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
|
||||||
|
PlanHash: planned.PlanHash,
|
||||||
|
ChunkS3Uris: [chunk.ChunkS3Uri],
|
||||||
|
AudioS3Uri: null,
|
||||||
|
OutputS3Uri: "s3://bucket/renders/v2/output.mp4",
|
||||||
|
Format: "mp4",
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
s3.ops.some((operation) => operation.kind === "download" && operation.uri === audioUri),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unknown actions", async () => {
|
it("rejects unknown actions", async () => {
|
||||||
const tmpRoot = makeTmpRoot();
|
const tmpRoot = makeTmpRoot();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -575,3 +757,27 @@ async function makeMinimalPlanTar(): Promise<Buffer> {
|
|||||||
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
|
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
|
||||||
return rf(tarPath);
|
return rf(tarPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
|
||||||
|
mkdirSync(join(dir, "meta"), { recursive: true });
|
||||||
|
mkdirSync(join(dir, "compiled"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "compiled", "index.html"), "<html>aws v2 fixture</html>");
|
||||||
|
const planJson = {
|
||||||
|
planHash: "a".repeat(64),
|
||||||
|
chunkCount: 1,
|
||||||
|
totalFrames: 30,
|
||||||
|
dimensions: { fpsNum: 30, fpsDen: 1, width: 640, height: 360, format: "mp4" },
|
||||||
|
ffmpegVersion: "6.0",
|
||||||
|
producerVersion: "test",
|
||||||
|
fontSnapshotSha: "font-snapshot-test",
|
||||||
|
};
|
||||||
|
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "meta", "chunks.json"),
|
||||||
|
JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]),
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, "meta", "encoder.json"), "{}");
|
||||||
|
if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC");
|
||||||
|
planJson.planHash = recomputePlanHashFromPlanDir(dir);
|
||||||
|
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ import {
|
|||||||
type AssembleResult,
|
type AssembleResult,
|
||||||
type ChunkResult,
|
type ChunkResult,
|
||||||
type DistributedRenderConfig,
|
type DistributedRenderConfig,
|
||||||
|
listPlanV2ArtifactsForTarget,
|
||||||
|
materializePlanV2Target,
|
||||||
plan,
|
plan,
|
||||||
|
planV2,
|
||||||
type PlanResult,
|
type PlanResult,
|
||||||
|
type PlanV2Artifact,
|
||||||
|
type PlanV2MaterializationTarget,
|
||||||
|
type PlanV2Result,
|
||||||
|
readPlanV2Manifest,
|
||||||
renderChunk,
|
renderChunk,
|
||||||
} from "@hyperframes/producer/distributed";
|
} from "@hyperframes/producer/distributed";
|
||||||
import { resolveChromeExecutablePath } from "./chromium.js";
|
import { resolveChromeExecutablePath } from "./chromium.js";
|
||||||
@@ -39,9 +46,12 @@ import type {
|
|||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
import {
|
import {
|
||||||
downloadS3ObjectToFile,
|
downloadS3ObjectToFile,
|
||||||
|
downloadS3ObjectToFileVerified,
|
||||||
parseS3Uri,
|
parseS3Uri,
|
||||||
|
sha256File,
|
||||||
tarDirectory,
|
tarDirectory,
|
||||||
untarDirectory,
|
untarDirectory,
|
||||||
|
uploadContentAddressedFileToS3,
|
||||||
uploadFileToS3,
|
uploadFileToS3,
|
||||||
} from "./s3Transport.js";
|
} from "./s3Transport.js";
|
||||||
|
|
||||||
@@ -67,6 +77,7 @@ export interface HandlerDeps {
|
|||||||
s3?: S3Client;
|
s3?: S3Client;
|
||||||
primitives?: {
|
primitives?: {
|
||||||
plan: typeof plan;
|
plan: typeof plan;
|
||||||
|
planV2?: typeof planV2;
|
||||||
renderChunk: typeof renderChunk;
|
renderChunk: typeof renderChunk;
|
||||||
assemble: typeof assemble;
|
assemble: typeof assemble;
|
||||||
};
|
};
|
||||||
@@ -109,6 +120,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
normalizeTerminalErrorName(err);
|
||||||
// Log before re-throwing so CloudWatch captures the structured
|
// Log before re-throwing so CloudWatch captures the structured
|
||||||
// error context alongside Lambda's default stack trace. Otherwise
|
// error context alongside Lambda's default stack trace. Otherwise
|
||||||
// ops only sees the trace and has to correlate with execution
|
// ops only sees the trace and has to correlate with execution
|
||||||
@@ -116,6 +128,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
|
|||||||
logEvent({
|
logEvent({
|
||||||
event: "handler_error",
|
event: "handler_error",
|
||||||
action: unwrapped.Action,
|
action: unwrapped.Action,
|
||||||
|
input: summarizeEvent(unwrapped),
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
name: err instanceof Error ? err.name : undefined,
|
name: err instanceof Error ? err.name : undefined,
|
||||||
});
|
});
|
||||||
@@ -123,6 +136,25 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS Lambda reports `Error.name` to Step Functions, while producer errors
|
||||||
|
* expose stable machine codes separately. Normalize the terminal codes
|
||||||
|
* whose historical class names differ from their orchestration contracts.
|
||||||
|
*/
|
||||||
|
// The explicit error-name mapping is the public Step Functions failure contract.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
function normalizeTerminalErrorName(error: unknown): void {
|
||||||
|
if (!error || typeof error !== "object") return;
|
||||||
|
const candidate = error as { code?: unknown; name?: string };
|
||||||
|
if (
|
||||||
|
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
|
||||||
|
candidate.code === "PLAN_TOO_LARGE" ||
|
||||||
|
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
|
||||||
|
) {
|
||||||
|
candidate.name = candidate.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk through Step Functions' Map-state and Task-state envelopes until
|
* Walk through Step Functions' Map-state and Task-state envelopes until
|
||||||
* the discriminated event is found.
|
* the discriminated event is found.
|
||||||
@@ -178,6 +210,8 @@ function logEvent(payload: Record<string, unknown>): void {
|
|||||||
* the routable fields (S3 URIs, chunk index, format) needed to triage
|
* the routable fields (S3 URIs, chunk index, format) needed to triage
|
||||||
* a failure from CloudWatch.
|
* a failure from CloudWatch.
|
||||||
*/
|
*/
|
||||||
|
// Keep event variants together so logs share one redaction and summarization boundary.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
function summarizeEvent(
|
function summarizeEvent(
|
||||||
event: PlanEvent | RenderChunkEvent | AssembleEvent,
|
event: PlanEvent | RenderChunkEvent | AssembleEvent,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -186,18 +220,25 @@ function summarizeEvent(
|
|||||||
return {
|
return {
|
||||||
projectS3Uri: event.ProjectS3Uri,
|
projectS3Uri: event.ProjectS3Uri,
|
||||||
planOutputS3Prefix: event.PlanOutputS3Prefix,
|
planOutputS3Prefix: event.PlanOutputS3Prefix,
|
||||||
|
planProtocol: event.PlanProtocol ?? "v1",
|
||||||
format: event.Config.format,
|
format: event.Config.format,
|
||||||
fps: event.Config.fps,
|
fps: event.Config.fps,
|
||||||
};
|
};
|
||||||
case "renderChunk":
|
case "renderChunk":
|
||||||
return {
|
return {
|
||||||
planS3Uri: event.PlanS3Uri,
|
planProtocol: event.PlanProtocol ?? "v1",
|
||||||
|
...(event.PlanProtocol === "v2"
|
||||||
|
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
|
||||||
|
: { planS3Uri: event.PlanS3Uri }),
|
||||||
chunkIndex: event.ChunkIndex,
|
chunkIndex: event.ChunkIndex,
|
||||||
format: event.Format,
|
format: event.Format,
|
||||||
};
|
};
|
||||||
case "assemble":
|
case "assemble":
|
||||||
return {
|
return {
|
||||||
planS3Uri: event.PlanS3Uri,
|
planProtocol: event.PlanProtocol ?? "v1",
|
||||||
|
...(event.PlanProtocol === "v2"
|
||||||
|
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
|
||||||
|
: { planS3Uri: event.PlanS3Uri }),
|
||||||
chunkCount: event.ChunkS3Uris.length,
|
chunkCount: event.ChunkS3Uris.length,
|
||||||
hasAudio: event.AudioS3Uri !== null,
|
hasAudio: event.AudioS3Uri !== null,
|
||||||
outputS3Uri: event.OutputS3Uri,
|
outputS3Uri: event.OutputS3Uri,
|
||||||
@@ -226,6 +267,9 @@ function primeRuntimeEnv(): void {
|
|||||||
// ── Plan ────────────────────────────────────────────────────────────────────
|
// ── Plan ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
|
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
|
||||||
|
if (event.PlanProtocol === "v2") {
|
||||||
|
return handlePlanV2(event, deps);
|
||||||
|
}
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const s3 = deps?.s3 ?? getS3Client();
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
const primitive = deps?.primitives?.plan ?? plan;
|
const primitive = deps?.primitives?.plan ?? plan;
|
||||||
@@ -297,12 +341,90 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan v2 orchestration is kept as one transactional boundary: stage, CAS upload,
|
||||||
|
// and manifest-last publication must stay ordered and fail together.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
async function handlePlanV2(
|
||||||
|
event: Extract<PlanEvent, { PlanProtocol: "v2" }>,
|
||||||
|
deps?: HandlerDeps,
|
||||||
|
): Promise<Extract<PlanLambdaResult, { PlanProtocol: "v2" }>> {
|
||||||
|
const started = Date.now();
|
||||||
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
|
const primitive = deps?.primitives?.planV2 ?? planV2;
|
||||||
|
if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||||
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-v2-"));
|
||||||
|
const projectArchive = join(work, "project.tar.gz");
|
||||||
|
const projectDir = join(work, "project");
|
||||||
|
const planV2Dir = join(work, "plan-v2");
|
||||||
|
try {
|
||||||
|
await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
|
||||||
|
await untarDirectory(projectArchive, projectDir);
|
||||||
|
const result: PlanV2Result = await primitive(projectDir, { ...event.Config }, planV2Dir);
|
||||||
|
const manifest = readPlanV2Manifest(planV2Dir);
|
||||||
|
if (manifest.planHash !== result.planHash) {
|
||||||
|
throwPlanHashMismatch(result.planHash, manifest.planHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPrefix = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/v2`;
|
||||||
|
const artifactPrefix = `${outputPrefix}/artifacts/sha256`;
|
||||||
|
const uniqueArtifacts = [
|
||||||
|
...new Map(manifest.artifacts.map((artifact) => [artifact.sha256, artifact])).values(),
|
||||||
|
];
|
||||||
|
await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
|
||||||
|
const localPath = planV2BlobPath(planV2Dir, artifact.sha256);
|
||||||
|
await uploadContentAddressedFileToS3(
|
||||||
|
s3,
|
||||||
|
localPath,
|
||||||
|
planV2BlobUri(artifactPrefix, artifact.sha256),
|
||||||
|
artifact.sha256,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish the manifest only after every referenced blob is durable.
|
||||||
|
const manifestUri = `${outputPrefix}/manifest.json`;
|
||||||
|
await uploadContentAddressedFileToS3(
|
||||||
|
s3,
|
||||||
|
result.manifestPath,
|
||||||
|
manifestUri,
|
||||||
|
await sha256File(result.manifestPath),
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Action: "plan",
|
||||||
|
PlanProtocol: "v2",
|
||||||
|
PlanV2ManifestS3Uri: manifestUri,
|
||||||
|
PlanV2ArtifactS3Prefix: artifactPrefix,
|
||||||
|
PlanHash: result.planHash,
|
||||||
|
ChunkCount: result.chunkCount,
|
||||||
|
TotalFrames: result.totalFrames,
|
||||||
|
Fps: result.fps,
|
||||||
|
Width: result.width,
|
||||||
|
Height: result.height,
|
||||||
|
Format: result.format,
|
||||||
|
HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
|
||||||
|
AudioS3Uri: null,
|
||||||
|
FfmpegVersion: result.ffmpegVersion,
|
||||||
|
ProducerVersion: result.producerVersion,
|
||||||
|
DurationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanupDir(work);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── RenderChunk ─────────────────────────────────────────────────────────────
|
// ── RenderChunk ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleRenderChunk(
|
async function handleRenderChunk(
|
||||||
event: RenderChunkEvent,
|
event: RenderChunkEvent,
|
||||||
deps?: HandlerDeps,
|
deps?: HandlerDeps,
|
||||||
): Promise<RenderChunkLambdaResult> {
|
): Promise<RenderChunkLambdaResult> {
|
||||||
|
if (event.PlanProtocol === "v2") {
|
||||||
|
return handleRenderChunkV2(event, deps);
|
||||||
|
}
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const s3 = deps?.s3 ?? getS3Client();
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
||||||
@@ -364,6 +486,53 @@ async function handleRenderChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The v2 chunk handler deliberately keeps download, verified materialization,
|
||||||
|
// render, and upload in one lifecycle so cleanup and errors remain atomic.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
async function handleRenderChunkV2(
|
||||||
|
event: Extract<RenderChunkEvent, { PlanProtocol: "v2" }>,
|
||||||
|
deps?: HandlerDeps,
|
||||||
|
): Promise<RenderChunkLambdaResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
|
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
||||||
|
if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||||
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
|
||||||
|
}
|
||||||
|
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-v2-"));
|
||||||
|
try {
|
||||||
|
const planDir = await downloadAndMaterializePlanV2(
|
||||||
|
s3,
|
||||||
|
event,
|
||||||
|
{ role: "chunk", chunkIndex: event.ChunkIndex },
|
||||||
|
work,
|
||||||
|
);
|
||||||
|
const chunkOutputBase = join(
|
||||||
|
work,
|
||||||
|
event.Format === "png-sequence"
|
||||||
|
? `chunk-${pad(event.ChunkIndex)}`
|
||||||
|
: `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`,
|
||||||
|
);
|
||||||
|
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
|
||||||
|
const chunkUri = await uploadChunkOutput(
|
||||||
|
s3,
|
||||||
|
result,
|
||||||
|
event.ChunkOutputS3Prefix,
|
||||||
|
event.ChunkIndex,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
Action: "renderChunk",
|
||||||
|
ChunkS3Uri: chunkUri,
|
||||||
|
ChunkIndex: event.ChunkIndex,
|
||||||
|
Sha256: result.sha256,
|
||||||
|
FramesEncoded: result.framesEncoded,
|
||||||
|
DurationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanupDir(work);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadChunkOutput(
|
async function uploadChunkOutput(
|
||||||
s3: S3Client,
|
s3: S3Client,
|
||||||
result: ChunkResult,
|
result: ChunkResult,
|
||||||
@@ -393,6 +562,9 @@ async function handleAssemble(
|
|||||||
event: AssembleEvent,
|
event: AssembleEvent,
|
||||||
deps?: HandlerDeps,
|
deps?: HandlerDeps,
|
||||||
): Promise<AssembleLambdaResult> {
|
): Promise<AssembleLambdaResult> {
|
||||||
|
if (event.PlanProtocol === "v2") {
|
||||||
|
return handleAssembleV2(event, deps);
|
||||||
|
}
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const s3 = deps?.s3 ?? getS3Client();
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
const primitive = deps?.primitives?.assemble ?? assemble;
|
const primitive = deps?.primitives?.assemble ?? assemble;
|
||||||
@@ -442,6 +614,123 @@ async function handleAssemble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assembly mirrors the chunk lifecycle while adding assembler-only artifacts;
|
||||||
|
// keeping the steps local makes its temporary-storage ownership explicit.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
async function handleAssembleV2(
|
||||||
|
event: Extract<AssembleEvent, { PlanProtocol: "v2" }>,
|
||||||
|
deps?: HandlerDeps,
|
||||||
|
): Promise<AssembleLambdaResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const s3 = deps?.s3 ?? getS3Client();
|
||||||
|
const primitive = deps?.primitives?.assemble ?? assemble;
|
||||||
|
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-v2-"));
|
||||||
|
try {
|
||||||
|
const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
|
||||||
|
// `downloadAndMaterializePlanV2` materializes atomically. Audio is
|
||||||
|
// assembler-only and lives at the familiar v1-compatible location.
|
||||||
|
const audioPath = existsSync(join(planDir, "audio.aac")) ? join(planDir, "audio.aac") : null;
|
||||||
|
const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
|
||||||
|
const finalOutput =
|
||||||
|
event.Format === "png-sequence"
|
||||||
|
? join(work, "output-frames")
|
||||||
|
: join(work, `output${formatExtension(event.Format)}`);
|
||||||
|
const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
|
||||||
|
cfr: event.Cfr === true,
|
||||||
|
});
|
||||||
|
if (event.Format === "png-sequence") {
|
||||||
|
const tarball = `${finalOutput}.tar.gz`;
|
||||||
|
await tarDirectory(finalOutput, tarball);
|
||||||
|
await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
|
||||||
|
} else {
|
||||||
|
await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Action: "assemble",
|
||||||
|
OutputS3Uri: event.OutputS3Uri,
|
||||||
|
FramesEncoded: result.framesEncoded,
|
||||||
|
FileSize: result.fileSize,
|
||||||
|
DurationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanupDir(work);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndMaterializePlanV2(
|
||||||
|
s3: S3Client,
|
||||||
|
event: {
|
||||||
|
PlanV2ManifestS3Uri: string;
|
||||||
|
PlanV2ArtifactS3Prefix: string;
|
||||||
|
PlanHash: string;
|
||||||
|
},
|
||||||
|
target: PlanV2MaterializationTarget,
|
||||||
|
work: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const transportDir = join(work, "plan-v2");
|
||||||
|
mkdirSync(transportDir, { recursive: true });
|
||||||
|
await downloadS3ObjectToFile(s3, event.PlanV2ManifestS3Uri, join(transportDir, "plan.json"));
|
||||||
|
const manifest = readPlanV2Manifest(transportDir);
|
||||||
|
if (manifest.planHash !== event.PlanHash) {
|
||||||
|
throwPlanHashMismatch(event.PlanHash, manifest.planHash);
|
||||||
|
}
|
||||||
|
const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
|
||||||
|
const uniqueArtifacts = [
|
||||||
|
...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values(),
|
||||||
|
];
|
||||||
|
await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
|
||||||
|
await downloadPlanV2Artifact(s3, event.PlanV2ArtifactS3Prefix, transportDir, artifact);
|
||||||
|
});
|
||||||
|
const planDir = join(work, "plan");
|
||||||
|
materializePlanV2Target(transportDir, target, planDir);
|
||||||
|
return planDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPlanV2Artifact(
|
||||||
|
s3: S3Client,
|
||||||
|
artifactPrefix: string,
|
||||||
|
planV2Dir: string,
|
||||||
|
artifact: Readonly<PlanV2Artifact>,
|
||||||
|
): Promise<void> {
|
||||||
|
await downloadS3ObjectToFileVerified(
|
||||||
|
s3,
|
||||||
|
planV2BlobUri(artifactPrefix, artifact.sha256),
|
||||||
|
planV2BlobPath(planV2Dir, artifact.sha256),
|
||||||
|
artifact.sha256,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planV2BlobPath(planV2Dir: string, digest: string): string {
|
||||||
|
return join(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planV2BlobUri(prefix: string, digest: string): string {
|
||||||
|
return `${trimTrailingSlash(prefix)}/${digest.slice(0, 2)}/${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwPlanHashMismatch(expected: string, actual: string): never {
|
||||||
|
const error = new Error(
|
||||||
|
`PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`,
|
||||||
|
);
|
||||||
|
error.name = "PLAN_HASH_MISMATCH";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapConcurrent<T>(
|
||||||
|
values: readonly T[],
|
||||||
|
concurrency: number,
|
||||||
|
fn: (value: T) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (cursor < values.length) {
|
||||||
|
const index = cursor++;
|
||||||
|
await fn(values[index]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadChunkObjects(
|
async function downloadChunkObjects(
|
||||||
s3: S3Client,
|
s3: S3Client,
|
||||||
uris: string[],
|
uris: string[],
|
||||||
@@ -479,16 +768,25 @@ async function downloadChunkObjects(
|
|||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Collect every S3 URI that the handler will touch for a given event. */
|
/** 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[] {
|
function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] {
|
||||||
switch (event.Action) {
|
switch (event.Action) {
|
||||||
case "plan":
|
case "plan":
|
||||||
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
|
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
|
||||||
case "renderChunk":
|
case "renderChunk":
|
||||||
return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
|
return event.PlanProtocol === "v2"
|
||||||
|
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
|
||||||
|
: [event.PlanS3Uri, event.ChunkOutputS3Prefix];
|
||||||
case "assemble":
|
case "assemble":
|
||||||
return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
|
return [
|
||||||
(u): u is string => u != null,
|
...(event.PlanProtocol === "v2"
|
||||||
);
|
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
|
||||||
|
: [event.PlanS3Uri]),
|
||||||
|
...event.ChunkS3Uris,
|
||||||
|
event.OutputS3Uri,
|
||||||
|
event.AudioS3Uri,
|
||||||
|
].filter((u): u is string => u != null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,20 @@ export {
|
|||||||
type AssembleLambdaResult,
|
type AssembleLambdaResult,
|
||||||
type LambdaAction,
|
type LambdaAction,
|
||||||
type LambdaEvent,
|
type LambdaEvent,
|
||||||
|
type LambdaPlanProtocol,
|
||||||
type LambdaResult,
|
type LambdaResult,
|
||||||
type PlanEvent,
|
type PlanEvent,
|
||||||
type PlanLambdaResult,
|
type PlanLambdaResult,
|
||||||
|
type PlanV1Event,
|
||||||
|
type PlanV1LambdaResult,
|
||||||
|
type PlanV2Event,
|
||||||
|
type PlanV2LambdaResult,
|
||||||
type RenderChunkEvent,
|
type RenderChunkEvent,
|
||||||
type RenderChunkLambdaResult,
|
type RenderChunkLambdaResult,
|
||||||
|
type RenderChunkV1Event,
|
||||||
|
type RenderChunkV2Event,
|
||||||
|
type AssembleV1Event,
|
||||||
|
type AssembleV2Event,
|
||||||
type SerializableDistributedRenderConfig,
|
type SerializableDistributedRenderConfig,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
// `_setSparticuzChromiumForTests` is intentionally NOT re-exported from
|
// `_setSparticuzChromiumForTests` is intentionally NOT re-exported from
|
||||||
@@ -47,11 +56,13 @@ export {
|
|||||||
} from "./chromium.js";
|
} from "./chromium.js";
|
||||||
export {
|
export {
|
||||||
downloadS3ObjectToFile,
|
downloadS3ObjectToFile,
|
||||||
|
downloadS3ObjectToFileVerified,
|
||||||
formatS3Uri,
|
formatS3Uri,
|
||||||
parseS3Uri,
|
parseS3Uri,
|
||||||
type S3Location,
|
type S3Location,
|
||||||
tarDirectory,
|
tarDirectory,
|
||||||
untarDirectory,
|
untarDirectory,
|
||||||
|
uploadContentAddressedFileToS3,
|
||||||
uploadFileToS3,
|
uploadFileToS3,
|
||||||
} from "./s3Transport.js";
|
} from "./s3Transport.js";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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;
|
let scratchRoot: string;
|
||||||
|
|
||||||
@@ -91,3 +99,110 @@ describe("tar round-trip", () => {
|
|||||||
expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
|
expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("content-addressed v2 artifacts", () => {
|
||||||
|
it("uploads once and reuses an object with matching digest metadata", async () => {
|
||||||
|
const source = join(scratchRoot, "artifact-upload.bin");
|
||||||
|
writeFileSync(source, "immutable bytes");
|
||||||
|
const digest = await sha256File(source);
|
||||||
|
const s3 = new ContentAddressedFakeS3();
|
||||||
|
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
|
||||||
|
|
||||||
|
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe(
|
||||||
|
"uploaded",
|
||||||
|
);
|
||||||
|
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe("reused");
|
||||||
|
expect(s3.putCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to overwrite an immutable key with conflicting metadata", async () => {
|
||||||
|
const source = join(scratchRoot, "artifact-conflict.bin");
|
||||||
|
writeFileSync(source, "expected bytes");
|
||||||
|
const digest = await sha256File(source);
|
||||||
|
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
|
||||||
|
const s3 = new ContentAddressedFakeS3();
|
||||||
|
s3.objects.set(uri, {
|
||||||
|
bytes: Buffer.from("same length!!"),
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest),
|
||||||
|
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
|
||||||
|
expect(s3.putCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a downloaded artifact when digest verification fails", async () => {
|
||||||
|
const expectedSource = join(scratchRoot, "artifact-expected.bin");
|
||||||
|
const destination = join(scratchRoot, "artifact-download.bin");
|
||||||
|
writeFileSync(expectedSource, "expected");
|
||||||
|
const expected = await sha256File(expectedSource);
|
||||||
|
const uri = "s3://bucket/v2/artifacts/corrupt";
|
||||||
|
const s3 = new ContentAddressedFakeS3();
|
||||||
|
s3.objects.set(uri, { bytes: Buffer.from("corrupt"), sha256: "f".repeat(64) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadS3ObjectToFileVerified(s3.asClient(), uri, destination, expected),
|
||||||
|
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
|
||||||
|
const { existsSync } = await import("node:fs");
|
||||||
|
expect(existsSync(destination)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class ContentAddressedFakeS3 {
|
||||||
|
readonly objects = new Map<string, { bytes: Buffer; sha256: string }>();
|
||||||
|
putCount = 0;
|
||||||
|
|
||||||
|
asClient(): import("@aws-sdk/client-s3").S3Client {
|
||||||
|
return this as unknown as import("@aws-sdk/client-s3").S3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This fake intentionally keeps the S3 command matrix in one stateful boundary;
|
||||||
|
// splitting commands across helpers would obscure the transport test behavior.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
async send(command: unknown): Promise<unknown> {
|
||||||
|
const value = command as {
|
||||||
|
constructor: { name: string };
|
||||||
|
input: {
|
||||||
|
Bucket: string;
|
||||||
|
Key: string;
|
||||||
|
Body?: NodeJS.ReadableStream;
|
||||||
|
Metadata?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const uri = `s3://${value.input.Bucket}/${value.input.Key}`;
|
||||||
|
if (value.constructor.name === "HeadObjectCommand") {
|
||||||
|
const object = this.objects.get(uri);
|
||||||
|
if (!object) {
|
||||||
|
const error = new Error("not found") as Error & {
|
||||||
|
$metadata: { httpStatusCode: number };
|
||||||
|
};
|
||||||
|
error.name = "NotFound";
|
||||||
|
error.$metadata = { httpStatusCode: 404 };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ContentLength: object.bytes.length,
|
||||||
|
Metadata: { sha256: object.sha256 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value.constructor.name === "GetObjectCommand") {
|
||||||
|
const object = this.objects.get(uri);
|
||||||
|
if (!object) throw new Error("missing fake object");
|
||||||
|
const { Readable } = await import("node:stream");
|
||||||
|
return { Body: Readable.from([object.bytes]) };
|
||||||
|
}
|
||||||
|
if (value.constructor.name === "PutObjectCommand") {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of value.input.Body ?? []) chunks.push(Buffer.from(chunk));
|
||||||
|
const bytes = Buffer.concat(chunks);
|
||||||
|
this.objects.set(uri, {
|
||||||
|
bytes,
|
||||||
|
sha256: value.input.Metadata?.sha256 ?? "",
|
||||||
|
});
|
||||||
|
this.putCount += 1;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected command ${value.constructor.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ import {
|
|||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { pipeline } from "node:stream/promises";
|
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";
|
import * as tar from "tar";
|
||||||
|
|
||||||
/** Parsed `s3://bucket/key` URI. */
|
/** Parsed `s3://bucket/key` URI. */
|
||||||
@@ -75,6 +81,26 @@ export async function downloadS3ObjectToFile(
|
|||||||
await pipeline(body, createWriteStream(destPath));
|
await pipeline(body, createWriteStream(destPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download and verify an immutable plan-v2 artifact before materialization. */
|
||||||
|
export async function downloadS3ObjectToFileVerified(
|
||||||
|
client: S3Client,
|
||||||
|
uri: string,
|
||||||
|
destPath: string,
|
||||||
|
expectedSha256: string,
|
||||||
|
): Promise<void> {
|
||||||
|
assertSha256(expectedSha256);
|
||||||
|
await downloadS3ObjectToFile(client, uri, destPath);
|
||||||
|
const actual = await sha256File(destPath);
|
||||||
|
if (actual !== expectedSha256) {
|
||||||
|
rmSync(destPath, { force: true });
|
||||||
|
const error = new Error(
|
||||||
|
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`,
|
||||||
|
);
|
||||||
|
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a local file's contents to an S3 URI using a streaming
|
* Upload a local file's contents to an S3 URI using a streaming
|
||||||
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
|
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
|
||||||
@@ -104,6 +130,94 @@ export async function uploadFileToS3(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload one content-addressed plan-v2 artifact exactly once.
|
||||||
|
*
|
||||||
|
* Existing objects are reused only when their immutable digest metadata and
|
||||||
|
* byte length agree. A conflicting object is never overwritten: doing so
|
||||||
|
* could change a plan already being consumed by another chunk invocation.
|
||||||
|
*/
|
||||||
|
export async function uploadContentAddressedFileToS3(
|
||||||
|
client: S3Client,
|
||||||
|
localPath: string,
|
||||||
|
uri: string,
|
||||||
|
expectedSha256: string,
|
||||||
|
contentType?: string,
|
||||||
|
): Promise<"uploaded" | "reused"> {
|
||||||
|
assertSha256(expectedSha256);
|
||||||
|
if (!existsSync(localPath)) {
|
||||||
|
throw new Error(`[s3Transport] upload source missing: ${localPath}`);
|
||||||
|
}
|
||||||
|
const actualSha256 = await sha256File(localPath);
|
||||||
|
if (actualSha256 !== expectedSha256) {
|
||||||
|
const error = new Error(
|
||||||
|
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`,
|
||||||
|
);
|
||||||
|
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bucket, key } = parseS3Uri(uri);
|
||||||
|
const size = statSync(localPath).size;
|
||||||
|
try {
|
||||||
|
const existing = await client.send(
|
||||||
|
new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" }),
|
||||||
|
);
|
||||||
|
if (existing.ContentLength === size && existing.Metadata?.sha256 === expectedSha256) {
|
||||||
|
return "reused";
|
||||||
|
}
|
||||||
|
const error = new Error(
|
||||||
|
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: immutable object ${uri} already exists with different digest metadata or size`,
|
||||||
|
);
|
||||||
|
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
|
||||||
|
throw error;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isS3NotFound(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: createReadStream(localPath),
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: size,
|
||||||
|
Metadata: { sha256: expectedSha256 },
|
||||||
|
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return "uploaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256File(path: string): Promise<string> {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
for await (const chunk of createReadStream(path)) {
|
||||||
|
hash.update(chunk as Buffer);
|
||||||
|
}
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSha256(value: string): void {
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`[s3Transport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isS3NotFound(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const candidate = error as {
|
||||||
|
name?: string;
|
||||||
|
$metadata?: { httpStatusCode?: number };
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
candidate.name === "NotFound" ||
|
||||||
|
candidate.name === "NoSuchKey" ||
|
||||||
|
candidate.$metadata?.httpStatusCode === 404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pack a directory into a `.tar.gz` at `destTarball`. Uses the `tar` npm
|
* 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
|
* package (pure JS over `node:zlib`) rather than spawning a system tar
|
||||||
|
|||||||
@@ -201,6 +201,30 @@ describe("getRenderProgress", () => {
|
|||||||
expect(progress.endedAt).not.toBeNull();
|
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 () => {
|
it("computes cost from observed billed duration", async () => {
|
||||||
const sfn = new FakeSFN();
|
const sfn = new FakeSFN();
|
||||||
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
|
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
|
|||||||
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda
|
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda
|
||||||
// result, so we re-extract them here from the state exit's
|
// result, so we re-extract them here from the state exit's
|
||||||
// own output rather than relying on the Lambda payload.
|
// own output rather than relying on the Lambda payload.
|
||||||
if (ev.stateExitedEventDetails?.name === "Assemble") {
|
if (isAssembleState(ev.stateExitedEventDetails?.name)) {
|
||||||
assembleComplete = true;
|
assembleComplete = true;
|
||||||
const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
|
const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
|
||||||
if (exitPayload && typeof exitPayload === "object") {
|
if (exitPayload && typeof exitPayload === "object") {
|
||||||
@@ -350,12 +350,20 @@ function applyPayloadFrameCounts(
|
|||||||
currentLambdaState: string | null,
|
currentLambdaState: string | null,
|
||||||
bump: (delta: number) => void,
|
bump: (delta: number) => void,
|
||||||
): void {
|
): void {
|
||||||
if (currentLambdaState !== "RenderChunk") return;
|
if (!isRenderChunkState(currentLambdaState)) return;
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
|
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
|
* Lambda success payloads from our handler include `DurationMs` — the
|
||||||
* wall-clock the handler observed. We use it as a best-effort proxy
|
* wall-clock the handler observed. We use it as a best-effort proxy
|
||||||
|
|||||||
@@ -89,9 +89,27 @@ describe("renderToLambda", () => {
|
|||||||
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
|
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
|
||||||
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
|
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
|
||||||
Config: baseConfig,
|
Config: baseConfig,
|
||||||
|
PlanProtocol: "v1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("derives the file extension from config.format", async () => {
|
||||||
const sfn = new FakeSFN();
|
const sfn = new FakeSFN();
|
||||||
const s3 = new FakeS3();
|
const s3 = new FakeS3();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
||||||
import type { S3Client } from "@aws-sdk/client-s3";
|
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 { formatExtension } from "../formatExtension.js";
|
||||||
import { formatS3Uri } from "../s3Transport.js";
|
import { formatS3Uri } from "../s3Transport.js";
|
||||||
import { deploySite, type SiteHandle } from "./deploySite.js";
|
import { deploySite, type SiteHandle } from "./deploySite.js";
|
||||||
@@ -37,6 +37,11 @@ export interface RenderToLambdaOptions {
|
|||||||
siteHandle?: SiteHandle;
|
siteHandle?: SiteHandle;
|
||||||
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
|
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
|
||||||
config: SerializableDistributedRenderConfig;
|
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`). */
|
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
|
||||||
bucketName: string;
|
bucketName: string;
|
||||||
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
|
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
|
||||||
@@ -110,6 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
|
|||||||
PlanOutputS3Prefix: planOutputS3Prefix,
|
PlanOutputS3Prefix: planOutputS3Prefix,
|
||||||
OutputS3Uri: outputS3Uri,
|
OutputS3Uri: outputS3Uri,
|
||||||
Config: opts.config,
|
Config: opts.config,
|
||||||
|
PlanProtocol: opts.planProtocol ?? "v1",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reject oversize input client-side. Step Functions Standard caps the
|
// Reject oversize input client-side. Step Functions Standard caps the
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"generatedAudio": {
|
||||||
|
"path": "assets/tone.wav",
|
||||||
|
"durationSeconds": 1,
|
||||||
|
"frequencyHz": 330,
|
||||||
|
"sampleRate": 48000
|
||||||
|
},
|
||||||
|
"generatedPressureFile": {
|
||||||
|
"path": "unused-pressure.bin",
|
||||||
|
"bytes": 65536,
|
||||||
|
"seed": 20260725
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=320, height=180" />
|
||||||
|
<title>Plan protocol size pressure</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
height: 180px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
.scene {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 40%, #22d3ee 0 18px, transparent 19px),
|
||||||
|
radial-gradient(circle at 68% 60%, #f472b6 0 26px, transparent 27px), #0f172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main
|
||||||
|
id="root"
|
||||||
|
data-composition-id="plan-protocol-size-pressure"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="1"
|
||||||
|
data-width="320"
|
||||||
|
data-height="180"
|
||||||
|
data-fps="30"
|
||||||
|
data-no-timeline
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
id="tone"
|
||||||
|
src="assets/tone.wav"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="1"
|
||||||
|
data-volume="0.5"
|
||||||
|
></audio>
|
||||||
|
<section class="scene clip" data-start="0" data-duration="1"></section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"generatedAudio": {
|
||||||
|
"path": "assets/tone.wav",
|
||||||
|
"durationSeconds": 2,
|
||||||
|
"frequencyHz": 440,
|
||||||
|
"sampleRate": 48000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=320, height=180" />
|
||||||
|
<title>Plan protocol visual and audio parity</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
height: 180px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #07111f;
|
||||||
|
}
|
||||||
|
.scene {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.first {
|
||||||
|
background: linear-gradient(135deg, #07111f, #0e7490);
|
||||||
|
}
|
||||||
|
.second {
|
||||||
|
background: linear-gradient(135deg, #312e81, #be185d);
|
||||||
|
}
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: 0 12px 30px rgb(0 0 0 / 35%);
|
||||||
|
}
|
||||||
|
.first .shape {
|
||||||
|
top: 38px;
|
||||||
|
left: 48px;
|
||||||
|
transform: rotate(12deg);
|
||||||
|
}
|
||||||
|
.second .shape {
|
||||||
|
right: 48px;
|
||||||
|
bottom: 38px;
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main
|
||||||
|
id="root"
|
||||||
|
data-composition-id="plan-protocol-visual-audio"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="2"
|
||||||
|
data-width="320"
|
||||||
|
data-height="180"
|
||||||
|
data-fps="30"
|
||||||
|
data-no-timeline
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
id="tone"
|
||||||
|
src="assets/tone.wav"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="2"
|
||||||
|
data-volume="0.5"
|
||||||
|
></audio>
|
||||||
|
<section class="scene first clip" data-start="0" data-duration="1">
|
||||||
|
<div class="shape"></div>
|
||||||
|
</section>
|
||||||
|
<section class="scene second clip" data-start="1" data-duration="1">
|
||||||
|
<div class="shape"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts",
|
"build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"parity:check": "tsx src/parity-harness.ts",
|
"parity:check": "tsx src/parity-harness.ts",
|
||||||
|
"parity:plans": "tsx src/plan-parity-harness.ts",
|
||||||
"parity:fixtures": "tsx src/parity-fixtures.ts",
|
"parity:fixtures": "tsx src/parity-fixtures.ts",
|
||||||
"parity:fixtures:ci": "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\"",
|
"parity:check:ci": "tsx src/parity-harness.ts --preview-url \"http://127.0.0.1:4173/minimal-wysiwyg.html\" --producer-url \"http://127.0.0.1:4173/minimal-wysiwyg.html?mode=producer\" --checkpoints \"0,0.5,1,1.5\" --allow-mismatch-ratio 0 --emulate-producer-swap true --artifacts-dir \".debug/parity-harness-ci\"",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { normalizeFfprobeMetadata, parseCanonicalFrameHashes } from "./plan-parity-analysis.js";
|
||||||
|
|
||||||
|
describe("parseCanonicalFrameHashes()", () => {
|
||||||
|
it("returns ordered SHA-256 hashes from ffmpeg framemd5 output", () => {
|
||||||
|
const first = "a".repeat(64);
|
||||||
|
const second = "b".repeat(64);
|
||||||
|
expect(
|
||||||
|
parseCanonicalFrameHashes(
|
||||||
|
[
|
||||||
|
"#format: frame checksums",
|
||||||
|
"#hash: SHA256",
|
||||||
|
`0, 0, 0, 1, 16, ${first}`,
|
||||||
|
`0, 1, 1, 1, 16, ${second}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
).toEqual([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses malformed or empty output", () => {
|
||||||
|
expect(() => parseCanonicalFrameHashes("# only comments")).toThrow(/no decoded/);
|
||||||
|
expect(() => parseCanonicalFrameHashes("0, 0, not-a-sha")).toThrow(/unexpected/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeFfprobeMetadata()", () => {
|
||||||
|
it("normalizes relevant video, audio, and duration fields", () => {
|
||||||
|
expect(
|
||||||
|
normalizeFfprobeMetadata({
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
codec_type: "video",
|
||||||
|
codec_name: "h264",
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
pix_fmt: "yuv420p",
|
||||||
|
avg_frame_rate: "30/1",
|
||||||
|
r_frame_rate: "30/1",
|
||||||
|
nb_frames: "60",
|
||||||
|
color_space: "bt709",
|
||||||
|
color_transfer: "bt709",
|
||||||
|
color_primaries: "bt709",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codec_type: "audio",
|
||||||
|
codec_name: "aac",
|
||||||
|
sample_rate: "48000",
|
||||||
|
channels: 2,
|
||||||
|
channel_layout: "stereo",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: { duration: "2.000000" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
video: {
|
||||||
|
codecName: "h264",
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
pixelFormat: "yuv420p",
|
||||||
|
averageFrameRate: "30/1",
|
||||||
|
realFrameRate: "30/1",
|
||||||
|
frameCount: 60,
|
||||||
|
colorSpace: "bt709",
|
||||||
|
colorTransfer: "bt709",
|
||||||
|
colorPrimaries: "bt709",
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
codecName: "aac",
|
||||||
|
sampleRate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
channelLayout: "stereo",
|
||||||
|
},
|
||||||
|
durationSeconds: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stream duration and maps N/A values to null", () => {
|
||||||
|
expect(
|
||||||
|
normalizeFfprobeMetadata({
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
codec_type: "video",
|
||||||
|
codec_name: "rawvideo",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
nb_frames: "N/A",
|
||||||
|
duration: "0.5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: {},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
video: { frameCount: null },
|
||||||
|
audio: null,
|
||||||
|
durationSeconds: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createReadStream, readdirSync, statSync, type Dirent } from "node:fs";
|
||||||
|
import { relative, resolve } from "node:path";
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import type {
|
||||||
|
PlanParityDriverResult,
|
||||||
|
PlanParityMediaMeasurement,
|
||||||
|
PlanParityMeasurement,
|
||||||
|
PlanParityStreamMetadata,
|
||||||
|
} from "./plan-parity-contract.js";
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 && value !== "N/A" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value !== "string" || value.length === 0 || value === "N/A") return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerValue(value: unknown): number | null {
|
||||||
|
const parsed = numberValue(value);
|
||||||
|
return parsed === null || !Number.isInteger(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCommandSuccess(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
maxBuffer = 64 * 1024 * 1024,
|
||||||
|
): Buffer {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "buffer",
|
||||||
|
maxBuffer,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${command} exited ${result.status}: ${result.stderr.toString("utf-8").trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCanonicalFrameHashes(framemd5: string): string[] {
|
||||||
|
const hashes: string[] = [];
|
||||||
|
for (const line of framemd5.split(/\r?\n/u)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
||||||
|
const columns = trimmed.split(",").map((column) => column.trim());
|
||||||
|
const hash = columns.at(-1);
|
||||||
|
if (!hash || !/^[a-f0-9]{64}$/iu.test(hash)) {
|
||||||
|
throw new Error(`unexpected framemd5 row: ${line}`);
|
||||||
|
}
|
||||||
|
hashes.push(hash.toLowerCase());
|
||||||
|
}
|
||||||
|
if (hashes.length === 0) {
|
||||||
|
throw new Error("ffmpeg produced no decoded video frame hashes");
|
||||||
|
}
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFfprobeMetadata(value: unknown): PlanParityStreamMetadata {
|
||||||
|
if (!isRecord(value)) throw new Error("ffprobe output must be a JSON object");
|
||||||
|
const rawStreams = value.streams;
|
||||||
|
if (!Array.isArray(rawStreams)) throw new Error("ffprobe output has no streams array");
|
||||||
|
const streams = rawStreams.filter(isRecord);
|
||||||
|
const video = streams.find((stream) => stream.codec_type === "video");
|
||||||
|
const audio = streams.find((stream) => stream.codec_type === "audio");
|
||||||
|
const format = isRecord(value.format) ? value.format : {};
|
||||||
|
const durationSeconds =
|
||||||
|
numberValue(format.duration) ??
|
||||||
|
Math.max(0, ...streams.map((stream) => numberValue(stream.duration) ?? 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
video: video
|
||||||
|
? {
|
||||||
|
codecName: stringValue(video.codec_name),
|
||||||
|
width: integerValue(video.width) ?? 0,
|
||||||
|
height: integerValue(video.height) ?? 0,
|
||||||
|
pixelFormat: stringValue(video.pix_fmt),
|
||||||
|
averageFrameRate: stringValue(video.avg_frame_rate),
|
||||||
|
realFrameRate: stringValue(video.r_frame_rate),
|
||||||
|
frameCount: integerValue(video.nb_frames),
|
||||||
|
colorSpace: stringValue(video.color_space),
|
||||||
|
colorTransfer: stringValue(video.color_transfer),
|
||||||
|
colorPrimaries: stringValue(video.color_primaries),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
audio: audio
|
||||||
|
? {
|
||||||
|
codecName: stringValue(audio.codec_name),
|
||||||
|
sampleRate: integerValue(audio.sample_rate),
|
||||||
|
channels: integerValue(audio.channels),
|
||||||
|
channelLayout: stringValue(audio.channel_layout),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
durationSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeMetadata(outputPath: string): PlanParityStreamMetadata {
|
||||||
|
const bytes = requireCommandSuccess("ffprobe", [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
[
|
||||||
|
"format=duration",
|
||||||
|
"stream=codec_type,codec_name,width,height,pix_fmt,avg_frame_rate,r_frame_rate,nb_frames",
|
||||||
|
"stream=color_space,color_transfer,color_primaries,sample_rate,channels,channel_layout,duration",
|
||||||
|
].join(":"),
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
return normalizeFfprobeMetadata(JSON.parse(bytes.toString("utf-8")) as unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalFrameHashes(outputPath: string): string[] {
|
||||||
|
const bytes = requireCommandSuccess(
|
||||||
|
"ffmpeg",
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
outputPath,
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-an",
|
||||||
|
"-pix_fmt",
|
||||||
|
"rgba",
|
||||||
|
"-hash",
|
||||||
|
"sha256",
|
||||||
|
"-f",
|
||||||
|
"framemd5",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
256 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
return parseCanonicalFrameHashes(bytes.toString("utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(path: string): Promise<{ sha256: string; bytes: number }> {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
let bytes = 0;
|
||||||
|
for await (const chunk of createReadStream(path)) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
hash.update(buffer);
|
||||||
|
bytes += buffer.length;
|
||||||
|
}
|
||||||
|
return { sha256: hash.digest("hex"), bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkFiles(root: string, current = root): Array<{ absolute: string; relative: string }> {
|
||||||
|
const entries: Dirent[] = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
);
|
||||||
|
const files: Array<{ absolute: string; relative: string }> = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const absolute = resolve(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...walkFiles(root, absolute));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push({ absolute, relative: relative(root, absolute).replaceAll("\\", "/") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashPath(path: string): Promise<{ sha256: string; bytes: number }> {
|
||||||
|
const stat = statSync(path);
|
||||||
|
if (stat.isFile()) return hashFile(path);
|
||||||
|
if (!stat.isDirectory()) throw new Error(`cannot hash non-file artifact ${path}`);
|
||||||
|
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
let bytes = 0;
|
||||||
|
for (const file of walkFiles(path)) {
|
||||||
|
const digest = await hashFile(file.absolute);
|
||||||
|
hash.update(file.relative);
|
||||||
|
hash.update("\0");
|
||||||
|
hash.update(digest.sha256);
|
||||||
|
hash.update("\0");
|
||||||
|
bytes += digest.bytes;
|
||||||
|
}
|
||||||
|
return { sha256: hash.digest("hex"), bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canonicalPcmAudio(
|
||||||
|
outputPath: string,
|
||||||
|
): Promise<PlanParityMediaMeasurement["pcmAudio"]> {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
"ffmpeg",
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
outputPath,
|
||||||
|
"-map",
|
||||||
|
"0:a:0",
|
||||||
|
"-vn",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
hash.update(chunk);
|
||||||
|
bytes += chunk.length;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
const message = Buffer.concat(stderr).toString("utf-8");
|
||||||
|
// A video-only fixture is a valid parity input. ffmpeg uses this
|
||||||
|
// wording when the optional audio map has no match.
|
||||||
|
if (/matches no streams|does not contain any stream/iu.test(message)) {
|
||||||
|
resolvePromise(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg PCM decode exited ${code}: ${message.trim()}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bytes % 4 !== 0) {
|
||||||
|
reject(new Error(`canonical stereo s16le byte count ${bytes} is not divisible by 4`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvePromise({
|
||||||
|
sha256: hash.digest("hex"),
|
||||||
|
sampleCount: bytes / 4,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzePlanParityDriverResult(
|
||||||
|
driverName: string,
|
||||||
|
result: PlanParityDriverResult,
|
||||||
|
): Promise<PlanParityMeasurement> {
|
||||||
|
const output = await hashPath(result.outputPath);
|
||||||
|
const metadata = probeMetadata(result.outputPath);
|
||||||
|
const [pcmAudio, frameSha256] = await Promise.all([
|
||||||
|
metadata.audio ? canonicalPcmAudio(result.outputPath) : Promise.resolve(null),
|
||||||
|
Promise.resolve(canonicalFrameHashes(result.outputPath)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for (const chunk of [...result.chunks].sort((left, right) => left.index - right.index)) {
|
||||||
|
const digest = await hashPath(chunk.path);
|
||||||
|
if (
|
||||||
|
chunk.reportedSha256 !== undefined &&
|
||||||
|
chunk.reportedSha256.toLowerCase() !== digest.sha256
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`chunk ${chunk.index} digest mismatch: adapter reported ${chunk.reportedSha256}, measured ${digest.sha256}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chunks.push({
|
||||||
|
index: chunk.index,
|
||||||
|
sha256: digest.sha256,
|
||||||
|
bytes: digest.bytes,
|
||||||
|
reportedSha256: chunk.reportedSha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const downloaded = result.transferBytes.downloaded;
|
||||||
|
const uploaded = result.transferBytes.uploaded;
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: result.protocol,
|
||||||
|
driver: driverName,
|
||||||
|
media: {
|
||||||
|
outputSha256: output.sha256,
|
||||||
|
outputBytes: output.bytes,
|
||||||
|
frameSha256,
|
||||||
|
pcmAudio,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
chunks,
|
||||||
|
transferBytes: {
|
||||||
|
downloaded,
|
||||||
|
uploaded,
|
||||||
|
total: downloaded + uploaded,
|
||||||
|
},
|
||||||
|
peakMaterializedBytes: result.peakMaterializedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
comparePlanParityMeasurements,
|
||||||
|
type PlanParityMeasurement,
|
||||||
|
type PlanParityProtocol,
|
||||||
|
} from "./plan-parity-contract.js";
|
||||||
|
|
||||||
|
function measurement(
|
||||||
|
protocol: PlanParityProtocol,
|
||||||
|
overrides: {
|
||||||
|
frames?: string[];
|
||||||
|
pcmSha?: string;
|
||||||
|
duration?: number;
|
||||||
|
outputSha?: string;
|
||||||
|
outputBytes?: number;
|
||||||
|
chunkSha?: string;
|
||||||
|
transferBytes?: number;
|
||||||
|
peakBytes?: number;
|
||||||
|
} = {},
|
||||||
|
): PlanParityMeasurement {
|
||||||
|
const transferBytes = overrides.transferBytes ?? 100;
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
driver: "test",
|
||||||
|
media: {
|
||||||
|
outputSha256: overrides.outputSha ?? "encoded",
|
||||||
|
outputBytes: overrides.outputBytes ?? 1000,
|
||||||
|
frameSha256: overrides.frames ?? ["frame-0", "frame-1"],
|
||||||
|
pcmAudio: {
|
||||||
|
sha256: overrides.pcmSha ?? "pcm",
|
||||||
|
sampleCount: 96_000,
|
||||||
|
bytes: 384_000,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
video: {
|
||||||
|
codecName: "h264",
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
pixelFormat: "yuv420p",
|
||||||
|
averageFrameRate: "30/1",
|
||||||
|
realFrameRate: "30/1",
|
||||||
|
frameCount: 60,
|
||||||
|
colorSpace: "bt709",
|
||||||
|
colorTransfer: "bt709",
|
||||||
|
colorPrimaries: "bt709",
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
codecName: "aac",
|
||||||
|
sampleRate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
channelLayout: "stereo",
|
||||||
|
},
|
||||||
|
durationSeconds: overrides.duration ?? 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunks: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
sha256: overrides.chunkSha ?? "chunk",
|
||||||
|
bytes: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transferBytes: {
|
||||||
|
downloaded: Math.floor(transferBytes / 2),
|
||||||
|
uploaded: Math.ceil(transferBytes / 2),
|
||||||
|
total: transferBytes,
|
||||||
|
},
|
||||||
|
peakMaterializedBytes: overrides.peakBytes ?? 2000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("comparePlanParityMeasurements()", () => {
|
||||||
|
it("accepts semantic parity while only reporting encoded and transport differences", () => {
|
||||||
|
const result = comparePlanParityMeasurements(
|
||||||
|
measurement("v1", {
|
||||||
|
outputSha: "container-v1",
|
||||||
|
outputBytes: 1000,
|
||||||
|
transferBytes: 10_000,
|
||||||
|
peakBytes: 20_000,
|
||||||
|
}),
|
||||||
|
measurement("v2", {
|
||||||
|
outputSha: "container-v2",
|
||||||
|
outputBytes: 900,
|
||||||
|
transferBytes: 5000,
|
||||||
|
peakBytes: 6000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.checks.find((entry) => entry.name === "encoded-output")?.detail).toContain(
|
||||||
|
"(reported)",
|
||||||
|
);
|
||||||
|
expect(result.checks.find((entry) => entry.name === "transfer-bytes")?.detail).toContain(
|
||||||
|
"v1=10000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on decoded frame, PCM, metadata, duration, or chunk drift", () => {
|
||||||
|
const cases: Array<[string, PlanParityMeasurement]> = [
|
||||||
|
["decoded-video-frames", measurement("v2", { frames: ["different"] })],
|
||||||
|
["canonical-pcm-audio", measurement("v2", { pcmSha: "different" })],
|
||||||
|
[
|
||||||
|
"ffprobe-stream-metadata",
|
||||||
|
{
|
||||||
|
...measurement("v2"),
|
||||||
|
media: {
|
||||||
|
...measurement("v2").media,
|
||||||
|
metadata: {
|
||||||
|
...measurement("v2").media.metadata,
|
||||||
|
video: {
|
||||||
|
...measurement("v2").media.metadata.video!,
|
||||||
|
width: 640,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["ffprobe-duration", measurement("v2", { duration: 2.1 })],
|
||||||
|
["chunk-hashes", measurement("v2", { chunkSha: "different" })],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [expectedFailure, v2] of cases) {
|
||||||
|
const result = comparePlanParityMeasurements(measurement("v1"), v2);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.checks.find((entry) => entry.name === expectedFailure)?.passed).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can enforce encoded equality and v2 resource ceilings", () => {
|
||||||
|
const result = comparePlanParityMeasurements(
|
||||||
|
measurement("v1"),
|
||||||
|
measurement("v2", {
|
||||||
|
outputSha: "different",
|
||||||
|
transferBytes: 101,
|
||||||
|
peakBytes: 201,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
requireEncodedOutputEquality: true,
|
||||||
|
maxV2TransferBytes: 100,
|
||||||
|
maxV2PeakMaterializedBytes: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.checks.filter((entry) => !entry.passed).map((entry) => entry.name)).toEqual([
|
||||||
|
"encoded-output",
|
||||||
|
"transfer-bytes",
|
||||||
|
"peak-materialized-working-set",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reversed or same-protocol comparisons", () => {
|
||||||
|
expect(() => comparePlanParityMeasurements(measurement("v2"), measurement("v1"))).toThrow(
|
||||||
|
/ordered v1\/v2/,
|
||||||
|
);
|
||||||
|
expect(() => comparePlanParityMeasurements(measurement("v1"), measurement("v1"))).toThrow(
|
||||||
|
/ordered v1\/v2/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose or compare planHash", () => {
|
||||||
|
const result = comparePlanParityMeasurements(measurement("v1"), measurement("v2"));
|
||||||
|
expect(JSON.stringify(result)).not.toContain("planHash");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Protocol-neutral contract for comparing distributed render plans.
|
||||||
|
*
|
||||||
|
* The comparator intentionally has no `planHash` field. v1 and v2 use
|
||||||
|
* different artifact layouts and hash schemas, so their plan hashes are not
|
||||||
|
* expected to match even when they render identical output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlanParityProtocol = "v1" | "v2";
|
||||||
|
|
||||||
|
export interface PlanParityRenderConfig {
|
||||||
|
fps: 24 | 30 | 60;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
format: "mp4";
|
||||||
|
chunkSize?: number;
|
||||||
|
maxParallelChunks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityDriverInput {
|
||||||
|
protocol: PlanParityProtocol;
|
||||||
|
projectDir: string;
|
||||||
|
outputDir: string;
|
||||||
|
renderConfig: PlanParityRenderConfig;
|
||||||
|
/**
|
||||||
|
* Test-only plan limit. The Lambda-local driver forwards this to v1 as
|
||||||
|
* `planDirSizeLimitBytes`; v2 ignores it because it does not materialize a
|
||||||
|
* monolithic plan directory.
|
||||||
|
*/
|
||||||
|
planSizeCapBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityChunkArtifact {
|
||||||
|
index: number;
|
||||||
|
path: string;
|
||||||
|
/** Adapter-reported digest, retained for diagnostics. */
|
||||||
|
reportedSha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityDriverResult {
|
||||||
|
protocol: PlanParityProtocol;
|
||||||
|
outputPath: string;
|
||||||
|
chunks: PlanParityChunkArtifact[];
|
||||||
|
transferBytes: {
|
||||||
|
downloaded: number;
|
||||||
|
uploaded: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Maximum materialized bytes observed in the worker scratch directory.
|
||||||
|
* This is distinct from S3 storage and from process RSS.
|
||||||
|
*/
|
||||||
|
peakMaterializedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityDriver {
|
||||||
|
readonly name: string;
|
||||||
|
render(input: PlanParityDriverInput): Promise<PlanParityDriverResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityStreamMetadata {
|
||||||
|
video: {
|
||||||
|
codecName: string | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pixelFormat: string | null;
|
||||||
|
averageFrameRate: string | null;
|
||||||
|
realFrameRate: string | null;
|
||||||
|
frameCount: number | null;
|
||||||
|
colorSpace: string | null;
|
||||||
|
colorTransfer: string | null;
|
||||||
|
colorPrimaries: string | null;
|
||||||
|
} | null;
|
||||||
|
audio: {
|
||||||
|
codecName: string | null;
|
||||||
|
sampleRate: number | null;
|
||||||
|
channels: number | null;
|
||||||
|
channelLayout: string | null;
|
||||||
|
} | null;
|
||||||
|
durationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityMediaMeasurement {
|
||||||
|
outputSha256: string;
|
||||||
|
outputBytes: number;
|
||||||
|
frameSha256: string[];
|
||||||
|
pcmAudio: {
|
||||||
|
sha256: string;
|
||||||
|
/**
|
||||||
|
* Interleaved audio frames after canonical decoding to signed 16-bit,
|
||||||
|
* 48 kHz, stereo PCM. One sample frame contains two channel samples.
|
||||||
|
*/
|
||||||
|
sampleCount: number;
|
||||||
|
bytes: number;
|
||||||
|
} | null;
|
||||||
|
metadata: PlanParityStreamMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityChunkMeasurement {
|
||||||
|
index: number;
|
||||||
|
sha256: string;
|
||||||
|
bytes: number;
|
||||||
|
reportedSha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityMeasurement {
|
||||||
|
protocol: PlanParityProtocol;
|
||||||
|
driver: string;
|
||||||
|
media: PlanParityMediaMeasurement;
|
||||||
|
chunks: PlanParityChunkMeasurement[];
|
||||||
|
transferBytes: {
|
||||||
|
downloaded: number;
|
||||||
|
uploaded: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
peakMaterializedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityComparisonOptions {
|
||||||
|
/** ffprobe duration tolerance. Defaults to 1 ms. */
|
||||||
|
durationToleranceSeconds?: number;
|
||||||
|
/**
|
||||||
|
* Encoded containers can contain non-semantic metadata. Default false:
|
||||||
|
* report output digest/size without requiring byte-identical containers.
|
||||||
|
*/
|
||||||
|
requireEncodedOutputEquality?: boolean;
|
||||||
|
/** Optional ceiling applied independently to the v2 run. */
|
||||||
|
maxV2TransferBytes?: number;
|
||||||
|
/** Optional ceiling applied independently to the v2 run. */
|
||||||
|
maxV2PeakMaterializedBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityCheck {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityComparison {
|
||||||
|
passed: boolean;
|
||||||
|
checks: PlanParityCheck[];
|
||||||
|
v1: PlanParityMeasurement;
|
||||||
|
v2: PlanParityMeasurement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(name: string, passed: boolean, detail: string): PlanParityCheck {
|
||||||
|
return { name, passed, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalJson(left: unknown, right: unknown): boolean {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparableMetadata(
|
||||||
|
metadata: PlanParityStreamMetadata,
|
||||||
|
): Omit<PlanParityStreamMetadata, "durationSeconds"> {
|
||||||
|
return {
|
||||||
|
video: metadata.video,
|
||||||
|
audio: metadata.audio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare semantic render output plus transport/resource measurements.
|
||||||
|
*
|
||||||
|
* `outputBytes`, transfer bytes, and working-set bytes are always surfaced.
|
||||||
|
* They are not equality gates by default: v2 is expected to change artifact
|
||||||
|
* packaging, and encoded containers may carry non-semantic differences.
|
||||||
|
*/
|
||||||
|
// This function is intentionally an exhaustive, flat contract checklist. Each
|
||||||
|
// branch emits a distinct diagnostic needed to root-cause parity failures.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function comparePlanParityMeasurements(
|
||||||
|
v1: PlanParityMeasurement,
|
||||||
|
v2: PlanParityMeasurement,
|
||||||
|
options: PlanParityComparisonOptions = {},
|
||||||
|
): PlanParityComparison {
|
||||||
|
if (v1.protocol !== "v1" || v2.protocol !== "v2") {
|
||||||
|
throw new Error(
|
||||||
|
`plan parity requires ordered v1/v2 measurements (got ${v1.protocol}/${v2.protocol})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationToleranceSeconds = options.durationToleranceSeconds ?? 0.001;
|
||||||
|
const durationDelta = Math.abs(
|
||||||
|
v1.media.metadata.durationSeconds - v2.media.metadata.durationSeconds,
|
||||||
|
);
|
||||||
|
const checks: PlanParityCheck[] = [
|
||||||
|
check(
|
||||||
|
"decoded-video-frames",
|
||||||
|
equalJson(v1.media.frameSha256, v2.media.frameSha256),
|
||||||
|
`v1=${v1.media.frameSha256.length} frames, v2=${v2.media.frameSha256.length} frames`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"canonical-pcm-audio",
|
||||||
|
equalJson(v1.media.pcmAudio, v2.media.pcmAudio),
|
||||||
|
v1.media.pcmAudio && v2.media.pcmAudio
|
||||||
|
? `v1=${v1.media.pcmAudio.sampleCount} samples, v2=${v2.media.pcmAudio.sampleCount} samples`
|
||||||
|
: `v1=${v1.media.pcmAudio ? "present" : "none"}, v2=${v2.media.pcmAudio ? "present" : "none"}`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ffprobe-stream-metadata",
|
||||||
|
equalJson(comparableMetadata(v1.media.metadata), comparableMetadata(v2.media.metadata)),
|
||||||
|
"normalized video/audio stream metadata",
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ffprobe-duration",
|
||||||
|
durationDelta <= durationToleranceSeconds,
|
||||||
|
`delta=${durationDelta.toFixed(6)}s, tolerance=${durationToleranceSeconds.toFixed(6)}s`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"chunk-hashes",
|
||||||
|
equalJson(
|
||||||
|
v1.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
|
||||||
|
v2.chunks.map(({ index, sha256, bytes }) => ({ index, sha256, bytes })),
|
||||||
|
),
|
||||||
|
`v1=${v1.chunks.length} chunks/${v1.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes, ` +
|
||||||
|
`v2=${v2.chunks.length} chunks/${v2.chunks.reduce((sum, chunk) => sum + chunk.bytes, 0)} bytes`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"encoded-output",
|
||||||
|
options.requireEncodedOutputEquality !== true ||
|
||||||
|
(v1.media.outputSha256 === v2.media.outputSha256 &&
|
||||||
|
v1.media.outputBytes === v2.media.outputBytes),
|
||||||
|
`v1=${v1.media.outputBytes} bytes/${v1.media.outputSha256}, ` +
|
||||||
|
`v2=${v2.media.outputBytes} bytes/${v2.media.outputSha256}` +
|
||||||
|
(options.requireEncodedOutputEquality === true ? " (strict)" : " (reported)"),
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"transfer-bytes",
|
||||||
|
options.maxV2TransferBytes === undefined ||
|
||||||
|
v2.transferBytes.total <= options.maxV2TransferBytes,
|
||||||
|
`v1=${v1.transferBytes.total} bytes, v2=${v2.transferBytes.total} bytes` +
|
||||||
|
(options.maxV2TransferBytes === undefined
|
||||||
|
? " (reported)"
|
||||||
|
: `, v2 limit=${options.maxV2TransferBytes}`),
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"peak-materialized-working-set",
|
||||||
|
options.maxV2PeakMaterializedBytes === undefined ||
|
||||||
|
v2.peakMaterializedBytes <= options.maxV2PeakMaterializedBytes,
|
||||||
|
`v1=${v1.peakMaterializedBytes} bytes, v2=${v2.peakMaterializedBytes} bytes` +
|
||||||
|
(options.maxV2PeakMaterializedBytes === undefined
|
||||||
|
? " (reported)"
|
||||||
|
: `, v2 limit=${options.maxV2PeakMaterializedBytes}`),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: checks.every((entry) => entry.passed),
|
||||||
|
checks,
|
||||||
|
v1,
|
||||||
|
v2,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
generatePressureBytes,
|
||||||
|
generateToneWav,
|
||||||
|
preparePlanParityFixture,
|
||||||
|
} from "./plan-parity-fixture.js";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const path of cleanup.splice(0)) {
|
||||||
|
rmSync(path, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plan parity generated fixtures", () => {
|
||||||
|
it("generates a valid deterministic mono PCM WAV", () => {
|
||||||
|
const config = { durationSeconds: 0.25, frequencyHz: 440, sampleRate: 48_000 };
|
||||||
|
const first = generateToneWav(config);
|
||||||
|
const second = generateToneWav(config);
|
||||||
|
expect(first.equals(second)).toBe(true);
|
||||||
|
expect(first.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||||
|
expect(first.subarray(8, 12).toString("ascii")).toBe("WAVE");
|
||||||
|
expect(first.readUInt32LE(24)).toBe(48_000);
|
||||||
|
expect(first.readUInt32LE(40)).toBe(24_000);
|
||||||
|
expect(first.length).toBe(24_044);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates deterministic size-pressure bytes that vary with the seed", () => {
|
||||||
|
const first = generatePressureBytes(65_536, 123);
|
||||||
|
const again = generatePressureBytes(65_536, 123);
|
||||||
|
const other = generatePressureBytes(65_536, 124);
|
||||||
|
expect(first.equals(again)).toBe(true);
|
||||||
|
expect(first.equals(other)).toBe(false);
|
||||||
|
expect(new Set(first).size).toBeGreaterThan(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes the checked-in visual/audio fixture", () => {
|
||||||
|
const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-fixture-"));
|
||||||
|
cleanup.push(target);
|
||||||
|
preparePlanParityFixture(
|
||||||
|
join(import.meta.dir, "..", "fixtures", "plan-parity-visual-audio"),
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
const tone = readFileSync(join(target, "assets", "tone.wav"));
|
||||||
|
expect(tone.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||||
|
expect(readFileSync(join(target, "index.html"), "utf-8")).toContain("assets/tone.wav");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes a small compression-resistant pressure payload", () => {
|
||||||
|
const target = mkdtempSync(join(tmpdir(), "hf-plan-parity-pressure-"));
|
||||||
|
cleanup.push(target);
|
||||||
|
preparePlanParityFixture(
|
||||||
|
join(import.meta.dir, "..", "fixtures", "plan-parity-size-pressure"),
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
const pressurePath = join(target, "unused-pressure.bin");
|
||||||
|
expect(statSync(pressurePath).size).toBe(65_536);
|
||||||
|
expect(createHash("sha256").update(readFileSync(pressurePath)).digest("hex")).toBe(
|
||||||
|
"0e5f00e17597a73cf7a273d772456db0544959ed19a437954fd800a78447f468",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const FIXTURE_CONFIG_FILE = ".plan-parity-fixture.json";
|
||||||
|
|
||||||
|
interface GeneratedAudioConfig {
|
||||||
|
path: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
frequencyHz: number;
|
||||||
|
sampleRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedPressureFileConfig {
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
seed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanParityFixtureConfig {
|
||||||
|
generatedAudio?: GeneratedAudioConfig;
|
||||||
|
generatedPressureFile?: GeneratedPressureFileConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveNumber(record: Record<string, unknown>, key: string): number {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a positive number`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePath(record: Record<string, unknown>, key: string): string {
|
||||||
|
const value = record[key];
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
value.length === 0 ||
|
||||||
|
value.startsWith("/") ||
|
||||||
|
value.split(/[\\/]/u).includes("..")
|
||||||
|
) {
|
||||||
|
throw new Error(`${FIXTURE_CONFIG_FILE}: ${key} must be a safe relative path`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeneratedAudio(value: unknown): GeneratedAudioConfig | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isRecord(value)) throw new Error(`${FIXTURE_CONFIG_FILE}: generatedAudio must be an object`);
|
||||||
|
return {
|
||||||
|
path: relativePath(value, "path"),
|
||||||
|
durationSeconds: positiveNumber(value, "durationSeconds"),
|
||||||
|
frequencyHz: positiveNumber(value, "frequencyHz"),
|
||||||
|
sampleRate: positiveNumber(value, "sampleRate"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePressureFile(value: unknown): GeneratedPressureFileConfig | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${FIXTURE_CONFIG_FILE}: generatedPressureFile must be an object`);
|
||||||
|
}
|
||||||
|
const bytes = positiveNumber(value, "bytes");
|
||||||
|
const seed = positiveNumber(value, "seed");
|
||||||
|
if (!Number.isInteger(bytes) || !Number.isInteger(seed)) {
|
||||||
|
throw new Error(`${FIXTURE_CONFIG_FILE}: pressure bytes and seed must be integers`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: relativePath(value, "path"),
|
||||||
|
bytes,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFixtureConfig(projectDir: string): PlanParityFixtureConfig {
|
||||||
|
const configPath = join(projectDir, FIXTURE_CONFIG_FILE);
|
||||||
|
if (!existsSync(configPath)) return {};
|
||||||
|
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
|
||||||
|
if (!isRecord(raw)) throw new Error(`${FIXTURE_CONFIG_FILE}: root must be an object`);
|
||||||
|
return {
|
||||||
|
generatedAudio: parseGeneratedAudio(raw.generatedAudio),
|
||||||
|
generatedPressureFile: parsePressureFile(raw.generatedPressureFile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAscii(buffer: Buffer, offset: number, value: string): void {
|
||||||
|
buffer.write(value, offset, value.length, "ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a deterministic mono PCM WAV without relying on ffmpeg. */
|
||||||
|
export function generateToneWav(config: Omit<GeneratedAudioConfig, "path">): Buffer {
|
||||||
|
const sampleCount = Math.round(config.durationSeconds * config.sampleRate);
|
||||||
|
const pcmBytes = sampleCount * 2;
|
||||||
|
const wav = Buffer.alloc(44 + pcmBytes);
|
||||||
|
writeAscii(wav, 0, "RIFF");
|
||||||
|
wav.writeUInt32LE(36 + pcmBytes, 4);
|
||||||
|
writeAscii(wav, 8, "WAVE");
|
||||||
|
writeAscii(wav, 12, "fmt ");
|
||||||
|
wav.writeUInt32LE(16, 16);
|
||||||
|
wav.writeUInt16LE(1, 20);
|
||||||
|
wav.writeUInt16LE(1, 22);
|
||||||
|
wav.writeUInt32LE(config.sampleRate, 24);
|
||||||
|
wav.writeUInt32LE(config.sampleRate * 2, 28);
|
||||||
|
wav.writeUInt16LE(2, 32);
|
||||||
|
wav.writeUInt16LE(16, 34);
|
||||||
|
writeAscii(wav, 36, "data");
|
||||||
|
wav.writeUInt32LE(pcmBytes, 40);
|
||||||
|
for (let sample = 0; sample < sampleCount; sample += 1) {
|
||||||
|
const phase = (2 * Math.PI * config.frequencyHz * sample) / config.sampleRate;
|
||||||
|
const value = Math.round(Math.sin(phase) * 0.25 * 32767);
|
||||||
|
wav.writeInt16LE(value, 44 + sample * 2);
|
||||||
|
}
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic xorshift bytes that do not collapse into a tiny gzip. */
|
||||||
|
export function generatePressureBytes(bytes: number, seed: number): Buffer {
|
||||||
|
const output = Buffer.alloc(bytes);
|
||||||
|
let state = seed >>> 0;
|
||||||
|
for (let index = 0; index < bytes; index += 1) {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >>> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
output[index] = state & 0xff;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a checked-in fixture into a driver-owned project directory and
|
||||||
|
* materialize its generated binary assets. Source fixtures stay small and
|
||||||
|
* reviewable; size-pressure tests can vary their v1 cap without allocating
|
||||||
|
* multi-gigabyte files.
|
||||||
|
*/
|
||||||
|
export function preparePlanParityFixture(sourceDir: string, projectDir: string): void {
|
||||||
|
mkdirSync(projectDir, { recursive: true });
|
||||||
|
cpSync(sourceDir, projectDir, { recursive: true });
|
||||||
|
const config = readFixtureConfig(projectDir);
|
||||||
|
|
||||||
|
if (config.generatedAudio) {
|
||||||
|
const target = join(projectDir, config.generatedAudio.path);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, generateToneWav(config.generatedAudio));
|
||||||
|
}
|
||||||
|
if (config.generatedPressureFile) {
|
||||||
|
const target = join(projectDir, config.generatedPressureFile.path);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
target,
|
||||||
|
generatePressureBytes(config.generatedPressureFile.bytes, config.generatedPressureFile.seed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "bun:test";
|
||||||
|
import type { PlanParityDriver, PlanParityMeasurement } from "./plan-parity-contract.js";
|
||||||
|
import {
|
||||||
|
classifyPlanTooLargeProbeFailure,
|
||||||
|
parsePlanParityArgs,
|
||||||
|
runPlanV2SizePressure,
|
||||||
|
} from "./plan-parity-harness.js";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const target of cleanup.splice(0)) {
|
||||||
|
rmSync(target, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeV2Measurement(): PlanParityMeasurement {
|
||||||
|
return {
|
||||||
|
protocol: "v2",
|
||||||
|
driver: "fake-lambda-local",
|
||||||
|
media: {
|
||||||
|
outputSha256: "output",
|
||||||
|
outputBytes: 123,
|
||||||
|
frameSha256: ["frame"],
|
||||||
|
pcmAudio: null,
|
||||||
|
metadata: {
|
||||||
|
video: null,
|
||||||
|
audio: null,
|
||||||
|
durationSeconds: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunks: [],
|
||||||
|
transferBytes: {
|
||||||
|
downloaded: 10,
|
||||||
|
uploaded: 20,
|
||||||
|
total: 30,
|
||||||
|
},
|
||||||
|
peakMaterializedBytes: 40,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parsePlanParityArgs()", () => {
|
||||||
|
it("defaults both explicit protocol runs to lambda-local", () => {
|
||||||
|
const options = parsePlanParityArgs(["node", "plan-parity"]);
|
||||||
|
expect(options.v1Target).toBe("lambda-local");
|
||||||
|
expect(options.v2Target).toBe("lambda-local");
|
||||||
|
expect(options.renderConfig).toMatchObject({
|
||||||
|
fps: 30,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
format: "mp4",
|
||||||
|
});
|
||||||
|
expect(options.expectV1PlanTooLarge).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses resource gates and a low v1 size cap", () => {
|
||||||
|
const options = parsePlanParityArgs([
|
||||||
|
"node",
|
||||||
|
"plan-parity",
|
||||||
|
"--v1-plan-size-cap-bytes=32768",
|
||||||
|
"--max-v2-transfer-bytes",
|
||||||
|
"1048576",
|
||||||
|
"--max-v2-peak-materialized-bytes",
|
||||||
|
"524288",
|
||||||
|
"--strict-encoded-output=false",
|
||||||
|
"--chunk-size",
|
||||||
|
"15",
|
||||||
|
"--expect-v1-plan-too-large",
|
||||||
|
]);
|
||||||
|
expect(options.v1PlanSizeCapBytes).toBe(32_768);
|
||||||
|
expect(options.comparison.maxV2TransferBytes).toBe(1_048_576);
|
||||||
|
expect(options.comparison.maxV2PeakMaterializedBytes).toBe(524_288);
|
||||||
|
expect(options.comparison.requireEncodedOutputEquality).toBe(false);
|
||||||
|
expect(options.renderConfig.chunkSize).toBe(15);
|
||||||
|
expect(options.expectV1PlanTooLarge).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves an explicit deployed-AWS target grammar", () => {
|
||||||
|
const options = parsePlanParityArgs([
|
||||||
|
"node",
|
||||||
|
"plan-parity",
|
||||||
|
"--v1-target",
|
||||||
|
"aws:hf-plan-v1-test",
|
||||||
|
"--v2-target",
|
||||||
|
"aws:hf-plan-v2-test",
|
||||||
|
]);
|
||||||
|
expect(options.v1Target).toBe("aws:hf-plan-v1-test");
|
||||||
|
expect(options.v2Target).toBe("aws:hf-plan-v2-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects bad targets and numeric flags", () => {
|
||||||
|
expect(() => parsePlanParityArgs(["node", "plan-parity", "--v2-target", "production"])).toThrow(
|
||||||
|
/lambda-local or aws/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
parsePlanParityArgs(["node", "plan-parity", "--v1-plan-size-cap-bytes", "-1"]),
|
||||||
|
).toThrow(/positive integer/);
|
||||||
|
expect(() => parsePlanParityArgs(["node", "plan-parity", "--fps", "25"])).toThrow(
|
||||||
|
/24, 30, or 60/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
parsePlanParityArgs(["node", "plan-parity", "--duration-tolerance-seconds", "not-a-number"]),
|
||||||
|
).toThrow(/non-negative finite number/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPlanV2SizePressure()", () => {
|
||||||
|
it("records typed v1 PLAN_TOO_LARGE and still completes explicit v2", async () => {
|
||||||
|
const fixtureDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-fixture-"));
|
||||||
|
const artifactsDir = mkdtempSync(join(tmpdir(), "hf-plan-pressure-report-"));
|
||||||
|
cleanup.push(fixtureDir, artifactsDir);
|
||||||
|
writeFileSync(join(fixtureDir, "index.html"), "<html></html>", "utf-8");
|
||||||
|
const calls: string[] = [];
|
||||||
|
const driver: PlanParityDriver = {
|
||||||
|
name: "fake-lambda-local",
|
||||||
|
async render(input) {
|
||||||
|
calls.push(input.protocol);
|
||||||
|
if (input.protocol === "v1") {
|
||||||
|
const error = new Error("synthetic cap exceeded") as Error & {
|
||||||
|
code: "PLAN_TOO_LARGE";
|
||||||
|
sizeBytes: number;
|
||||||
|
limitBytes: number;
|
||||||
|
};
|
||||||
|
error.code = "PLAN_TOO_LARGE";
|
||||||
|
error.sizeBytes = 65_536;
|
||||||
|
error.limitBytes = 32_768;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
protocol: "v2",
|
||||||
|
outputPath: join(input.outputDir, "output.mp4"),
|
||||||
|
chunks: [],
|
||||||
|
transferBytes: { downloaded: 10, uploaded: 20 },
|
||||||
|
peakMaterializedBytes: 40,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = await runPlanV2SizePressure({
|
||||||
|
fixtureDir,
|
||||||
|
artifactsDir,
|
||||||
|
driver,
|
||||||
|
renderConfig: {
|
||||||
|
fps: 30,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
format: "mp4",
|
||||||
|
},
|
||||||
|
v1PlanSizeCapBytes: 32_768,
|
||||||
|
analyzeDriverResult: async () => fakeV2Measurement(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual(["v1", "v2"]);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
expect(report.v1).toEqual({
|
||||||
|
status: "expected-failure",
|
||||||
|
code: "PLAN_TOO_LARGE",
|
||||||
|
message: "synthetic cap exceeded",
|
||||||
|
sizeBytes: 65_536,
|
||||||
|
limitBytes: 32_768,
|
||||||
|
});
|
||||||
|
expect(report.v2.status).toBe("success");
|
||||||
|
const written = JSON.parse(
|
||||||
|
readFileSync(join(artifactsDir, "plan-too-large-v2-report.json"), "utf-8"),
|
||||||
|
) as {
|
||||||
|
passed: boolean;
|
||||||
|
v1: { code: string };
|
||||||
|
v2: { status: string };
|
||||||
|
};
|
||||||
|
expect(written).toMatchObject({
|
||||||
|
passed: true,
|
||||||
|
v1: { code: "PLAN_TOO_LARGE" },
|
||||||
|
v2: { status: "success" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes non-PLAN_TOO_LARGE failures", () => {
|
||||||
|
const error = new Error("network down") as Error & { code: string };
|
||||||
|
error.code = "S3_UNAVAILABLE";
|
||||||
|
expect(classifyPlanTooLargeProbeFailure(error)).toEqual({
|
||||||
|
status: "unexpected-failure",
|
||||||
|
code: "S3_UNAVAILABLE",
|
||||||
|
message: "network down",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { analyzePlanParityDriverResult } from "./plan-parity-analysis.js";
|
||||||
|
import {
|
||||||
|
comparePlanParityMeasurements,
|
||||||
|
type PlanParityComparison,
|
||||||
|
type PlanParityComparisonOptions,
|
||||||
|
type PlanParityDriver,
|
||||||
|
type PlanParityDriverResult,
|
||||||
|
type PlanParityMeasurement,
|
||||||
|
type PlanParityRenderConfig,
|
||||||
|
} from "./plan-parity-contract.js";
|
||||||
|
import { preparePlanParityFixture } from "./plan-parity-fixture.js";
|
||||||
|
|
||||||
|
export type PlanParityTarget = "lambda-local" | `aws:${string}`;
|
||||||
|
|
||||||
|
export interface RunPlanProtocolParityOptions {
|
||||||
|
fixtureDir: string;
|
||||||
|
artifactsDir: string;
|
||||||
|
v1Driver: PlanParityDriver;
|
||||||
|
v2Driver: PlanParityDriver;
|
||||||
|
renderConfig: PlanParityRenderConfig;
|
||||||
|
v1PlanSizeCapBytes?: number;
|
||||||
|
comparison?: PlanParityComparisonOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanParityCliOptions {
|
||||||
|
fixtureDir: string;
|
||||||
|
artifactsDir: string;
|
||||||
|
v1Target: PlanParityTarget;
|
||||||
|
v2Target: PlanParityTarget;
|
||||||
|
renderConfig: PlanParityRenderConfig;
|
||||||
|
v1PlanSizeCapBytes?: number;
|
||||||
|
expectV1PlanTooLarge: boolean;
|
||||||
|
comparison: PlanParityComparisonOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunPlanV2SizePressureOptions {
|
||||||
|
fixtureDir: string;
|
||||||
|
artifactsDir: string;
|
||||||
|
driver: PlanParityDriver;
|
||||||
|
renderConfig: PlanParityRenderConfig;
|
||||||
|
v1PlanSizeCapBytes: number;
|
||||||
|
/**
|
||||||
|
* Test seam for the post-render media analyzer. Production callers use
|
||||||
|
* the canonical ffmpeg/ffprobe analyzer.
|
||||||
|
*/
|
||||||
|
analyzeDriverResult?: (
|
||||||
|
driverName: string,
|
||||||
|
result: PlanParityDriverResult,
|
||||||
|
) => Promise<PlanParityMeasurement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanTooLargeProbeOutcome =
|
||||||
|
| {
|
||||||
|
status: "expected-failure";
|
||||||
|
code: "PLAN_TOO_LARGE";
|
||||||
|
message: string;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
limitBytes: number | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "unexpected-success";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "unexpected-failure";
|
||||||
|
code: string | null;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanV2PressureOutcome =
|
||||||
|
| {
|
||||||
|
status: "success";
|
||||||
|
measurement: PlanParityMeasurement;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "failure";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlanV2SizePressureReport {
|
||||||
|
passed: boolean;
|
||||||
|
checks: Array<{
|
||||||
|
name: "v1-plan-too-large" | "v2-render-success";
|
||||||
|
passed: boolean;
|
||||||
|
detail: string;
|
||||||
|
}>;
|
||||||
|
v1: PlanTooLargeProbeOutcome;
|
||||||
|
v2: PlanV2PressureOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInteger(name: string, value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`plan parity: --${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeNumber(name: string, value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new Error(`plan parity: --${name} must be a non-negative finite number`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(name: string, value: string | undefined): boolean {
|
||||||
|
if (value === undefined || value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
throw new Error(`plan parity: --${name} must be true or false`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(name: string, value: string | undefined): PlanParityTarget {
|
||||||
|
const target = value ?? "lambda-local";
|
||||||
|
if (target === "lambda-local") return target;
|
||||||
|
if (target.startsWith("aws:") && target.length > "aws:".length) {
|
||||||
|
return target as `aws:${string}`;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`plan parity: --${name} must be lambda-local or aws:<isolated-stack> (got ${JSON.stringify(target)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectArgs(argv: string[]): Map<string, string> {
|
||||||
|
const args = new Map<string, string>();
|
||||||
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
|
const token = argv[index];
|
||||||
|
if (!token?.startsWith("--")) {
|
||||||
|
throw new Error(`plan parity: unexpected positional argument ${JSON.stringify(token)}`);
|
||||||
|
}
|
||||||
|
const equals = token.indexOf("=");
|
||||||
|
if (equals > 2) {
|
||||||
|
args.set(token.slice(2, equals), token.slice(equals + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = token.slice(2);
|
||||||
|
const next = argv[index + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
args.set(key, "true");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
args.set(key, next);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlanParityArgs(argv: string[]): PlanParityCliOptions {
|
||||||
|
const args = collectArgs(argv);
|
||||||
|
const fixtureDir = resolve(args.get("fixture") ?? "fixtures/plan-parity-visual-audio");
|
||||||
|
const artifactsDir = resolve(args.get("artifacts-dir") ?? ".debug/plan-protocol-parity");
|
||||||
|
const fps = parsePositiveInteger("fps", args.get("fps")) ?? 30;
|
||||||
|
if (fps !== 24 && fps !== 30 && fps !== 60) {
|
||||||
|
throw new Error("plan parity: --fps must be 24, 30, or 60");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fixtureDir,
|
||||||
|
artifactsDir,
|
||||||
|
v1Target: parseTarget("v1-target", args.get("v1-target")),
|
||||||
|
v2Target: parseTarget("v2-target", args.get("v2-target")),
|
||||||
|
renderConfig: {
|
||||||
|
fps,
|
||||||
|
width: parsePositiveInteger("width", args.get("width")) ?? 320,
|
||||||
|
height: parsePositiveInteger("height", args.get("height")) ?? 180,
|
||||||
|
format: "mp4",
|
||||||
|
chunkSize: parsePositiveInteger("chunk-size", args.get("chunk-size")),
|
||||||
|
maxParallelChunks: parsePositiveInteger(
|
||||||
|
"max-parallel-chunks",
|
||||||
|
args.get("max-parallel-chunks"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
v1PlanSizeCapBytes: parsePositiveInteger(
|
||||||
|
"v1-plan-size-cap-bytes",
|
||||||
|
args.get("v1-plan-size-cap-bytes"),
|
||||||
|
),
|
||||||
|
expectV1PlanTooLarge:
|
||||||
|
args.get("expect-v1-plan-too-large") === undefined
|
||||||
|
? false
|
||||||
|
: parseBoolean("expect-v1-plan-too-large", args.get("expect-v1-plan-too-large")),
|
||||||
|
comparison: {
|
||||||
|
durationToleranceSeconds: parseNonNegativeNumber(
|
||||||
|
"duration-tolerance-seconds",
|
||||||
|
args.get("duration-tolerance-seconds"),
|
||||||
|
),
|
||||||
|
requireEncodedOutputEquality:
|
||||||
|
args.get("strict-encoded-output") === undefined
|
||||||
|
? false
|
||||||
|
: parseBoolean("strict-encoded-output", args.get("strict-encoded-output")),
|
||||||
|
maxV2TransferBytes: parsePositiveInteger(
|
||||||
|
"max-v2-transfer-bytes",
|
||||||
|
args.get("max-v2-transfer-bytes"),
|
||||||
|
),
|
||||||
|
maxV2PeakMaterializedBytes: parsePositiveInteger(
|
||||||
|
"max-v2-peak-materialized-bytes",
|
||||||
|
args.get("max-v2-peak-materialized-bytes"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown): string | null {
|
||||||
|
if (isRecord(error) && typeof error.code === "string") {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorNumber(error: unknown, field: "sizeBytes" | "limitBytes"): number | null {
|
||||||
|
if (isRecord(error)) {
|
||||||
|
const value = error[field];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyPlanTooLargeProbeFailure(error: unknown): PlanTooLargeProbeOutcome {
|
||||||
|
const code = errorCode(error);
|
||||||
|
if (code === "PLAN_TOO_LARGE") {
|
||||||
|
return {
|
||||||
|
status: "expected-failure",
|
||||||
|
code,
|
||||||
|
message: errorMessage(error),
|
||||||
|
sizeBytes: errorNumber(error, "sizeBytes"),
|
||||||
|
limitBytes: errorNumber(error, "limitBytes"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "unexpected-failure",
|
||||||
|
code,
|
||||||
|
message: errorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove the migration boundary directly:
|
||||||
|
*
|
||||||
|
* 1. explicit v1 with a deliberately low cap must fail PLAN_TOO_LARGE;
|
||||||
|
* 2. explicit v2 must still run to completion for the same prepared fixture.
|
||||||
|
*
|
||||||
|
* Unlike semantic parity mode, this API does not compare v1/v2 output because
|
||||||
|
* the expected v1 run never produces output. Its durable report records both
|
||||||
|
* the typed failure and the fully analyzed v2 success.
|
||||||
|
*/
|
||||||
|
export async function runPlanV2SizePressure(
|
||||||
|
options: RunPlanV2SizePressureOptions,
|
||||||
|
): Promise<PlanV2SizePressureReport> {
|
||||||
|
if (!existsSync(options.fixtureDir)) {
|
||||||
|
throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
|
||||||
|
}
|
||||||
|
mkdirSync(options.artifactsDir, { recursive: true });
|
||||||
|
const v1ProjectDir = resolve(options.artifactsDir, "v1-low-cap", "project");
|
||||||
|
const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
|
||||||
|
preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
|
||||||
|
preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
|
||||||
|
|
||||||
|
let v1: PlanTooLargeProbeOutcome;
|
||||||
|
try {
|
||||||
|
await options.driver.render({
|
||||||
|
protocol: "v1",
|
||||||
|
projectDir: v1ProjectDir,
|
||||||
|
outputDir: resolve(options.artifactsDir, "v1-low-cap", "run"),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
planSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||||
|
});
|
||||||
|
v1 = {
|
||||||
|
status: "unexpected-success",
|
||||||
|
message: `v1 rendered despite plan cap ${options.v1PlanSizeCapBytes}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
v1 = classifyPlanTooLargeProbeFailure(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let v2: PlanV2PressureOutcome;
|
||||||
|
try {
|
||||||
|
const result = await options.driver.render({
|
||||||
|
protocol: "v2",
|
||||||
|
projectDir: v2ProjectDir,
|
||||||
|
outputDir: resolve(options.artifactsDir, "v2", "run"),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
});
|
||||||
|
const analyze = options.analyzeDriverResult ?? analyzePlanParityDriverResult;
|
||||||
|
v2 = {
|
||||||
|
status: "success",
|
||||||
|
measurement: await analyze(options.driver.name, result),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
v2 = {
|
||||||
|
status: "failure",
|
||||||
|
message: errorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks: PlanV2SizePressureReport["checks"] = [
|
||||||
|
{
|
||||||
|
name: "v1-plan-too-large",
|
||||||
|
passed: v1.status === "expected-failure",
|
||||||
|
detail:
|
||||||
|
v1.status === "expected-failure"
|
||||||
|
? `PLAN_TOO_LARGE size=${v1.sizeBytes ?? "unknown"} limit=${v1.limitBytes ?? "unknown"}`
|
||||||
|
: v1.message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v2-render-success",
|
||||||
|
passed: v2.status === "success",
|
||||||
|
detail:
|
||||||
|
v2.status === "success"
|
||||||
|
? `${v2.measurement.media.frameSha256.length} frames, ${v2.measurement.media.outputBytes} output bytes`
|
||||||
|
: v2.message,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const report: PlanV2SizePressureReport = {
|
||||||
|
passed: checks.every((entry) => entry.passed),
|
||||||
|
checks,
|
||||||
|
v1,
|
||||||
|
v2,
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
resolve(options.artifactsDir, "plan-too-large-v2-report.json"),
|
||||||
|
`${JSON.stringify(report, null, 2)}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the same prepared project through explicit v1 and v2 drivers, analyze
|
||||||
|
* their artifacts, and persist a machine-readable comparison report.
|
||||||
|
*/
|
||||||
|
export async function runPlanProtocolParity(
|
||||||
|
options: RunPlanProtocolParityOptions,
|
||||||
|
): Promise<PlanParityComparison> {
|
||||||
|
if (!existsSync(options.fixtureDir)) {
|
||||||
|
throw new Error(`plan parity fixture does not exist: ${options.fixtureDir}`);
|
||||||
|
}
|
||||||
|
mkdirSync(options.artifactsDir, { recursive: true });
|
||||||
|
|
||||||
|
const v1ProjectDir = resolve(options.artifactsDir, "v1", "project");
|
||||||
|
const v2ProjectDir = resolve(options.artifactsDir, "v2", "project");
|
||||||
|
preparePlanParityFixture(options.fixtureDir, v1ProjectDir);
|
||||||
|
preparePlanParityFixture(options.fixtureDir, v2ProjectDir);
|
||||||
|
|
||||||
|
const v1Result = await options.v1Driver.render({
|
||||||
|
protocol: "v1",
|
||||||
|
projectDir: v1ProjectDir,
|
||||||
|
outputDir: resolve(options.artifactsDir, "v1", "run"),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
planSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||||
|
});
|
||||||
|
const v2Result = await options.v2Driver.render({
|
||||||
|
protocol: "v2",
|
||||||
|
projectDir: v2ProjectDir,
|
||||||
|
outputDir: resolve(options.artifactsDir, "v2", "run"),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
});
|
||||||
|
const [v1, v2] = await Promise.all([
|
||||||
|
analyzePlanParityDriverResult(options.v1Driver.name, v1Result),
|
||||||
|
analyzePlanParityDriverResult(options.v2Driver.name, v2Result),
|
||||||
|
]);
|
||||||
|
const comparison = comparePlanParityMeasurements(v1, v2, options.comparison);
|
||||||
|
writeFileSync(
|
||||||
|
resolve(options.artifactsDir, "comparison.json"),
|
||||||
|
`${JSON.stringify(comparison, null, 2)}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LambdaLocalDriverModule {
|
||||||
|
createLambdaLocalPlanParityDriver(): PlanParityDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLambdaLocalDriverModule(value: unknown): value is LambdaLocalDriverModule {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"createLambdaLocalPlanParityDriver" in value &&
|
||||||
|
typeof value.createLambdaLocalPlanParityDriver === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDriver(target: PlanParityTarget): Promise<PlanParityDriver> {
|
||||||
|
if (target.startsWith("aws:")) {
|
||||||
|
throw new Error(
|
||||||
|
`plan parity target ${target} requires the deployed-AWS driver package; ` +
|
||||||
|
"use the library API to inject that driver until it is installed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Indirect path keeps producer's build from pulling @hyperframes/aws-lambda
|
||||||
|
// into its declaration emit before that workspace package has built.
|
||||||
|
const modulePath = "./plan-parity-lambda-local-driver.js";
|
||||||
|
const loaded: unknown = await import(modulePath);
|
||||||
|
if (!isLambdaLocalDriverModule(loaded)) {
|
||||||
|
throw new Error("lambda-local parity driver module has an invalid shape");
|
||||||
|
}
|
||||||
|
return loaded.createLambdaLocalPlanParityDriver();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const options = parsePlanParityArgs(process.argv);
|
||||||
|
if (options.expectV1PlanTooLarge) {
|
||||||
|
if (options.v1PlanSizeCapBytes === undefined) {
|
||||||
|
throw new Error("plan parity: --expect-v1-plan-too-large requires --v1-plan-size-cap-bytes");
|
||||||
|
}
|
||||||
|
if (options.v1Target !== options.v2Target) {
|
||||||
|
throw new Error(
|
||||||
|
"plan parity: pressure mode requires the same target for its v1 probe and v2 proof",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const report = await runPlanV2SizePressure({
|
||||||
|
fixtureDir: options.fixtureDir,
|
||||||
|
artifactsDir: options.artifactsDir,
|
||||||
|
driver: await loadDriver(options.v1Target),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||||
|
});
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
if (!report.passed) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comparison = await runPlanProtocolParity({
|
||||||
|
fixtureDir: options.fixtureDir,
|
||||||
|
artifactsDir: options.artifactsDir,
|
||||||
|
v1Driver: await loadDriver(options.v1Target),
|
||||||
|
v2Driver: await loadDriver(options.v2Target),
|
||||||
|
renderConfig: options.renderConfig,
|
||||||
|
v1PlanSizeCapBytes: options.v1PlanSizeCapBytes,
|
||||||
|
comparison: options.comparison,
|
||||||
|
});
|
||||||
|
process.stdout.write(`${JSON.stringify(comparison, null, 2)}\n`);
|
||||||
|
if (!comparison.passed) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectRun =
|
||||||
|
process.argv[1] !== undefined &&
|
||||||
|
resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||||
|
if (isDirectRun) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// fallow-ignore-file unused-file
|
||||||
|
// Loaded by a runtime-only dynamic import so producer declaration emit does
|
||||||
|
// not eagerly resolve the AWS workspace package.
|
||||||
|
/**
|
||||||
|
* Lambda-local adapter for the generic v1/v2 parity runner.
|
||||||
|
*
|
||||||
|
* Kept separate from the protocol-neutral harness because it imports
|
||||||
|
* `@hyperframes/aws-lambda`; producer's declaration emit runs before the
|
||||||
|
* Lambda workspace package is built in some CI stages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { PlanParityDriver } from "./plan-parity-contract.js";
|
||||||
|
import { runLambdaLocalRender } from "./regression-harness-lambda-local.js";
|
||||||
|
|
||||||
|
export function createLambdaLocalPlanParityDriver(): PlanParityDriver {
|
||||||
|
return {
|
||||||
|
name: "lambda-local",
|
||||||
|
async render(input) {
|
||||||
|
if (existsSync(input.outputDir)) {
|
||||||
|
rmSync(input.outputDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
return runLambdaLocalRender({
|
||||||
|
protocol: input.protocol,
|
||||||
|
projectDir: input.projectDir,
|
||||||
|
tempRoot: input.outputDir,
|
||||||
|
renderedOutputPath: join(input.outputDir, "output.mp4"),
|
||||||
|
fps: input.renderConfig.fps,
|
||||||
|
width: input.renderConfig.width,
|
||||||
|
height: input.renderConfig.height,
|
||||||
|
format: input.renderConfig.format,
|
||||||
|
chunkSize: input.renderConfig.chunkSize,
|
||||||
|
maxParallelChunks: input.renderConfig.maxParallelChunks,
|
||||||
|
planDirSizeLimitBytes: input.planSizeCapBytes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import type { DistributedFormat } from "./services/distributed/shared.js";
|
|||||||
|
|
||||||
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
|
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
|
||||||
export interface RunLambdaLocalInput {
|
export interface RunLambdaLocalInput {
|
||||||
|
/** Explicit plan transport. Omitted only for legacy regression callers, where it defaults to v1. */
|
||||||
|
protocol?: "v1" | "v2";
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
tempRoot: string;
|
tempRoot: string;
|
||||||
renderedOutputPath: string;
|
renderedOutputPath: string;
|
||||||
@@ -32,8 +34,25 @@ export interface RunLambdaLocalInput {
|
|||||||
codec?: "h264" | "h265";
|
codec?: "h264" | "h265";
|
||||||
chunkSize?: number;
|
chunkSize?: number;
|
||||||
maxParallelChunks?: number;
|
maxParallelChunks?: number;
|
||||||
|
/** Test-only low cap for exercising typed v1 PLAN_TOO_LARGE behavior. */
|
||||||
|
planDirSizeLimitBytes?: number;
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LambdaLocalRenderResult {
|
||||||
|
protocol: "v1" | "v2";
|
||||||
|
outputPath: string;
|
||||||
|
chunks: Array<{
|
||||||
|
index: number;
|
||||||
|
path: string;
|
||||||
|
reportedSha256: string;
|
||||||
|
}>;
|
||||||
|
transferBytes: {
|
||||||
|
downloaded: number;
|
||||||
|
uploaded: number;
|
||||||
|
};
|
||||||
|
peakMaterializedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Public signature of the dynamically-loaded `runLambdaLocalRender`. */
|
/** Public signature of the dynamically-loaded `runLambdaLocalRender`. */
|
||||||
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<void>;
|
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<LambdaLocalRenderResult>;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
createWriteStream,
|
createWriteStream,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -37,7 +38,6 @@ import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperfram
|
|||||||
import { handler } from "@hyperframes/aws-lambda/handler";
|
import { handler } from "@hyperframes/aws-lambda/handler";
|
||||||
import type {
|
import type {
|
||||||
AssembleEvent,
|
AssembleEvent,
|
||||||
AssembleLambdaResult,
|
|
||||||
HandlerDeps,
|
HandlerDeps,
|
||||||
PlanEvent,
|
PlanEvent,
|
||||||
PlanLambdaResult,
|
PlanLambdaResult,
|
||||||
@@ -46,8 +46,14 @@ import type {
|
|||||||
SerializableDistributedRenderConfig,
|
SerializableDistributedRenderConfig,
|
||||||
} from "@hyperframes/aws-lambda";
|
} from "@hyperframes/aws-lambda";
|
||||||
|
|
||||||
export type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
export type {
|
||||||
import type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
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";
|
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
|
* Run plan → renderChunk × N → assemble through the OSS handler with a
|
||||||
* filesystem-backed fake S3. Output lands at `input.renderedOutputPath`.
|
* filesystem-backed fake S3. Output lands at `input.renderedOutputPath`.
|
||||||
*/
|
*/
|
||||||
export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<void> {
|
export async function runLambdaLocalRender(
|
||||||
|
input: RunLambdaLocalInput,
|
||||||
|
): Promise<LambdaLocalRenderResult> {
|
||||||
|
const protocol = input.protocol ?? "v1";
|
||||||
const s3Root = join(input.tempRoot, "s3");
|
const s3Root = join(input.tempRoot, "s3");
|
||||||
mkdirSync(s3Root, { recursive: true });
|
mkdirSync(s3Root, { recursive: true });
|
||||||
|
|
||||||
@@ -81,7 +90,26 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
|||||||
skipChromeResolution: true,
|
skipChromeResolution: true,
|
||||||
tmpRoot: join(input.tempRoot, "lambda-tmp"),
|
tmpRoot: join(input.tempRoot, "lambda-tmp"),
|
||||||
};
|
};
|
||||||
mkdirSync(deps.tmpRoot as string, { recursive: true });
|
const lambdaTmpRoot = join(input.tempRoot, "lambda-tmp");
|
||||||
|
mkdirSync(lambdaTmpRoot, { recursive: true });
|
||||||
|
let peakObservedMaterializedBytes = measureDirectoryBytes(lambdaTmpRoot);
|
||||||
|
const observe = async <Result>(operation: () => Promise<Result>): Promise<Result> => {
|
||||||
|
const sample = (): void => {
|
||||||
|
peakObservedMaterializedBytes = Math.max(
|
||||||
|
peakObservedMaterializedBytes,
|
||||||
|
measureDirectoryBytes(lambdaTmpRoot),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
sample();
|
||||||
|
const timer = setInterval(sample, 2);
|
||||||
|
timer.unref();
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
clearInterval(timer);
|
||||||
|
sample();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const config: SerializableDistributedRenderConfig = {
|
const config: SerializableDistributedRenderConfig = {
|
||||||
fps: input.fps,
|
fps: input.fps,
|
||||||
@@ -91,6 +119,7 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
|||||||
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
|
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
|
||||||
chunkSize: input.chunkSize,
|
chunkSize: input.chunkSize,
|
||||||
maxParallelChunks: input.maxParallelChunks,
|
maxParallelChunks: input.maxParallelChunks,
|
||||||
|
planDirSizeLimitBytes: input.planDirSizeLimitBytes,
|
||||||
hdrMode: "force-sdr",
|
hdrMode: "force-sdr",
|
||||||
// Forward `variables` through the event boundary so lambda-local mode
|
// Forward `variables` through the event boundary so lambda-local mode
|
||||||
// exercises the same variables-in-encoder.json path that real Lambda
|
// 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
|
// STEP A: plan
|
||||||
const planPrefix = `renders/harness/${Date.now()}/`;
|
const planPrefix = `renders/harness/${Date.now()}/`;
|
||||||
const planEvent: PlanEvent = {
|
const planEvent: PlanEvent =
|
||||||
Action: "plan",
|
protocol === "v2"
|
||||||
ProjectS3Uri: uri(projectKey),
|
? {
|
||||||
PlanOutputS3Prefix: uri(planPrefix),
|
Action: "plan",
|
||||||
Config: config,
|
PlanProtocol: "v2",
|
||||||
};
|
ProjectS3Uri: uri(projectKey),
|
||||||
const planResult = (await handler(planEvent, deps)) as PlanLambdaResult;
|
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.
|
// STEP B: render every chunk through the handler.
|
||||||
const chunkUris: string[] = [];
|
const chunkUris: string[] = [];
|
||||||
|
const chunks: LambdaLocalRenderResult["chunks"] = [];
|
||||||
for (let i = 0; i < planResult.ChunkCount; i++) {
|
for (let i = 0; i < planResult.ChunkCount; i++) {
|
||||||
const chunkEvent: RenderChunkEvent = {
|
const shared = {
|
||||||
Action: "renderChunk",
|
Action: "renderChunk" as const,
|
||||||
PlanS3Uri: planResult.PlanS3Uri,
|
|
||||||
PlanHash: planResult.PlanHash,
|
PlanHash: planResult.PlanHash,
|
||||||
ChunkIndex: i,
|
ChunkIndex: i,
|
||||||
ChunkOutputS3Prefix: uri(planPrefix),
|
ChunkOutputS3Prefix: uri(planPrefix),
|
||||||
Format: input.format,
|
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);
|
chunkUris.push(chunkResult.ChunkS3Uri);
|
||||||
|
chunks.push({
|
||||||
|
index: i,
|
||||||
|
path: fakeS3Path(s3Root, chunkResult.ChunkS3Uri),
|
||||||
|
reportedSha256: chunkResult.Sha256,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP C: assemble
|
// STEP C: assemble
|
||||||
const finalUri = uri(
|
const finalUri = uri(
|
||||||
`${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`,
|
`${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`,
|
||||||
);
|
);
|
||||||
const assembleEvent: AssembleEvent = {
|
const assembleShared = {
|
||||||
Action: "assemble",
|
Action: "assemble" as const,
|
||||||
PlanS3Uri: planResult.PlanS3Uri,
|
|
||||||
ChunkS3Uris: chunkUris,
|
ChunkS3Uris: chunkUris,
|
||||||
AudioS3Uri: planResult.AudioS3Uri,
|
AudioS3Uri: planResult.AudioS3Uri,
|
||||||
OutputS3Uri: finalUri,
|
OutputS3Uri: finalUri,
|
||||||
Format: input.format,
|
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
|
// Copy the final output from fake-S3 land back out to the path the
|
||||||
// harness expects. For png-sequence, untar into the dir.
|
// harness expects. For png-sequence, untar into the dir.
|
||||||
@@ -152,6 +234,57 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
|||||||
input.renderedOutputPath,
|
input.renderedOutputPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
outputPath: input.renderedOutputPath,
|
||||||
|
chunks,
|
||||||
|
transferBytes,
|
||||||
|
peakMaterializedBytes: peakObservedMaterializedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeS3Path(s3Root: string, s3Uri: string): string {
|
||||||
|
const prefix = `s3://${FAKE_BUCKET}/`;
|
||||||
|
if (!s3Uri.startsWith(prefix)) {
|
||||||
|
throw new Error(`lambda-local: unexpected fake S3 URI ${s3Uri}`);
|
||||||
|
}
|
||||||
|
return join(s3Root, s3Uri.slice(prefix.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireV1PlanResult(
|
||||||
|
result: PlanLambdaResult,
|
||||||
|
): Extract<PlanLambdaResult, { PlanS3Uri: string }> {
|
||||||
|
if (!("PlanS3Uri" in result)) {
|
||||||
|
throw new Error("lambda-local: v1 plan returned v2 locators");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireV2PlanResult(
|
||||||
|
result: PlanLambdaResult,
|
||||||
|
): Extract<PlanLambdaResult, { PlanProtocol: "v2" }> {
|
||||||
|
if (!("PlanProtocol" in result) || result.PlanProtocol !== "v2") {
|
||||||
|
throw new Error("lambda-local: v2 plan did not return explicit v2 locators");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recursive walk is the measurement itself; extracting its two filesystem
|
||||||
|
// branches would make this small test-harness utility harder to audit.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
function measureDirectoryBytes(path: string): number {
|
||||||
|
if (!existsSync(path)) return 0;
|
||||||
|
let bytes = 0;
|
||||||
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
||||||
|
const child = join(path, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
bytes += measureDirectoryBytes(child);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
bytes += statSync(child).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,8 +295,19 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
|
|||||||
* without going through a real S3 endpoint.
|
* without going through a real S3 endpoint.
|
||||||
*/
|
*/
|
||||||
class FilesystemBackedFakeS3 {
|
class FilesystemBackedFakeS3 {
|
||||||
|
private downloadedBytes = 0;
|
||||||
|
private uploadedBytes = 0;
|
||||||
|
private readonly metadata = new Map<string, Record<string, string>>();
|
||||||
|
|
||||||
constructor(private readonly root: string) {}
|
constructor(private readonly root: string) {}
|
||||||
|
|
||||||
|
get transferBytes(): { downloaded: number; uploaded: number } {
|
||||||
|
return {
|
||||||
|
downloaded: this.downloadedBytes,
|
||||||
|
uploaded: this.uploadedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async send(command: unknown): Promise<unknown> {
|
async send(command: unknown): Promise<unknown> {
|
||||||
const cmdName = (command as { constructor: { name: string } }).constructor.name;
|
const cmdName = (command as { constructor: { name: string } }).constructor.name;
|
||||||
const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input;
|
const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input;
|
||||||
@@ -180,6 +324,7 @@ class FilesystemBackedFakeS3 {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const bytes = readFileSync(fsPath);
|
const bytes = readFileSync(fsPath);
|
||||||
|
this.downloadedBytes += bytes.length;
|
||||||
return { Body: Readable.from([bytes]) };
|
return { Body: Readable.from([bytes]) };
|
||||||
}
|
}
|
||||||
if (cmdName === "PutObjectCommand") {
|
if (cmdName === "PutObjectCommand") {
|
||||||
@@ -192,7 +337,11 @@ class FilesystemBackedFakeS3 {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`);
|
throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`);
|
||||||
}
|
}
|
||||||
return { ETag: `"fake-${statSync(fsPath).size}"` };
|
const size = statSync(fsPath).size;
|
||||||
|
this.uploadedBytes += size;
|
||||||
|
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
|
||||||
|
if (metadata) this.metadata.set(input.Key, metadata);
|
||||||
|
return { ETag: `"fake-${size}"` };
|
||||||
}
|
}
|
||||||
if (cmdName === "HeadObjectCommand") {
|
if (cmdName === "HeadObjectCommand") {
|
||||||
if (!existsSync(fsPath)) {
|
if (!existsSync(fsPath)) {
|
||||||
@@ -204,7 +353,11 @@ class FilesystemBackedFakeS3 {
|
|||||||
err.$metadata = { httpStatusCode: 404 };
|
err.$metadata = { httpStatusCode: 404 };
|
||||||
throw err;
|
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}`);
|
throw new Error(`FakeS3: unexpected command ${cmdName}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"dist",
|
"dist",
|
||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
"src/**/__test_utils__/**",
|
"src/**/__test_utils__/**",
|
||||||
"src/regression-harness-lambda-local.ts"
|
"src/regression-harness-lambda-local.ts",
|
||||||
|
"src/plan-parity-lambda-local-driver.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user