feat!: remove Plan v1 distributed transport (#6970)

* feat!: remove Plan v1 distributed transport

BREAKING CHANGE: Cloud adapters accept only Plan v2; producer v1 exports are removed.

* ci: authorize Plan v1 cleanup deletions

* docs: avoid citing generated smoke result file

* fix(gcp): reject unsupported plan protocols in workflow

* ci: preserve gcp package size reduction

* fix: reject stale Plan v1 SDK options

* chore: remove retired Plan parity scripts
This commit is contained in:
James Russo
2026-11-02 04:13:57 +00:00
committed by GitHub
parent 99223d993c
commit 7f48a6894c
60 changed files with 508 additions and 5190 deletions
@@ -1,239 +0,0 @@
#!/usr/bin/env bash
# Canonical decoded-output comparison shared by the real-AWS smoke test and
# its local unit test. This file defines functions only; sourcing it has no
# side effects.
hf_sha256() {
sha256sum "$1" | awk '{print $1}'
}
hf_canonical_video_framemd5() {
local input="$1" output="$2" raw
raw=$(mktemp)
if ! ffmpeg -nostdin -v error -y -i "$input" \
-map 0:v:0 -an -vf format=rgba -fps_mode passthrough \
-f framemd5 "$raw"; then
rm -f "$raw"
return 1
fi
# Ignore container timestamp/header differences here: normalized ffprobe
# metadata and duration are gated separately. This file pins decoded pixel
# bytes, frame order, frame count, and per-frame byte size.
awk -F',' '
!/^#/ && NF >= 6 {
size=$5; hash=$6
gsub(/[[:space:]]/, "", size)
gsub(/[[:space:]]/, "", hash)
print size "," hash
}
' "$raw" > "$output"
rm -f "$raw"
[ -s "$output" ]
}
hf_normalized_ffprobe_metadata() {
local input="$1" output="$2"
ffprobe -v error \
-show_entries \
stream=index,codec_type,codec_name,profile,pix_fmt,width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate,avg_frame_rate,time_base,color_range,color_space,color_transfer,color_primaries,chroma_location,field_order,sample_fmt,sample_rate,channels,channel_layout \
-of json "$input" |
jq -S '{
streams: ((.streams // [])
| sort_by(.codec_type, .index)
| map(del(.index)))
}' > "$output"
}
hf_duration_seconds() {
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1"
}
hf_has_audio() {
[ -n "$(ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$1" 2>/dev/null | head -1)" ]
}
hf_decode_pcm() {
ffmpeg -nostdin -v error -i "$1" -map 0:a:0 -vn \
-ac 2 -ar 48000 -c:a pcm_s16le -f s16le "$2"
}
# Chunks run in Distributed Map child executions; the Map's exit output carries every result.
hf_extract_chunk_hashes() {
local history="$1" output="$2"
jq '[
.events[] |
select(.type == "MapStateExited") |
.stateExitedEventDetails |
select(.name == "RenderChunks" or .name == "RenderChunksV2") |
(.output | fromjson | .Chunks // [])[] |
{ChunkIndex, Sha256, FramesEncoded}
] | sort_by(.ChunkIndex)' "$history" > "$output"
}
# Compare two rendered outputs. Writes durable evidence at <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 ]
}
@@ -1,118 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_semantic-compare.sh
source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_smoke-config.sh
source "$SCRIPT_DIR/_smoke-config.sh"
for cmd in ffmpeg ffprobe jq sha256sum cmp; do
command -v "$cmd" >/dev/null 2>&1 || {
echo "missing test dependency: $cmd" >&2
exit 1
}
done
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
printf '{"minPsnr":25}\n' > "$WORK/meta.json"
[ "$(hf_resolve_psnr_threshold "" "$WORK/meta.json")" = "25" ]
[ "$(hf_resolve_psnr_threshold "37.5" "$WORK/meta.json")" = "37.5" ]
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/original.mp4"
# Rewrapping changes encoded bytes while preserving decoded semantics.
ffmpeg -nostdin -v error -y -i "$WORK/original.mp4" \
-map 0 -c copy -metadata comment="different container bytes" "$WORK/rewrapped.mp4"
hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/equal"
jq -e '
.semanticEqual == true and
.video.equal == true and
.audio.equal == true and
.metadata.equal == true and
.duration.equal == true and
.encoded.equal == false
' "$WORK/equal.json" >/dev/null
# Map exit output as Step Functions records it: the whole state, chunk results under Chunks.
map_exit() {
jq -nc --arg name "$1" --argjson chunks "$2" \
'{type: "MapStateExited", stateExitedEventDetails: {name: $name, output: ({Plan: {ChunkCount: 2}, Chunks: $chunks} | tojson)}}'
}
CHUNKS='[{"ChunkIndex":1,"Sha256":"bbb","FramesEncoded":12,"DurationMs":9},{"ChunkIndex":0,"Sha256":"aaa","FramesEncoded":12,"DurationMs":8}]'
jq -n --argjson exit "$(map_exit RenderChunks "$CHUNKS")" \
'{events: [{type: "TaskSucceeded", taskSucceededEventDetails: {output: "{}"}}, $exit]}' \
> "$WORK/v1-history.json"
jq -n --argjson exit "$(map_exit RenderChunksV2 "$CHUNKS")" '{events: [$exit]}' \
> "$WORK/v2-history.json"
hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-equal" \
"$WORK/v1-history.json" "$WORK/v2-history.json"
jq -e '.semanticEqual == true and .chunks == {checked:true,equal:true,v1Count:2,v2Count:2}' \
"$WORK/chunks-equal.json" >/dev/null
jq -n --argjson exit "$(map_exit RenderChunksV2 "$(jq -c '.[1].Sha256 = "different"' <<<"$CHUNKS")")" \
'{events: [$exit]}' > "$WORK/v2-history-mismatch.json"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-mismatch" \
"$WORK/v1-history.json" "$WORK/v2-history-mismatch.json"; then
echo "expected chunk-hash mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .chunks.equal == false' \
"$WORK/chunks-mismatch.json" >/dev/null
if REQUIRE_ENCODED_SHA_EQUAL=true \
hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/encoded-gated"; then
echo "expected encoded-SHA gate to reject rewrapped output" >&2
exit 1
fi
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=blue:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/video-mismatch.mp4"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/video-mismatch.mp4" "$WORK/video-mismatch"; then
echo "expected decoded-video mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .video.equal == false' \
"$WORK/video-mismatch.json" >/dev/null
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=880:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/audio-mismatch.mp4"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/audio-mismatch.mp4" "$WORK/audio-mismatch"; then
echo "expected decoded-audio mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .video.equal == true and .audio.equal == false' \
"$WORK/audio-mismatch.json" >/dev/null
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-an -c:v mpeg4 -q:v 3 "$WORK/silent.mp4"
ffmpeg -nostdin -v error -y -i "$WORK/silent.mp4" \
-map 0 -c copy -metadata comment="silent rewrap" "$WORK/silent-rewrapped.mp4"
hf_compare_render_semantics \
"$WORK/silent.mp4" "$WORK/silent-rewrapped.mp4" "$WORK/silent-equal"
jq -e '.semanticEqual == true and .audio.state == "no-audio-on-either"' \
"$WORK/silent-equal.json" >/dev/null
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/silent.mp4" "$WORK/audio-stream-mismatch"; then
echo "expected audio-stream presence mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .audio.state == "audio-stream-mismatch"' \
"$WORK/audio-stream-mismatch.json" >/dev/null
echo "semantic compare shell tests passed"
+1 -55
View File
@@ -31,7 +31,6 @@
# --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
# --plan-protocol <v1|v2|both> (default: v2)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
@@ -54,8 +53,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.."
# shellcheck source=./_semantic-compare.sh
source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
# shellcheck source=./_smoke-config.sh
@@ -82,7 +79,6 @@ SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v2}"
KEEP_STACK="false"
SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
@@ -110,7 +106,6 @@ Flags:
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
--region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE)
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v2)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
@@ -138,7 +133,6 @@ while [ $# -gt 0 ]; do
--stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;;
--plan-protocol) PLAN_PROTOCOL="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;;
--require-encoded-sha-equal) REQUIRE_ENCODED_SHA_EQUAL="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;;
@@ -148,10 +142,6 @@ while [ $# -gt 0 ]; do
esac
done
if [ "$PLAN_PROTOCOL" != "v1" ] && [ "$PLAN_PROTOCOL" != "v2" ] && [ "$PLAN_PROTOCOL" != "both" ]; then
echo "ERROR: --plan-protocol must be v1, v2, or both." >&2
exit 1
fi
PROJECT_NAME=$(hf_derive_project_name "$STACK_NAME")
# Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the
@@ -519,12 +509,7 @@ RESULTS_JSON="$ARTIFACT_DIR/results.json"
echo "[]" > "$RESULTS_JSON"
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
if [ "$PLAN_PROTOCOL" = "both" ]; then
PROTOCOLS=(v1 v2)
else
PROTOCOLS=("$PLAN_PROTOCOL")
fi
for PROTOCOL in "${PROTOCOLS[@]}"; do
PROTOCOL="v2"
for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-$PROTOCOL-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
@@ -623,41 +608,6 @@ for N in "${COUNTS[@]}"; do
'. += [{planProtocol: $protocol, chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/\($protocol)-N\($n)-output.mp4", history: "renders/\($protocol)-N\($n)-history.json"}]' \
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
done
done
# ── 6. Direct v1 ↔ v2 semantic equivalence ────────────────────────────────
SEMANTIC_FAILED=0
SEMANTIC_RESULTS_JSON="$ARTIFACT_DIR/semantic-comparisons.json"
echo "[]" > "$SEMANTIC_RESULTS_JSON"
if [ "$PLAN_PROTOCOL" = "both" ]; then
for N in "${COUNTS[@]}"; do
V1_OUTPUT="$ARTIFACT_DIR/renders/v1-N$N-output.mp4"
V2_OUTPUT="$ARTIFACT_DIR/renders/v2-N$N-output.mp4"
V1_HISTORY="$ARTIFACT_DIR/renders/v1-N$N-history.json"
V2_HISTORY="$ARTIFACT_DIR/renders/v2-N$N-history.json"
SEMANTIC_PREFIX="$ARTIFACT_DIR/renders/v1-v2-N$N"
COMPARE_STATUS=0
if hf_compare_render_semantics \
"$V1_OUTPUT" "$V2_OUTPUT" "$SEMANTIC_PREFIX" "$V1_HISTORY" "$V2_HISTORY"; then
echo "PASS: v1/v2 semantic equivalence at N=$N"
else
COMPARE_STATUS=$?
if [ ! -f "${SEMANTIC_PREFIX}.json" ]; then
jq -n --argjson status "$COMPARE_STATUS" \
'{semanticEqual: false, comparisonError: true, comparisonExitCode: $status}' \
> "${SEMANTIC_PREFIX}.json"
fi
echo "FAIL: v1/v2 semantic comparison at N=$N exited $COMPARE_STATUS (see ${SEMANTIC_PREFIX}.json)" >&2
SEMANTIC_FAILED=$((SEMANTIC_FAILED + 1))
fi
jq --argjson n "$N" --slurpfile comparison "${SEMANTIC_PREFIX}.json" \
'. += [($comparison[0] + {chunkCount: $n})]' \
"$SEMANTIC_RESULTS_JSON" > "$SEMANTIC_RESULTS_JSON.tmp" &&
mv "$SEMANTIC_RESULTS_JSON.tmp" "$SEMANTIC_RESULTS_JSON"
jq -r '" chunks=\(.chunks.equal // "error") decoded-video=\(.video.equal // "error") audio=\(.audio.equal // "error") metadata=\(.metadata.equal // "error") duration=\(.duration.equal // "error") encoded-sha=\(.encoded.equal // "error") (informational unless gated)"' \
"${SEMANTIC_PREFIX}.json"
done
fi
# ── 7. Gate on baseline PSNR threshold ────────────────────────────────────
FAILED=0
@@ -680,10 +630,6 @@ jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RE
echo
echo "Artifacts: $ARTIFACT_DIR"
if [ "$SEMANTIC_FAILED" -gt 0 ]; then
echo "FAILED ($SEMANTIC_FAILED v1/v2 semantic mismatches)" >&2
cleanup_and_exit 6
fi
if [ "$FAILED" -gt 0 ]; then
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
cleanup_and_exit 5