Merge remote-tracking branch 'origin/main' into 07-27-feat_producer_lower_parallel-de_router_floor_to_700_frames_power-state_telemetry

# Conflicts:
#	packages/cli/src/telemetry/events.ts
This commit is contained in:
Vance Ingalls
2026-07-28 05:01:24 -07:00
717 changed files with 64257 additions and 16525 deletions
+46 -2
View File
@@ -39,7 +39,7 @@ ffmpeg -y -stream_loop 15 -i <SKILL_DIR>/assets/bg-pattern.mp4 -t <TOTAL> \
cp <SKILL_DIR>/examples/master-skeleton.html project/index.html
```
Then **read `references/build-spec.md` end-to-end** (not skimmed) — it defines the brand tokens (TT Norms Pro + ABC Solar Display + TT Norms Mono, cream `#f5f6f4`, rationed green `#5ef17c`, glass cards with green-tinted borders, kicker/sec-chip pill shape, caption rail at `top: 1002`) that every scene inherits from the scaffold.
Then **read `references/build-spec.md` end-to-end** (not skimmed) — it defines the brand tokens (TT Norms Pro + ABC Solar Display + TT Norms Mono, cream `#f5f6f4`, rationed green `#5ef17c`, glass cards with green-tinted borders, kicker/sec-chip pill shape, 32px caption rail at `top: 990`) that every scene inherits from the scaffold.
Only THEN begin steps 1-6 below. Steps 1-4 (parse, route, script, VO) plan what goes into the scaffold; step 5 fills placeholders (`<RANGE>`, `<TOTAL>`, `<CUT_N>`, `<DUR_N>`, scene bodies) inside the already-copied `project/index.html` — you do NOT rewrite the scaffold's chrome, fonts, palette, or layout shell.
@@ -99,6 +99,26 @@ The aligner prints `MISMATCH` warnings — resolve every one before building
is the clock**: all beat times come from `vo-words.json`; a VO regen re-opens
every seam.
**Word-timings are a hard gate.** Before moving on to step 5, verify
`vo-words.json` is non-empty and has a `words: [...]` array with `start`/`end`
per word. If it's empty (0 bytes) or missing the array — a known failure mode
when the TTS provider returns audio but no timestamp payload — DO NOT proceed
without them. Fallback: forced-align the produced audio against the display
script using local whisper:
```bash
uvx --from openai-whisper whisper voiceover.mp3 \
--model base.en --language en --word_timestamps True \
--output_format json --output_dir .
# then run align-captions.mjs with --words voiceover.json (same shape)
```
Whisper mishears TTS renderings ("gee-sap" → "gsap", "heyjen" → "hey Jen",
etc.) — captions still use the DISPLAY spelling from `script-tokens.json`;
whisper only supplies the timestamps. `align-captions.mjs` handles the join.
This fallback is the difference between a captioned build and a silently
uncaptioned one.
### 5 · Build
Follow `references/build-spec.md` exactly: brand tokens + fonts (bundled in
@@ -107,6 +127,22 @@ chrome, caption rail, one rationed green moment per scene. Then the doctrine
order: `ledger.json` (all ordinary seams cut-the-curve LEFT) → seam-stamp →
internal beats on VO words → seam-gate verify.
**Captions are non-optional.** The master-skeleton ships a caption-rail IIFE
that reads a `LINES` array — leaving that array empty is a shipped bug, not a
style choice. Populate it from `captions.json` before proceeding to step 6:
```javascript
// paste in place of "const LINES = /* … */ []" in the caption-rail IIFE:
const LINES = /* contents of captions.json */ [
{ id: 0, end: 2.74, w: [["This", 0.0], ["week,", 0.30], ] },
];
```
If `align-captions.mjs` was skipped or `LINES` is `[]`, the frame check in
step 6 will fail — do not paper over it by removing `#cap-line` from the
scaffold.
### 6 · Gates (all green before presenting)
1. `bun run --cwd packages/cli hyperframes check` (or the installed
@@ -120,6 +156,12 @@ internal beats on VO words → seam-gate verify.
4. Do NOT render unless the user asks. After a requested render, verify
frames from the MP4 (`ffmpeg -ss <t> … -frames:v 1`): captions present,
background video not black, no tiny/frozen frames.
5. **Caption presence gate — hard fail.** Sample 3-4 frames spread across
the VO's spoken window (e.g. `t=3`, `t=15`, `t=30`, `t=42` for a 48s VO)
and confirm the caption rail at `top: 990` renders visible text on each.
If any frame in a spoken interval is missing captions, the build ships
uncaptioned — treat it as a red gate and re-check step 5's `LINES`
population. This is exactly what went wrong on the Jul 13-20 v4 build.
## Project layout
@@ -137,7 +179,7 @@ projects/active/weekly-changelog-<range>/
## Anti-patterns
| Don't | Instead |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Bullet-point slides for UI changes | Mock the surface acting out the change |
| Fake UI for un-representable items | Honest checklist scene |
| Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard |
@@ -148,3 +190,5 @@ projects/active/weekly-changelog-<range>/
| Starting from a prior video's index.html | Step 0 — copy `examples/master-skeleton.html` from this skill into `project/index.html`, always |
| Hand-crafted `@font-face` / WebGL shader / custom BGM | Step 0 — copy this skill's `assets/` verbatim; the skill's assets ARE the brand |
| Delivered without CloudFront invalidation | Run `aws cloudfront create-invalidation` on distribution `E2BSLVSZ7FG3U0` for the exact path after any S3 replace — CDN caches the old file otherwise |
| Shipping with the `LINES` array empty in the scaffold | Step 4 must produce a populated `captions.json`; step 5 must paste it into the IIFE; step 6 gate 5 must confirm captions on rendered frames. An empty `LINES` = uncaptioned ship = re-do the run |
| No `vo-words.json` → skip captions and ship anyway | Fall back to whisper forced alignment on the produced audio; captions are non-optional |
@@ -33,9 +33,9 @@
.glass { background: rgba(10,12,11,.78); border: 1px solid rgba(190,255,205,.32);
border-radius: 22px; box-shadow: 0 24px 60px rgba(0,0,0,.5); }
/* caption rail — overlay on top of the film, never a reserved band */
#cap-line { position: absolute; left: 0; right: 0; top: 1002px; height: 40px; text-align: center;
z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 25px;
letter-spacing: .01em; color: rgba(245,246,244,.92);
#cap-line { position: absolute; left: 0; right: 0; top: 990px; height: 52px; text-align: center;
z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 32px;
letter-spacing: .01em; color: rgba(245,246,244,.94);
text-shadow: 0 2px 14px rgba(0,0,0,.85), 0 0 3px rgba(0,0,0,.6);
white-space: nowrap; pointer-events: none; }
.cap-phrase { position: absolute; left: 0; right: 0; }
@@ -76,7 +76,7 @@ otherwise) and must stay flat 2D (no 3D ancestors).
y ∈ [288, 944].
- Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule,
mono URL chip, tag line. Fade all + chrome ~0.5s before end.
- Caption rail per `script-voice.md` (top: 1002).
- Caption rail per `script-voice.md` (top: 990, font-size: 32px, height: 52). Mandatory — populate the master-skeleton's `LINES` array from `captions.json` before render; see SKILL.md step 5.
## Seams + internal life (doctrine mechanics)
@@ -105,7 +105,7 @@ transcript) before the captions are trusted.
## Caption rail (rendering)
Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line,
bottom-center (top: 1002px on 1080-square), TT Norms Pro 500 25px,
ink .92, soft dark text-shadow, words fading in (0.12s) on their timestamps,
phrase swaps as sets. Keep critical small text out of the bottom ~80px
bottom-center (top: 990px, height: 52px on 1080-square), TT Norms Pro 500 32px,
ink .94, soft dark text-shadow, words fading in (0.12s) on their timestamps,
phrase swaps as sets. Keep critical small text out of the bottom ~100px
center span; everything else may run under the rail.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.65",
"version": "0.7.77",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+46 -2
View File
@@ -39,7 +39,7 @@ ffmpeg -y -stream_loop 15 -i <SKILL_DIR>/assets/bg-pattern.mp4 -t <TOTAL> \
cp <SKILL_DIR>/examples/master-skeleton.html project/index.html
```
Then **read `references/build-spec.md` end-to-end** (not skimmed) — it defines the brand tokens (TT Norms Pro + ABC Solar Display + TT Norms Mono, cream `#f5f6f4`, rationed green `#5ef17c`, glass cards with green-tinted borders, kicker/sec-chip pill shape, caption rail at `top: 1002`) that every scene inherits from the scaffold.
Then **read `references/build-spec.md` end-to-end** (not skimmed) — it defines the brand tokens (TT Norms Pro + ABC Solar Display + TT Norms Mono, cream `#f5f6f4`, rationed green `#5ef17c`, glass cards with green-tinted borders, kicker/sec-chip pill shape, 32px caption rail at `top: 990`) that every scene inherits from the scaffold.
Only THEN begin steps 1-6 below. Steps 1-4 (parse, route, script, VO) plan what goes into the scaffold; step 5 fills placeholders (`<RANGE>`, `<TOTAL>`, `<CUT_N>`, `<DUR_N>`, scene bodies) inside the already-copied `project/index.html` — you do NOT rewrite the scaffold's chrome, fonts, palette, or layout shell.
@@ -99,6 +99,26 @@ The aligner prints `MISMATCH` warnings — resolve every one before building
is the clock**: all beat times come from `vo-words.json`; a VO regen re-opens
every seam.
**Word-timings are a hard gate.** Before moving on to step 5, verify
`vo-words.json` is non-empty and has a `words: [...]` array with `start`/`end`
per word. If it's empty (0 bytes) or missing the array — a known failure mode
when the TTS provider returns audio but no timestamp payload — DO NOT proceed
without them. Fallback: forced-align the produced audio against the display
script using local whisper:
```bash
uvx --from openai-whisper whisper voiceover.mp3 \
--model base.en --language en --word_timestamps True \
--output_format json --output_dir .
# then run align-captions.mjs with --words voiceover.json (same shape)
```
Whisper mishears TTS renderings ("gee-sap" → "gsap", "heyjen" → "hey Jen",
etc.) — captions still use the DISPLAY spelling from `script-tokens.json`;
whisper only supplies the timestamps. `align-captions.mjs` handles the join.
This fallback is the difference between a captioned build and a silently
uncaptioned one.
### 5 · Build
Follow `references/build-spec.md` exactly: brand tokens + fonts (bundled in
@@ -107,6 +127,22 @@ chrome, caption rail, one rationed green moment per scene. Then the doctrine
order: `ledger.json` (all ordinary seams cut-the-curve LEFT) → seam-stamp →
internal beats on VO words → seam-gate verify.
**Captions are non-optional.** The master-skeleton ships a caption-rail IIFE
that reads a `LINES` array — leaving that array empty is a shipped bug, not a
style choice. Populate it from `captions.json` before proceeding to step 6:
```javascript
// paste in place of "const LINES = /* … */ []" in the caption-rail IIFE:
const LINES = /* contents of captions.json */ [
{ id: 0, end: 2.74, w: [["This", 0.0], ["week,", 0.30], ] },
];
```
If `align-captions.mjs` was skipped or `LINES` is `[]`, the frame check in
step 6 will fail — do not paper over it by removing `#cap-line` from the
scaffold.
### 6 · Gates (all green before presenting)
1. `bun run --cwd packages/cli hyperframes check` (or the installed
@@ -120,6 +156,12 @@ internal beats on VO words → seam-gate verify.
4. Do NOT render unless the user asks. After a requested render, verify
frames from the MP4 (`ffmpeg -ss <t> … -frames:v 1`): captions present,
background video not black, no tiny/frozen frames.
5. **Caption presence gate — hard fail.** Sample 3-4 frames spread across
the VO's spoken window (e.g. `t=3`, `t=15`, `t=30`, `t=42` for a 48s VO)
and confirm the caption rail at `top: 990` renders visible text on each.
If any frame in a spoken interval is missing captions, the build ships
uncaptioned — treat it as a red gate and re-check step 5's `LINES`
population. This is exactly what went wrong on the Jul 13-20 v4 build.
## Project layout
@@ -137,7 +179,7 @@ projects/active/weekly-changelog-<range>/
## Anti-patterns
| Don't | Instead |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Bullet-point slides for UI changes | Mock the surface acting out the change |
| Fake UI for un-representable items | Honest checklist scene |
| Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard |
@@ -148,3 +190,5 @@ projects/active/weekly-changelog-<range>/
| Starting from a prior video's index.html | Step 0 — copy `examples/master-skeleton.html` from this skill into `project/index.html`, always |
| Hand-crafted `@font-face` / WebGL shader / custom BGM | Step 0 — copy this skill's `assets/` verbatim; the skill's assets ARE the brand |
| Delivered without CloudFront invalidation | Run `aws cloudfront create-invalidation` on distribution `E2BSLVSZ7FG3U0` for the exact path after any S3 replace — CDN caches the old file otherwise |
| Shipping with the `LINES` array empty in the scaffold | Step 4 must produce a populated `captions.json`; step 5 must paste it into the IIFE; step 6 gate 5 must confirm captions on rendered frames. An empty `LINES` = uncaptioned ship = re-do the run |
| No `vo-words.json` → skip captions and ship anyway | Fall back to whisper forced alignment on the produced audio; captions are non-optional |
@@ -33,9 +33,9 @@
.glass { background: rgba(10,12,11,.78); border: 1px solid rgba(190,255,205,.32);
border-radius: 22px; box-shadow: 0 24px 60px rgba(0,0,0,.5); }
/* caption rail — overlay on top of the film, never a reserved band */
#cap-line { position: absolute; left: 0; right: 0; top: 1002px; height: 40px; text-align: center;
z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 25px;
letter-spacing: .01em; color: rgba(245,246,244,.92);
#cap-line { position: absolute; left: 0; right: 0; top: 990px; height: 52px; text-align: center;
z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 32px;
letter-spacing: .01em; color: rgba(245,246,244,.94);
text-shadow: 0 2px 14px rgba(0,0,0,.85), 0 0 3px rgba(0,0,0,.6);
white-space: nowrap; pointer-events: none; }
.cap-phrase { position: absolute; left: 0; right: 0; }
@@ -76,7 +76,7 @@ otherwise) and must stay flat 2D (no 3D ancestors).
y ∈ [288, 944].
- Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule,
mono URL chip, tag line. Fade all + chrome ~0.5s before end.
- Caption rail per `script-voice.md` (top: 1002).
- Caption rail per `script-voice.md` (top: 990, font-size: 32px, height: 52). Mandatory — populate the master-skeleton's `LINES` array from `captions.json` before render; see SKILL.md step 5.
## Seams + internal life (doctrine mechanics)
@@ -105,7 +105,7 @@ transcript) before the captions are trusted.
## Caption rail (rendering)
Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line,
bottom-center (top: 1002px on 1080-square), TT Norms Pro 500 25px,
ink .92, soft dark text-shadow, words fading in (0.12s) on their timestamps,
phrase swaps as sets. Keep critical small text out of the bottom ~80px
bottom-center (top: 990px, height: 52px on 1080-square), TT Norms Pro 500 32px,
ink .94, soft dark text-shadow, words fading in (0.12s) on their timestamps,
phrase swaps as sets. Keep critical small text out of the bottom ~100px
center span; everything else may run under the rail.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.65",
"version": "0.7.77",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "hyperframes",
"displayName": "HyperFrames by HeyGen",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.65",
"version": "0.7.77",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com"
+30
View File
@@ -4,6 +4,7 @@
"packages/producer/src/**/*.test.ts",
"packages/aws-lambda/src/**/*.test.ts",
"packages/gcp-cloud-run/src/**/*.test.ts",
"packages/gcp-cloud-run/terraform/*.test.ts",
"packages/producer/src/regression-harness.ts",
"packages/producer/src/regression-harness-distributed.test.ts",
"packages/producer/src/regression-harness-lambda-local.ts",
@@ -397,6 +398,18 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
// extracting a shared cloud abstraction would couple independent packages.
"packages/aws-lambda/src/handler.ts",
"packages/aws-lambda/src/s3Transport.ts",
// The GCP handler deliberately mirrors the AWS protocol lifecycle while
// retaining provider-specific GCS, HTTP, and Cloud Workflows semantics.
// Its tests also mirror the same wire-contract cases; a cross-provider
// test abstraction would hide the adapter boundary being asserted.
"packages/gcp-cloud-run/src/server.ts",
"packages/gcp-cloud-run/src/server.test.ts",
// sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
@@ -593,6 +606,23 @@
// makes non-trivial.
"packages/studio-server/src/helpers/screenshotClip.ts",
"packages/studio/vite.browser.ts",
// off_pivot_rotation Kåsa circle fit (feat/needle-pivot-offset-check):
// fitCirclePoints in layout-audit.browser.js and fitCircle in
// checkPipeline.ts are the same least-squares circle fit, but the browser
// copy is injected as a raw string via page.addScriptTag and cannot import
// the Node-side module across puppeteer's serialization boundary. The two
// copies carry matching "KEEP IN SYNC" headers; the duplication is
// intentional and per-language, so it's exempted here rather than faked
// away with cosmetic divergence.
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/utils/checkPipeline.ts",
// check.test.ts: the fakeDriver-based command tests share a pre-existing
// arrange/act/assert scaffold (runScenario + vi.fn runPipeline + spy +
// createCheckCommand). Adding the required collectOffPivotRotationSample
// stub to the CheckAuditDriver fake shifts line numbers and re-flags that
// inherited clone; consistent with the norm above of leaving parallel
// command-test cases unabstracted.
"packages/cli/src/commands/check.test.ts",
],
},
"health": {
+50
View File
@@ -34,6 +34,8 @@ jobs:
code: ${{ steps.filter.outputs.code }}
cli: ${{ steps.filter.outputs.cli }}
skills: ${{ steps.filter.outputs.skills }}
codex_plugin: ${{ steps.filter.outputs.codex_plugin }}
gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
@@ -68,6 +70,40 @@ jobs:
- "scripts/check-skill-mirror.mjs"
- "package.json"
- ".github/workflows/ci.yml"
codex_plugin:
- ".codex-plugin/**"
- "assets/**"
- "skills/**"
- "scripts/package-codex-plugin.mjs"
- "package.json"
- ".github/workflows/ci.yml"
gcp_beginframe:
- "packages/gcp-cloud-run/Dockerfile"
- "packages/aws-lambda/scripts/probe-beginframe.ts"
- "packages/engine/src/services/browserManager.ts"
- "package.json"
- "bun.lock"
- ".github/workflows/ci.yml"
gcp-beginframe-contract:
name: GCP BeginFrame image contract
needs: changes
if: needs.changes.outputs.gcp_beginframe == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
file: packages/gcp-cloud-run/Dockerfile
target: beginframe-contract
push: false
cache-from: type=gha,scope=gcp-beginframe-contract
cache-to: type=gha,mode=max,scope=gcp-beginframe-contract
build:
name: Build
@@ -339,6 +375,20 @@ jobs:
- name: Verify .claude/skills/ and .agents/skills/ are byte-identical
run: node scripts/check-skill-mirror.mjs
codex-plugin-package:
name: "Codex plugin package"
needs: changes
if: needs.changes.outputs.codex_plugin == 'true'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Build upload-ready Codex plugin
run: node scripts/package-codex-plugin.mjs
cli-npx-shim:
name: "CLI: npx shim (${{ matrix.os }})"
needs: changes
+16 -25
View File
@@ -29,6 +29,7 @@ jobs:
timeout-minutes: 2
outputs:
code: ${{ steps.filter.outputs.code }}
matrix: ${{ steps.shards.outputs.matrix }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
@@ -46,6 +47,14 @@ jobs:
- "packages/producer/**"
- "packages/engine/**"
- "Dockerfile*"
# Bin-pack the shard matrix from recorded per-fixture timings rather than
# a hand-written list. Fails if any fixture on disk is neither scheduled
# nor explicitly excluded, so a new fixture cannot silently never run.
- name: Plan regression shards
id: shards
run: |
echo "matrix=$(node packages/producer/scripts/plan-regression-shards.mjs)" >> "$GITHUB_OUTPUT"
node packages/producer/scripts/plan-regression-shards.mjs --pretty
preflight:
name: Preflight (lint + format)
@@ -64,30 +73,10 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
# Shards are bin-packed by measured per-test duration (LPT heuristic on
# CI run 25893372795) so each row carries ~15-16 min of work. When a
# new fixture lands, drop it into the currently-lightest shard or
# re-balance against fresh `test_suite_summary` timings. Worst-shard
# work time was 19.3 min under the old tag-based split (styles-e);
# the rebalance brings every shard within ~40s of the others.
include:
- shard: shard-1
args: "hdr-regression style-5-prod style-3-prod mov-prores"
- shard: shard-2
args: "style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets"
- shard: shard-3
args: "style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9"
- shard: shard-4
args: "style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr"
- shard: shard-5
args: "style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression"
- shard: shard-6
args: "overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed"
- shard: shard-7
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector"
- shard: shard-8
args: "style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity"
# Bin-packed at run time from packages/producer/tests/shard-schedule.json.
# To rebalance, refresh the timings in that file — no YAML edit needed.
# To change shard count, set "shardCount" there.
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- name: Checkout (with LFS)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -124,15 +113,17 @@ jobs:
# concurrent exports cannot exhaust the Actions cache service.
cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max,scope=regression-test-image' || '' }}
- name: "Run regression shard: ${{ matrix.shard }}"
- name: "Run regression shard: ${{ matrix.shard }} (${{ matrix.mode }})"
run: |
echo "Shard: ${{ matrix.shard }}"
echo "Mode: ${{ matrix.mode }}"
echo "Args: ${{ matrix.args }}"
docker run --rm \
--security-opt seccomp=unconfined \
--shm-size=4g \
-v ${{ github.workspace }}/packages/producer/tests:/app/packages/producer/tests \
hyperframes-producer:test \
--mode=${{ matrix.mode }} \
${{ matrix.args }}
- name: Upload failure artifacts
+16 -3
View File
@@ -90,13 +90,22 @@ COPY packages/sdk/package.json packages/sdk/package.json
COPY packages/sdk-playground/package.json packages/sdk-playground/package.json
RUN bun install --frozen-lockfile
# Copy source
# Copy source in dependency order, running each build as soon as its own
# inputs are present.
#
# Every package used to be copied here before any build ran, which put the
# `COPY packages/producer/` layer above the core build. Docker invalidates
# every layer below a changed one, so a producer-only change rebuilt core —
# which cannot depend on producer. On CI run 30229469233 those two layers
# cost 86s and 63s of the ~4m image build, in all 8 shards, on every PR.
# Locally the same producer-only rebuild goes from 18s to 1s after this split.
#
# Keep the ordering dependency-correct: anything the core build reads must be
# copied above it, and packages nothing above depends on stay below.
COPY packages/parsers/ packages/parsers/
COPY packages/lint/ packages/lint/
COPY packages/studio-server/ packages/studio-server/
COPY packages/core/ packages/core/
COPY packages/engine/ packages/engine/
COPY packages/producer/ packages/producer/
# Build workspace packages so "node" export conditions resolve to built dist
RUN bun run --filter '@hyperframes/{parsers,lint,studio-server}' build \
@@ -105,6 +114,10 @@ RUN bun run --filter '@hyperframes/{parsers,lint,studio-server}' build \
# Build core runtime artifacts (needed by renderer)
RUN bun run --filter @hyperframes/core build:hyperframes-runtime:modular
# Nothing above reads these, so they land after the core build to keep it cached.
COPY packages/engine/ packages/engine/
COPY packages/producer/ packages/producer/
# Generate embedded font data (deterministicFonts.ts imports this at runtime)
RUN cd packages/producer && bunx tsx scripts/generate-font-data.ts
+10
View File
@@ -59,6 +59,16 @@ Default to the **core set** — the router installs each creation workflow on de
Installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
### Upload to Codex
Build the upload-ready Codex plugin archive from the committed `HEAD` version of the manifest, brand assets, and skills:
```bash
bun run package:codex-plugin
```
This writes `dist/hyperframes-plugin.zip` with a `hyperframes/` root folder and fails if the archive exceeds Codex's 100 MB upload limit.
### Router
| Skill | Use when |
+16 -13
View File
@@ -23,7 +23,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -43,6 +43,7 @@
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
"yaml": "^2.9.0",
},
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
@@ -55,7 +56,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.7.60",
"version": "0.7.71",
"bin": {
"hyperframes": "./bin/hyperframes.mjs",
},
@@ -106,7 +107,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*",
@@ -131,7 +132,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -150,7 +151,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -166,16 +167,18 @@
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
"yaml": "^2.9.0",
},
},
"packages/lint": {
"name": "@hyperframes/lint",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.4",
},
"devDependencies": {
"@types/node": "^25.0.10",
@@ -187,7 +190,7 @@
},
"packages/parsers": {
"name": "@hyperframes/parsers",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@babel/parser": "^7.27.0",
"acorn": "^8.17.0",
@@ -207,7 +210,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@hyperframes/core": "workspace:*",
},
@@ -222,7 +225,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -267,7 +270,7 @@
},
"packages/sdk": {
"name": "@hyperframes/sdk",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
@@ -297,7 +300,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -309,7 +312,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -357,7 +360,7 @@
},
"packages/studio-server": {
"name": "@hyperframes/studio-server",
"version": "0.7.60",
"version": "0.7.71",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
+356
View File
@@ -8,6 +8,362 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
<Update
label="HyperFrames v0.7.77"
description="Released - 2026-07-28"
tags={["Release", "Studio", "CLI", "Check"]}
>
Studio's timeline now exposes expandable property lanes, track headers, precise
keyframe retiming, and selection-safe editing across nested compositions. This
release also makes stalled CLI download cleanup reliable on Windows and adds an
opt-in prose-coverage floor to layout checks.
## Features
- **Studio:** Add expanded keyframe lanes, track headers, retiming interactions,
and nested-composition timeline support
([#2791](https://github.com/heygen-com/hyperframes/pull/2791))
- **Check:** Add the opt-in `proseCoverageFloor` layout rule
([#2834](https://github.com/heygen-com/hyperframes/pull/2834))
## Fixes
- **CLI:** Wait for stalled download pipelines to release temporary files before
cleanup, fixing the Windows locked-file failure
([#2835](https://github.com/heygen-com/hyperframes/pull/2835))
- **Telemetry:** Send CLI feedback through the plain-event path instead of the
PostHog survey API
([#2831](https://github.com/heygen-com/hyperframes/pull/2831))
## Performance
- **CI:** Distribute the two heaviest render fixtures and improve Docker layer
reuse for producer-only changes
([#2825](https://github.com/heygen-com/hyperframes/pull/2825),
[#2822](https://github.com/heygen-com/hyperframes/pull/2822))
## Docs & Examples
- **Send-to guide:** Clarify that enhance turns are free and rendering is the
paid step
([#2827](https://github.com/heygen-com/hyperframes/pull/2827))
## Internal
- **CLI:** Separate telemetry delivery from event policy while preserving the
queued flush and process-exit behavior
([#2344](https://github.com/heygen-com/hyperframes/pull/2344))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.76...v0.7.77).
</Update>
<Update
label="HyperFrames v0.7.76"
description="Released - 2026-07-27"
tags={["Release", "Check", "Producer"]}
>
Plan v2 chunk materialization now recreates declared video directory skeletons even
when a sparse chunk needs no frames, so inactive-video chunks render without
downloading unused artifacts. Layout checks also improve rotation-pivot drift
diagnostics.
## Fixes
- **Producer:** Materialize declared video directories for sparse Plan v2 chunks ([ddb59d356](https://github.com/heygen-com/hyperframes/commit/ddb59d3567fc70d808f86ef8b5b531078e3df2d6), [#2823](https://github.com/heygen-com/hyperframes/pull/2823))
- **Check:** Improve elongated rotation-pivot drift detection and detached-connector diagnostics ([75ed99e1d](https://github.com/heygen-com/hyperframes/commit/75ed99e1d4f45015812575ff26c07efb0b253f21), [#2819](https://github.com/heygen-com/hyperframes/pull/2819))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.75...v0.7.76).
</Update>
<Update
label="HyperFrames v0.7.75"
description="Released - 2026-07-27"
tags={["Release", "Producer"]}
>
Distributed capture now preflights `BeginFrame` support and falls back to screenshot
capture when the browser cannot provide a healthy `BeginFrame` session. A targeted
retry also recovers from `BeginFrame`-specific failures without masking unrelated errors.
## Fixes
- **Producer:** Fall back from unhealthy distributed `BeginFrame` capture safely ([96cafb47c](https://github.com/heygen-com/hyperframes/commit/96cafb47c6c9850939c85e2cc76e578d3b6dbd1b), [#2821](https://github.com/heygen-com/hyperframes/pull/2821))
- **Regression:** Schedule the Plan v2 color fixture in regression shards ([51cbbe6fc](https://github.com/heygen-com/hyperframes/commit/51cbbe6fc93e025ee94fc73ad2b65afce8a35d3b), [#2820](https://github.com/heygen-com/hyperframes/pull/2820))
## Internal
- **Regression:** Compute the shard matrix from recorded fixture timings ([f67012eb9](https://github.com/heygen-com/hyperframes/commit/f67012eb9f6a953de90a3d7828979b56a3c96006), [#2815](https://github.com/heygen-com/hyperframes/pull/2815))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.74...v0.7.75).
</Update>
<Update
label="HyperFrames v0.7.74"
description="Released - 2026-07-27"
tags={["Release", "Producer", "GCP Cloud Run", "Core"]}
>
Distributed Plan v2 now handles extraction-cache sentinels and partial color metadata
without corrupting chunk dependencies. Distributed cold starts also preserve live GSAP
volume state, while GCP captures honor the effective `BeginFrame` boundary.
## Fixes
- **Producer:** Handle partial color metadata and extraction sentinels in Plan v2 ([58869f087](https://github.com/heygen-com/hyperframes/commit/58869f0878e304fd39d564b93cc4a8b18e885b4e), [#2814](https://github.com/heygen-com/hyperframes/pull/2814))
- **GCP Cloud Run:** Enforce effective `BeginFrame` capture ([2a284a8e3](https://github.com/heygen-com/hyperframes/commit/2a284a8e3aca62100ec037c23c98706e7146aa14), [#2817](https://github.com/heygen-com/hyperframes/pull/2817))
- **Core:** Avoid live volume probes during render ([477defc7e](https://github.com/heygen-com/hyperframes/commit/477defc7e7397684c2def4459b3981f62a06816d), [#2816](https://github.com/heygen-com/hyperframes/pull/2816))
## Performance
- **Producer:** Compute regression PSNR in one ffmpeg pass ([98a4cd70f](https://github.com/heygen-com/hyperframes/commit/98a4cd70fd2df38f300a2e19c86f75cd8dd93895), [#2813](https://github.com/heygen-com/hyperframes/pull/2813))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.73...v0.7.74).
</Update>
<Update
label="HyperFrames v0.7.73"
description="Released - 2026-07-26"
tags={["Release", "Producer", "Render", "Engine"]}
>
Distributed Plan v2 publishers now stream content-addressed artifacts directly to
S3 and GCS, while oversized-plan diagnostics identify the dominant files and phases.
Rendering is more resilient through atomic media downloads, typed extraction retries,
and frame-coverage accounting aligned with FFmpeg's CFR rounding.
## Features
- **Studio:** Add professional grading controls ([20ef48abc](https://github.com/heygen-com/hyperframes/commit/20ef48abcb190e3e9ef132d1b1dd711201d3c58b))
- **CLI:** Expose agent-native color grading ([6d5961b80](https://github.com/heygen-com/hyperframes/commit/6d5961b8024fe70f87879f271dd91149717d41a8))
- **Gcp Cloud Run:** Publish plan v2 directly to GCS ([74d7bfde4](https://github.com/heygen-com/hyperframes/commit/74d7bfde4870bbc1c6c4471cfc004964807df3e7))
- **AWS Lambda:** Publish plan v2 directly to S3 ([09998789b](https://github.com/heygen-com/hyperframes/commit/09998789b5ff012adcd97e9fb33537e473f1cc52))
- **Core:** Add professional color grading controls ([f99fc4e56](https://github.com/heygen-com/hyperframes/commit/f99fc4e5686239f5ef56d4eb6083bee796ceeddc))
## Fixes
- **Producer:** Align frame coverage with extraction rounding ([f0c2c7d23](https://github.com/heygen-com/hyperframes/commit/f0c2c7d23384de589f54c11ff093f128c9a39e56))
- **Render:** Aggregate extraction launch failures ([33ca1de06](https://github.com/heygen-com/hyperframes/commit/33ca1de0631be66bfa5c591a231256c69667226e))
- **Producer:** Narrow extraction error shapes honestly ([c01e1a5f9](https://github.com/heygen-com/hyperframes/commit/c01e1a5f96839e1a2650516ceb3745ed7b28517f))
- **Producer:** Type video extraction failures ([9b63646c8](https://github.com/heygen-com/hyperframes/commit/9b63646c8aa036b786513137f2efcdf637ad432c))
- **Engine:** Block future-use IPv4 downloads ([2e84faeb2](https://github.com/heygen-com/hyperframes/commit/2e84faeb28ff8689da212a5b7db805f3b454d4d9))
- **Engine:** Close downloader trust-boundary gaps ([4b81f7858](https://github.com/heygen-com/hyperframes/commit/4b81f785868362fbb5c4bd7f1c5be24b2ccb7f94))
- **Engine:** Narrow network error shapes honestly ([5ce2eb879](https://github.com/heygen-com/hyperframes/commit/5ce2eb879db1bb2b3dd740bcd7e9abf8adaa3230))
- **Engine:** Make video downloads atomic and retry transient failures ([c01f6b446](https://github.com/heygen-com/hyperframes/commit/c01f6b446829f15c2c18dfd43da7013b412b2bf7))
- **Producer:** Reset distributed plan scratch state ([ebb02cafe](https://github.com/heygen-com/hyperframes/commit/ebb02cafe7e1d1e067bc37c00fd3613e20230ee5))
- **Producer:** Attribute and stop oversized plans early ([d699cbf01](https://github.com/heygen-com/hyperframes/commit/d699cbf014ac2232e3d2cec5c06c9d74103fa61f))
- **Studio:** Align professional grading contracts ([794930a07](https://github.com/heygen-com/hyperframes/commit/794930a07568deddd55ba0d891b68bec739641fa))
- **Studio:** Clear stale color scopes ([c1fcf7534](https://github.com/heygen-com/hyperframes/commit/c1fcf7534f730f5677b0d5201e6af6d17bd19cb0))
- **Core:** Register media analyzer subpath ([9b504045f](https://github.com/heygen-com/hyperframes/commit/9b504045f7cfd1a13e7b4d128cfdfe2e0631017f))
- **CLI:** Address media treatment review findings ([c1dde2898](https://github.com/heygen-com/hyperframes/commit/c1dde2898076b4d9b71b81d86cdf0f419350d93f))
- **Core:** Align secondary mask contracts ([e446de602](https://github.com/heygen-com/hyperframes/commit/e446de60234b837672100334fd169d2f2b547218))
- **Core:** Preserve curve compiler subpath exports ([9a515858c](https://github.com/heygen-com/hyperframes/commit/9a515858c9c65b749f1a976b3a72c5f4868a8256))
- **Core:** Address grading review findings ([1ffef9a26](https://github.com/heygen-com/hyperframes/commit/1ffef9a262ca13ada04d53d93d6f8bfbbfa83f26))
## Internal
- **CLI:** Harden media treatment parity ([20f4bde46](https://github.com/heygen-com/hyperframes/commit/20f4bde46a8862584470f895c8a6a4bf69716b2a))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.72...v0.7.73).
</Update>
<Update
label="HyperFrames v0.7.72"
description="Released - 2026-07-26"
tags={["Release", "Producer", "Gcp Cloud Run", "AWS Lambda"]}
>
Distributed rendering now has a versioned Plan v2 contract with integrity-checked,
content-addressed artifacts and a remote publisher seam for object-storage-backed
workers, while retaining Plan v1 compatibility. This release also improves audio
duration accuracy, diagnostics, deterministic media treatments, and animation tooling.
## Features
- **AWS Lambda:** Support plan protocol v2 ([5bf61d6df](https://github.com/heygen-com/hyperframes/commit/5bf61d6df0694c3077ecc7e84cd9d72f2029a5e6), [#2789](https://github.com/heygen-com/hyperframes/pull/2789))
- **Producer:** Version distributed plan protocol ([f9f00b0ef](https://github.com/heygen-com/hyperframes/commit/f9f00b0efc2d1006967d5b2e0009ea3c3f6ed2e6), [#2777](https://github.com/heygen-com/hyperframes/pull/2777))
- **Lint:** Dense motion re-sampling for content_overlap ([72e2f08f1](https://github.com/heygen-com/hyperframes/commit/72e2f08f15ceec105eb2bca6e9e35b8020e040be), [#2746](https://github.com/heygen-com/hyperframes/pull/2746))
- **Studio:** Add keyframe ease editor ([c253dec23](https://github.com/heygen-com/hyperframes/commit/c253dec23b0a6f1ccfe49c17371fdc9823f73b4c))
- **Core:** Add deterministic keyframe ease runtime ([5acbf240c](https://github.com/heygen-com/hyperframes/commit/5acbf240cbab38594d06b59b9ef7f2a4b37d31b7))
- **Studio:** Add media treatment inspector ([39c2341c4](https://github.com/heygen-com/hyperframes/commit/39c2341c4d1edf52a00fb8db4a49553c739860cc))
- **CLI:** Add agent-first media treatment tools ([4582881d0](https://github.com/heygen-com/hyperframes/commit/4582881d002c361afff59cc1c74ae072a50e17f7))
- **Lint:** Add off_pivot_rotation hub-referenced layout check ([e710a1686](https://github.com/heygen-com/hyperframes/commit/e710a1686f2442b460ea4973dca0be97c81ef184), [#2744](https://github.com/heygen-com/hyperframes/pull/2744))
- **Runtime:** Render media treatments deterministically ([944640c32](https://github.com/heygen-com/hyperframes/commit/944640c3283d604383fd437fcca8c63941d8e3d7))
## Fixes
- **Gcp Cloud Run:** Normalize v2 integrity codes ([0499a5cbc](https://github.com/heygen-com/hyperframes/commit/0499a5cbcb298c7dc4352a2d672b9e18c9db1d81), [#2790](https://github.com/heygen-com/hyperframes/pull/2790))
- **Producer:** Document read-only plan hashing ([c6fdd9c01](https://github.com/heygen-com/hyperframes/commit/c6fdd9c0154aacef9e16ab9c89d3249d0ef5d356), [#2788](https://github.com/heygen-com/hyperframes/pull/2788))
- **Producer:** Use portable audio padding filter ([3b9552ef9](https://github.com/heygen-com/hyperframes/commit/3b9552ef9db36b133485e4ce805892346e0b9006))
- **Producer:** Normalize padded audio on sample timeline ([4b116b988](https://github.com/heygen-com/hyperframes/commit/4b116b98802c05f0eeb541878806692eea975541))
- **Engine:** Stop mux at shortest normalized stream ([63bc525ca](https://github.com/heygen-com/hyperframes/commit/63bc525ca90b08356d50ebb41d4e15581c9d3470))
- **Producer:** Preserve normalized audio mux duration ([532461599](https://github.com/heygen-com/hyperframes/commit/532461599b7518f4609002cdb314ff2ce9f3a70f))
- **Render:** Cap final mux to video duration ([19258ea5b](https://github.com/heygen-com/hyperframes/commit/19258ea5ba47067e9d5756b122051c467ece0b04))
- **Render:** Cap trimmed audio container duration ([afc4e96bb](https://github.com/heygen-com/hyperframes/commit/afc4e96bbed21dbcd29544a6be95d35db7dd4eed))
- **Render:** Scope M4A priming preservation to trims ([9289551e9](https://github.com/heygen-com/hyperframes/commit/9289551e98958f006ec14af8ae9058096a4932fe))
- **Render:** Preserve normalized M4A edit timing ([59c56d325](https://github.com/heygen-com/hyperframes/commit/59c56d325723bbcb7c54315023aedeb72b376cd8))
- **Render:** Trim AAC packet padding exactly ([113a4985b](https://github.com/heygen-com/hyperframes/commit/113a4985b51bc1d77babe7113d6c96bbe46d41bf))
- **Engine:** Preserve bounded ffprobe diagnostics ([8c5077068](https://github.com/heygen-com/hyperframes/commit/8c50770684bc87ca67592c45d4101e3c029193d2), [#2772](https://github.com/heygen-com/hyperframes/pull/2772))
- **Audio:** Preserve causes and use portable padding ([37b88688e](https://github.com/heygen-com/hyperframes/commit/37b88688e7ae854e377c47fe3d9560ab0c930161), [#2769](https://github.com/heygen-com/hyperframes/pull/2769))
- **Parsers:** Preserve duration-authored keyframe timing ([4bfbd89d6](https://github.com/heygen-com/hyperframes/commit/4bfbd89d633d5fd227023643db62d2a566984edb))
- **Parsers:** Preserve authored keyframe intent ([d84e999f7](https://github.com/heygen-com/hyperframes/commit/d84e999f728e25cee15a71995805a52d0a7907c4))
- **Studio:** Preserve composed media treatments ([d5c7d3ee1](https://github.com/heygen-com/hyperframes/commit/d5c7d3ee16c2db53b91c66353d4f6387fe23e920))
- **Lint:** Drop false media_in_subcomposition rule ([e7f9918d2](https://github.com/heygen-com/hyperframes/commit/e7f9918d21f9fa57f1799c7b3ba38963cfcb52f1), [#2765](https://github.com/heygen-com/hyperframes/pull/2765))
## Catalog
- **Registry:** Add media treatment overlays ([b0d3164dd](https://github.com/heygen-com/hyperframes/commit/b0d3164ddb6177c6b31634f02a908fdae607850f))
## Internal
- **Producer:** Add remote-ready plan v2 publisher ([07f9a3de9](https://github.com/heygen-com/hyperframes/commit/07f9a3de954d663e61d0e7da233fe8d33a06a5f9), [#2792](https://github.com/heygen-com/hyperframes/pull/2792))
- **Producer:** Remove stale audio concat remnants ([3ef194234](https://github.com/heygen-com/hyperframes/commit/3ef194234e6104664b8b1902ae916dd7480c3617))
- **Render:** Cover duration-capped audio mux ([20188d637](https://github.com/heygen-com/hyperframes/commit/20188d637b51bed607593592469863ba189ea4ec))
- **CLI:** Move render module collection outside hooks ([3b3d4f559](https://github.com/heygen-com/hyperframes/commit/3b3d4f559ca0b97a4d48d5dbaef10c296407c9cb), [#2780](https://github.com/heygen-com/hyperframes/pull/2780))
- **Studio:** Simplify preview workspace layout ([bf47416e1](https://github.com/heygen-com/hyperframes/commit/bf47416e144bbd45c41375f357b229215aa11ccb))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.71...v0.7.72).
</Update>
<Update
label="HyperFrames v0.7.71"
description="Released - 2026-07-24"
tags={["Release", "Lint", "Preview", "Core"]}
>
Finite compositions can now use GSAP `repeat: -1` loops when an explicit root
`data-duration` bounds the export, while unbounded infinite timelines remain
blocking. Preview also serves external symlink assets correctly, and this release
adds media-treatment capabilities plus performance and stability improvements
for data-driven caption templates.
## Features
- **Core:** Define media treatment capabilities ([70213c5a8](https://github.com/heygen-com/hyperframes/commit/70213c5a8526b12c8b26f01b8288d78bb9edc917))
## Fixes
- **Lint:** Allow bounded GSAP infinite repeats ([adb149b86](https://github.com/heygen-com/hyperframes/commit/adb149b86939e61bb3fce91cb8d0e5530f7bd29c), [#2763](https://github.com/heygen-com/hyperframes/pull/2763))
- **Preview:** Serve external symlink assets ([7778c093b](https://github.com/heygen-com/hyperframes/commit/7778c093b6288756cf5e27311828e6049e7c85c3), [#2764](https://github.com/heygen-com/hyperframes/pull/2764))
- **Caption Weight Shift:** Remove O(n^2) redundant hide-all-others loop ([5af6203ae](https://github.com/heygen-com/hyperframes/commit/5af6203ae7fcd506511ca5683a9097d666c4a63e))
- **Caption Editorial Emphasis / Emoji Pop:** Remove O(n²) redundant hide-all-others loop ([18de2b1f1](https://github.com/heygen-com/hyperframes/commit/18de2b1f1de8062a79ae7aa6fa4796e320fe6131))
## Catalog
- **Registry:** Liberal emoji-pop brand colors, weight-shift fit fixes, 8192 clamp ([8bf939043](https://github.com/heygen-com/hyperframes/commit/8bf939043fb44e05f6dd7caba81822ac5ef18334))
- **Registry:** Reject non-numeric caption-data versions; boot fetch never clobbers a manual attach ([5c2981d06](https://github.com/heygen-com/hyperframes/commit/5c2981d066480000d623e4d023e9cf918b2772e3))
- **Registry:** Encapsulate caption template runtimes in IIFEs ([e2846eb7c](https://github.com/heygen-com/hyperframes/commit/e2846eb7cc81821f7fc21a9c4dccdd85c7ef3429))
- **Registry:** Clear brand CSS custom properties on unbranded re-attach; remove dead italic path ([4f6994719](https://github.com/heygen-com/hyperframes/commit/4f6994719196e72dc01ebe8f60d869e064ec7685))
- **Registry:** Make caption-editorial-emphasis data-driven with emphasis heuristic ([ed8973952](https://github.com/heygen-com/hyperframes/commit/ed8973952dda2aa43931e0ffa05b3fc6ba0cb1cb))
- **Registry:** Make caption-highlight data-driven with automatic grouping ([7d4e71d10](https://github.com/heygen-com/hyperframes/commit/7d4e71d10b617b858fde2e764e15d530e9ffdf19))
- **Registry:** Make caption-emoji-pop data-driven with generic emoji lexicon ([392a9d251](https://github.com/heygen-com/hyperframes/commit/392a9d251a4feadbf7703ba4f9157c1857c7a59b))
- **Registry:** Force GSAP render at attach — seek(0) is a no-op on a fresh timeline ([020c8986f](https://github.com/heygen-com/hyperframes/commit/020c8986f46f795757203900cd251ad551ccc620))
- **Registry:** Make caption-pill-karaoke data-driven via caption-data runtime ([08620b75d](https://github.com/heygen-com/hyperframes/commit/08620b75df5275739fd9b0b8f480527fdc1d9a98))
- **Registry:** Make caption-weight-shift data-driven via caption-data runtime ([c67e9dc1f](https://github.com/heygen-com/hyperframes/commit/c67e9dc1f843122225468ce63a67c57f70c2bba2))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.70...v0.7.71).
</Update>
<Update
label="HyperFrames v0.7.70"
description="Released - 2026-07-24"
tags={["Release", "Producer", "Engine"]}
>
Multi-worker renders using experimental fast capture (`--experimental-fast-capture`) now self-verify their frames on the disk path and automatically fall back to screenshot capture when GPU or memory pressure corrupts output — previously that corruption (e.g. a worker's frames displaced into vertical strips) could ship silently.
## Fixes
- **Engine/Producer:** Parallel and sequential disk-path drawElement capture now verify sampled frames against pre-injection ground truth and screenshot-retry on a breach — closing the gap where `--experimental-fast-capture --workers N` shipped compositor-damaged frames with no error (PRINFRA-352) ([060b6f8ae](https://github.com/heygen-com/hyperframes/commit/060b6f8ae53e8ae243d8e2c2c3afd690dbe7f99c), [ec791e91d](https://github.com/heygen-com/hyperframes/commit/ec791e91d97c0070502c4ad208923d4f98005846), [9fc1c2f15](https://github.com/heygen-com/hyperframes/commit/9fc1c2f15990fc44f50533362bec2f075aa1d53d), [c85cfae8f](https://github.com/heygen-com/hyperframes/commit/c85cfae8fa97229e9c4f4d7217cf08b4bc859f3d))
- **Producer:** Close the orphaned probe session before a verify-triggered retry so the probe Chrome process isn't leaked under the GPU/memory pressure the retry is recovering from ([b8e101547](https://github.com/heygen-com/hyperframes/commit/b8e10154762f5a0fe71fb4a6a9f3d472e9bf99ec))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.69...v0.7.70).
</Update>
<Update
label="HyperFrames v0.7.69"
description="Released - 2026-07-23"
tags={["Release", "Sdk", "Lint", "Producer"]}
>
HyperFrames now catches rotation pivot drift during layout audits, helping surface off-center spinners before render. This release also preserves editable \<br\> line breaks in SDK text edits, strengthens producer font, probe, and worker-memory handling, and improves preflight and pre-commit feedback.
## Features
- **Lint:** Add rotation_pivot_drift layout check ([222aec45a](https://github.com/heygen-com/hyperframes/commit/222aec45ab0553c014dddccd563e273affd9b71a), [#2741](https://github.com/heygen-com/hyperframes/pull/2741))
## Fixes
- **Sdk:** Keep \<br\> line breaks editable and uncorrupted in setText ([dd7378bbd](https://github.com/heygen-com/hyperframes/commit/dd7378bbd934ecc0da85c1edfae7946ac7e2271a), [#2742](https://github.com/heygen-com/hyperframes/pull/2742))
- **Lint:** Address render preflight review feedback ([7a294f195](https://github.com/heygen-com/hyperframes/commit/7a294f19562928036dae20d5e73c2637d1e19060), [#2739](https://github.com/heygen-com/hyperframes/pull/2739))
- **Producer:** Resolve residual font and probe failures ([948264d6b](https://github.com/heygen-com/hyperframes/commit/948264d6b205cadd191e04f36af332ed4a6bb233), [#2738](https://github.com/heygen-com/hyperframes/pull/2738))
- **Hooks:** Pre-commit gate denies commit instead of ending the turn ([97ec7db5c](https://github.com/heygen-com/hyperframes/commit/97ec7db5cccd14a47fcfbe534d269dfea7a0d37d))
- **Producer:** Emit heap advisory at orchestrator, lock message + telemetry props with tests ([c6462a0a2](https://github.com/heygen-com/hyperframes/commit/c6462a0a225772c7a357afd5fe2f5c9a39bca238))
- **Engine:** Realistic worker memory budget + sizing/feedback telemetry ([12e599a6b](https://github.com/heygen-com/hyperframes/commit/12e599a6bac7dea5feec59c71dd0e5aeecab354e))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.68...v0.7.69).
</Update>
<Update
label="HyperFrames v0.7.68"
description="Released - 2026-07-22"
tags={["Release", "Core", "Producer", "Skills"]}
>
Bug-fix release. The video coverage gate no longer aborts renders of looping short videos, and composition-level CSS with native CSS Nesting now applies to nested selectors instead of being silently ignored.
## Fixes
- **Producer:** Credit looping short videos in coverage gate (#2665). A short clip in a longer slot with `loop` set delivers all its source frames once and reuses them per repeat; the gate now measures against source-source instead of unique-source-vs-slot, so a 3s looping clip in a 10s slot no longer aborts at 30% coverage ([a637f394e](https://github.com/heygen-com/hyperframes/commit/a637f394ee900c64c8f2e1ee78cf1e0ce17b8739), [#2732](https://github.com/heygen-com/hyperframes/pull/2732))
- **Core:** Preserve nested-rule selectors in composition CSS scoping (#2721). Native CSS Nesting (Chrome 112+ / Firefox 117+ / Safari 16.5+) now works inside a composition's `<style>` block; the scoper no longer re-applies the composition selector to nested rules, so `[data-composition-id="foo"] { .title { … } }` correctly matches `.title` inside the composition ([1e2c7d673](https://github.com/heygen-com/hyperframes/commit/1e2c7d673fc0fe8da9a822bb7a8744f84f44d9d4), [#2733](https://github.com/heygen-com/hyperframes/pull/2733))
## Docs & Examples
- **Skills:** Make captions non-optional in changelog-video ([807078c7c](https://github.com/heygen-com/hyperframes/commit/807078c7cde9d5c8403588722d1cd9397c513a0d), [#2729](https://github.com/heygen-com/hyperframes/pull/2729))
- **Changelog:** Weekly digest 2026-07-132026-07-20 ([2e97e5b1e](https://github.com/heygen-com/hyperframes/commit/2e97e5b1ef78c18c31b042b082bb7cc7cc44ac1c), [#2664](https://github.com/heygen-com/hyperframes/pull/2664))
## Other Changes
- Revert "feat(producer): renderStretch to re-time short compositions across longer scenes (#2676)" ([69446e772](https://github.com/heygen-com/hyperframes/commit/69446e77265a420c4f24a0395682212793eb323f), [#2730](https://github.com/heygen-com/hyperframes/pull/2730))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.67...v0.7.68).
</Update>
<Update
label="HyperFrames v0.7.67"
description="Released - 2026-07-21"
tags={["Release", "Producer", "Core", "Skills"]}
>
Distributed renders now carry runtime media variables through planning and honor runtime durations during extraction and volume automation, preventing placeholder metadata from clipping full-length audio. This release also adds RenderStretch for longer scenes, applies position edits to SVG elements, and warns when live map viewports are captured.
## Features
- **Producer:** RenderStretch to re-time short compositions across longer scenes ([e786b78b3](https://github.com/heygen-com/hyperframes/commit/e786b78b3311d697e95269ec8fc21a4159af909d), [#2676](https://github.com/heygen-com/hyperframes/pull/2676))
- **Engine:** Warn when a live map viewport is detected at capture init ([30ca51c61](https://github.com/heygen-com/hyperframes/commit/30ca51c615fce20f5266278cdf5f4c97fd691c88))
## Fixes
- **Producer:** Preserve runtime audio variables in distributed plans ([465c9e764](https://github.com/heygen-com/hyperframes/commit/465c9e764138b94faa48badbee468eb42bd1a39d), [#2725](https://github.com/heygen-com/hyperframes/pull/2725))
- **Core:** Apply position edits to SVG elements, not just HTML ([63539a0cd](https://github.com/heygen-com/hyperframes/commit/63539a0cdef75597d2e301736740cf3bdd905596), [#2724](https://github.com/heygen-com/hyperframes/pull/2724))
- **Skills:** Anonymize CLI feedback repro guidance ([78ab9bc88](https://github.com/heygen-com/hyperframes/commit/78ab9bc889908e412e806d80f4ffbd938efc7a78))
## Internal
- **Skills:** Package Codex plugin upload ([696cbdbbd](https://github.com/heygen-com/hyperframes/commit/696cbdbbd0e5c83faf72c767126d4a153110f130), [#2668](https://github.com/heygen-com/hyperframes/pull/2668))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.66...v0.7.67).
</Update>
<Update
label="HyperFrames v0.7.66"
description="Released - 2026-07-21"
tags={["Release", "Studio", "Skills", "Engine"]}
>
Studio preview audio now plays at playback speeds above 1x instead of being silently muted, and the mute control stays usable at every speed. This release also fixes an engine worker frame-stride false-positive and lands a c2v skills mining pass with new blueprints and rules.
## Features
- **Skills:** C2v mining pass — 7 new blueprints, 10 new rules, compacted recipe corpus ([853256403](https://github.com/heygen-com/hyperframes/commit/853256403b3ffd3dc0b616785ab876b4c0f04a89), [#2680](https://github.com/heygen-com/hyperframes/pull/2680))
## Fixes
- **Studio:** Play preview audio at speeds above 1x ([07965e9fe](https://github.com/heygen-com/hyperframes/commit/07965e9fe93fc7b53dfe7c933f8abd0c4b10c86d), [#2691](https://github.com/heygen-com/hyperframes/pull/2691))
- **Engine:** Carry frameStride onto WorkerResult (fixes interleaved worker false-positive) ([4f53dd4f2](https://github.com/heygen-com/hyperframes/commit/4f53dd4f2cd607de17b2d2329746fa5da0cadeaa))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.65...v0.7.66).
</Update>
<Update
label="HyperFrames v0.7.65"
description="Released - 2026-07-21"
+2 -2
View File
@@ -87,8 +87,8 @@ the shader grading, finishing details, blur/pixelate effects, and optional LUT:
```html compositions/hero.html
<html data-composition-variables='[
{"id":"gradingPreset","type":"enum","label":"Preset","default":"natural-lift",
"options":[{"value":"natural-lift","label":"Natural Lift"},{"value":"warm-daylight","label":"Warm Daylight"}]},
{"id":"gradingPreset","type":"enum","label":"Preset","default":"clean-studio",
"options":[{"value":"clean-studio","label":"Clean Studio"},{"value":"warm-daylight","label":"Warm Daylight"}]},
{"id":"gradingIntensity","type":"number","label":"Preset strength","default":0.75,"min":0,"max":1,"step":0.05},
{"id":"gradingExposure","type":"number","label":"Exposure","default":0,"min":-2,"max":2,"step":0.05},
{"id":"gradingVibrance","type":"number","label":"Vibrance","default":0.08,"min":-1,"max":1,"step":0.01}
+2
View File
@@ -79,6 +79,8 @@
"guides/authentication",
"guides/video-components",
"guides/color-grading",
"guides/media-effects",
"guides/media-overlays",
"guides/html-in-canvas",
"guides/website-to-video",
"guides/figma",
@@ -30,8 +30,8 @@ The single most important consequence: **there is no file tree on the other side
1. **You (Claude Design)** — author a valid HyperFrames composition as a single self-contained HTML.
2. **Send to HyperFrames** — one click. The importer fetches your HTML, validates it, and creates a hosted HeyGen project. **Import is free.**
3. **Enhance in HyperFrames** — a motion-design agent adds what your export can't: sound effects, background music, and (later) HeyGen media. This is the paid step.
4. **Render** — the cloud pipeline produces the MP4.
3. **Enhance in HyperFrames** — a motion-design agent adds what your export can't: sound effects, background music, and (later) HeyGen media. **Enhance turns are free.**
4. **Render** — the cloud pipeline produces the MP4. **Render is the paid step:** free accounts get 3 renders per month; paid plans are charged 20 credits per rendered minute.
Your job is step 1: a composition that imports cleanly and is a strong on-brand starting point.
+261 -162
View File
@@ -1,193 +1,187 @@
---
title: Color Grading
description: "Apply real-time color grading, presets, LUTs, vignette, grain, blur, and pixelate to video and image media in Studio and final renders."
description: "Correct and creatively grade video or image media with presets, scopes, color wheels, curves, HSL selections, and custom LUTs."
---
HyperFrames Studio can color grade project-local `<video>` and `<img>` media directly in the preview. The same `data-color-grading` settings are used by the render pipeline, so the exported video should match the look you preview.
Use Color Grading to correct and creatively grade real `<video>` and `<img>`
media in Studio or through an agent with the same validated SDR/Rec.709 shader
contract in preview and render.
This is a lightweight media color tool for generated videos, uploaded footage, social variants, and agent-authored compositions. It is not a DaVinci Resolve, Premiere, ACES, or OCIO finishing pipeline.
## Choose the Right Tool
## What Is New
| Capability | Status | Notes |
| Stage | Purpose | HyperFrames tools |
| --- | --- | --- |
| Studio Color Grading panel | Supported | Appears on selected `<video>` and `<img>` elements. |
| Manual controls | Supported | Exposure, contrast, highlights, shadows, white point, black point, warmth, tint, vibrance, saturation. |
| Presets | Supported | Named HyperFrames presets backed by shader settings, not bundled third-party LUT packs. |
| Custom LUT upload | Supported | Project-local 3D `.cube` LUT files with strength control. |
| Finishing | Supported | Vignette and grain, with advanced settings behind the settings icon. |
| Effects | Supported | Blur and pixelate on the selected media surface. |
| Before preview | Supported | Hold the compare button to temporarily show the ungraded media. |
| Render parity | Supported | The render pipeline redraws the color-grading shader after video-frame injection. |
| Correct | Fix exposure, contrast, tonal balance, and casts | Adjust controls and source analysis |
| Grade | Shape color and mood | Tonal wheels, RGB curves, hue curves, and HSL color selections |
| Apply a look | Start from a tested style or an existing external look | Presets and custom 3D `.cube` LUTs |
| Finish | Add restrained optical texture | Vignette and grain |
| Stylize | Transform the pixels beyond normal grading | [Media Effects](/guides/media-effects) |
| Dress | Add an authored HUD, flash, light leak, or freeze-frame layer | [Media Overlays](/guides/media-overlays) |
Studio labels `whites`, `blacks`, and `temperature` as White Point, Black Point, and Warmth. Use the JSON keys shown in the data shape when authoring `data-color-grading` by hand or through an agent.
All pixel-level stages are stored together on the selected media element. An
overlay is different: it is an ordinary editable composition layer whose final
paint order follows its authored track and CSS `z-index`.
## Support Matrix
## Quick Start
| Source / workflow | Supported? | What to expect |
| --- | --- | --- |
| 1080p SDR video | Yes | Best default path. Good for most uploaded/generated MP4/WebM/MOV media that browsers can decode. |
| 4K SDR video | Yes | Works when the browser and machine can decode it. Preview/render cost is higher. A 4K source only produces 4K output when the composition/render is also 4K. |
| 1080p / 4K images | Yes | Works on normal project-local images. Output resolution follows the element/composition render size, not hidden extra detail beyond that size. |
| iPhone SDR video | Yes | Treat as normal SDR media when it is tagged/decoded as SDR. |
| iPhone HDR / HLG / Dolby Vision-style uploads | Partial | The media can be loaded if browser/FFmpeg support the file, and Studio warns when HDR metadata is detected. The live Color Grading shader is still an SDR preview path, not true HDR grading. Use the existing [HDR Rendering](/guides/hdr) pipeline for HDR delivery and verify the output. |
| HDR render output | Related, not new | HyperFrames already has HDR render support. Color Grading does not yet provide HDR-aware grading controls. |
| LOG camera footage | Partial | Sliders and LUTs can be applied, but HyperFrames does not auto-detect camera LOG profiles or apply ACES/OCIO input transforms. Use a matching conversion/look LUT if you know the source profile. |
| Rec.709 creative LUTs | Yes | Best LUT path today. Use project-local 3D `.cube` files. |
| Camera conversion LUTs | Partial | Technically accepted if they are supported 3D `.cube` files, but correctness depends on the source footage matching the LUT's expected input color space. |
| Full-scene grading including text/DOM | Not yet | Color Grading is media-only. Captions, text, SVG, and regular DOM overlays stay unchanged. |
| Remote media URLs | Partial | WebGL pixel processing requires compatible CORS headers. Project-local assets are the reliable path. |
| Professional ACES/OCIO/HDR finishing | Not yet | Future render/color-management work, not this Studio shader path. |
<Tabs>
<Tab title="Studio">
Select a real `<img>` or `<video>` element to open Color Grading in the
Design panel.
## How It Works
<Steps>
<Step title="Correct the source">
Open **Scopes**, then adjust exposure, tonal balance, white balance, and
saturation before applying a stronger look.
</Step>
<Step title="Shape the grade">
Use tonal wheels, curves, or an HSL Color Selection only where the
source needs more precise control.
</Step>
<Step title="Choose a look and verify">
Preview a preset or load a 3D `.cube` LUT, compare with the source, and
scrub representative frames before rendering.
</Step>
</Steps>
</Tab>
<Tab title="Agent / CLI">
Inspect the media, apply one validated payload, then verify representative
frames:
Color grading is stored on media elements as `data-color-grading`:
```bash Terminal
npx hyperframes media-treatment \
--project . \
--file compositions/interview.html \
--selector '#interview' \
--analyze \
--json
```html index.html
<video
id="hero-video"
src="assets/hero.mp4"
data-start="0"
data-duration="6"
muted
playsinline
data-color-grading='{
"preset":"clean-studio",
"intensity":0.85,
"adjust":{
"exposure":0.05,
"contrast":0.08,
"highlights":-0.08,
"shadows":0.06,
"vibrance":0.04,
"saturation":0.04
},
"details":{
"vignette":0.08,
"vignetteFeather":0.72,
"grain":0.12,
"grainSize":0.25,
"grainRoughness":0.55
},
"effects":{
"blur":0.08
},
"colorSpace":"rec709"
}'
></video>
npx hyperframes media-treatment \
--project . \
--file compositions/interview.html \
--selector '#interview' \
--grading '{"adjust":{"highlights":-0.08,"shadows":0.06},"wheels":{"midtones":{"hue":32,"amount":0.05}}}' \
--apply \
--json
```
The runtime creates a sibling WebGL canvas for the media element, samples the current video or image frame, applies shader uniforms, then hides the native media only after a shader frame is ready.
Use `--dry-run` when target or scope is uncertain. Use `--clear` to remove
the complete treatment.
</Tab>
</Tabs>
<Note>
Color Grading is intentionally **media-only**. It applies to `<video>` and `<img>` sources. Captions, text, divs, SVG, and UI graphics remain ungraded unless you render them into media first.
</Note>
To reuse a grade in Studio, use **Copy grade to**, choose **Current file media**
or **All project media**, then click **Apply**. Project-wide copy refuses
project-relative LUT paths because the same path may resolve differently in
another composition; use current-file copy or a project-root path/data URL. The
copy changes only the treatment payload, not overlays, captions, or DOM layers.
<Note>
Project-local media is the safest path. Remote media must be served with compatible CORS headers and should use `crossorigin="anonymous"` when pixel processing is needed.
</Note>
Project-local media is the reliable path. Remote media requires compatible CORS
headers. Color Grading does not process captions, text, SVG, arbitrary DOM, or
CSS background images.
## Data Shape
## Professional Controls
```json
{
"preset": "natural-lift",
"intensity": 1,
"adjust": {
"exposure": 0,
"contrast": 0,
"highlights": 0,
"shadows": 0,
"whites": 0,
"blacks": 0,
"temperature": 0,
"tint": 0,
"vibrance": 0,
"saturation": 0
},
"details": {
"vignette": 0,
"vignetteMidpoint": 0.5,
"vignetteRoundness": 0,
"vignetteFeather": 0.65,
"grain": 0,
"grainSize": 0.25,
"grainRoughness": 0.5
},
"effects": {
"blur": 0,
"pixelate": 0
},
"lut": {
"src": "assets/luts/look.cube",
"intensity": 0.75
},
"colorSpace": "rec709"
}
```
### Color Wheels
Omit `enabled`; the presence of `data-color-grading` implies that grading is active. All numeric controls are clamped by the runtime. The current color grading path is Rec.709/sRGB-oriented and assumes browser-decoded media frames.
The three wheels target broad tonal zones:
## Custom LUTs
- **Shadows** shapes dark regions.
- **Midtones** shapes most faces, products, and general scene color.
- **Highlights** shapes bright regions and specular areas.
HyperFrames supports project-local 3D `.cube` LUT files:
Use small amounts first. The **Level** control changes the brightness of the
same tonal zone, while hue and amount introduce color.
```html index.html
<img
src="assets/product.jpg"
data-color-grading='{
"lut":{"src":"assets/luts/product-pop.cube","intensity":0.7}
}'
/>
```
### RGB Curves
Use `.cube` LUTs when users already have a look from another editor or camera workflow.
- **Master** remaps overall luminance.
- **Red**, **Green**, and **Blue** remap individual channels.
- HyperFrames currently supports 3D `.cube` LUTs for this path.
- 3D cube LUTs up to `LUT_3D_SIZE 64` are supported.
- 1D `.cube` LUTs and mixed 1D+3D LUT files are not supported yet.
- Supported headers include common `DOMAIN_MIN` / `DOMAIN_MAX` and DaVinci/IRIDAS-style `LUT_3D_INPUT_RANGE`.
- LUTs are not universal. A LUT looks correct only when the source footage roughly matches the LUT's expected input color space.
- Rec.709 creative LUTs are the safest fit today.
- LOG/camera conversion LUTs can be used, but HyperFrames does not yet manage camera color profiles for you.
Curve points are normalized `[input, output]` pairs. Resolved curves contain
input endpoints `0` and `1`; HyperFrames infers missing endpoints, and the
216-point limit includes those inferred points. An S-curve increases contrast;
lifting the lower-left region raises shadows; channel curves can build
split-tone or cast-removal adjustments.
## Render Behavior
### Hue Curves
Color Grading is part of the media runtime, so render uses the same settings as Studio preview. During render, HyperFrames injects exact video frames and asks the color-grading runtime to redraw before capture.
- **Hue vs Hue** moves a selected hue toward another hue.
- **Hue vs Saturation** changes saturation around a selected hue.
- **Hue vs Luma** changes brightness around a selected hue.
For 4K output, use the existing [4K Rendering](/guides/4k-rendering) workflow. Color Grading can run at 4K when the composition/render surface is 4K, but a 1080p source video does not become sharper just because the final render is 4K.
Hue curve points are `[hueDegrees, delta]` pairs and wrap around the color
wheel. An authored hue curve requires 316 unique hue inputs from `0` up to,
but not including, `360`.
For HDR output, use the existing [HDR Rendering](/guides/hdr) workflow. Color Grading currently warns on detected HDR media, but the grading controls themselves are not HDR-aware.
### HSL Color Selections
When grading a video, animate opacity on a wrapper element instead of directly on the `<video>` element. The runtime hides the native media and draws the graded result through a sibling canvas, so wrapper opacity preserves preview/render parity.
A color selection qualifies pixels by hue, saturation, and luma, then applies a
correction only inside that matte. This is useful for restrained tasks such as
reducing an overly saturated shirt, cooling a background color, or protecting
skin from a broad creative grade.
## What Belongs Where
HyperFrames supports up to four ordered selections. These are static
media-level qualifiers: they do not include object tracking, rotoscoping,
facial recognition, or spatial masks.
| User wants | Use |
### Scopes
| Scope | Use it for |
| --- | --- |
| Make uploaded footage look cleaner | Color Grading preset + adjust controls |
| Use a look from another editor | Custom 3D `.cube` LUT |
| Add polish to a product shot | Vignette, subtle grain, contrast, vibrance |
| Blur or pixelate selected media | Effects inside Color Grading |
| Make a presenter float over graphics | Existing [Remove Background](/guides/remove-background) workflow |
| Put text behind a presenter | Existing `remove-background --background-output` workflow |
| Render HDR delivery files | Existing [HDR Rendering](/guides/hdr) workflow |
| Render 4K | Existing [4K Rendering](/guides/4k-rendering) workflow |
| Remove a person and reconstruct the room | External video inpainting, not HyperFrames background removal |
| Green screen keying | Preprocess with FFmpeg `chromakey` or a future HyperFrames command |
| Grade every pixel in the full scene including captions/DOM | Future compositor or render post-process |
| Professional ACES/OCIO color pipeline | Future high-fidelity color-management pipeline |
| Histogram | Overall distribution from dark to bright |
| Waveform | Brightness by horizontal image position |
| RGB Parade | Channel balance and clipped individual channels |
| Vectorscope | Hue direction and saturation |
## Agent-Friendly Examples
Studio scopes analyze a captured selected-media frame with the current
treatment applied. They refresh as the grade changes, but are inspection tools
and do not modify the grade.
For AI agents, keep the instruction declarative:
## Presets
```text
Apply a clean studio preset to assets/interview.mp4, reduce highlights slightly,
lift shadows, add subtle vignette, and keep captions ungraded.
HyperFrames ships tested shader-setting presets, not bundled LUT files:
| Intent | Presets |
| --- | --- |
| Natural and corrective | Neutral, Warm Daylight, Clean Studio, Skin Soft, Food Pop, Night Lift |
| Editorial and tonal | Muted Editorial, Vintage Wash, Soft Boost, Bright Pop, Deep Contrast |
| Monochrome | Mono Clean, Mono Fade |
Use these as starting points and tune them for the actual source. Presets that
also activate a stylized shader treatment are documented under
[Media Effects](/guides/media-effects).
## Agent Guidance
Agents should use the CLI as the normal authoring surface. The
`data-color-grading` attribute is the persistence contract, not the first thing
an agent needs to memorize.
Users do not need to name a technical control. Requests such as _"this
interview feels too dark and cold"_ or _"polish the footage without making it
look filtered"_ route through the `media-use` skill, which inspects the source,
discovers the relevant contract, applies a deterministic payload, and verifies
the result.
Start with the concise capability overview, then query only the contract needed
for the current intent:
```bash Terminal
npx hyperframes media-treatment --capabilities --json
npx hyperframes media-treatment --capability grading --json
npx hyperframes media-treatment --capability curves --json
```
Expected markup:
Focused queries such as `wheels`, `hue-curves`, `secondary`, `scopes`, or `lut`
return exact controls, bounds, and examples. Start from source analysis or a
tested preset, then add small, explainable adjustments. Avoid inventing many
unrelated curve and secondary values: they are difficult to review and easy to
overcook.
## Low-Level HTML Contract
The CLI and Studio persist the resolved grade in `data-color-grading`:
```html index.html
<video
@@ -199,26 +193,131 @@ Expected markup:
playsinline
data-color-grading='{
"preset":"clean-studio",
"intensity":0.8,
"adjust":{"highlights":-0.08,"shadows":0.08},
"details":{"vignette":0.08}
"intensity":0.85,
"adjust":{
"exposure":0.04,
"highlights":-0.08,
"shadows":0.06,
"temperature":0.03
},
"wheels":{
"shadows":{"hue":218,"amount":0.04,"level":-0.01},
"midtones":{"hue":32,"amount":0.05,"level":0.01},
"highlights":{"hue":42,"amount":0.03,"level":0}
},
"curves":{
"master":[[0,0],[0.28,0.25],[0.72,0.76],[1,1]]
},
"hueCurves":{
"hueVsSaturation":[[0,0],[28,-0.05],[52,0]]
},
"secondaries":[{
"enabled":true,
"key":{
"hue":{"center":28,"range":18,"softness":12},
"saturation":{"min":0.15,"max":0.9,"softness":0.08},
"luma":{"min":0.12,"max":0.95,"softness":0.08}
},
"correction":{"saturation":-0.04,"temperature":0.03}
}],
"details":{"vignette":0.05,"grain":0.03},
"colorSpace":"rec709"
}'
></video>
```
## Related Guides
The contract rejects unknown keys and clamps numeric values to Core-owned
bounds. Query the current contract instead of copying bounds into agent
instructions:
```bash Terminal
npx hyperframes media-treatment --capability secondary --json
npx hyperframes media-treatment --all --json
```
Use `--all` only for exhaustive tooling or contract inspection.
## Custom LUTs
HyperFrames supports project-local 3D `.cube` LUTs:
```json data-color-grading
{
"lut": {
"src": "assets/luts/product-look.cube",
"intensity": 0.7
}
}
```
- 3D `.cube` LUTs up to `LUT_3D_SIZE 64` are supported.
- 1D and mixed 1D+3D `.cube` files are not supported.
- Common `DOMAIN_MIN`, `DOMAIN_MAX`, and DaVinci/IRIDAS-style
`LUT_3D_INPUT_RANGE` headers are supported.
- HyperFrames does not bundle third-party LUT packs.
- A LUT is only correct when its expected input color space matches the source.
- Rec.709 creative LUTs are the safest current workflow.
- Camera LOG conversion LUTs can be loaded, but HyperFrames does not identify
camera profiles or apply ACES/OCIO input transforms automatically.
Never paste a `.cube` body into an agent prompt. LUT files commonly contain
tens of thousands of numeric rows. Keep the file local and use metadata,
validation, and rendered comparisons to evaluate it.
## Support Matrix
| Source or workflow | Status | What to expect |
| --- | --- | --- |
| 1080p SDR video | Supported | Recommended default path |
| 4K SDR video or image | Supported | Higher preview/render cost; output resolution still follows the composition |
| iPhone SDR video | Supported | Treated as normal browser-decoded SDR media |
| iPhone HDR, HLG, or Dolby Vision-style upload | Partial | Studio can show an SDR shader preview, but native HDR delivery bypasses the SDR treatment for native HDR source pixels |
| HDR delivery render | Separate workflow | The native HDR compositor preserves HDR source pixels and does not apply this SDR grading/effects pipeline to native HDR layers |
| LOG camera footage | Partial | Requires a known matching transform/LUT; no automatic camera profile management |
| Full-scene grade including DOM/text | Not supported | Current grading targets individual media elements |
| Face or region tracking | Not supported | Use a separate isolated media layer or external tracking/masking workflow |
| Remote media | Partial | Requires compatible CORS headers; project-local assets are reliable |
| ACES/OCIO finishing | Not supported | Outside the current browser shader pipeline |
## Render and Performance
The runtime creates a sibling WebGL canvas, uploads the current image/video
frame as a texture, applies the same grading contract used by Studio, and hides
the native source after a shader frame is ready. During final rendering,
HyperFrames injects exact video frames and waits for the grading runtime to
redraw before capture.
That render-parity statement applies to SDR output. In the native HDR render
path, HyperFrames keeps HDR source pixels out of the SDR DOM capture and
composites them separately at higher bit depth. The current SDR grading canvas
is therefore not applied to native HDR layers. Convert or tone-map the source to
SDR first when these grading controls must appear in the result, or use
[HDR Rendering](/guides/hdr) to preserve the untreated HDR source.
For 4K output, follow [4K Rendering](/guides/4k-rendering). A 1080p source does
not gain new detail merely because the composition is rendered at 4K.
When animating a graded media layer's opacity, animate a wrapper rather than the
`<video>` itself. The visible pixels are drawn by the sibling grading canvas,
so wrapper opacity keeps the media and canvas together.
Project-local media is the safest path. Remote media must provide compatible
CORS headers and should use `crossorigin="anonymous"` when pixel access is
required.
## Next Steps
<CardGroup cols={2}>
<Card title="Remove Background" icon="scissors" href="/guides/remove-background">
Create transparent video/image cutouts for presenter and product overlays.
<Card title="Media Effects" icon="wand-magic-sparkles" href="/guides/media-effects">
Apply and animate shader-based optical, retro, print, and art treatments.
</Card>
<Card title="Rendering" icon="film" href="/guides/rendering#transparent-background">
Render transparent overlays and final MP4/WebM/MOV outputs.
<Card title="Media Overlays" icon="layer-group" href="/guides/media-overlays">
Add editable HUD, flash, light-leak, and freeze-frame composition layers.
</Card>
<Card title="HDR Rendering" icon="sun" href="/guides/hdr">
Render HDR10 outputs when your project uses HDR video or image sources.
Render HDR10 outputs and understand the boundary with SDR grading.
</Card>
<Card title="4K Rendering" icon="up-right-and-down-left-from-center" href="/guides/4k-rendering">
Render at 4K and understand what supersampling does and does not improve.
Render at 4K and understand source-versus-output resolution.
</Card>
</CardGroup>
+8 -6
View File
@@ -127,12 +127,13 @@ Only the existence (or in some cases the value) of these variables is checked
### CLI feedback
Event: `cli_render_feedback`
| Field | Value |
|-------|-------|
| `$survey_id` | `render_satisfaction` |
| `$survey_response` | Raw rating (010) |
| `rating` | Raw rating (010) |
| `rating_scale` | `10` for the current recommendation scale |
| `$survey_response_2` | Free-text comment (only when provided) |
| `comment` | Free-text comment (only when provided) |
| `render_duration_ms` | Time the render took in milliseconds |
| `doctor_summary` | System context (see below) |
@@ -146,12 +147,13 @@ It may also include `wsl` or sandbox runtime flags when those environments are d
### Studio feedback
Event: `studio_feedback`
| Field | Value |
|-------|-------|
| `$survey_id` | `studio_experience` |
| `$survey_response` | Raw rating (010) |
| `rating` | Raw rating (010) |
| `rating_scale` | `10` for the current recommendation scale |
| `$survey_response_2` | Free-text comment (only when provided) |
| `comment` | Free-text comment (only when provided) |
| `source` | `studio` |
| `doctor_summary` | Browser context (platform, screen, CPU cores, device memory, network type) |
+323
View File
@@ -0,0 +1,323 @@
---
title: Media Effects
description: "Apply deterministic shader effects to video and image media, combine them with color grading, and animate supported strengths with GSAP."
---
Use Media Effects to transform real `<video>` and `<img>` pixels with
deterministic blur, retro, print, and art treatments that can share one payload
with [Color Grading](/guides/color-grading).
## Effect Families
### Essentials
| Effect | What it does |
| --- | --- |
| Blur | Defocuses or softens the complete media layer |
| Pixelate | Converts the layer into a block mosaic |
| Bloom | Adds thresholded glow around bright regions |
### Retro & Glitch
| Effect | What it does |
| --- | --- |
| Chroma Softening | Smears chroma while retaining central luma |
| Tape Damage | Adds deterministic tracking errors, noise, ghosting, and dropouts |
| Film Artifacts | Adds deterministic dust and short scratches |
| Scanlines | Adds configurable horizontal display lines |
| CRT Curvature | Warps media toward curved display geometry |
| Channel Separation | Offsets color channels along an angle |
| Digital Glitch | Combines line tears, blocks, displacement, pixelation, and channel split |
When authoring JSON directly, **Chroma Softening** uses `chromaBleed` and
**Channel Separation** uses `chromaticAberration`. Query the capability command
for the canonical payload keys of all other controls.
### Print
| Effect | What it does |
| --- | --- |
| Halftone | Renders source color through a print-dot raster |
| Two-Ink Print | Reduces media to the built-in two-ink treatment |
| Ordered Dither | Quantizes media into an ordered limited palette |
| Mono Screen | Builds monochrome dot, shape, or line artwork |
### Art
| Effect | What it does |
| --- | --- |
| ASCII | Renders media as configurable procedural glyph cells |
| Engraving | Translates luminance into directional engraved lines |
| Crosshatch | Translates media into layered hand-hatched lines |
| Kuwahara Paint | Applies edge-preserving painterly smoothing |
## Quick Start
<Tabs>
<Tab title="Studio">
<Steps>
<Step title="Select real media">
Select a real `<img>` or `<video>` in the preview, Layers panel, or
Timeline. Effects do not appear for arbitrary divs or CSS background
images.
</Step>
<Step title="Choose and tune an effect">
Preview an effect preset or open **Effects**. Enabling an effect uses a
calibrated default; expand it to adjust only the controls relevant to
the intended result.
</Step>
<Step title="Verify motion and framing">
Play and scrub videos at the beginning, middle, and end. Confirm that
moving effects and object-fit/object-position framing remain correct.
</Step>
</Steps>
</Tab>
<Tab title="Agent / CLI">
Discover the narrow effect contract, then apply the complete treatment in
one mutation:
```bash Terminal
npx hyperframes media-treatment --capabilities --json
npx hyperframes media-treatment --capability digitalGlitch --json
npx hyperframes media-treatment \
--project . \
--file compositions/scene.html \
--selector '#hero' \
--grading '{"effects":{"digitalGlitch":0.55,"digitalGlitchColorSplit":0.25,"digitalGlitchLineTear":0.25,"digitalGlitchPixelate":0.15,"digitalGlitchBlockAmount":0.5,"digitalGlitchBlockDisplacement":0.25,"digitalGlitchSpeed":0.5}}' \
--apply \
--json
```
</Tab>
</Tabs>
Project-local media is recommended; remote media requires compatible CORS.
[Media Overlays](/guides/media-overlays) are separate editable composition
layers rather than shader properties.
<Warning>
Media Effects use the SDR/Rec.709 shader pipeline. Studio can preview that
pipeline on browser-decoded HDR media, but native HDR rendering preserves the
HDR source separately and does not apply these SDR effects to native HDR
layers. Convert or tone-map to SDR when an effect must appear in the final
output. See [Color Grading support](/guides/color-grading#support-matrix) and
[HDR Rendering](/guides/hdr).
</Warning>
## Effect Presets
`Creator Camcorder`, `VHS Playback`, `8mm Home Movie`, `Editorial Halftone`, and
`Two-Ink Print` provide tested combinations of effect, correction, and
finishing settings. Preview one as a starting point, then tune the underlying
controls. They are normal shader payloads, not baked media or bundled LUTs.
## Agent Guidance
Agents should discover only the part of the toolbox relevant to the user's
intent:
The user may simply ask for _"an old home-video feel"_, _"a useful privacy
reveal"_, or _"something more graphic for this poster."_ The `media-use` skill
classifies that intent, then queries the narrow effect or recipe details instead
of loading the exhaustive contract.
```bash Terminal
npx hyperframes media-treatment --capabilities --json
npx hyperframes media-treatment --capability retro-glitch --json
npx hyperframes media-treatment --capability digitalGlitch --json
```
The concise overview lists capability families. A focused query returns the
effect's calibrated apply payload, controls, render lane, palette support, and
seek-safe animation path. Use the canonical payload instead of guessing
sub-control defaults.
Recipes are tested shortcuts, not a closed list. An agent may assemble a custom
payload when the source and intent justify it, but it should:
1. Choose one primary visual intent.
2. Query the exact effect or family contract.
3. Add only controls that visibly support that intent.
4. Avoid stacking several dominant stylizations without a reason.
5. Verify representative frames and a short render.
## Palettes
`palette` accepts two to six exact `#RRGGBB` colors in authored order.
Dark-to-light order creates normal luminance mapping; reversing the array
intentionally inverts that mapping.
```json data-color-grading
{
"effects": {
"dither": 1,
"ditherSize": 0.5
},
"palette": ["#080717", "#3c185f", "#d9339f", "#ff6b66", "#aafae0"]
}
```
Compatible effects are ASCII, Ordered Dither, Mono Screen, Engraving, and
Crosshatch. A palette alone does not activate an effect.
Discover built-in palettes without copying their values into instructions:
```bash Terminal
npx hyperframes media-treatment --capability palettes --json
npx hyperframes media-treatment --capability electric-ink --json
```
## Animation and Keyframes
The following paths have seek-safe CSS custom properties and may be animated by
a paused, registered GSAP timeline:
- Global treatment intensity
- LUT intensity
- Exposure
- Blur
- Bloom
- Kuwahara Paint
- Pixelate
- ASCII
- Ordered Dither
Example blur-to-focus reveal:
```html index.html
<div
class="clip"
data-composition-id="media-effect-demo"
data-start="0"
data-duration="4"
>
<video
id="hero"
src="assets/hero.mp4"
muted
playsinline
style="--hf-color-grading-blur: 0.8"
data-color-grading='{"effects":{"blur":0.8}}'
></video>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to(
"#hero",
{
"--hf-color-grading-blur": 0,
duration: 1.2,
ease: "power2.out",
},
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["media-effect-demo"] = tl;
</script>
```
Author the initial custom-property value inline. Do not use timers, unseeded
randomness, frame-zero `set()` calls, or `onUpdate` callbacks. Query an effect's
focused capability to get its exact animation property and range.
Tape damage, digital glitch, film artifacts, and similar effects may contain
deterministic internal motion even when their overall strength is not one of
the keyframeable paths above.
## Important Effect Behavior
- **Tape Damage** is the primary analog-tape amount. Tracking controls moving
tears, Noise controls row jitter/noise, and Speed controls their deterministic
motion. Those subordinate controls do not activate tape damage by themselves.
- **Film Artifacts** adds sparse deterministic dust and scratches. Grain,
vignette, color, and optional wrapper motion such as a subtle gate weave are
separate choices.
- **Halftone** uses fixed print-oriented channel angles and edge behavior.
Cell size remains adjustable.
- **Two-Ink Print** is HyperFrames' own fixed two-ink mapping. Do not describe
it as a named commercial print process or stack it with Halftone by default.
- **ASCII** uses procedural glyph cells. Size, style, ink behavior, rotation,
and a compatible palette remain configurable.
- **Ordered Dither** uses a temporally stable 4x4 Bayer matrix. It is not
Floyd-Steinberg or another sequential error-diffusion algorithm.
- **Bloom** extracts bright regions before blurring them; it is different from
raising highlights or whites.
- **Kuwahara Paint** smooths regions while preserving major edges. Radius,
sharpness, and saturation shape the result.
## Deterministic Pipeline Order
HyperFrames evaluates combined treatment stages in one fixed order:
1. Source framing and multipass Blur/Kuwahara preparation
2. Chromatic and digital-glitch transforms
3. Primary correction, color grading, and LUT blended by global intensity
4. Grain and film artifacts
5. Mono, engraving, crosshatch, halftone, two-ink, dither, and ASCII
6. Bloom, scanlines, vignette, and CRT display masking
7. Before/after comparison
The order cannot currently be rearranged. A fixed order keeps Studio,
playback, seeking, and final rendering deterministic and prevents agents from
inventing incompatible effect graphs.
## Performance
Performance follows the number of treated pixels and the selected render lane,
not only the number of elements.
- Blur, Bloom, and Kuwahara use multipass rendering.
- Several tiled media elements can be cheaper than several overlapping
full-frame elements.
- When more than two full-frame multipass-treated elements are visible
together, verify continuous playback on the target machine.
- Simplify or pre-render a stack when preview frames drop.
- Kuwahara uses bounded half-float intermediate targets when supported and
otherwise reports itself unavailable.
For 4K delivery, verify the effect at the final composition size. Resolution-
aware effects preserve their intended scale, but 4K still processes more pixels
than 1080p.
## Low-Level HTML Contract
Media effects live in the same persisted contract as grading:
```html index.html
<img
id="poster"
src="assets/poster.jpg"
data-color-grading='{
"effects":{
"ascii":1,
"asciiSize":0.066,
"asciiStyle":0,
"asciiColor":1,
"asciiRotation":0
},
"palette":["#001100","#00ff00"],
"colorSpace":"rec709"
}'
/>
```
Unknown keys are rejected and values are normalized by the Core contract. The
CLI should remain the primary authoring surface for agents.
## Next Steps
<CardGroup cols={2}>
<Card title="Color Grading" icon="palette" href="/guides/color-grading">
Correct and grade media with scopes, wheels, curves, selections, and LUTs.
</Card>
<Card title="Media Overlays" icon="layer-group" href="/guides/media-overlays">
Add authored HUD, flash, light-leak, and freeze-frame layers.
</Card>
<Card title="Keyframes" icon="diamond" href="/guides/keyframes">
Edit and verify deterministic GSAP animation in Studio.
</Card>
<Card title="Performance" icon="gauge-high" href="/guides/performance">
Diagnose source resolution, browser, decoder, and composition cost.
</Card>
</CardGroup>
+171
View File
@@ -0,0 +1,171 @@
---
title: Media Overlays
description: "Add editable, timeline-driven HUD, flash, light-leak, and freeze-frame dressing alongside video or image media."
---
Use Media Overlays to add editable, timeline-driven HUD, flash, light-leak, and
freeze-frame dressing around a video or image without baking it into the source
pixels.
## Included Overlays
| Registry block | Purpose |
| --- | --- |
| `camcorder-hud` | Responsive REC indicator, battery, editable date placeholder, and timeline-driven counter |
| `editorial-flash-overlay` | Finite neutral-warm light layers for a camera-flash cut or social reveal |
| `organic-light-leak-overlay` | Finite CSS light leak for memory beats and motivated transitions |
| `freeze-frame-dressing` | Timeline-driven paper, tape, and flash dressing for a freeze frame or background-removed subject |
These are first-party authored blocks. Their Catalog poster/video assets are
previews only; the installed result is editable composition source.
Unlike [Color Grading](/guides/color-grading) and
[Media Effects](/guides/media-effects), overlays are real HTML, CSS, and paused
GSAP timelines installed from the HyperFrames Registry. Their final paint order
follows authored track placement and CSS `z-index`.
## Quick Start
<Tabs>
<Tab title="Studio">
<Steps>
<Step title="Select the media beat">
Select the `<video>` or `<img>` the overlay should accompany.
</Step>
<Step title="Insert an overlay">
Open **Overlays** in the Design panel and choose a preview card. Studio
installs the composition source and aligns it to the selected beat.
</Step>
<Step title="Edit and verify">
Use Layers, Timeline, Design, or Code to change content, timing, color,
animation, or placement, then play through the entrance and exit.
</Step>
</Steps>
</Tab>
<Tab title="Agent / CLI">
Discover the available overlay blocks, then install the one that supports
the intended beat:
```bash Terminal
npx hyperframes media-treatment --capabilities --json
npx hyperframes add camcorder-hud --no-clipboard
```
The command installs editable composition source under the project. Embed
and time it with the host composition's normal sub-composition rules.
</Tab>
</Tabs>
When the selected media has an authored track, Studio starts the overlay on the
next track. Otherwise, normal block placement defaults apply. The left Catalog
remains the browse-all Registry surface; **Overlays** is the selected-media
shortcut.
## Agent Guidance
An agent may combine grading, a shader effect, treatment animation, and an
overlay when they support one coherent intent. A restrained camcorder treatment
can combine a small source correction, tape damage on the media, the
`camcorder-hud` block, and a finite reveal or exit timed to the edit.
Use these constraints:
- Use an overlay when it adds information, motivated light, or useful visual
language.
- Keep text and HUD fields editable.
- Align finite overlays to a deliberate beat rather than leaving them active
for the entire video.
- Avoid adding decorative overlays automatically to accuracy-sensitive,
brand-sensitive, or deliberately clean footage.
- Verify that the overlay does not obscure captions, faces, product UI, or
essential content.
## Choosing the Right Overlay
### Camcorder HUD
Use for creator-camera, home-video, recording, or found-footage language. The
counter is timeline-driven and the REC behavior is deterministic. Edit the
date/label placeholders instead of rendering arbitrary timestamp text into the
source media.
### Editorial Flash
Use as a short cut accent or social reveal. It is finite by design and should
land on a motivated edit, capture, pose, or beat. It is not a permanent glow or
a replacement for shader Bloom.
### Organic Light Leak
Use sparingly for memory, warmth, film-language transitions, or a motivated
light event. It is a CSS/GSAP overlay rather than a LUT and does not permanently
change source color.
### Freeze-Frame Dressing
Use with a held frame or a subject cutout. The block provides paper, tape, and
flash dressing; it does not perform background removal itself.
For a transparent subject:
```bash Terminal
npx hyperframes remove-background subject.mp4 -o subject.webm
npx hyperframes add freeze-frame-dressing --no-clipboard
```
See [Remove Background](/guides/remove-background) for cutout and background
plate behavior.
## How Overlays Combine with Pixel Treatments
The compositing model is:
```text
source <img>/<video>
-> color correction and grading
-> LUT and shader effects
-> visible media canvas
overlay, caption, and other composition layers
-> composed with the visible media canvas by authored track and z-index
```
Color grading does not recolor overlay text or graphics. This is intentional:
HUD labels remain crisp and an organic light leak can be adjusted independently
from the source grade. Place captions, HUD fields, and other important graphics
on the intended track and verify their final stacking in Studio.
If an overlay should appear behind a subject, separate the subject into its own
transparent media layer and place the overlay between the background and
foreground layers.
## Editing and Reuse
Registry overlays are copied into the project rather than fetched at render
time. This keeps rendering deterministic and lets teams:
- Change the HTML and CSS.
- Replace labels and colors.
- Retune the paused GSAP timeline.
- Reuse the block across multiple compositions.
- Remove it without changing the media's `data-color-grading` payload.
Keep host-specific timing and placement in the host composition when possible.
That preserves the Registry block as a reusable visual unit.
## Next Steps
<CardGroup cols={2}>
<Card title="Color Grading" icon="palette" href="/guides/color-grading">
Correct and grade source media before adding visual dressing.
</Card>
<Card title="Media Effects" icon="wand-magic-sparkles" href="/guides/media-effects">
Apply shader-based optical, retro, print, and art treatments.
</Card>
<Card title="Remove Background" icon="scissors" href="/guides/remove-background">
Create transparent subject layers and paired background plates.
</Card>
<Card title="Keyframes" icon="diamond" href="/guides/keyframes">
Edit deterministic overlay and host animation in Studio.
</Card>
</CardGroup>
+48
View File
@@ -21,6 +21,7 @@ npx hyperframes <command>
- Lint compositions for structural issues (`lint`)
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text, plus verify motion intent against the seeked timeline (`inspect`)
- Capture key frames as PNG screenshots (`snapshot`)
- Discover, analyze, and apply media grading/effects (`media-treatment`)
- Check your environment for missing dependencies (`doctor`)
**Use a different package if you want to:**
@@ -797,6 +798,53 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
Runs multiple render configurations (varying fps, quality, and worker count) and compares timing and file size for each.
</Tab>
<Tab title="Utilities">
### `media-treatment`
Discover the media-treatment contract, analyze a local media source, and
apply validated grading or effects to a real `<img>` or `<video>`:
```bash Terminal
# Concise capability index, then one focused contract
npx hyperframes media-treatment --capabilities --json
npx hyperframes media-treatment --capability grading --json
# Analyze and update one selected media element
npx hyperframes media-treatment \
--project . \
--file compositions/scene.html \
--selector '#hero' \
--analyze \
--json
npx hyperframes media-treatment \
--project . \
--file compositions/scene.html \
--selector '#hero' \
--grading '{"adjust":{"exposure":0.05},"effects":{"bloom":0.15}}' \
--apply \
--json
```
| Flag | Description |
|------|-------------|
| `--capabilities` | Print the concise capability-family index |
| `--capability <id>` | Print exact controls and examples for one family, preset, palette, adjustment, or effect |
| `--all` | Print the exhaustive contract for tooling; avoid for routine agent context |
| `--project <dir>` | Project directory; defaults to the current directory |
| `--file <path>` | HTML composition containing the target; defaults to `index.html` |
| `--selector <css>` | CSS selector for the target real media element |
| `--selector-index <n>` | Zero-based match when the selector is intentionally non-unique |
| `--analyze` | Measure a local source and return metadata, warnings, diagnosis, and bounded correction suggestions |
| `--grading <json>` | Validated grading/effects patch to merge into the target |
| `--apply` | Persist the validated patch; without it, no file is written |
| `--clear` | Remove the complete media treatment from the target |
| `--dry-run` | Report the mutation without writing |
| `--json` | Output a machine-readable result |
Use the [Color Grading](/guides/color-grading) and
[Media Effects](/guides/media-effects) guides for workflow guidance. The
command authors the low-level `data-color-grading` persistence contract so
agents do not need to construct HTML mutations by hand.
### `doctor`
Check your environment for required dependencies:
+29
View File
@@ -63,6 +63,7 @@ Common sizes:
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. Read via `getVariables()` in scripts, or consumed automatically by declarative bindings. |
| `data-var-src` | img, video, audio | No | Binds the element's `src` to a declared variable id — the runtime substitutes the value (URL string or image `{url}`); the authored `src` is the fallback. |
| `data-var-text` | any | No | Binds the element's own text to a scalar variable id. Element children are preserved. |
| `data-color-grading` | img, video | No | Validated JSON payload for media-level correction, grading, LUT, finishing, and shader effects. Prefer Studio or `hyperframes media-treatment` to author it. |
| `data-width` | div | On compositions | Composition width in pixels. |
| `data-height` | div | On compositions | Composition height in pixels. |
@@ -162,6 +163,34 @@ Common sizes:
</Accordion>
</AccordionGroup>
## Media Treatments
Real `<img>` and `<video>` elements may carry a `data-color-grading` payload:
```html index.html
<video
id="hero"
src="assets/hero.mp4"
data-start="0"
data-track-index="0"
muted
playsinline
data-color-grading='{
"preset":"clean-studio",
"intensity":0.8,
"adjust":{"highlights":-0.08,"shadows":0.06},
"effects":{"bloom":0.12},
"colorSpace":"rec709"
}'
></video>
```
The runtime renders the complete payload on a sibling WebGL canvas. It does not
apply to text, SVG, arbitrary DOM, or CSS background images. Use Studio or
[`media-treatment`](/packages/cli#media-treatment) for normal authoring; see
[Color Grading](/guides/color-grading) and [Media Effects](/guides/media-effects)
for the supported workflow and current SDR/HDR boundary.
## Relative Timing
Reference another clip's ID in `data-start` to mean "start when that clip ends":
+5
View File
@@ -51,6 +51,11 @@
"description": "Automatically create H.264 proxies for browser-hostile video codecs on supported preview surfaces. Defaults to true."
}
}
},
"authoringSkill": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$",
"description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag."
}
}
}
@@ -9,8 +9,8 @@ When SFX resolution succeeds through the bundled fallback because the HeyGen CLI
- Reuse the existing HeyGen CLI error classification produced by the failed catalog provider call.
- Retain only the latest actionable `not_found` or `outdated` remediation for the current resolver process.
- When `bundled.sfx` wins after that failure, include a structured advisory in JSON output and print the same concise hint in human output.
- Use the existing canonical commands:
- Missing: `curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth`
- Use the existing canonical remediation:
- Missing: install the [HeyGen CLI](https://developers.heygen.com/cli), then run `heygen auth login --oauth`
- Outdated: `heygen update`
- Do not emit this advisory for authentication, quota, network, or legitimate empty-catalog results; `--local-only`; or an explicitly forced bundled provider.
- Do not execute the install or update command.
+98
View File
@@ -9,3 +9,101 @@ Weekly HyperFrames highlights across releases, examples, docs, and community upd
For exact versioned release notes, see the [Changelog](/changelog).
{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */}
<Update
label="Week of July 20, 2026"
description="Weekly digest - July 20, 2026 - July 27, 2026"
tags={["Weekly update", "Highlights"]}
>
<Frame>
<video controls muted playsinline src="https://static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-2026-07-20-2026-07-27.mp4"></video>
</Frame>
Professional color grading is the headline. Master and per-channel curves, hue curves, three-way wheels, and HSL secondaries land in core, Studio, and the CLI, so you can shape shadows, midtones, and highlights on any image or video without leaving the composition. The distributed plan gains an explicit protocol version and can publish artifacts straight to S3 and GCS. Studio adds a keyframe ease editor, registry caption components become transcript-driven, and a long run of audio, download, and capture reliability fixes lands underneath.
## Features
- **Professional color grading.** Core adds master and per-channel RGB tone curves, hue-vs-hue, hue-vs-saturation and hue-vs-luma curves, three-way shadows, midtones and highlights wheels, and HSL secondary qualifiers. Everything normalizes through one shared contract, so a grade you author resolves the same way in preview and in render ([f99fc4e56](https://github.com/heygen-com/hyperframes/commit/f99fc4e5686239f5ef56d4eb6083bee796ceeddc)).
- **Grading controls in Studio.** The inspector exposes the new curve and wheel controls with live previews, so you can grade a clip on the canvas instead of hand-writing a `data-color-grading` payload ([20ef48abc](https://github.com/heygen-com/hyperframes/commit/20ef48abcb190e3e9ef132d1b1dd711201d3c58b)).
- **Agent-native grading from the CLI.** `hyperframes media-treatment` applies, previews, and clears a grading payload by selector, with `--dry-run` before writing and `--clear` to remove. The media-use skill now reads the source and picks a treatment instead of guessing a preset name ([6d5961b80](https://github.com/heygen-com/hyperframes/commit/6d5961b8024fe70f87879f271dd91149717d41a8), [4582881d0](https://github.com/heygen-com/hyperframes/commit/4582881d002c361afff59cc1c74ae072a50e17f7)).
- **Media treatments.** A media treatment is a source-aware plan that composes existing color, effect, timeline, and registry primitives rather than adding a second runtime schema. Core defines the capability catalog, the runtime renders treatments deterministically, Studio ships an inspector for them, and the registry adds matching overlays ([70213c5a8](https://github.com/heygen-com/hyperframes/commit/70213c5a8526b12c8b26f01b8288d78bb9edc917), [944640c32](https://github.com/heygen-com/hyperframes/commit/944640c3283d604383fd437fcca8c63941d8e3d7), [39c2341c4](https://github.com/heygen-com/hyperframes/commit/39c2341c4d1edf52a00fb8db4a49553c739860cc), [b0d3164dd](https://github.com/heygen-com/hyperframes/commit/b0d3164ddb6177c6b31634f02a908fdae607850f)).
- **Keyframe ease editor.** Studio gets an ease curve editor with a preset library, editable ease parameters, and an SVG curve preview. A deterministic ease runtime backs it, so an authored ease replays identically at render time ([c253dec23](https://github.com/heygen-com/hyperframes/commit/c253dec23b0a6f1ccfe49c17371fdc9823f73b4c), [5acbf240c](https://github.com/heygen-com/hyperframes/commit/5acbf240cbab38594d06b59b9ef7f2a4b37d31b7)).
- **Versioned distributed plan protocol.** Distributed plans now carry an explicit protocol descriptor. Plans written before the descriptor still load as v1, while a partial, malformed, or unknown descriptor fails closed before any layout-specific read. Plan hashes and rendered pixels are unchanged ([f9f00b0ef](https://github.com/heygen-com/hyperframes/commit/f9f00b0efc2d1006967d5b2e0009ea3c3f6ed2e6), [#2777](https://github.com/heygen-com/hyperframes/pull/2777)).
- **Plan v2 publishing straight to object storage.** A storage-neutral, manifest-last publisher lets cloud adapters write plan artifacts directly to S3 and GCS instead of a shared filesystem, and AWS Lambda now reads the v2 protocol. This is the groundwork for lifting the plan size limit on very large distributed renders ([09998789b](https://github.com/heygen-com/hyperframes/commit/09998789b5ff012adcd97e9fb33537e473f1cc52), [74d7bfde4](https://github.com/heygen-com/hyperframes/commit/74d7bfde4870bbc1c6c4471cfc004964807df3e7), [5bf61d6df](https://github.com/heygen-com/hyperframes/commit/5bf61d6df0694c3077ecc7e84cd9d72f2029a5e6), [#2789](https://github.com/heygen-com/hyperframes/pull/2789), [07f9a3de9](https://github.com/heygen-com/hyperframes/commit/07f9a3de954d663e61d0e7da233fe8d33a06a5f9), [#2792](https://github.com/heygen-com/hyperframes/pull/2792)).
- **Data-driven caption components.** caption-highlight, caption-weight-shift, caption-pill-karaoke, caption-emoji-pop, and caption-editorial-emphasis now build themselves from a shared caption-data runtime, with automatic grouping, an emphasis heuristic, and a generic emoji lexicon. A transcript drives the animation instead of hand-authored per-word markup ([7d4e71d10](https://github.com/heygen-com/hyperframes/commit/7d4e71d10b617b858fde2e764e15d530e9ffdf19), [c67e9dc1f](https://github.com/heygen-com/hyperframes/commit/c67e9dc1f843122225468ce63a67c57f70c2bba2), [08620b75d](https://github.com/heygen-com/hyperframes/commit/08620b75df5275739fd9b0b8f480527fdc1d9a98), [392a9d251](https://github.com/heygen-com/hyperframes/commit/392a9d251a4feadbf7703ba4f9157c1857c7a59b), [ed8973952](https://github.com/heygen-com/hyperframes/commit/ed8973952dda2aa43931e0ffa05b3fc6ba0cb1cb)).
- **New layout lint checks.** Lint adds `rotation_pivot_drift` and `off_pivot_rotation` for hub-referenced rotation, and re-samples dense motion for `content_overlap` so fast collisions between samples are no longer missed ([222aec45a](https://github.com/heygen-com/hyperframes/commit/222aec45ab0553c014dddccd563e273affd9b71a), [#2741](https://github.com/heygen-com/hyperframes/pull/2741), [e710a1686](https://github.com/heygen-com/hyperframes/commit/e710a1686f2442b460ea4973dca0be97c81ef184), [#2744](https://github.com/heygen-com/hyperframes/pull/2744), [72e2f08f1](https://github.com/heygen-com/hyperframes/commit/72e2f08f15ceec105eb2bca6e9e35b8020e040be), [#2746](https://github.com/heygen-com/hyperframes/pull/2746)).
- **Live map capture warning.** The engine detects a live map viewport at capture init, names the map library it found, and points at the basemap-baking path, so streaming tiles do not silently produce a nondeterministic render ([30ca51c61](https://github.com/heygen-com/hyperframes/commit/30ca51c615fce20f5266278cdf5f4c97fd691c88)).
- **Leaner, larger skills corpus.** The composition skills gain seven blueprints and ten rules from a mining pass, while the router now routes once and dispatches packet-scoped workers so each run costs less context ([853256403](https://github.com/heygen-com/hyperframes/commit/853256403b3ffd3dc0b616785ab876b4c0f04a89), [#2680](https://github.com/heygen-com/hyperframes/pull/2680), [6ad738b58](https://github.com/heygen-com/hyperframes/commit/6ad738b580adf157393fde02351af64669c9fbc5), [#2618](https://github.com/heygen-com/hyperframes/pull/2618)).
## Fixes
- **Audio no longer runs past the picture.** Padded audio is normalized on a sample timeline, the mux stops at the shortest normalized stream, the final mux is capped to video duration, AAC packet padding is trimmed exactly, and M4A edit timing and priming survive normalization ([4b116b988](https://github.com/heygen-com/hyperframes/commit/4b116b98802c05f0eeb541878806692eea975541), [532461599](https://github.com/heygen-com/hyperframes/commit/532461599b7518f4609002cdb314ff2ce9f3a70f), [63bc525ca](https://github.com/heygen-com/hyperframes/commit/63bc525ca90b08356d50ebb41d4e15581c9d3470), [19258ea5b](https://github.com/heygen-com/hyperframes/commit/19258ea5ba47067e9d5756b122051c467ece0b04), [afc4e96bb](https://github.com/heygen-com/hyperframes/commit/afc4e96bbed21dbcd29544a6be95d35db7dd4eed), [9289551e9](https://github.com/heygen-com/hyperframes/commit/9289551e98958f006ec14af8ae9058096a4932fe), [59c56d325](https://github.com/heygen-com/hyperframes/commit/59c56d325723bbcb7c54315023aedeb72b376cd8), [113a4985b](https://github.com/heygen-com/hyperframes/commit/113a4985b51bc1d77babe7113d6c96bbe46d41bf)).
- **Portable audio padding.** The audio filter chain drops an FFmpeg option the bundled Windows build rejects, so compositions that rendered video but failed to mux audio now complete. Typed audio failure causes are preserved through the engine, the in-process producer, and distributed planning ([3b9552ef9](https://github.com/heygen-com/hyperframes/commit/3b9552ef9db36b133485e4ce805892346e0b9006), [37b88688e](https://github.com/heygen-com/hyperframes/commit/37b88688e7ae854e377c47fe3d9560ab0c930161), [#2769](https://github.com/heygen-com/hyperframes/pull/2769)).
- **Probe failures say why.** ffprobe keeps a bounded stderr tail instead of running quiet, so a failed probe reports the actual error rather than a blank diagnostic ([8c5077068](https://github.com/heygen-com/hyperframes/commit/8c50770684bc87ca67592c45d4101e3c029193d2), [#2772](https://github.com/heygen-com/hyperframes/pull/2772)).
- **Hardened media downloads.** Video downloads are atomic and retry transient failures, reserved and future-use IPv4 ranges are blocked, downloader trust-boundary gaps are closed, and network error shapes are narrowed honestly ([c01f6b446](https://github.com/heygen-com/hyperframes/commit/c01f6b446829f15c2c18dfd43da7013b412b2bf7), [2e84faeb2](https://github.com/heygen-com/hyperframes/commit/2e84faeb28ff8689da212a5b7db805f3b454d4d9), [4b81f7858](https://github.com/heygen-com/hyperframes/commit/4b81f785868362fbb5c4bd7f1c5be24b2ccb7f94), [5ce2eb879](https://github.com/heygen-com/hyperframes/commit/5ce2eb879db1bb2b3dd740bcd7e9abf8adaa3230)).
- **Distributed render reliability.** Sparse video directories are materialized, distributed capture falls back safely, plan scratch state resets between runs, oversized plans are attributed and stopped early, partial color metadata is accepted in plan v2, and Cloud Run enforces effective BeginFrame capture ([ddb59d356](https://github.com/heygen-com/hyperframes/commit/ddb59d3567fc70d808f86ef8b5b531078e3df2d6), [96cafb47c](https://github.com/heygen-com/hyperframes/commit/96cafb47c6c9850939c85e2cc76e578d3b6dbd1b), [ebb02cafe](https://github.com/heygen-com/hyperframes/commit/ebb02cafe7e1d1e067bc37c00fd3613e20230ee5), [d699cbf01](https://github.com/heygen-com/hyperframes/commit/d699cbf014ac2232e3d2cec5c06c9d74103fa61f), [58869f087](https://github.com/heygen-com/hyperframes/commit/58869f0878e304fd39d564b93cc4a8b18e885b4e), [#2814](https://github.com/heygen-com/hyperframes/pull/2814), [2a284a8e3](https://github.com/heygen-com/hyperframes/commit/2a284a8e3aca62100ec037c23c98706e7146aa14)).
- **Honest extraction errors.** Frame-extraction launch failures aggregate into one typed error with narrowed shapes instead of surfacing as an opaque crash ([33ca1de06](https://github.com/heygen-com/hyperframes/commit/33ca1de0631be66bfa5c591a231256c69667226e), [c01e1a5f9](https://github.com/heygen-com/hyperframes/commit/c01e1a5f96839e1a2650516ceb3745ed7b28517f), [9b63646c8](https://github.com/heygen-com/hyperframes/commit/9b63646c8aa036b786513137f2efcdf637ad432c)).
- **Capture self-verification.** Parallel and sequential disk drawElement samples self-verify, screenshot retry recovers disk-path verify failures, a verify failure rethrows past the completeness check, orphaned probe sessions close before retries, and frame stride carries onto worker results so interleaved workers stop reporting false positives ([060b6f8ae](https://github.com/heygen-com/hyperframes/commit/060b6f8ae53e8ae243d8e2c2c3afd690dbe7f99c), [9fc1c2f15](https://github.com/heygen-com/hyperframes/commit/9fc1c2f15990fc44f50533362bec2f075aa1d53d), [ec791e91d](https://github.com/heygen-com/hyperframes/commit/ec791e91d97c0070502c4ad208923d4f98005846), [c85cfae8f](https://github.com/heygen-com/hyperframes/commit/c85cfae8fa97229e9c4f4d7217cf08b4bc859f3d), [b8e101547](https://github.com/heygen-com/hyperframes/commit/b8e10154762f5a0fe71fb4a6a9f3d472e9bf99ec), [4f53dd4f2](https://github.com/heygen-com/hyperframes/commit/4f53dd4f2cd607de17b2d2329746fa5da0cadeaa)).
- **Studio editing.** Preview audio plays at speeds above 1x, flat keyframe retiming is hardened, tween keyframe diamonds retime correctly, composed media treatments survive a round trip, stale color scopes clear, and the grading contracts line up across panels ([07965e9fe](https://github.com/heygen-com/hyperframes/commit/07965e9fe93fc7b53dfe7c933f8abd0c4b10c86d), [#2691](https://github.com/heygen-com/hyperframes/pull/2691), [270179d94](https://github.com/heygen-com/hyperframes/commit/270179d94b3ca3b82f61655b354855187f0d210c), [f25a13692](https://github.com/heygen-com/hyperframes/commit/f25a1369279c99081c56966b14991649138bc035), [d5c7d3ee1](https://github.com/heygen-com/hyperframes/commit/d5c7d3ee16c2db53b91c66353d4f6387fe23e920), [c1fcf7534](https://github.com/heygen-com/hyperframes/commit/c1fcf7534f730f5677b0d5201e6af6d17bd19cb0), [794930a07](https://github.com/heygen-com/hyperframes/commit/794930a07568deddd55ba0d891b68bec739641fa)).
- **Authoring fidelity.** Position edits apply to SVG elements and not just HTML, nested-rule selectors survive composition CSS scoping, `setText` keeps `<br>` line breaks editable, and duration-authored keyframe timing and intent are preserved ([63539a0cd](https://github.com/heygen-com/hyperframes/commit/63539a0cdef75597d2e301736740cf3bdd905596), [#2724](https://github.com/heygen-com/hyperframes/pull/2724), [1e2c7d673](https://github.com/heygen-com/hyperframes/commit/1e2c7d673fc0fe8da9a822bb7a8744f84f44d9d4), [#2733](https://github.com/heygen-com/hyperframes/pull/2733), [dd7378bbd](https://github.com/heygen-com/hyperframes/commit/dd7378bbd934ecc0da85c1edfae7946ac7e2271a), [#2742](https://github.com/heygen-com/hyperframes/pull/2742), [4bfbd89d6](https://github.com/heygen-com/hyperframes/commit/4bfbd89d633d5fd227023643db62d2a566984edb), [d84e999f7](https://github.com/heygen-com/hyperframes/commit/d84e999f728e25cee15a71995805a52d0a7907c4)).
- **Fewer false lint failures.** The `media_in_subcomposition` rule is dropped, bounded GSAP infinite repeats are allowed, compiler-derived `data-end` is recognized as legitimate, and the pivot-drift and `connector_detached` checks are tightened against counterfactuals ([e7f9918d2](https://github.com/heygen-com/hyperframes/commit/e7f9918d21f9fa57f1799c7b3ba38963cfcb52f1), [#2765](https://github.com/heygen-com/hyperframes/pull/2765), [adb149b86](https://github.com/heygen-com/hyperframes/commit/adb149b86939e61bb3fce91cb8d0e5530f7bd29c), [#2763](https://github.com/heygen-com/hyperframes/pull/2763), [ac9f46310](https://github.com/heygen-com/hyperframes/commit/ac9f463108531d28eee496bd837aab542eb9e409), [75ed99e1d](https://github.com/heygen-com/hyperframes/commit/75ed99e1d4f45015812575ff26c07efb0b253f21), [#2819](https://github.com/heygen-com/hyperframes/pull/2819), [7a294f195](https://github.com/heygen-com/hyperframes/commit/7a294f19562928036dae20d5e73c2637d1e19060), [#2739](https://github.com/heygen-com/hyperframes/pull/2739)).
- **Preview and coverage.** The preview server serves external symlink assets, looping short videos are credited in the coverage gate, frame coverage aligns with extraction rounding, and invalid render durations are bounded ([7778c093b](https://github.com/heygen-com/hyperframes/commit/7778c093b6288756cf5e27311828e6049e7c85c3), [#2764](https://github.com/heygen-com/hyperframes/pull/2764), [a637f394e](https://github.com/heygen-com/hyperframes/commit/a637f394ee900c64c8f2e1ee78cf1e0ce17b8739), [#2732](https://github.com/heygen-com/hyperframes/pull/2732), [f0c2c7d23](https://github.com/heygen-com/hyperframes/commit/f0c2c7d23384de589f54c11ff093f128c9a39e56), [344d9c0a8](https://github.com/heygen-com/hyperframes/commit/344d9c0a87aeba01beca618e21d2469921506cdf)).
- **Runtime audio variables in distributed plans.** Audio variables resolved at runtime are carried into distributed plans instead of being dropped when a render fans out ([465c9e764](https://github.com/heygen-com/hyperframes/commit/465c9e764138b94faa48badbee468eb42bd1a39d), [#2725](https://github.com/heygen-com/hyperframes/pull/2725)).
- **CLI process lifecycle.** Command failures report once, error telemetry is awaited before finalization, the post-render exit reset stays root-owned, and the lifecycle migration is complete ([73d3b4e1f](https://github.com/heygen-com/hyperframes/commit/73d3b4e1f491e5211a960bac86fbb674909be7ad), [a9338a4e0](https://github.com/heygen-com/hyperframes/commit/a9338a4e0f91b8482fe32bef50018209c29ce031), [e0bda7a17](https://github.com/heygen-com/hyperframes/commit/e0bda7a17111753f76b7e073d654be802c06c129), [619406a23](https://github.com/heygen-com/hyperframes/commit/619406a23b061bf659d107f83beec4eada6ff086)).
- **Caption template hygiene.** Caption runtimes are wrapped in IIFEs, non-numeric caption-data versions are rejected, a boot fetch never clobbers a manual attach, brand custom properties clear on unbranded re-attach, GSAP renders at attach, and a quadratic hide-all-others loop is gone ([e2846eb7c](https://github.com/heygen-com/hyperframes/commit/e2846eb7cc81821f7fc21a9c4dccdd85c7ef3429), [5c2981d06](https://github.com/heygen-com/hyperframes/commit/5c2981d066480000d623e4d023e9cf918b2772e3), [4f6994719](https://github.com/heygen-com/hyperframes/commit/4f6994719196e72dc01ebe8f60d869e064ec7685), [020c8986f](https://github.com/heygen-com/hyperframes/commit/020c8986f46f795757203900cd251ad551ccc620), [5af6203ae](https://github.com/heygen-com/hyperframes/commit/5af6203ae7fcd506511ca5683a9097d666c4a63e), [18de2b1f1](https://github.com/heygen-com/hyperframes/commit/18de2b1f1de8062a79ae7aa6fa4796e320fe6131), [8bf939043](https://github.com/heygen-com/hyperframes/commit/8bf939043fb44e05f6dd7caba81822ac5ef18334)).
- **Feedback telemetry.** CLI feedback is sent as plain events, and the repro guidance no longer embeds identifying detail ([597c14a88](https://github.com/heygen-com/hyperframes/commit/597c14a8874401fe252d238c1ed4a0b6e2812a2c), [78ab9bc88](https://github.com/heygen-com/hyperframes/commit/78ab9bc889908e412e806d80f4ffbd938efc7a78)).
## Docs
- **Send-to guide is discoverable.** The Send-to guide is published in the nav and in `llms.txt`, so agents can find it without being handed the path ([911b332bb](https://github.com/heygen-com/hyperframes/commit/911b332bb2131f1b3fc4abbea563bf0c70d165c4), [#2667](https://github.com/heygen-com/hyperframes/pull/2667)).
- **Changelog video skill.** Captions are non-optional in the changelog-video skill, and a pre-build gate stops a run before it produces an unbuildable composition ([807078c7c](https://github.com/heygen-com/hyperframes/commit/807078c7cde9d5c8403588722d1cd9397c513a0d), [#2729](https://github.com/heygen-com/hyperframes/pull/2729), [7d312bd17](https://github.com/heygen-com/hyperframes/commit/7d312bd170baa6fb1d2c247e14fef7c4d1022279)).
- **Codex plugin packaging.** The skills bundle now packages a Codex plugin upload alongside the existing surfaces ([696cbdbbd](https://github.com/heygen-com/hyperframes/commit/696cbdbbd0e5c83faf72c767126d4a153110f130), [#2668](https://github.com/heygen-com/hyperframes/pull/2668)).
For exact versioned release notes, see the [Changelog](/changelog).
</Update>
<Update
label="Week of July 13, 2026"
description="Weekly digest - July 13, 2026 - July 20, 2026"
tags={["Weekly update", "Highlights"]}
>
<Frame>
<video controls muted playsinline src="https://static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-jul13-20.mp4"></video>
</Frame>
Automatic media proxying is the headline. Any video codec your FFmpeg can decode now plays on every live surface, from preview and Studio to play and published pages, while render keeps using the originals. Media Use gains a video generator, Studio's flat inspector ships on by default, and the engine's timeout errors now name the fix. A large batch of render, lint, and CLI reliability fixes lands alongside.
## Features
- **Automatic media proxying.** Studio-server probes each source's codec facts, transcodes a bounded H.264 proxy on demand, and the runtime swaps an undecodable source to its proxy so browser-hostile footage plays instead of showing a black frame. Render always uses the originals ([9ca1e1710](https://github.com/heygen-com/hyperframes/commit/9ca1e171013c0d74a868f5eda3ebdf93c9566632), [#2587](https://github.com/heygen-com/hyperframes/pull/2587), [9d148d288](https://github.com/heygen-com/hyperframes/commit/9d148d288aa1ea1ad4ea687fc21d92f8b5008286), [#2589](https://github.com/heygen-com/hyperframes/pull/2589), [39b588cbd](https://github.com/heygen-com/hyperframes/commit/39b588cbd0e196d1d1db14e959f837dc90cf7788), [#2592](https://github.com/heygen-com/hyperframes/pull/2592)).
- **Proxies across the authoring surfaces.** Proxies serve from the preview route, play, and the static project server, and bake into published archives. Projects can opt out with `media.autoProxy` or `--no-proxy` ([67eab59f4](https://github.com/heygen-com/hyperframes/commit/67eab59f44c0609c7299dc7127d864b37d2d1715), [#2590](https://github.com/heygen-com/hyperframes/pull/2590), [74b4f1e8c](https://github.com/heygen-com/hyperframes/commit/74b4f1e8c3cb0058583c6d9048708519a6d94417), [#2593](https://github.com/heygen-com/hyperframes/pull/2593), [35eff5038](https://github.com/heygen-com/hyperframes/commit/35eff5038b5f8e825b416b05bd7ea5baa38684a0), [#2595](https://github.com/heygen-com/hyperframes/pull/2595), [645880706](https://github.com/heygen-com/hyperframes/commit/6458807066bd4e0c1a6f573423879e97b91ad1c3), [#2591](https://github.com/heygen-com/hyperframes/pull/2591)).
- **Alpha-capable proxies.** Alpha sources get a VP9 and yuva420p WebM proxy instead of a refusal, so a ProRes 4444 file previews rather than going black ([e8371a7ac](https://github.com/heygen-com/hyperframes/commit/e8371a7accfb1ccd88c792898b65910aec60b0bd), [#2598](https://github.com/heygen-com/hyperframes/pull/2598)).
- **Media Use video generation.** `resolve --type video` generates a HeyGen avatar video, free for new API users, and falls back to local LTX-2 when HeyGen is unavailable or you pass `--local-only` ([0a66671fc](https://github.com/heygen-com/hyperframes/commit/0a66671fc576b6b7d4a1b433ff97467dcba20b17), [#2614](https://github.com/heygen-com/hyperframes/pull/2614)).
- **Flat inspector on by default.** Studio's flat inspector is now the default panel after this cycle's fixes. Set `VITE_STUDIO_FLAT_INSPECTOR_ENABLED=false` to return to the legacy panel ([a4167ede0](https://github.com/heygen-com/hyperframes/commit/a4167ede074cc4a3e86bc14571ff6a406d664271)).
- **Size-aware cloud archives.** Cloud render and publish honor `.hyperframesignore`, drop root render and snapshot output by default, and add `cloud render --dry-run` diagnostics so projects stay under the 200MB upload limit ([e73304fb0](https://github.com/heygen-com/hyperframes/commit/e73304fb0e94d2272839e55a0bcc4dd00210db34)).
- **Clearer engine timeout errors.** Puppeteer and page-navigation timeouts now name the env vars and escape hatches that fix them, and streaming-encode auto-disables on Windows software-GPU setups ([6944a1c2d](https://github.com/heygen-com/hyperframes/commit/6944a1c2d0430c8d42c6c5e1408d640b9674a2c8), [58cff5f6d](https://github.com/heygen-com/hyperframes/commit/58cff5f6d5dc8a136617df1a1b1712b142ec0986), [cbf2a2ec6](https://github.com/heygen-com/hyperframes/commit/cbf2a2ec69f12f4b5aad384d538d354321ce64cf)).
- **New GSAP lint rules.** Lint flags seek-order and SVG draw-on hazards, relative-value second writers, `tl.set` initial hides, and cold-seek opacity reveals that break at render time ([f3d210066](https://github.com/heygen-com/hyperframes/commit/f3d21006633014fcb29b7a51571cd50ce832fed3), [#2611](https://github.com/heygen-com/hyperframes/pull/2611), [4ad582606](https://github.com/heygen-com/hyperframes/commit/4ad582606bd2c0da9e20c83faa1acb3b79fe6e47), [#2612](https://github.com/heygen-com/hyperframes/pull/2612), [55ee559e4](https://github.com/heygen-com/hyperframes/commit/55ee559e40e2e84e11fe5e09e8bf57b755ea03cb), [#2503](https://github.com/heygen-com/hyperframes/pull/2503)).
- **CLI quality-of-life.** The transcribe timeout is configurable with a duration-scaled default, `--resolution` accepts portrait aspects, and `doctor` surfaces the extract-cache directory alongside a new `--frames-cache-dir` flag ([f8210d96d](https://github.com/heygen-com/hyperframes/commit/f8210d96daf7fd081e9304f26a288a0a1420db66), [46e9ecf3f](https://github.com/heygen-com/hyperframes/commit/46e9ecf3f2f66f7a8b145fc87a36184541b3ad13), [ca3522750](https://github.com/heygen-com/hyperframes/commit/ca352275062574b8b96aaef2ac06f8bae0a1ccc1)).
- **SDK base variable reads.** `getVariableValue({ base: true })` reads the declared default before overrides, and `attachSync` re-syncs the override snapshot on iframe load ([db5e06221](https://github.com/heygen-com/hyperframes/commit/db5e062211fbad324b67bcc672d4e92cc4e2d351), [#2499](https://github.com/heygen-com/hyperframes/pull/2499), [4682da14f](https://github.com/heygen-com/hyperframes/commit/4682da14f19061aeac25b723e1cbb6a98f5d86ad)).
## Fixes
- **Deep sub-composition nesting.** Recursive sub-composition inlining now handles depth-3 and deeper nesting ([d21883fe0](https://github.com/heygen-com/hyperframes/commit/d21883fe05a5819e910d4747683dc008a0ab5147), [#2660](https://github.com/heygen-com/hyperframes/pull/2660)).
- **Final frame holds.** Video holds its final frame through the rest of the composition instead of dropping to blank ([2e8f871bc](https://github.com/heygen-com/hyperframes/commit/2e8f871bc86d29ec3369f0eb11f0183b2001a07a)).
- **No phantom capture duplicates.** Capture stops compositing phantom duplicates when captureBeyondViewport is on ([2be8a62c0](https://github.com/heygen-com/hyperframes/commit/2be8a62c0009e61aeae7f713773013afd9b6f173), [#2607](https://github.com/heygen-com/hyperframes/pull/2607)).
- **Clean CLI output.** Diagnostics and the SystemMemory cgroup notice now go to stderr, keeping `--json` output and stdout parsers clean ([b179c9536](https://github.com/heygen-com/hyperframes/commit/b179c95362645c3ca06fa869d698429e2e3d1e61), [#2520](https://github.com/heygen-com/hyperframes/pull/2520), [d92d1d4f5](https://github.com/heygen-com/hyperframes/commit/d92d1d4f51e2737d19db8a67073da8ae04a16789), [#2522](https://github.com/heygen-com/hyperframes/pull/2522)).
- **Studio reliability.** Composition timelines are hardened, stale failed sidecars are ignored for existing renders, and stale SwiftShader layers are prevented ([2b65b4efc](https://github.com/heygen-com/hyperframes/commit/2b65b4efcef9f69ab294aa608b54a89a600ab76f), [#2615](https://github.com/heygen-com/hyperframes/pull/2615), [2577aaffe](https://github.com/heygen-com/hyperframes/commit/2577aaffeb9703be3d9dd17c0c5fb3c45b6e32e7), [#2621](https://github.com/heygen-com/hyperframes/pull/2621), [54a3ef200](https://github.com/heygen-com/hyperframes/commit/54a3ef2000da635b93c03a41c129a78f0276bf38)).
- **Platform fixes.** Intel macOS background removal is restored, Windows work dirs avoid the output path limit, and a dyld crash on older macOS now points at `HYPERFRAMES_BROWSER_PATH` ([04954ead8](https://github.com/heygen-com/hyperframes/commit/04954ead818d5db91efbdd7bbb2bd1e5f07a2f60), [#2480](https://github.com/heygen-com/hyperframes/pull/2480), [882c20324](https://github.com/heygen-com/hyperframes/commit/882c203241b43e6a1515c2672a4285d0d4f5425c), [#2479](https://github.com/heygen-com/hyperframes/pull/2479), [0d16f19b0](https://github.com/heygen-com/hyperframes/commit/0d16f19b07b1c7f60d54cac4f05f93ea3abacbcd)).
- **Media Use asset cleanup.** Failed asset reservations are cleaned up instead of leaking zero-byte placeholders ([49113eb08](https://github.com/heygen-com/hyperframes/commit/49113eb08487b2c53b7404bc26c5e419244ae97f), [#2627](https://github.com/heygen-com/hyperframes/pull/2627)).
- **Producer coverage gate.** Held video tails are credited in the coverage gate ([209784ab2](https://github.com/heygen-com/hyperframes/commit/209784ab27801b70c9d8e636e4eaf2e3dc796d2b), [#2606](https://github.com/heygen-com/hyperframes/pull/2606)).
## Docs & Examples
- **Automatic proxying guide.** New docs cover the proxy cache, published-proxy baking, the render-original invariant, FFmpeg requirements, and both opt-out forms ([8c1b6c515](https://github.com/heygen-com/hyperframes/commit/8c1b6c515401a03e1a8394cff60b4e7410f14f0d), [#2596](https://github.com/heygen-com/hyperframes/pull/2596)).
- **Send-to guides consolidated.** The Send-to import guidance now lives in one guide, resolving an earlier fidelity contradiction ([8bfc67688](https://github.com/heygen-com/hyperframes/commit/8bfc676881c07c3c8ee1f0b6b247cc5f207cd3fd), [#2619](https://github.com/heygen-com/hyperframes/pull/2619), [7acabbcde](https://github.com/heygen-com/hyperframes/commit/7acabbcdeb9b55ce9f75c7d35d7f281273a99f29), [#2620](https://github.com/heygen-com/hyperframes/pull/2620)).
- **Core skills install by default.** The core skill set now installs by default on every surface ([3bb26b0f0](https://github.com/heygen-com/hyperframes/commit/3bb26b0f08142e95126b4934511a09a1e68c143d), [#2554](https://github.com/heygen-com/hyperframes/pull/2554)).
- **TTS docs aligned.** The skill's text-to-speech docs now match the CLI contract ([428e57191](https://github.com/heygen-com/hyperframes/commit/428e571914ee979c815097fbbd26d56757a11056), [#2483](https://github.com/heygen-com/hyperframes/pull/2483)).
For exact versioned release notes, see the [Changelog](/changelog).
</Update>
+26 -5
View File
@@ -75,6 +75,7 @@ aws stepfunctions start-execution \
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
"PlanProtocol": "v1",
"Config": {
"fps": 30,
"width": 1920,
@@ -91,6 +92,9 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
`PlanProtocol` may be `"v1"` or `"v2"`; absent defaults to v1. V2 uses
separate manifest and content-addressed artifact locators throughout the
workflow and never places a v2 object in `PlanS3Uri`.
## Local invocation
@@ -119,13 +123,14 @@ the architecture works on a deployed Lambda — use the local smoke
script:
```bash
# All defaults (mp4-h264-sdr fixture, chunk counts 2/4/8, PSNR >= 40 dB).
# Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
./scripts/smoke.sh
# Customised:
./scripts/smoke.sh \
--fixture mp4-h264-sdr \
--chunk-counts 2,4,8,16 \
--plan-protocol both \
--psnr-threshold 40 \
--reserved-concurrency 8
@@ -141,7 +146,21 @@ per-run stack name, renders the fixture at each chunk count via the
Step Functions state machine, PSNR-compares against the in-process
baseline (which is git-LFS tracked under
`packages/producer/tests/distributed/<fixture>/output/`), captures
per-execution Step Functions history, and tears the stack down.
per-execution Step Functions history, and tears the stack down. Use
`--plan-protocol both` to run v1 and v2 through the same deployed Lambda
package and baseline. Each v1/v2 pair is also gated directly on per-chunk
hashes from Step Functions history, normalized decoded RGBA frame hashes,
decoded 48 kHz stereo s16le PCM hashes and byte counts, normalized stream
metadata, and duration. Encoded MP4 SHA equality is reported but is
informational unless `--require-encoded-sha-equal` is set. The script
assigns unique function/state-machine names, uses a
dedicated temporary SAM artifact bucket, and removes render objects,
retained buckets, the implicit Lambda log group, and deployment artifacts
on teardown. Suspended-version buckets are purged in 1,000-entry batches,
including concrete versions, null versions, and delete markers. It then
verifies that the stack, both buckets, Lambda, state-machine, and both log
groups are absent; an otherwise-successful run fails if cleanup cannot be
proven.
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
local-vs-Lambda "speedup" column. The local timing includes `bun` +
@@ -162,9 +181,11 @@ spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
- `results.json``chunkCount × wallClockMs × psnrAvgDb`
- `renders/N<N>-output.mp4` — each rendered chunk count
- `renders/N<N>-history.json` — full Step Functions execution history
- `results.json``planProtocol × chunkCount × wallClockMs × psnrAvgDb`
- `semantic-comparisons.json` — direct v1/v2 semantic gate results
- `renders/<protocol>-N<N>-output.mp4` — each rendered variant
- `renders/<protocol>-N<N>-history.json` — full Step Functions execution history
- `renders/v1-v2-N<N>.*` — normalized frame hashes, ffprobe metadata, and comparison JSON
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
`jq`, `zip`. AWS credentials come from the standard resolution chain
@@ -0,0 +1,11 @@
{
"Action": "assemble",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkS3Uris": ["s3://example-bucket/renders/sample/chunks/0000.mp4"],
"AudioS3Uri": null,
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
"Format": "mp4"
}
@@ -0,0 +1,15 @@
{
"Action": "plan",
"PlanProtocol": "v2",
"ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": {
"fps": 30,
"width": 1920,
"height": 1080,
"format": "mp4",
"chunkSize": 240,
"maxParallelChunks": 8,
"runtimeCap": "lambda"
}
}
@@ -0,0 +1,10 @@
{
"Action": "renderChunk",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Format": "mp4"
}
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# AWS resource-name isolation and failed-deploy discovery helpers.
hf_new_smoke_run_id() {
local seconds seed digest
seconds=$(date +%s)
seed="${seconds}:$$:${RANDOM}:${BASHPID:-$$}"
digest=$(printf '%s' "$seed" | sha256sum | awk '{print substr($1,1,16)}')
printf '%s-%s\n' "$seconds" "$digest"
}
hf_sam_deploy_bucket_name() {
local account_id="$1" region="$2" run_id="$3" digest
digest=$(printf '%s' "$run_id" | sha256sum | awk '{print substr($1,1,20)}')
printf 'hf-sam-%s-%s-%s\n' "$account_id" "$region" "$digest"
}
hf_derive_project_name() {
local stack_name="$1" prefix digest
prefix=$(printf '%s' "$stack_name" |
tr -c '[:alnum:]-' '-' |
sed -E 's/^-+//; s/-+$//' |
cut -c1-36)
[ -n "$prefix" ] || prefix="hf-smoke"
digest=$(printf '%s' "$stack_name" | sha256sum | awk '{print substr($1,1,12)}')
printf '%s-%s\n' "$prefix" "$digest"
}
hf_known_absent() {
local pattern="$1" output_file="$2"
grep -Eiq "$pattern" "$output_file"
}
hf_assert_command_absent() {
local label="$1" absent_pattern="$2"
shift 2
local output_file status detail
output_file=$(mktemp)
if "$@" >"$output_file" 2>&1; then
echo "ERROR: destructive-isolation collision: $label already exists" >&2
rm -f "$output_file"
return 1
else
status=$?
fi
if ! hf_known_absent "$absent_pattern" "$output_file"; then
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
echo "ERROR: could not prove $label absent (exit=$status): $detail" >&2
rm -f "$output_file"
return 2
fi
rm -f "$output_file"
}
hf_assert_named_list_absent() {
local label="$1"
shift
local output_file output status detail
output_file=$(mktemp)
if output=$("$@" 2>"$output_file"); then
if [ -n "$output" ]; then
echo "ERROR: destructive-isolation collision: $label already exists ($output)" >&2
rm -f "$output_file"
return 1
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
echo "ERROR: could not verify $label absence (exit=$status): $detail" >&2
rm -f "$output_file"
return 2
fi
rm -f "$output_file"
}
# Fail closed unless every exact name this smoke run can destructively clean
# is absent. Call before arming cleanup or creating any AWS resource.
hf_assert_deploy_isolation() {
local stack_name="$1" project_name="$2"
local function_name="${project_name}-render"
local lambda_log="/aws/lambda/${function_name}"
local states_log="/aws/states/${function_name}"
hf_assert_command_absent "CloudFormation stack $stack_name" "does not exist" \
aws cloudformation describe-stacks --stack-name "$stack_name" &&
hf_assert_command_absent "Lambda function $function_name" \
"ResourceNotFoundException|Function not found" \
aws lambda get-function --function-name "$function_name" &&
hf_assert_named_list_absent "Step Functions state machine $function_name" \
aws stepfunctions list-state-machines \
--query "stateMachines[?name=='$function_name'].stateMachineArn" --output text &&
hf_assert_named_list_absent "log group $lambda_log" \
aws logs describe-log-groups --log-group-name-prefix "$lambda_log" \
--query "logGroups[?logGroupName=='$lambda_log'].logGroupName" --output text &&
hf_assert_named_list_absent "log group $states_log" \
aws logs describe-log-groups --log-group-name-prefix "$states_log" \
--query "logGroups[?logGroupName=='$states_log'].logGroupName" --output text
}
# Atomically reserve the exact stack name before SAM can create or update it.
# CloudFormation's create-stack call is the compare-and-set: only one concurrent
# smoke run can acquire a name that both preflight checks observed as absent.
hf_reserve_smoke_stack() {
local stack_name="$1" run_id="$2"
aws cloudformation create-stack \
--stack-name "$stack_name" \
--template-body \
'{"Resources":{"SmokeOwnershipHandle":{"Type":"AWS::CloudFormation::WaitConditionHandle"}}}' \
--tags "Key=HyperframesSmokeRun,Value=$run_id" >/dev/null &&
aws cloudformation wait stack-create-complete --stack-name "$stack_name"
}
# Print "owned" when the stack has this run's ownership tag and
# "absent" when there is no stack. Any foreign/missing tag or AWS API error
# fails closed so a cleanup trap cannot delete a concurrent run's resources.
hf_stack_ownership_status() {
local stack_name="$1" run_id="$2" output_file error_file owner status detail
output_file=$(mktemp)
error_file=$(mktemp)
if aws cloudformation describe-stacks \
--stack-name "$stack_name" \
--query "Stacks[0].Tags[?Key=='HyperframesSmokeRun'].Value | [0]" \
--output text >"$output_file" 2>"$error_file"; then
owner=$(tr -d '\r\n' <"$output_file")
rm -f "$output_file" "$error_file"
if [ "$owner" != "$run_id" ]; then
echo "ERROR: refusing cleanup: stack ownership is '${owner:-missing}', expected '$run_id'" >&2
return 3
fi
printf 'owned\n'
return
else
status=$?
fi
if hf_known_absent "does not exist" "$error_file"; then
rm -f "$output_file" "$error_file"
printf 'absent\n'
return
fi
detail=$(tr '\n' ' ' <"$error_file" | cut -c1-240)
echo "ERROR: could not verify stack ownership (exit=$status): $detail" >&2
rm -f "$output_file" "$error_file"
return 2
}
# Return a JSON object with any physical resources CloudFormation managed to
# create, even when stack outputs were never populated. A genuinely absent
# stack is an empty result; auth/network/query failures are errors.
hf_discover_stack_resources() {
local stack_name="$1" output_file error_file status detail
output_file=$(mktemp)
error_file=$(mktemp)
if aws cloudformation list-stack-resources \
--stack-name "$stack_name" --output json >"$output_file" 2>"$error_file"; then
jq '{
renderBucket: (
[.StackResourceSummaries[]?
| select(.LogicalResourceId == "RenderBucket")
| .PhysicalResourceId][0] // ""
),
stateMachineArn: (
[.StackResourceSummaries[]?
| select(.LogicalResourceId == "RenderStateMachine")
| .PhysicalResourceId][0] // ""
)
}' "$output_file"
rm -f "$output_file" "$error_file"
return
else
status=$?
fi
if hf_known_absent "does not exist" "$error_file"; then
printf '{"renderBucket":"","stateMachineArn":""}\n'
rm -f "$output_file" "$error_file"
return
fi
detail=$(tr '\n' ' ' < "$error_file" | cut -c1-240)
echo "ERROR: failed to discover physical stack resources (exit=$status): $detail" >&2
rm -f "$output_file" "$error_file"
return 2
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# S3 bucket cleanup helpers. Sourcing this file has no side effects.
# Delete every concrete object version and delete marker from a bucket.
#
# We intentionally re-list the first 1,000 entries after every delete batch
# instead of advancing markers through a mutating result set. This handles
# arbitrary pagination depth while avoiding skipped keys when the page being
# used as a cursor has just been removed. It is also required for buckets with
# versioning Suspended: `aws s3 rm` only creates null-version delete markers
# and leaves the historical/null versions behind.
hf_purge_s3_bucket_versions() {
local bucket="$1" work page delete_request delete_response count errors rounds=0
work=$(mktemp -d)
page="$work/page.json"
delete_request="$work/delete.json"
delete_response="$work/delete-response.json"
while true; do
rounds=$((rounds + 1))
if [ "$rounds" -gt 100000 ]; then
echo "ERROR: S3 purge exceeded 100000 batches for s3://$bucket" >&2
rm -rf "$work"
return 1
fi
if ! aws s3api list-object-versions \
--bucket "$bucket" \
--max-keys 1000 \
--no-paginate \
--output json > "$page"; then
echo "ERROR: failed to list object versions for s3://$bucket" >&2
rm -rf "$work"
return 1
fi
jq '{
Objects: [
(.Versions // [])[],
(.DeleteMarkers // [])[]
] | map({Key, VersionId}),
Quiet: true
}' "$page" > "$delete_request"
count=$(jq '.Objects | length' "$delete_request")
if [ "$count" -eq 0 ]; then
break
fi
if ! aws s3api delete-objects \
--bucket "$bucket" \
--delete "file://$delete_request" \
--output json > "$delete_response"; then
echo "ERROR: failed to delete a version batch from s3://$bucket" >&2
rm -rf "$work"
return 1
fi
# Successful Quiet=true deletes may produce a zero-byte response body.
# Slurp mode treats that as an empty input set and therefore zero errors,
# while still counting per-object Errors when AWS returns a JSON object.
errors=$(jq -s '[.[] | (.Errors // [])[]] | length' "$delete_response")
if [ "$errors" -ne 0 ]; then
echo "ERROR: S3 returned per-object deletion errors for s3://$bucket:" >&2
jq -c '.Errors[]' "$delete_response" >&2
rm -rf "$work"
return 1
fi
echo " purged $count object versions/delete markers from s3://$bucket"
done
rm -rf "$work"
}
hf_delete_s3_bucket_completely() {
local bucket="$1"
hf_purge_s3_bucket_versions "$bucket" &&
aws s3api delete-bucket --bucket "$bucket"
}
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# Canonical decoded-output comparison shared by the real-AWS smoke test and
# its local unit test. This file defines functions only; sourcing it has no
# side effects.
hf_sha256() {
sha256sum "$1" | awk '{print $1}'
}
hf_canonical_video_framemd5() {
local input="$1" output="$2" raw
raw=$(mktemp)
if ! ffmpeg -nostdin -v error -y -i "$input" \
-map 0:v:0 -an -vf format=rgba -fps_mode passthrough \
-f framemd5 "$raw"; then
rm -f "$raw"
return 1
fi
# Ignore container timestamp/header differences here: normalized ffprobe
# metadata and duration are gated separately. This file pins decoded pixel
# bytes, frame order, frame count, and per-frame byte size.
awk -F',' '
!/^#/ && NF >= 6 {
size=$5; hash=$6
gsub(/[[:space:]]/, "", size)
gsub(/[[:space:]]/, "", hash)
print size "," hash
}
' "$raw" > "$output"
rm -f "$raw"
[ -s "$output" ]
}
hf_normalized_ffprobe_metadata() {
local input="$1" output="$2"
ffprobe -v error \
-show_entries \
stream=index,codec_type,codec_name,profile,pix_fmt,width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate,avg_frame_rate,time_base,color_range,color_space,color_transfer,color_primaries,chroma_location,field_order,sample_fmt,sample_rate,channels,channel_layout \
-of json "$input" |
jq -S '{
streams: ((.streams // [])
| sort_by(.codec_type, .index)
| map(del(.index)))
}' > "$output"
}
hf_duration_seconds() {
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1"
}
hf_has_audio() {
[ -n "$(ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$1" 2>/dev/null | head -1)" ]
}
hf_decode_pcm() {
ffmpeg -nostdin -v error -i "$1" -map 0:a:0 -vn \
-ac 2 -ar 48000 -c:a pcm_s16le -f s16le "$2"
}
hf_extract_chunk_hashes() {
local history="$1" output="$2"
jq '[
.events[] |
.taskSucceededEventDetails.output? |
select(type == "string") |
(try fromjson catch empty) |
.Payload |
select(type == "object" and .Action == "renderChunk") |
{ChunkIndex, Sha256, FramesEncoded}
] | sort_by(.ChunkIndex)' "$history" > "$output"
}
# Compare two rendered outputs. Writes durable evidence at <prefix>.* and a
# machine-readable <prefix>.json. Returns 0 only for semantic equivalence.
hf_compare_render_semantics() {
local v1="$1" v2="$2" prefix="$3"
local v1_history="${4:-}" v2_history="${5:-}"
local tolerance="${SEMANTIC_DURATION_TOLERANCE_SECONDS:-0.001}"
local work
work=$(mktemp -d)
local v1_frames="${prefix}.v1.framemd5"
local v2_frames="${prefix}.v2.framemd5"
local v1_meta="${prefix}.v1.ffprobe.json"
local v2_meta="${prefix}.v2.ffprobe.json"
local v1_duration v2_duration duration_delta
local v1_encoded_sha v2_encoded_sha encoded_equal
local video_equal metadata_equal duration_equal
local audio_state audio_equal v1_audio_sha="" v2_audio_sha=""
local v1_audio_bytes=0 v2_audio_bytes=0
local encoded_gated=false
local chunks_checked=false chunks_equal=true v1_chunk_count=0 v2_chunk_count=0
if [ "${REQUIRE_ENCODED_SHA_EQUAL:-false}" = true ]; then encoded_gated=true; fi
if ! hf_canonical_video_framemd5 "$v1" "$v1_frames" ||
! hf_canonical_video_framemd5 "$v2" "$v2_frames" ||
! hf_normalized_ffprobe_metadata "$v1" "$v1_meta" ||
! hf_normalized_ffprobe_metadata "$v2" "$v2_meta"; then
rm -rf "$work"
return 2
fi
if cmp -s "$v1_frames" "$v2_frames"; then video_equal=true; else video_equal=false; fi
if cmp -s "$v1_meta" "$v2_meta"; then metadata_equal=true; else metadata_equal=false; fi
if ! v1_duration=$(hf_duration_seconds "$v1") ||
! v2_duration=$(hf_duration_seconds "$v2"); then
rm -rf "$work"
return 2
fi
duration_delta=$(awk -v a="$v1_duration" -v b="$v2_duration" \
'BEGIN { d=a-b; if (d<0) d=-d; printf("%.9f", d) }')
if awk -v d="$duration_delta" -v t="$tolerance" 'BEGIN { exit !(d <= t) }'; then
duration_equal=true
else
duration_equal=false
fi
local v1_has_audio=false v2_has_audio=false
if hf_has_audio "$v1"; then v1_has_audio=true; fi
if hf_has_audio "$v2"; then v2_has_audio=true; fi
if [ "$v1_has_audio" = false ] && [ "$v2_has_audio" = false ]; then
audio_state="no-audio-on-either"
audio_equal=true
elif [ "$v1_has_audio" != "$v2_has_audio" ]; then
audio_state="audio-stream-mismatch"
audio_equal=false
else
audio_state="decoded-pcm"
if ! hf_decode_pcm "$v1" "$work/v1.pcm" ||
! hf_decode_pcm "$v2" "$work/v2.pcm"; then
rm -rf "$work"
return 2
fi
v1_audio_sha=$(hf_sha256 "$work/v1.pcm")
v2_audio_sha=$(hf_sha256 "$work/v2.pcm")
v1_audio_bytes=$(wc -c < "$work/v1.pcm" | tr -d '[:space:]')
v2_audio_bytes=$(wc -c < "$work/v2.pcm" | tr -d '[:space:]')
if [ "$v1_audio_sha" = "$v2_audio_sha" ] && [ "$v1_audio_bytes" = "$v2_audio_bytes" ]; then
audio_equal=true
else
audio_equal=false
fi
fi
v1_encoded_sha=$(hf_sha256 "$v1")
v2_encoded_sha=$(hf_sha256 "$v2")
if [ "$v1_encoded_sha" = "$v2_encoded_sha" ]; then encoded_equal=true; else encoded_equal=false; fi
local semantic_equal=false
if [ "$video_equal" = true ] &&
[ "$metadata_equal" = true ] &&
[ "$duration_equal" = true ] &&
[ "$audio_equal" = true ]; then
semantic_equal=true
fi
if [ "$encoded_gated" = true ] && [ "$encoded_equal" != true ]; then
semantic_equal=false
fi
if [ -n "$v1_history" ] || [ -n "$v2_history" ]; then
chunks_checked=true
if [ -z "$v1_history" ] || [ -z "$v2_history" ] ||
! hf_extract_chunk_hashes "$v1_history" "${prefix}.v1.chunk-hashes.json" ||
! hf_extract_chunk_hashes "$v2_history" "${prefix}.v2.chunk-hashes.json"; then
rm -rf "$work"
return 2
fi
v1_chunk_count=$(jq 'length' "${prefix}.v1.chunk-hashes.json")
v2_chunk_count=$(jq 'length' "${prefix}.v2.chunk-hashes.json")
if [ "$v1_chunk_count" -eq 0 ] ||
[ "$v2_chunk_count" -eq 0 ] ||
! cmp -s "${prefix}.v1.chunk-hashes.json" "${prefix}.v2.chunk-hashes.json"; then
chunks_equal=false
semantic_equal=false
fi
fi
jq -n \
--arg v1 "$v1" --arg v2 "$v2" \
--argjson semanticEqual "$semantic_equal" \
--argjson videoEqual "$video_equal" \
--argjson v1VideoFrameCount "$(wc -l < "$v1_frames" | tr -d '[:space:]')" \
--argjson v2VideoFrameCount "$(wc -l < "$v2_frames" | tr -d '[:space:]')" \
--argjson metadataEqual "$metadata_equal" \
--arg v1Duration "$v1_duration" --arg v2Duration "$v2_duration" \
--arg durationDelta "$duration_delta" --arg durationTolerance "$tolerance" \
--arg audioState "$audio_state" --argjson audioEqual "$audio_equal" \
--arg v1AudioSha256 "$v1_audio_sha" --arg v2AudioSha256 "$v2_audio_sha" \
--argjson v1AudioBytes "$v1_audio_bytes" --argjson v2AudioBytes "$v2_audio_bytes" \
--arg v1EncodedSha256 "$v1_encoded_sha" --arg v2EncodedSha256 "$v2_encoded_sha" \
--argjson encodedShaEqual "$encoded_equal" \
--argjson encodedShaGated "$encoded_gated" \
--argjson chunksChecked "$chunks_checked" \
--argjson chunksEqual "$chunks_equal" \
--argjson v1ChunkCount "$v1_chunk_count" \
--argjson v2ChunkCount "$v2_chunk_count" \
'{
v1: $v1,
v2: $v2,
semanticEqual: $semanticEqual,
video: {
equal: $videoEqual,
v1FrameCount: $v1VideoFrameCount,
v2FrameCount: $v2VideoFrameCount
},
metadata: {equal: $metadataEqual},
chunks: {
checked: $chunksChecked,
equal: $chunksEqual,
v1Count: $v1ChunkCount,
v2Count: $v2ChunkCount
},
duration: {
v1Seconds: ($v1Duration | tonumber),
v2Seconds: ($v2Duration | tonumber),
deltaSeconds: ($durationDelta | tonumber),
toleranceSeconds: ($durationTolerance | tonumber),
equal: (($durationDelta | tonumber) <= ($durationTolerance | tonumber))
},
audio: {
state: $audioState,
equal: $audioEqual,
v1Sha256: $v1AudioSha256,
v2Sha256: $v2AudioSha256,
v1Bytes: $v1AudioBytes,
v2Bytes: $v2AudioBytes
},
encoded: {
equal: $encodedShaEqual,
gated: $encodedShaGated,
v1Sha256: $v1EncodedSha256,
v2Sha256: $v2EncodedSha256
}
}' > "${prefix}.json"
rm -rf "$work"
[ "$semantic_equal" = true ]
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Small configuration helpers shared by smoke.sh and shell tests.
hf_resolve_psnr_threshold() {
local explicit="$1" fixture_meta="$2"
if [ -n "$explicit" ]; then
printf '%s\n' "$explicit"
return
fi
jq -er '(.minPsnr // 40) | numbers' "$fixture_meta"
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_aws-isolation.sh
source "$SCRIPT_DIR/_aws-isolation.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
cat > "$WORK/bin/aws" <<'MOCK_AWS'
#!/usr/bin/env bash
set -euo pipefail
operation="${1:-} ${2:-}"
case "$MOCK_MODE:$operation" in
absent:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
absent:"lambda get-function")
echo "ResourceNotFoundException: Function not found" >&2
exit 254
;;
absent:"stepfunctions list-state-machines"|absent:"logs describe-log-groups")
exit 0
;;
collision:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
collision:"lambda get-function")
printf '{"Configuration":{"FunctionName":"collision"}}\n'
;;
auth:"cloudformation describe-stacks")
echo "AccessDenied: credentials expired" >&2
exit 253
;;
owned:"cloudformation describe-stacks")
printf 'run-a\n'
;;
foreign:"cloudformation describe-stacks")
printf 'run-b\n'
;;
ownership-missing:"cloudformation describe-stacks")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
reserve:"cloudformation create-stack"|reserve:"cloudformation wait")
exit 0
;;
discovery:"cloudformation list-stack-resources")
cat <<'JSON'
{"StackResourceSummaries":[
{"LogicalResourceId":"RenderBucket","PhysicalResourceId":"physical-render-bucket"},
{"LogicalResourceId":"RenderStateMachine","PhysicalResourceId":"arn:aws:states:us-east-2:1:stateMachine:physical"}
]}
JSON
;;
missing:"cloudformation list-stack-resources")
echo "ValidationError: Stack with id smoke does not exist" >&2
exit 255
;;
*)
echo "unexpected mock request: $MOCK_MODE $operation" >&2
exit 2
;;
esac
MOCK_AWS
chmod 755 "$WORK/bin/aws"
name_a=$(hf_derive_project_name "hyperframes-lambda-smoke-a-very-long-shared-prefix-111")
name_b=$(hf_derive_project_name "hyperframes-lambda-smoke-a-very-long-shared-prefix-222")
[ "$name_a" != "$name_b" ]
[ "${#name_a}" -le 49 ]
[ "${#name_b}" -le 49 ]
run_a=$(hf_new_smoke_run_id)
run_b=$(hf_new_smoke_run_id)
[ "$run_a" != "$run_b" ]
bucket_a=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_a")
bucket_b=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_b")
[ "$bucket_a" != "$bucket_b" ]
[ "${#bucket_a}" -le 63 ]
[ "${#bucket_b}" -le 63 ]
MOCK_MODE=absent PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a"
if MOCK_MODE=collision PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a" 2>"$WORK/collision-error"; then
echo "expected exact-name collision to fail closed" >&2
exit 1
fi
grep -q "collision" "$WORK/collision-error"
if MOCK_MODE=auth PATH="$WORK/bin:$PATH" \
hf_assert_deploy_isolation "smoke" "$name_a" 2>"$WORK/auth-error"; then
echo "expected verification API error to fail closed" >&2
exit 1
fi
grep -q "could not prove" "$WORK/auth-error"
[ "$(MOCK_MODE=owned PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a")" = "owned" ]
[ "$(MOCK_MODE=ownership-missing PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a")" = "absent" ]
if MOCK_MODE=foreign PATH="$WORK/bin:$PATH" \
hf_stack_ownership_status "smoke" "run-a" 2>"$WORK/foreign-error"; then
echo "expected foreign stack ownership to fail closed" >&2
exit 1
fi
grep -q "refusing cleanup" "$WORK/foreign-error"
MOCK_MODE=reserve PATH="$WORK/bin:$PATH" \
hf_reserve_smoke_stack "smoke" "run-a"
discovered=$(MOCK_MODE=discovery PATH="$WORK/bin:$PATH" \
hf_discover_stack_resources "smoke")
[ "$(jq -r .renderBucket <<<"$discovered")" = "physical-render-bucket" ]
[ "$(jq -r .stateMachineArn <<<"$discovered")" = \
"arn:aws:states:us-east-2:1:stateMachine:physical" ]
missing=$(MOCK_MODE=missing PATH="$WORK/bin:$PATH" \
hf_discover_stack_resources "smoke")
[ "$(jq -r .renderBucket <<<"$missing")" = "" ]
[ "$(jq -r .stateMachineArn <<<"$missing")" = "" ]
echo "aws isolation shell test passed"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
printf '1001\n' > "$WORK/remaining"
: > "$WORK/delete-batches"
cat > "$WORK/bin/aws" <<'MOCK_AWS'
#!/usr/bin/env bash
set -euo pipefail
operation="${1:-} ${2:-}"
shift 2
bucket=""
delete_file=""
while [ $# -gt 0 ]; do
case "$1" in
--bucket) bucket="$2"; shift 2 ;;
--delete) delete_file="${2#file://}"; shift 2 ;;
*) shift ;;
esac
done
[ "$bucket" = "mock-bucket" ]
case "$operation" in
"s3api list-object-versions")
remaining=$(cat "$MOCK_WORK/remaining")
if [ "$remaining" -gt 1000 ]; then
jq -n '{
IsTruncated: true,
Versions: [range(0;999) | {Key:("version-" + tostring),VersionId:("v-" + tostring)}],
DeleteMarkers: [{Key:"deleted-null-object",VersionId:"null"}]
}'
elif [ "$remaining" -gt 0 ]; then
jq -n --argjson remaining "$remaining" '{
IsTruncated: false,
Versions: [range(0;$remaining) | {Key:("tail-" + tostring),VersionId:"null"}],
DeleteMarkers: []
}'
else
jq -n '{IsTruncated:false,Versions:[],DeleteMarkers:[]}'
fi
;;
"s3api delete-objects")
count=$(jq '.Objects | length' "$delete_file")
jq -e '
if (.Objects | length) == 1000
then any(.Objects[]; .Key == "deleted-null-object" and .VersionId == "null")
else true
end
' "$delete_file" >/dev/null
remaining=$(cat "$MOCK_WORK/remaining")
printf '%s\n' "$((remaining - count))" > "$MOCK_WORK/remaining"
printf '%s\n' "$count" >> "$MOCK_WORK/delete-batches"
# Real AWS returns a blank body for a successful Quiet=true delete.
:
;;
"s3api delete-bucket")
[ "$(cat "$MOCK_WORK/remaining")" -eq 0 ]
touch "$MOCK_WORK/bucket-deleted"
;;
*)
echo "unexpected mock operation: $operation" >&2
exit 2
;;
esac
MOCK_AWS
chmod 755 "$WORK/bin/aws"
MOCK_WORK="$WORK" PATH="$WORK/bin:$PATH" \
hf_delete_s3_bucket_completely "mock-bucket" 2>"$WORK/stderr"
[ "$(cat "$WORK/remaining")" -eq 0 ]
[ "$(paste -sd, "$WORK/delete-batches")" = "1000,1" ]
[ -f "$WORK/bucket-deleted" ]
[ ! -s "$WORK/stderr" ]
echo "s3 purge shell test passed"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_semantic-compare.sh
source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_smoke-config.sh
source "$SCRIPT_DIR/_smoke-config.sh"
for cmd in ffmpeg ffprobe jq sha256sum cmp; do
command -v "$cmd" >/dev/null 2>&1 || {
echo "missing test dependency: $cmd" >&2
exit 1
}
done
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
printf '{"minPsnr":25}\n' > "$WORK/meta.json"
[ "$(hf_resolve_psnr_threshold "" "$WORK/meta.json")" = "25" ]
[ "$(hf_resolve_psnr_threshold "37.5" "$WORK/meta.json")" = "37.5" ]
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/original.mp4"
# Rewrapping changes encoded bytes while preserving decoded semantics.
ffmpeg -nostdin -v error -y -i "$WORK/original.mp4" \
-map 0 -c copy -metadata comment="different container bytes" "$WORK/rewrapped.mp4"
hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/equal"
jq -e '
.semanticEqual == true and
.video.equal == true and
.audio.equal == true and
.metadata.equal == true and
.duration.equal == true and
.encoded.equal == false
' "$WORK/equal.json" >/dev/null
cat > "$WORK/v1-history.json" <<'JSON'
{"events":[
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":1,\"Sha256\":\"bbb\",\"FramesEncoded\":12}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"plan\",\"ChunkCount\":2}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"aaa\",\"FramesEncoded\":12}}"}}
]}
JSON
cat > "$WORK/v2-history.json" <<'JSON'
{"events":[
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"aaa\",\"FramesEncoded\":12}}"}},
{"taskSucceededEventDetails":{"output":"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":1,\"Sha256\":\"bbb\",\"FramesEncoded\":12}}"}}
]}
JSON
hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-equal" \
"$WORK/v1-history.json" "$WORK/v2-history.json"
jq -e '.semanticEqual == true and .chunks == {checked:true,equal:true,v1Count:2,v2Count:2}' \
"$WORK/chunks-equal.json" >/dev/null
jq '(.events[0].taskSucceededEventDetails.output) =
"{\"Payload\":{\"Action\":\"renderChunk\",\"ChunkIndex\":0,\"Sha256\":\"different\",\"FramesEncoded\":12}}"' \
"$WORK/v2-history.json" > "$WORK/v2-history-mismatch.json"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/chunks-mismatch" \
"$WORK/v1-history.json" "$WORK/v2-history-mismatch.json"; then
echo "expected chunk-hash mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .chunks.equal == false' \
"$WORK/chunks-mismatch.json" >/dev/null
if REQUIRE_ENCODED_SHA_EQUAL=true \
hf_compare_render_semantics "$WORK/original.mp4" "$WORK/rewrapped.mp4" "$WORK/encoded-gated"; then
echo "expected encoded-SHA gate to reject rewrapped output" >&2
exit 1
fi
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=blue:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/video-mismatch.mp4"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/video-mismatch.mp4" "$WORK/video-mismatch"; then
echo "expected decoded-video mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .video.equal == false' \
"$WORK/video-mismatch.json" >/dev/null
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-f lavfi -i "sine=frequency=880:sample_rate=48000:duration=0.5" \
-shortest -c:v mpeg4 -q:v 3 -c:a aac "$WORK/audio-mismatch.mp4"
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/audio-mismatch.mp4" "$WORK/audio-mismatch"; then
echo "expected decoded-audio mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .video.equal == true and .audio.equal == false' \
"$WORK/audio-mismatch.json" >/dev/null
ffmpeg -nostdin -v error -y \
-f lavfi -i "color=c=red:s=64x64:r=24:d=0.5" \
-an -c:v mpeg4 -q:v 3 "$WORK/silent.mp4"
ffmpeg -nostdin -v error -y -i "$WORK/silent.mp4" \
-map 0 -c copy -metadata comment="silent rewrap" "$WORK/silent-rewrapped.mp4"
hf_compare_render_semantics \
"$WORK/silent.mp4" "$WORK/silent-rewrapped.mp4" "$WORK/silent-equal"
jq -e '.semanticEqual == true and .audio.state == "no-audio-on-either"' \
"$WORK/silent-equal.json" >/dev/null
if hf_compare_render_semantics \
"$WORK/original.mp4" "$WORK/silent.mp4" "$WORK/audio-stream-mismatch"; then
echo "expected audio-stream presence mismatch" >&2
exit 1
fi
jq -e '.semanticEqual == false and .audio.state == "audio-stream-mismatch"' \
"$WORK/audio-stream-mismatch.json" >/dev/null
echo "semantic compare shell tests passed"
+336 -43
View File
@@ -18,17 +18,20 @@
# - sam (AWS SAM CLI, >= 1.100)
# - bun (>= 1.3, to build the handler ZIP)
# - ffmpeg (system or built-in; PSNR computation)
# - ffprobe (normalized stream metadata + duration)
# - jq
# - sha256sum + cmp
# - zip
#
# Inputs (flags or env vars):
# --fixture <name> (default: mp4-h264-sdr)
# --chunk-counts <list> (default: 2,4,8)
# --psnr-threshold <db> (default: 40)
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>)
# --psnr-threshold <db> (default: fixture meta.json minPsnr)
# --stack-name <name> (default: hyperframes-lambda-smoke-<unique-run-id>)
# --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
# --plan-protocol <v1|v2|both> (default: v1)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
@@ -51,6 +54,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.."
# shellcheck source=./_semantic-compare.sh
source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
# shellcheck source=./_smoke-config.sh
source "$SCRIPT_DIR/_smoke-config.sh"
# shellcheck source=./_aws-isolation.sh
source "$SCRIPT_DIR/_aws-isolation.sh"
# ── Defaults ──────────────────────────────────────────────────────────────
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
@@ -63,19 +74,25 @@ CHUNK_COUNTS="${CHUNK_COUNTS:-2,4,8}"
# than the in-process baseline (Debian-bookworm-slim's apt ffmpeg +
# Puppeteer-managed chrome-headless-shell). Expected drift across those
# environments is ~3 dB on simple fixtures, more on font-heavy ones.
# The gate defaults to 40 dB to absorb that drift; tighten it via
# --psnr-threshold for a stricter check.
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}"
# The gate defaults to the fixture's own `meta.json.minPsnr`, which is
# calibrated for that content/runtime boundary. Override it via
# --psnr-threshold (or PSNR_THRESHOLD) for a stricter experiment.
PSNR_THRESHOLD="${PSNR_THRESHOLD-}"
SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}"
KEEP_STACK="false"
SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
# Lambda Map-state concurrency cap. 16 fans out the chunks aggressively
# at the cost of a higher peak Lambda bill. Drop to 2-4 for cheaper runs;
# raise as far as your AWS account's regional concurrency quota allows.
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
PROJECT_NAME=""
SAM_DEPLOY_BUCKET=""
usage() {
cat <<'EOF'
@@ -89,12 +106,14 @@ output against the in-process baseline, and tears the stack down.
Flags:
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>)
--psnr-threshold <db> PSNR floor (default: fixture meta.json minPsnr)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
--region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE)
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
--skip-build reuse existing dist/handler.zip
-h, --help show this help and exit
@@ -105,7 +124,8 @@ Cost notes:
per run before S3 PUT/GET. Set --reserved-concurrency lower for
cost-conscious accounts.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3), ffmpeg, jq, zip.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3),
ffmpeg, ffprobe, jq, sha256sum, cmp, zip.
EOF
}
@@ -118,7 +138,9 @@ while [ $# -gt 0 ]; do
--stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;;
--plan-protocol) PLAN_PROTOCOL="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;;
--require-encoded-sha-equal) REQUIRE_ENCODED_SHA_EQUAL="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;;
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
@@ -126,6 +148,12 @@ while [ $# -gt 0 ]; do
esac
done
if [ "$PLAN_PROTOCOL" != "v1" ] && [ "$PLAN_PROTOCOL" != "v2" ] && [ "$PLAN_PROTOCOL" != "both" ]; then
echo "ERROR: --plan-protocol must be v1, v2, or both." >&2
exit 1
fi
PROJECT_NAME=$(hf_derive_project_name "$STACK_NAME")
# Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the
# standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's
# default resolution (env → ~/.aws/config → IMDS) take over without us
@@ -143,6 +171,62 @@ fi
# ── Cleanup helper (defined early so the failure paths below can call it) ─
BUCKET=""
STATE_MACHINE_ARN=""
verify_absent_api() {
local label="$1" absent_pattern="$2"
shift 2
local output_file status
output_file=$(mktemp)
if "$@" >"$output_file" 2>&1; then
leaks+=("$label")
rm -f "$output_file"
return
else
status=$?
fi
if ! grep -Eiq "$absent_pattern" "$output_file"; then
local detail
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:$label:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_log_group_absent() {
local log_group="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws logs describe-log-groups \
--log-group-name-prefix "$log_group" \
--query "logGroups[?logGroupName=='$log_group'].logGroupName" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("log-group:$log_group")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:log-group:$log_group:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_state_machine_name_absent() {
local state_machine_name="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws stepfunctions list-state-machines \
--query "stateMachines[?name=='$state_machine_name'].stateMachineArn" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("state-machine-name:$state_machine_name:$output")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:state-machine-name:$state_machine_name:exit=$status:$detail")
fi
rm -f "$output_file"
}
cleanup_and_exit() {
local exit_code="${1:-0}"
@@ -157,24 +241,135 @@ cleanup_and_exit() {
fi
else
echo "→ Tearing down stack $STACK_NAME"
if [ -n "$BUCKET" ]; then
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true
local cleanup_identity_ok=true
local stack_cleanup_allowed=false
local ownership_status=""
local discovery_errors=()
if ! aws sts get-caller-identity >/dev/null; then
cleanup_identity_ok=false
echo "ERROR: AWS identity check failed before cleanup; absence cannot be trusted" >&2
fi
(cd "$SAM_DIR" && sam delete \
if [ "$cleanup_identity_ok" = true ]; then
if ownership_status=$(hf_stack_ownership_status "$STACK_NAME" "$SMOKE_RUN_ID"); then
if [ "$ownership_status" = "owned" ]; then
stack_cleanup_allowed=true
else
echo "→ Stack is absent; skipping stack-scoped destructive cleanup"
fi
else
discovery_errors+=("verification-error:cloudformation-stack-ownership")
fi
fi
if [ "$stack_cleanup_allowed" = true ]; then
local discovered
if discovered=$(hf_discover_stack_resources "$STACK_NAME"); then
if [ -z "$BUCKET" ]; then
BUCKET=$(jq -r '.renderBucket' <<<"$discovered")
fi
if [ -z "$STATE_MACHINE_ARN" ]; then
STATE_MACHINE_ARN=$(jq -r '.stateMachineArn' <<<"$discovered")
fi
else
discovery_errors+=("verification-error:cloudformation-resource-discovery")
fi
if [ -n "$BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$BUCKET"; then
echo "WARN: failed to purge/delete retained render bucket s3://$BUCKET" >&2
fi
fi
if ! (cd "$SAM_DIR" && sam delete \
--stack-name "$STACK_NAME" \
--no-prompts) >/dev/null 2>&1 || true
--region "$AWS_REGION" \
--no-prompts); then
echo "WARN: sam delete failed for $STACK_NAME" >&2
fi
if ! aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"; then
echo "WARN: CloudFormation did not confirm stack deletion for $STACK_NAME" >&2
fi
if aws logs describe-log-groups \
--log-group-name-prefix "/aws/lambda/${PROJECT_NAME}-render" \
--query "logGroups[?logGroupName=='/aws/lambda/${PROJECT_NAME}-render'].logGroupName" \
--output text | grep -q .; then
if ! aws logs delete-log-group --log-group-name "/aws/lambda/${PROJECT_NAME}-render"; then
echo "WARN: failed to delete Lambda log group" >&2
fi
fi
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$SAM_DEPLOY_BUCKET"; then
echo "WARN: failed to purge/delete SAM deployment bucket s3://$SAM_DEPLOY_BUCKET" >&2
fi
fi
local leaks=()
if [ "${#discovery_errors[@]}" -gt 0 ]; then
leaks+=("${discovery_errors[@]}")
fi
if [ "$cleanup_identity_ok" != true ]; then
leaks+=("verification-error:aws-identity-unavailable")
fi
verify_absent_api "cloudformation-stack:$STACK_NAME" \
"does not exist" \
aws cloudformation describe-stacks --stack-name "$STACK_NAME"
if [ -n "$BUCKET" ]; then
verify_absent_api "render-bucket:s3://$BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$BUCKET"
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
verify_absent_api "sam-bucket:s3://$SAM_DEPLOY_BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$SAM_DEPLOY_BUCKET"
fi
verify_absent_api "lambda-function:${PROJECT_NAME}-render" \
"ResourceNotFoundException|Function not found" \
aws lambda get-function --function-name "${PROJECT_NAME}-render"
if [ -n "$STATE_MACHINE_ARN" ]; then
verify_absent_api "state-machine:$STATE_MACHINE_ARN" \
"StateMachineDoesNotExist|does not exist" \
aws stepfunctions describe-state-machine --state-machine-arn "$STATE_MACHINE_ARN"
fi
verify_state_machine_name_absent "${PROJECT_NAME}-render"
local lambda_log="/aws/lambda/${PROJECT_NAME}-render"
local states_log="/aws/states/${PROJECT_NAME}-render"
verify_log_group_absent "$lambda_log"
verify_log_group_absent "$states_log"
if [ "${#leaks[@]}" -gt 0 ]; then
echo "ERROR: AWS cleanup verification found leaked resources:" >&2
printf ' - %s\n' "${leaks[@]}" >&2
if [ "$exit_code" -eq 0 ]; then
exit_code=7
fi
else
echo "→ Cleanup verified: no scoped AWS resources remain"
fi
mkdir -p "$ARTIFACT_DIR"
local cleanup_lines
cleanup_lines=$(mktemp)
if [ "${#leaks[@]}" -gt 0 ]; then
printf '%s\n' "${leaks[@]}" > "$cleanup_lines"
fi
jq -Rn \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg checkedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson originalExitCode "${1:-0}" \
'{
stackName: $stackName,
projectName: $projectName,
checkedAt: $checkedAt,
originalExitCode: $originalExitCode,
leaks: [inputs | select(length > 0)],
cleanupVerified: false
} | .cleanupVerified = (.leaks | length == 0)' \
< "$cleanup_lines" > "$ARTIFACT_DIR/cleanup-verification.json"
rm -f "$cleanup_lines"
fi
exit "$exit_code"
}
# Trap unexpected failures (set -e trips, SIGINT, etc.) so we don't leak
# the deployed stack + bucket on a non-routed error path. Explicit
# cleanup_and_exit calls disarm the trap first so teardown runs once.
trap 'cleanup_and_exit $?' EXIT
# ── Pre-flight checks ─────────────────────────────────────────────────────
for cmd in aws sam bun ffmpeg jq zip; do
for cmd in aws sam bun ffmpeg ffprobe jq zip sha256sum cmp; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: '$cmd' not found on PATH." >&2
exit 1
@@ -203,6 +398,25 @@ if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
exit 1
fi
# This check runs before cleanup is armed or any resource is created.
echo "→ Pre-flight: proving exact AWS resource names are unused"
if ! hf_assert_deploy_isolation "$STACK_NAME" "$PROJECT_NAME"; then
echo "ERROR: refusing to reuse or clean resources not created by this run." >&2
exit 1
fi
# Arm cleanup before atomically reserving the stack name. If another smoke run
# wins the create-stack race, ownership verification prevents this run from
# touching it. If this run wins, every later destructive action requires the
# same ownership tag.
trap 'cleanup_and_exit $?' EXIT
echo "→ Pre-flight: atomically reserving stack name for this smoke run"
if ! hf_reserve_smoke_stack "$STACK_NAME" "$SMOKE_RUN_ID"; then
echo "ERROR: could not reserve stack name; another run may have won the race." >&2
cleanup_and_exit 1
fi
mkdir -p "$ARTIFACT_DIR/renders"
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
@@ -223,21 +437,29 @@ echo "→ SAM validate"
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
# ProjectName is intentionally NOT set to $STACK_NAME — the template
# uses ProjectName only for the function/state-machine human-facing
# names, and forcing it long here doesn't help. The BucketName is
# auto-generated by CloudFormation per stack so concurrent smoke runs
# don't collide. Pass --region explicitly here even though
# AWS_DEFAULT_REGION is set, so a stray samconfig.toml in the working
# directory can't override the script's choice.
# Use a per-run resource prefix and deployment bucket. The template has
# explicit FunctionName/StateMachineName properties, so leaving ProjectName
# at its default makes concurrent smoke stacks overwrite/collide. A dedicated
# SAM bucket also lets teardown remove every object created by this run.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
SAM_DEPLOY_BUCKET=$(hf_sam_deploy_bucket_name "$ACCOUNT_ID" "$AWS_REGION" "$SMOKE_RUN_ID")
if [ "$AWS_REGION" = "us-east-1" ]; then
aws s3api create-bucket --bucket "$SAM_DEPLOY_BUCKET" >/dev/null
else
aws s3api create-bucket \
--bucket "$SAM_DEPLOY_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_REGION" >/dev/null
fi
if ! (cd "$SAM_DIR" && sam deploy \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--resolve-s3 \
--s3-bucket "$SAM_DEPLOY_BUCKET" \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--tags "HyperframesSmokeRun=$SMOKE_RUN_ID" \
--parameter-overrides \
"ProjectName=$PROJECT_NAME" \
ChromeSource=sparticuz \
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then
echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2
@@ -254,6 +476,27 @@ STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
--output text)
echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN"
jq -n \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg region "$AWS_REGION" \
--arg renderBucket "$BUCKET" \
--arg samDeployBucket "$SAM_DEPLOY_BUCKET" \
--arg lambdaFunction "${PROJECT_NAME}-render" \
--arg stateMachineArn "$STATE_MACHINE_ARN" \
--arg lambdaLogGroup "/aws/lambda/${PROJECT_NAME}-render" \
--arg statesLogGroup "/aws/states/${PROJECT_NAME}-render" \
'{
stackName: $stackName,
projectName: $projectName,
region: $region,
renderBucket: $renderBucket,
samDeployBucket: $samDeployBucket,
lambdaFunction: $lambdaFunction,
stateMachineArn: $stateMachineArn,
lambdaLogGroup: $lambdaLogGroup,
statesLogGroup: $statesLogGroup
}' > "$ARTIFACT_DIR/aws-resource-scope.json"
# ── 4. Upload fixture as a project tarball ────────────────────────────────
# tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not
@@ -267,6 +510,7 @@ rm -rf "$TMP_ARCHIVE"
# ── 5. Render at each chunk count ─────────────────────────────────────────
FIXTURE_META="$FIXTURE_DIR/meta.json"
PSNR_THRESHOLD=$(hf_resolve_psnr_threshold "$PSNR_THRESHOLD" "$FIXTURE_META")
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
@@ -275,13 +519,20 @@ RESULTS_JSON="$ARTIFACT_DIR/results.json"
echo "[]" > "$RESULTS_JSON"
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
if [ "$PLAN_PROTOCOL" = "both" ]; then
PROTOCOLS=(v1 v2)
else
PROTOCOLS=("$PLAN_PROTOCOL")
fi
for PROTOCOL in "${PROTOCOLS[@]}"; do
for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-N$N-$(date +%s)"
EXEC_NAME="smoke-$PROTOCOL-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
INPUT_JSON=$(jq -n \
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
--arg output "s3://$BUCKET/$OUTPUT_KEY" \
--arg protocol "$PROTOCOL" \
--argjson n "$N" \
--argjson fps "$BASE_FPS" \
--argjson w "$BASE_W" \
@@ -290,6 +541,7 @@ for N in "${COUNTS[@]}"; do
ProjectS3Uri: $project,
PlanOutputS3Prefix: $prefix,
OutputS3Uri: $output,
PlanProtocol: $protocol,
Config: {
fps: $fps,
width: $w,
@@ -301,7 +553,7 @@ for N in "${COUNTS[@]}"; do
}')
echo
echo "================== N=$N =================="
echo "================== protocol=$PROTOCOL N=$N =================="
echo "$INPUT_JSON" | jq .
START_MS=$(date +%s%3N)
@@ -323,21 +575,21 @@ for N in "${COUNTS[@]}"; do
WALL_MS=$((END_MS - START_MS))
if [ "$STATUS" != "SUCCEEDED" ]; then
echo "ERROR: N=$N execution did not succeed ($STATUS)." >&2
echo "ERROR: protocol=$PROTOCOL N=$N execution did not succeed ($STATUS)." >&2
aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" \
> "$ARTIFACT_DIR/renders/N$N-execution.json"
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-execution.json"
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 200 \
> "$ARTIFACT_DIR/renders/N$N-history.json" || true
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json" || true
cleanup_and_exit 4
fi
aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 1000 --output json \
> "$ARTIFACT_DIR/renders/N$N-history.json"
> "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/N$N-output.mp4"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/$PROTOCOL-N$N-output.mp4"
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL"
PSNR_LOG=$(mktemp)
@@ -363,34 +615,75 @@ for N in "${COUNTS[@]}"; do
' "$PSNR_LOG")
rm -f "$PSNR_LOG"
echo "N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
echo "protocol=$PROTOCOL N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
jq --argjson n "$N" \
--argjson wall "$WALL_MS" \
--arg psnr "$PSNR_AVG" \
'. += [{chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/N\($n)-output.mp4", history: "renders/N\($n)-history.json"}]' \
--arg protocol "$PROTOCOL" \
'. += [{planProtocol: $protocol, chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/\($protocol)-N\($n)-output.mp4", history: "renders/\($protocol)-N\($n)-history.json"}]' \
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
done
done
# ── 6. Gate on PSNR threshold ─────────────────────────────────────────────
# ── 6. Direct v1 ↔ v2 semantic equivalence ────────────────────────────────
SEMANTIC_FAILED=0
SEMANTIC_RESULTS_JSON="$ARTIFACT_DIR/semantic-comparisons.json"
echo "[]" > "$SEMANTIC_RESULTS_JSON"
if [ "$PLAN_PROTOCOL" = "both" ]; then
for N in "${COUNTS[@]}"; do
V1_OUTPUT="$ARTIFACT_DIR/renders/v1-N$N-output.mp4"
V2_OUTPUT="$ARTIFACT_DIR/renders/v2-N$N-output.mp4"
V1_HISTORY="$ARTIFACT_DIR/renders/v1-N$N-history.json"
V2_HISTORY="$ARTIFACT_DIR/renders/v2-N$N-history.json"
SEMANTIC_PREFIX="$ARTIFACT_DIR/renders/v1-v2-N$N"
COMPARE_STATUS=0
if hf_compare_render_semantics \
"$V1_OUTPUT" "$V2_OUTPUT" "$SEMANTIC_PREFIX" "$V1_HISTORY" "$V2_HISTORY"; then
echo "PASS: v1/v2 semantic equivalence at N=$N"
else
COMPARE_STATUS=$?
if [ ! -f "${SEMANTIC_PREFIX}.json" ]; then
jq -n --argjson status "$COMPARE_STATUS" \
'{semanticEqual: false, comparisonError: true, comparisonExitCode: $status}' \
> "${SEMANTIC_PREFIX}.json"
fi
echo "FAIL: v1/v2 semantic comparison at N=$N exited $COMPARE_STATUS (see ${SEMANTIC_PREFIX}.json)" >&2
SEMANTIC_FAILED=$((SEMANTIC_FAILED + 1))
fi
jq --argjson n "$N" --slurpfile comparison "${SEMANTIC_PREFIX}.json" \
'. += [($comparison[0] + {chunkCount: $n})]' \
"$SEMANTIC_RESULTS_JSON" > "$SEMANTIC_RESULTS_JSON.tmp" &&
mv "$SEMANTIC_RESULTS_JSON.tmp" "$SEMANTIC_RESULTS_JSON"
jq -r '" chunks=\(.chunks.equal // "error") decoded-video=\(.video.equal // "error") audio=\(.audio.equal // "error") metadata=\(.metadata.equal // "error") duration=\(.duration.equal // "error") encoded-sha=\(.encoded.equal // "error") (informational unless gated)"' \
"${SEMANTIC_PREFIX}.json"
done
fi
# ── 7. Gate on baseline PSNR threshold ────────────────────────────────────
FAILED=0
while read -r row; do
N=$(echo "$row" | jq -r .chunkCount)
PROTOCOL=$(echo "$row" | jq -r .planProtocol)
P=$(echo "$row" | jq -r .psnrAvgDb)
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
echo "FAIL: N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
echo "FAIL: protocol=$PROTOCOL N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
FAILED=$((FAILED + 1))
fi
done < <(jq -c '.[]' "$RESULTS_JSON")
# ── 7. Summary ────────────────────────────────────────────────────────────
# ── 8. Summary ────────────────────────────────────────────────────────────
echo
echo "================ RESULTS ================"
printf '%-10s %-12s %-10s\n' "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-12s %-10s\n", $1, $2, $3}'
printf '%-10s %-10s %-12s %-10s\n' "Protocol" "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-10s %-12s %-10s\n", $1, $2, $3, $4}'
echo
echo "Artifacts: $ARTIFACT_DIR"
if [ "$SEMANTIC_FAILED" -gt 0 ]; then
echo "FAILED ($SEMANTIC_FAILED v1/v2 semantic mismatches)" >&2
cleanup_and_exit 6
fi
if [ "$FAILED" -gt 0 ]; then
echo "FAILED ($FAILED renders below PSNR threshold)" >&2
cleanup_and_exit 5
+195 -2
View File
@@ -158,6 +158,7 @@ Resources:
# Lambda's Node 22 runtime sets these by default; explicit for
# clarity + so users can override during local SAM invoke.
TMPDIR: /tmp
HYPERFRAMES_RENDER_BUCKET: !Ref RenderBucket
Policies:
- S3CrudPolicy:
BucketName: !Ref RenderBucket
@@ -207,8 +208,27 @@ Resources:
# compound into a multi-hour execution. The longest legitimate
# render observed in PR 880's eval was ~3 minutes.
TimeoutSeconds: 3600
StartAt: Plan
StartAt: SelectPlanProtocol
States:
SelectPlanProtocol:
Type: Choice
Choices:
- Variable: $.PlanProtocol
StringEquals: v2
Next: PlanV2
- Variable: $.PlanProtocol
StringEquals: v1
Next: Plan
- Variable: $.PlanProtocol
IsPresent: true
Next: UnsupportedPlanProtocol
Default: Plan
UnsupportedPlanProtocol:
Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED
Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1).
Plan:
Type: Task
Resource: arn:aws:states:::lambda:invoke
@@ -220,6 +240,7 @@ Resources:
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanProtocol: v1
PlanS3Uri.$: "$.Payload.PlanS3Uri"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
@@ -239,6 +260,12 @@ Resources:
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
- ErrorEquals: [States.ALL]
@@ -248,6 +275,52 @@ Resources:
MaxDelaySeconds: 60
Next: BuildChunkList
PlanV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: plan
PlanProtocol: v2
ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanProtocol: v2
PlanV2ManifestS3Uri.$: "$.Payload.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Payload.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
Format.$: "$.Payload.Format"
HasAudio.$: "$.Payload.HasAudio"
ResultPath: $.Plan
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
Next: BuildChunkList
BuildChunkList:
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
# Map state below has something to iterate. Range is the
@@ -269,7 +342,7 @@ Resources:
Choices:
- Variable: $.Plan.ChunkCount
NumericGreaterThan: 0
Next: RenderChunks
Next: SelectWorkerProtocol
Default: PlanProducedZeroChunks
PlanProducedZeroChunks:
@@ -277,6 +350,14 @@ Resources:
Error: PLAN_TOO_LARGE
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
SelectWorkerProtocol:
Type: Choice
Choices:
- Variable: $.Plan.PlanProtocol
StringEquals: v2
Next: RenderChunksV2
Default: RenderChunks
RenderChunks:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
@@ -320,6 +401,12 @@ Resources:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
@@ -356,6 +443,112 @@ Resources:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
RenderChunksV2:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
ItemSelector:
ChunkIndex.$: "$$.Map.Item.Value"
PlanV2ManifestS3Uri.$: "$.Plan.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Plan.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Plan.PlanHash"
ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Format.$: "$.Plan.Format"
MaxConcurrencyPath: $.Plan.ChunkCount
ResultPath: $.Chunks
ItemProcessor:
ProcessorConfig:
Mode: INLINE
StartAt: RenderChunkV2
States:
RenderChunkV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: renderChunk
PlanProtocol: v2
ChunkIndex.$: "$.ChunkIndex"
PlanV2ManifestS3Uri.$: "$.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.PlanHash"
ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
Format.$: "$.Format"
ResultSelector:
ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
ChunkIndex.$: "$.Payload.ChunkIndex"
Sha256.$: "$.Payload.Sha256"
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
Next: AssembleV2
AssembleV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: assemble
PlanProtocol: v2
PlanV2ManifestS3Uri.$: "$.Plan.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Plan.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Plan.PlanHash"
ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
AudioS3Uri: null
OutputS3Uri.$: "$.OutputS3Uri"
Format.$: "$.Plan.Format"
ResultSelector:
OutputS3Uri.$: "$.Payload.OutputS3Uri"
FramesEncoded.$: "$.Payload.FramesEncoded"
FileSize.$: "$.Payload.FileSize"
ResultPath: $.Output
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
+110 -25
View File
@@ -1,51 +1,136 @@
# Google Cloud Run example
End-to-end deployment + smoke for [`@hyperframes/gcp-cloud-run`](../../packages/gcp-cloud-run) — the Cloud Run + Cloud Workflows adapter for HyperFrames distributed rendering.
End-to-end deployment and parity testing for
[`@hyperframes/gcp-cloud-run`](../../packages/gcp-cloud-run), the Cloud Run +
Cloud Workflows adapter for HyperFrames distributed rendering.
## Layout
```
scripts/smoke.sh Real-GCP smoke: build → deploy render → PSNR → destroy
sample-events/ Example request bodies for the Cloud Run handler
(plan.json, render-chunk.json, assemble.json)
```text
scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup
sample-events/ v1 and v2 handler request examples
```
The Terraform module and the Cloud Workflows definition that the smoke deploys live with the package, at `packages/gcp-cloud-run/terraform/` (including `workflow.yaml`).
The Terraform module and Cloud Workflows definition live in
`packages/gcp-cloud-run/terraform/`.
## Protocol rollout
The workflow defaults to plan protocol v1 when `PlanProtocol` is absent. V2 is
accepted only when the caller explicitly sends `PlanProtocol: "v2"`.
V1 and v2 use disjoint plan locators:
- v1: `PlanGcsUri`
- v2: `PlanV2ManifestGcsUri` and `PlanV2ArtifactGcsPrefix`
The workflow validates that the plan response matches the selected protocol
before starting chunk fan-out. It never silently falls back from v2 to v1.
Deploy the v2 workflow only with a Cloud Run image whose handler implements
the matching v2 request/response contract. An older v1-only handler will keep
serving default v1 requests, but explicit v2 smoke executions will fail closed.
## Prerequisites
- `gcloud` authenticated, with a project that has **billing enabled**
- `terraform` ( 1.5), `docker`, `ffmpeg`, `jq` on PATH
- `gcloud` authenticated to a project with billing enabled
- `terraform` (>= 1.5), `ffmpeg`, `ffprobe`, `jq`, `tar`, and `sha256sum`
- the required project APIs already enabled, plus permission to run Cloud
Build and manage Cloud Run, Workflows, GCS, IAM service accounts,
Monitoring, and Artifact Registry resources
## Run the smoke
V1 remains the safe default:
```bash
# Renders the mp4-h264-sdr fixture through the workflow and PSNR-compares it
# against the in-process baseline, then tears the stack down.
./scripts/smoke.sh --project YOUR_GCP_PROJECT --region us-central1
# Keep the stack up to poke at it:
./scripts/smoke.sh --project YOUR_GCP_PROJECT --keep-stack
# Render at several chunk sizes to see the fan-out scaling:
./scripts/smoke.sh --project YOUR_GCP_PROJECT --chunk-sizes 30,15,10
./scripts/smoke.sh \
--project YOUR_GCP_PROJECT \
--region us-central1
```
Outputs land in `scripts/gcp-smoke-artifacts/`: `results.json`
(`chunkSize × wallClockMs × psnrAvgDb`), the rendered MP4s, and each
workflow execution's describe output.
Explicitly run v1/v2 end-to-end parity at one or more chunk sizes:
```bash
./scripts/smoke.sh \
--project YOUR_GCP_PROJECT \
--region us-central1 \
--protocols v1,v2 \
--chunk-sizes 30,15,10 \
--owner plan-v2-parity
```
For each chunk size, parity requires exact equality of:
- decoded RGBA video frames
- decoded 48 kHz stereo PCM audio
- normalized `ffprobe` stream and duration metadata
The encoded MP4 hash and byte count are recorded but are not the equality
oracle because mux metadata can differ without changing decoded output.
Each render is also PSNR-compared with the checked-in in-process fixture
baseline.
## Isolation and cleanup
Every invocation hashes the owner, project, region, and a fresh invocation
nonce into a unique resource prefix such as `hf-smoke-a1b2c3d4e5`. Reusing an
owner label does not reuse old Terraform state or cloud resources. This prefix
stays within GCP service account naming limits. The smoke:
- never uses the static `hyperframes` prefix
- copies the Terraform module into an owner-scoped work directory and uses an
isolated Terraform data directory and state file
- scopes GCS keys, render outputs, the image package/tag, and the default
Artifact Registry repository to that owner
- deletes only an image it built
- deletes the Artifact Registry repository only when that invocation created it
- refuses to enable project APIs, because APIs are shared project state
- stages the bounded Cloud Build source archive in an owner-scoped bucket,
writes build logs to Cloud Logging, and deletes the staging bucket
Cleanup is on by default. It empties and destroys the owner-scoped bucket and
stack, deletes owned image/repository/build-staging resources, then verifies
the Cloud Run service, workflow, buckets, both service accounts, image, and any
test-created repository are absent. Cleanup fails on API or authentication
errors rather than interpreting them as successful deletion. GCP retains the
Cloud Build execution record and Cloud Logging audit entries as project-level
operational history; the smoke test does not attempt to erase audit records.
`--keep-stack` deliberately retains the stack, image, and repository and
prints the exact isolated state directory and Terraform cleanup commands.
Never use it for unattended CI.
Evidence lands under:
```text
scripts/gcp-smoke-artifacts/<owner-hash>/
results.json
parity.json
renders/
terraform/
terraform-data/
```
Use `--image` to test a caller-owned existing image. That image is never
deleted. `--skip-build` requires `--image`; new invocations never inherit an
old invocation's state or image implicitly.
## Test the handler locally
The sample events exercise the same body shape Cloud Workflows sends. With the
container running locally (`PORT=8080`) and credentials that can reach a GCS
bucket, you can drive a single action:
The sample events mirror the request bodies sent by Cloud Workflows:
```bash
# V1
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan.json | jq .
# Explicit v2
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan-v2.json | jq .
```
Replace the `PROJECT` placeholder bucket names and `REPLACE_WITH_PLAN_HASH`
with real values from a prior `plan` response.
Replace `PROJECT`, locator placeholders, and plan hashes with values returned
by the preceding plan action. A complete action sequence is
`plan → renderChunk(s) → assemble`.
@@ -0,0 +1,14 @@
{
"Action": "assemble",
"PlanProtocol": "v2",
"PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
"PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4"
],
"AudioGcsUri": null,
"OutputGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/output.mp4",
"Format": "mp4"
}
@@ -1,5 +1,6 @@
{
"Action": "assemble",
"PlanProtocol": "v1",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
@@ -0,0 +1,7 @@
{
"Action": "plan",
"PlanProtocol": "v2",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
}
@@ -1,5 +1,6 @@
{
"Action": "plan",
"PlanProtocol": "v1",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
@@ -0,0 +1,10 @@
{
"Action": "renderChunk",
"PlanProtocol": "v2",
"PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
"PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0,
"ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Format": "mp4"
}
@@ -1,5 +1,6 @@
{
"Action": "renderChunk",
"PlanProtocol": "v1",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0,
+603 -156
View File
@@ -1,56 +1,44 @@
#!/usr/bin/env bash
# Real-GCP smoke + benchmark for the HyperFrames Cloud Run adapter.
# Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames
# Cloud Run adapter.
#
# Run from a workstation with `gcloud` credentials. Builds the render
# container, pushes it to Artifact Registry, applies the Terraform module at
# packages/gcp-cloud-run/terraform to your project, renders a fixture
# composition through the Cloud Workflows definition, PSNR-compares the
# output against the in-process baseline, and tears the stack down.
# The default is intentionally v1-only. Plan protocol v2 must be opted into
# explicitly with --protocols v1,v2. Every invocation derives a unique,
# length-safe resource prefix and uses an isolated Terraform working directory
# and state file. Cleanup verifies every owned resource is absent and fails
# closed on API/authentication errors.
#
# Usage:
# ./smoke.sh --project <gcp-project>
# ./smoke.sh --project p --fixture mp4-h264-sdr --chunk-sizes 15,30
# ./smoke.sh --project p --keep-stack
# ./smoke.sh --project p --protocols v1,v2 --chunk-sizes 15,30
# ./smoke.sh --project p --owner james-plan-v2 --keep-stack
#
# Required tools on PATH:
# - gcloud (authenticated; the target project must have billing enabled)
# - terraform (>= 1.5)
# - docker
# - ffmpeg (PSNR computation)
# - jq
#
# Inputs (flags or env vars):
# --project <id> (required; or $GCP_PROJECT)
# --region <region> (default: us-central1)
# --fixture <name> (default: mp4-h264-sdr — under packages/producer/tests/distributed/)
# --chunk-sizes <list> (default: from the fixture meta; CSV of chunkSize overrides)
# --psnr-threshold <db> (default: 35)
# --repo <ar-repo> (Artifact Registry repo name, default: hyperframes)
# --keep-stack (skip `terraform destroy` at the end)
# --skip-build (reuse the last-pushed image tag in ./gcp-smoke-artifacts/image.txt)
#
# Outputs:
# ./gcp-smoke-artifacts/results.json (chunkSize x wallClockMs x psnrAvgDb)
# ./gcp-smoke-artifacts/renders/c<N>-output.mp4
# ./gcp-smoke-artifacts/renders/c<N>-execution.json
# Required tools:
# gcloud, terraform (>= 1.5), ffmpeg, ffprobe, jq, tar, sha256sum
#
# Exit codes:
# 0 all good 1 arg/pre-flight 2 build/push 3 terraform apply
# 4 a render failed 5 PSNR below threshold
# 0 success 1 arguments/pre-flight
# 2 image build/push failed 3 terraform apply failed
# 4 render failed 5 baseline PSNR failed
# 6 v1/v2 parity failed 7 cleanup or cleanup verification failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TF_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform"
TF_SOURCE_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform"
# ── Defaults ──────────────────────────────────────────────────────────────
PROJECT="${GCP_PROJECT:-}"
REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
AR_REPO="${AR_REPO:-hyperframes}"
PROTOCOLS="${PROTOCOLS:-v1}"
OWNER="${HYPERFRAMES_SMOKE_OWNER:-}"
AR_REPO="${AR_REPO:-}"
AR_REPO_WAS_EXPLICIT=0
[ -z "$AR_REPO" ] || AR_REPO_WAS_EXPLICIT=1
EXPLICIT_IMAGE="${HYPERFRAMES_GCP_IMAGE:-}"
KEEP_STACK=0
SKIP_BUILD=0
@@ -61,179 +49,638 @@ while [ $# -gt 0 ]; do
--fixture) FIXTURE="$2"; shift 2 ;;
--chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--repo) AR_REPO="$2"; shift 2 ;;
--protocols) PROTOCOLS="$2"; shift 2 ;;
--owner) OWNER="$2"; shift 2 ;;
--repo) AR_REPO="$2"; AR_REPO_WAS_EXPLICIT=1; shift 2 ;;
--image) EXPLICIT_IMAGE="$2"; shift 2 ;;
--keep-stack) KEEP_STACK=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; exit 1 ;;
esac
done
[ -n "$PROJECT" ] || { echo "ERROR: --project (or \$GCP_PROJECT) is required" >&2; exit 1; }
for tool in gcloud terraform docker ffmpeg jq; do
command -v "$tool" >/dev/null || { echo "ERROR: $tool not on PATH" >&2; exit 1; }
[ -n "$PROJECT" ] || {
echo "ERROR: --project (or GCP_PROJECT) is required" >&2
exit 1
}
for tool in gcloud terraform ffmpeg ffprobe jq tar sha256sum; do
command -v "$tool" >/dev/null || {
echo "ERROR: $tool is not available on PATH" >&2
exit 1
}
done
PROTOCOLS="${PROTOCOLS//[[:space:]]/}"
case ",$PROTOCOLS," in
*,v1,*|*,v2,*) ;;
*) echo "ERROR: --protocols must contain v1 and/or v2" >&2; exit 1 ;;
esac
IFS=',' read -ra PROTOCOL_LIST <<< "$PROTOCOLS"
declare -A SEEN_PROTOCOLS=()
for protocol in "${PROTOCOL_LIST[@]}"; do
case "$protocol" in
v1|v2) ;;
*) echo "ERROR: unsupported protocol '$protocol'; expected v1 or v2" >&2; exit 1 ;;
esac
[ -z "${SEEN_PROTOCOLS[$protocol]:-}" ] || {
echo "ERROR: duplicate protocol '$protocol'" >&2
exit 1
}
SEEN_PROTOCOLS[$protocol]=1
done
if [ -z "$OWNER" ]; then
OWNER="$(id -un)-$(date -u +%Y%m%dT%H%M%SZ)-$$"
fi
# Include an invocation nonce even when the human-readable owner is reused.
# This prevents a new run from ever inheriting an old run's Terraform state or
# colliding with old resources.
RUN_NONCE="$(date -u +%Y%m%dT%H%M%S)-$$-$RANDOM"
OWNER_HASH="$(printf '%s' "$OWNER:$PROJECT:$REGION:$RUN_NONCE" | sha256sum | cut -c1-10)"
# 19 chars. With the longest "-run"/"-wf" suffix, service-account IDs stay
# comfortably under GCP's 30-character limit.
STACK_NAME="hf-smoke-$OWNER_HASH"
[ "$STACK_NAME" != "hyperframes" ] || {
echo "ERROR: refusing to use the shared static resource prefix" >&2
exit 1
}
[ -n "$AR_REPO" ] || AR_REPO="$STACK_NAME"
ARTIFACT_ROOT="$SCRIPT_DIR/gcp-smoke-artifacts"
ARTIFACT_DIR="$ARTIFACT_ROOT/$OWNER_HASH"
RENDER_DIR="$ARTIFACT_DIR/renders"
TF_WORK_DIR="$ARTIFACT_DIR/terraform"
TF_DATA_DIR="$ARTIFACT_DIR/terraform-data"
mkdir -p "$RENDER_DIR" "$TF_WORK_DIR" "$TF_DATA_DIR"
export TF_DATA_DIR
for module_file in "$TF_SOURCE_DIR"/*.tf "$TF_SOURCE_DIR/workflow.yaml"; do
cp "$module_file" "$TF_WORK_DIR/"
done
FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE"
FIXTURE_META="$FIXTURE_DIR/meta.json"
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
[ -d "$FIXTURE_DIR/src" ] || { echo "ERROR: fixture src missing: $FIXTURE_DIR/src" >&2; exit 1; }
[ -f "$BASELINE_MP4" ] || { echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2; exit 1; }
[ -d "$FIXTURE_DIR/src" ] || {
echo "ERROR: fixture source missing: $FIXTURE_DIR/src" >&2
exit 1
}
[ -f "$FIXTURE_META" ] || {
echo "ERROR: fixture metadata missing: $FIXTURE_META" >&2
exit 1
}
[ -f "$BASELINE_MP4" ] || {
echo "ERROR: baseline video missing: $BASELINE_MP4" >&2
exit 1
}
ARTIFACT_DIR="$SCRIPT_DIR/gcp-smoke-artifacts"
mkdir -p "$ARTIFACT_DIR/renders"
BUCKET="$STACK_NAME-render-$PROJECT"
SERVICE_NAME="$STACK_NAME-render"
WORKFLOW_NAME="$STACK_NAME-render"
RUN_SA="$STACK_NAME-run@$PROJECT.iam.gserviceaccount.com"
WORKFLOW_SA="$STACK_NAME-wf@$PROJECT.iam.gserviceaccount.com"
IMAGE=""
IMAGE_PACKAGE=""
CREATED_IMAGE=0
CREATED_REPO=0
CREATED_BUILD_BUCKET=0
STACK_APPLIED=0
BUILD_BUCKET="$STACK_NAME-build-$PROJECT"
echo "→ Project: $PROJECT Region: $REGION Fixture: $FIXTURE"
is_not_found() {
grep -Eqi 'NOT_FOUND|not found|does not exist|was not found|could not be found|cannot find' "$1"
}
# ── 1. Enable APIs ──────────────────────────────────────────────────────────
echo "→ Enabling required APIs (idempotent)"
gcloud services enable \
run.googleapis.com workflows.googleapis.com workflowexecutions.googleapis.com \
artifactregistry.googleapis.com cloudbuild.googleapis.com monitoring.googleapis.com \
--project "$PROJECT" >/dev/null
# ── 2. Build + push the render image ────────────────────────────────────────
IMAGE_TXT="$ARTIFACT_DIR/image.txt"
if [ "$SKIP_BUILD" -eq 1 ] && [ -f "$IMAGE_TXT" ]; then
IMAGE="$(cat "$IMAGE_TXT")"
echo "→ Reusing image $IMAGE"
else
gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 || \
gcloud artifacts repositories create "$AR_REPO" --repository-format docker \
--location "$REGION" --project "$PROJECT" >/dev/null
TAG="$(date +%Y%m%d-%H%M%S)"
IMAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/hyperframes-render:$TAG"
echo "→ Building + pushing $IMAGE via Cloud Build"
# The Dockerfile lives at packages/gcp-cloud-run/Dockerfile, not the repo
# root, so we drive the build with an inline cloudbuild config rather than
# `--tag` (which assumes a root Dockerfile).
CB_CONFIG="$ARTIFACT_DIR/cloudbuild.yaml"
cat > "$CB_CONFIG" <<EOF
steps:
- name: gcr.io/cloud-builders/docker
args: ["build","-f","packages/gcp-cloud-run/Dockerfile","-t","$IMAGE","."]
images: ["$IMAGE"]
timeout: 3600s
options:
machineType: E2_HIGHCPU_8
EOF
gcloud builds submit "$REPO_ROOT" --project "$PROJECT" --config "$CB_CONFIG" \
|| { echo "ERROR: image build/push failed" >&2; exit 2; }
echo "$IMAGE" > "$IMAGE_TXT"
verify_absent() {
local label="$1"
shift
local error_file="$ARTIFACT_DIR/cleanup-${label//[^a-zA-Z0-9]/-}.stderr"
if "$@" >/dev/null 2>"$error_file"; then
echo "$label still exists" >&2
return 1
fi
# ── 3. terraform apply ──────────────────────────────────────────────────────
# The google provider authenticates via Application Default Credentials. If
# ADC isn't configured (common on a box set up with only `gcloud auth login`),
# fall back to a short-lived access token from the active gcloud account.
if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then
echo "→ ADC not configured; using a gcloud access token for Terraform"
export GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)"
export GOOGLE_PROJECT="$PROJECT"
if is_not_found "$error_file"; then
echo "$label absent"
return 0
fi
echo "→ terraform apply"
terraform -chdir="$TF_DIR" init -input=false >/dev/null
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
|| { echo "ERROR: terraform apply failed" >&2; exit 3; }
echo " ✗ could not verify $label absence (API/auth error):" >&2
sed -n '1,12p' "$error_file" >&2
return 1
}
BUCKET="$(terraform -chdir="$TF_DIR" output -raw render_bucket_name)"
SERVICE_URL="$(terraform -chdir="$TF_DIR" output -raw service_url)"
WORKFLOW="$(terraform -chdir="$TF_DIR" output -raw workflow_name)"
echo " bucket=$BUCKET service=$SERVICE_URL workflow=$WORKFLOW"
verify_service_account_absent() {
local label="$1"
local email="$2"
local error_file="$ARTIFACT_DIR/cleanup-${label//[^a-zA-Z0-9]/-}.stderr"
local matches
if ! matches="$(gcloud iam service-accounts list \
--project "$PROJECT" \
--filter "email:$email" \
--format 'value(email)' 2>"$error_file")"; then
echo " ✗ could not verify $label absence (API/auth error):" >&2
sed -n '1,12p' "$error_file" >&2
return 1
fi
if [ -n "$matches" ]; then
echo "$label still exists" >&2
return 1
fi
echo "$label absent"
}
delete_owned_image() {
local error_file="$ARTIFACT_DIR/cleanup-image-delete.stderr"
[ "$CREATED_IMAGE" -eq 1 ] || return 0
echo "→ Deleting owner-scoped test image package $IMAGE_PACKAGE"
if gcloud artifacts docker images delete "$IMAGE_PACKAGE" --delete-tags --quiet \
--project "$PROJECT" >/dev/null 2>"$error_file"; then
return 0
fi
if is_not_found "$error_file"; then
return 0
fi
sed -n '1,12p' "$error_file" >&2
return 1
}
cleanup() {
if [ "$KEEP_STACK" -eq 0 ]; then
echo "→ terraform destroy"
# Apply force_destroy=true into state FIRST. Terraform reads the bucket's
# force_destroy from prior state during the destroy step, so a destroy
# alone can't flip it; a quick apply updates the attribute, then destroy
# can empty + remove the (scratch) bucket.
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" >/dev/null 2>&1 || true
terraform -chdir="$TF_DIR" destroy -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" || true
else
echo "→ --keep-stack set; leaving the stack up. Destroy with:"
echo " terraform -chdir=$TF_DIR destroy -var project_id=$PROJECT -var region=$REGION -var image=$IMAGE -var bucket_force_destroy=true"
local original_rc=$?
local cleanup_rc=0
trap - EXIT
set +e
if [ "$KEEP_STACK" -eq 1 ]; then
echo "→ --keep-stack set; retaining this invocation's stack, image, and repository."
echo " Owner: $OWNER ($OWNER_HASH)"
echo " Isolated Terraform state: $TF_WORK_DIR/terraform.tfstate"
echo " Destroy with TF_DATA_DIR=$TF_DATA_DIR terraform -chdir=$TF_WORK_DIR apply -auto-approve -var project_id=$PROJECT -var region=$REGION -var project_name=$STACK_NAME -var image=$IMAGE -var bucket_force_destroy=true"
echo " Then run the matching terraform destroy command with the same variables."
exit "$original_rc"
fi
if [ "$STACK_APPLIED" -eq 1 ]; then
echo "→ Destroying owner-scoped Terraform stack $STACK_NAME"
# Update force_destroy only when this isolated state already owns the
# bucket. A partial apply that failed before bucket creation must not make
# cleanup create a new bucket merely to destroy it.
if terraform -chdir="$TF_WORK_DIR" state show google_storage_bucket.render \
>/dev/null 2>&1; then
terraform -chdir="$TF_WORK_DIR" apply -input=false -auto-approve \
-target=google_storage_bucket.render \
-var "project_id=$PROJECT" \
-var "region=$REGION" \
-var "project_name=$STACK_NAME" \
-var "image=$IMAGE" \
-var "bucket_force_destroy=true" >/dev/null
[ $? -eq 0 ] || cleanup_rc=1
fi
terraform -chdir="$TF_WORK_DIR" destroy -input=false -auto-approve \
-var "project_id=$PROJECT" \
-var "region=$REGION" \
-var "project_name=$STACK_NAME" \
-var "image=$IMAGE" \
-var "bucket_force_destroy=true"
[ $? -eq 0 ] || cleanup_rc=1
verify_absent "cloud-run-service" \
gcloud run services describe "$SERVICE_NAME" --region "$REGION" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
verify_absent "workflow" \
gcloud workflows describe "$WORKFLOW_NAME" --location "$REGION" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
verify_absent "render-bucket" \
gcloud storage buckets describe "gs://$BUCKET" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
verify_service_account_absent "run-service-account" "$RUN_SA"
[ $? -eq 0 ] || cleanup_rc=1
verify_service_account_absent "workflow-service-account" "$WORKFLOW_SA"
[ $? -eq 0 ] || cleanup_rc=1
fi
delete_owned_image
[ $? -eq 0 ] || cleanup_rc=1
if [ "$CREATED_IMAGE" -eq 1 ]; then
verify_absent "artifact-image" \
gcloud artifacts docker images describe "$IMAGE_PACKAGE" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
fi
if [ "$CREATED_BUILD_BUCKET" -eq 1 ]; then
echo "→ Deleting owner-scoped Cloud Build staging bucket $BUILD_BUCKET"
gcloud storage rm --recursive "gs://$BUILD_BUCKET" --project "$PROJECT" >/dev/null
[ $? -eq 0 ] || cleanup_rc=1
verify_absent "cloud-build-staging-bucket" \
gcloud storage buckets describe "gs://$BUILD_BUCKET" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
fi
if [ "$CREATED_REPO" -eq 1 ]; then
echo "→ Deleting test-created Artifact Registry repository $AR_REPO"
gcloud artifacts repositories delete "$AR_REPO" --location "$REGION" \
--project "$PROJECT" --quiet
[ $? -eq 0 ] || cleanup_rc=1
verify_absent "artifact-repository" \
gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT"
[ $? -eq 0 ] || cleanup_rc=1
fi
if [ "$cleanup_rc" -ne 0 ]; then
echo "ERROR: cleanup or cleanup verification failed; see $ARTIFACT_DIR/cleanup-*.stderr" >&2
exit 7
fi
exit "$original_rc"
}
trap cleanup EXIT
# ── 4. Upload the fixture as a project tarball ──────────────────────────────
echo "→ Project: $PROJECT"
echo " Region: $REGION"
echo " Owner: $OWNER ($OWNER_HASH)"
echo " Resource prefix: $STACK_NAME"
echo " Protocols: $PROTOCOLS"
echo " Isolated Terraform directory: $TF_WORK_DIR"
echo "→ Verifying required project APIs are already enabled"
for api in \
run.googleapis.com \
workflows.googleapis.com \
workflowexecutions.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
monitoring.googleapis.com; do
enabled_api="$(gcloud services list \
--enabled \
--project "$PROJECT" \
--filter "config.name=$api" \
--format 'value(config.name)')" || {
echo "ERROR: could not verify required API $api" >&2
exit 1
}
[ "$enabled_api" = "$api" ] || {
echo "ERROR: required API $api is not enabled; refusing to mutate project-shared API state" >&2
exit 1
}
done
echo "→ Verifying the unique resource names are unused"
verify_absent "preflight-cloud-run-service" \
gcloud run services describe "$SERVICE_NAME" --region "$REGION" --project "$PROJECT" || exit 1
verify_absent "preflight-workflow" \
gcloud workflows describe "$WORKFLOW_NAME" --location "$REGION" --project "$PROJECT" || exit 1
verify_absent "preflight-render-bucket" \
gcloud storage buckets describe "gs://$BUCKET" --project "$PROJECT" || exit 1
verify_absent "preflight-run-service-account" \
gcloud iam service-accounts describe "$RUN_SA" --project "$PROJECT" || exit 1
verify_absent "preflight-workflow-service-account" \
gcloud iam service-accounts describe "$WORKFLOW_SA" --project "$PROJECT" || exit 1
verify_absent "preflight-cloud-build-staging-bucket" \
gcloud storage buckets describe "gs://$BUILD_BUCKET" --project "$PROJECT" || exit 1
IMAGE_TXT="$ARTIFACT_DIR/image.txt"
if [ -n "$EXPLICIT_IMAGE" ]; then
IMAGE="$EXPLICIT_IMAGE"
echo "→ Using caller-owned image $IMAGE"
elif [ "$SKIP_BUILD" -eq 1 ]; then
echo "ERROR: --skip-build requires --image; new runs never reuse prior owner state" >&2
exit 1
else
REPO_ERROR="$ARTIFACT_DIR/repository-describe.stderr"
if gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" \
--project "$PROJECT" >/dev/null 2>"$REPO_ERROR"; then
if [ "$AR_REPO_WAS_EXPLICIT" -eq 0 ]; then
echo "ERROR: generated repository $AR_REPO already exists; refusing to reuse owner state" >&2
exit 1
fi
echo "→ Reusing caller-selected Artifact Registry repository $AR_REPO"
elif is_not_found "$REPO_ERROR"; then
echo "→ Creating owner-scoped Artifact Registry repository $AR_REPO"
gcloud artifacts repositories create "$AR_REPO" \
--repository-format docker \
--location "$REGION" \
--project "$PROJECT" >/dev/null
CREATED_REPO=1
else
echo "ERROR: repository lookup failed (not a NOT_FOUND response)" >&2
sed -n '1,12p' "$REPO_ERROR" >&2
exit 1
fi
IMAGE_PACKAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/$STACK_NAME-render"
IMAGE="$IMAGE_PACKAGE:$OWNER_HASH"
verify_absent "preflight-artifact-image" \
gcloud artifacts docker images describe "$IMAGE_PACKAGE" --project "$PROJECT" || exit 1
CREATED_IMAGE=1
BUILD_CONFIG="$ARTIFACT_DIR/cloudbuild.json"
BUILD_IGNORE_FILE="$ARTIFACT_DIR/gcloudignore"
cat > "$BUILD_IGNORE_FILE" <<'EOF'
**
!package.json
!bun.lock
!scripts/
!scripts/package-subpaths.mjs
!packages/
!packages/core/
!packages/core/**
!packages/engine/
!packages/engine/**
!packages/producer/
!packages/producer/**
!packages/gcp-cloud-run/
!packages/gcp-cloud-run/**
!packages/lint/
!packages/lint/**
!packages/parsers/
!packages/parsers/**
!packages/sdk/
!packages/sdk/**
!packages/sdk-playground/
!packages/sdk-playground/**
!packages/studio-server/
!packages/studio-server/**
!packages/player/
!packages/player/package.json
!packages/cli/
!packages/cli/package.json
!packages/studio/
!packages/studio/package.json
!packages/shader-transitions/
!packages/shader-transitions/package.json
!packages/aws-lambda/
!packages/aws-lambda/package.json
!packages/aws-lambda/scripts/
!packages/aws-lambda/scripts/probe-beginframe.ts
packages/**/node_modules/**
packages/**/dist/**
packages/**/coverage/**
packages/**/output/**
packages/**/tests/**
packages/**/*.test.ts
packages/**/*.test.tsx
packages/gcp-cloud-run/terraform/**
EOF
jq -n --arg image "$IMAGE" '{
steps: [{
name: "gcr.io/cloud-builders/docker",
args: ["build", "-f", "packages/gcp-cloud-run/Dockerfile", "-t", $image, "."]
}],
images: [$image],
timeout: "3600s",
options: {
machineType: "E2_HIGHCPU_8",
logging: "CLOUD_LOGGING_ONLY"
}
}' > "$BUILD_CONFIG"
echo "→ Creating owner-scoped Cloud Build staging bucket $BUILD_BUCKET"
gcloud storage buckets create "gs://$BUILD_BUCKET" \
--location "$REGION" \
--uniform-bucket-level-access \
--project "$PROJECT" >/dev/null
CREATED_BUILD_BUCKET=1
echo "→ Building and pushing owner-scoped image $IMAGE via Cloud Build"
gcloud builds submit "$REPO_ROOT" \
--project "$PROJECT" \
--config "$BUILD_CONFIG" \
--ignore-file "$BUILD_IGNORE_FILE" \
--gcs-source-staging-dir "gs://$BUILD_BUCKET/source" || {
echo "ERROR: image build/push failed" >&2
exit 2
}
printf '%s\n' "$IMAGE" > "$IMAGE_TXT"
fi
# Terraform can use ADC or a short-lived token from the active gcloud login.
if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then
echo "→ ADC not configured; using a short-lived gcloud token for Terraform"
export GOOGLE_OAUTH_ACCESS_TOKEN
GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)"
export GOOGLE_PROJECT="$PROJECT"
fi
echo "→ Applying isolated Terraform stack"
terraform -chdir="$TF_WORK_DIR" init -input=false >/dev/null
# Mark the stack cleanup-eligible before apply. Terraform may persist a
# partially-created stack even when apply itself exits nonzero.
STACK_APPLIED=1
terraform -chdir="$TF_WORK_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" \
-var "region=$REGION" \
-var "project_name=$STACK_NAME" \
-var "image=$IMAGE" || {
echo "ERROR: terraform apply failed" >&2
exit 3
}
BUCKET="$(terraform -chdir="$TF_WORK_DIR" output -raw render_bucket_name)"
SERVICE_URL="$(terraform -chdir="$TF_WORK_DIR" output -raw service_url)"
WORKFLOW_NAME="$(terraform -chdir="$TF_WORK_DIR" output -raw workflow_name)"
echo " bucket=$BUCKET service=$SERVICE_URL workflow=$WORKFLOW_NAME"
SITE_TAR="$ARTIFACT_DIR/project.tar.gz"
tar -czf "$SITE_TAR" -C "$FIXTURE_DIR/src" .
PROJECT_GCS="gs://$BUCKET/sites/$FIXTURE/project.tar.gz"
PROJECT_GCS="gs://$BUCKET/sites/smoke/$STACK_NAME/$FIXTURE/project.tar.gz"
gcloud storage cp "$SITE_TAR" "$PROJECT_GCS" --project "$PROJECT" >/dev/null
echo "→ Uploaded fixture to $PROJECT_GCS"
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
META_CHUNK=$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META")
BASE_FPS="$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")"
META_CHUNK="$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META")"
[ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}"
IFS=',' read -ra SIZES <<< "$CHUNK_SIZES"
for chunk_size in "${SIZES[@]}"; do
[[ "$chunk_size" =~ ^[1-9][0-9]*$ ]] || {
echo "ERROR: invalid positive integer chunk size '$chunk_size'" >&2
exit 1
}
done
echo "[]" > "$ARTIFACT_DIR/results.json"
jq -n '[]' > "$ARTIFACT_DIR/results.json"
jq -n '[]' > "$ARTIFACT_DIR/parity.json"
OVERALL_RC=0
IFS=',' read -ra SIZES <<< "$CHUNK_SIZES"
for CS in "${SIZES[@]}"; do
RENDER_ID="hf-smoke-c${CS}-$(date +%s)"
OUT_GCS="gs://$BUCKET/renders/$RENDER_ID/output.mp4"
ARG=$(jq -n \
--arg svc "$SERVICE_URL" \
--arg proj "$PROJECT_GCS" \
--arg prefix "gs://$BUCKET/renders/$RENDER_ID/" \
--arg out "$OUT_GCS" \
for protocol in "${PROTOCOL_LIST[@]}"; do
for chunk_size in "${SIZES[@]}"; do
RENDER_ID="$STACK_NAME-$protocol-c$chunk_size"
OUTPUT_GCS="gs://$BUCKET/renders/smoke/$STACK_NAME/$protocol/c$chunk_size/output.mp4"
PLAN_PREFIX="gs://$BUCKET/renders/smoke/$STACK_NAME/$protocol/c$chunk_size/plan/"
ARGUMENTS="$(jq -n \
--arg service "$SERVICE_URL" \
--arg project_uri "$PROJECT_GCS" \
--arg plan_prefix "$PLAN_PREFIX" \
--arg output_uri "$OUTPUT_GCS" \
--arg protocol "$protocol" \
--argjson fps "$BASE_FPS" \
--argjson cs "$CS" \
'{ServiceUrl:$svc, ProjectGcsUri:$proj, PlanOutputGcsPrefix:$prefix, OutputGcsUri:$out,
Config:{fps:$fps, width:640, height:360, format:"mp4", chunkSize:$cs}}')
--argjson chunk_size "$chunk_size" \
'{
ServiceUrl: $service,
ProjectGcsUri: $project_uri,
PlanOutputGcsPrefix: $plan_prefix,
OutputGcsUri: $output_uri,
PlanProtocol: $protocol,
Config: {fps: $fps, width: 640, height: 360, format: "mp4", chunkSize: $chunk_size}
}')"
echo "→ Render chunkSize=$CS (renderId=$RENDER_ID)"
START_MS=$(date +%s%3N)
EXEC=$(gcloud workflows execute "$WORKFLOW" --location "$REGION" --project "$PROJECT" \
--data "$ARG" --format='value(name)')
# Poll until terminal.
echo "→ Render protocol=$protocol chunkSize=$chunk_size (renderId=$RENDER_ID)"
START_MS="$(date +%s%3N)"
EXECUTION="$(gcloud workflows execute "$WORKFLOW_NAME" \
--location "$REGION" \
--project "$PROJECT" \
--data "$ARGUMENTS" \
--format='value(name)')"
STATE="ACTIVE"
while [ "$STATE" = "ACTIVE" ] || [ "$STATE" = "QUEUED" ]; do
sleep 5
STATE=$(gcloud workflows executions describe "$EXEC" --location "$REGION" \
--project "$PROJECT" --format='value(state)')
STATE="$(gcloud workflows executions describe "$EXECUTION" \
--location "$REGION" \
--project "$PROJECT" \
--format='value(state)')"
done
END_MS=$(date +%s%3N)
WALL=$((END_MS - START_MS))
END_MS="$(date +%s%3N)"
WALL_MS=$((END_MS - START_MS))
gcloud workflows executions describe "$EXEC" --location "$REGION" --project "$PROJECT" \
--format=json > "$ARTIFACT_DIR/renders/c$CS-execution.json"
EXECUTION_JSON="$RENDER_DIR/$protocol-c$chunk_size-execution.json"
gcloud workflows executions describe "$EXECUTION" \
--location "$REGION" \
--project "$PROJECT" \
--format=json > "$EXECUTION_JSON"
if [ "$STATE" != "SUCCEEDED" ]; then
echo " ✗ execution state=$STATE"
jq -r '.error.payload // empty' "$ARTIFACT_DIR/renders/c$CS-execution.json" | head -c 800
OVERALL_RC=4
jq -r '.error.payload // empty' "$EXECUTION_JSON" | head -c 800
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=4
continue
fi
OUT_LOCAL="$ARTIFACT_DIR/renders/c$CS-output.mp4"
gcloud storage cp "$OUT_GCS" "$OUT_LOCAL" --project "$PROJECT" >/dev/null
CAPTURE_MODES="$(jq -r \
'.result | fromjson | [.Chunks[]?.CaptureMode // "<missing>"] | unique | join(",")' \
"$EXECUTION_JSON")"
if jq -e \
'.result | fromjson | .Chunks as $chunks |
(($chunks | length) > 0 and all($chunks[]; .CaptureMode == "beginframe"))' \
"$EXECUTION_JSON" >/dev/null; then
echo " ✓ effective capture mode=beginframe"
else
echo " ✗ expected every chunk to use beginframe; observed=${CAPTURE_MODES:-<none>}"
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
fi
# PSNR vs the in-process baseline.
PSNR_LOG="$ARTIFACT_DIR/renders/c$CS-psnr.log"
ffmpeg -y -i "$OUT_LOCAL" -i "$BASELINE_MP4" \
OUTPUT_LOCAL="$RENDER_DIR/$protocol-c$chunk_size-output.mp4"
gcloud storage cp "$OUTPUT_GCS" "$OUTPUT_LOCAL" --project "$PROJECT" >/dev/null
PSNR_LOG="$RENDER_DIR/$protocol-c$chunk_size-psnr.log"
ffmpeg -y -i "$OUTPUT_LOCAL" -i "$BASELINE_MP4" \
-lavfi "psnr=stats_file=$PSNR_LOG" -f null - 2>/dev/null || true
PSNR_AVG=$(awk -F'psnr_avg:' '/psnr_avg:/{split($2,a," "); s+=a[1]; n++} END{if(n>0) printf "%.2f", s/n; else print "0"}' "$PSNR_LOG" 2>/dev/null || echo "0")
PSNR_AVG="$(awk -F'psnr_avg:' \
'/psnr_avg:/{split($2,a," "); s+=a[1]; n++} END{if(n>0) printf "%.2f", s/n; else print "0"}' \
"$PSNR_LOG" 2>/dev/null || echo "0")"
ENCODED_SHA="$(sha256sum "$OUTPUT_LOCAL" | cut -d' ' -f1)"
ENCODED_BYTES="$(wc -c < "$OUTPUT_LOCAL" | tr -d ' ')"
echo " ✓ state=SUCCEEDED wall=${WALL}ms psnr_avg=${PSNR_AVG}dB"
jq --argjson cs "$CS" --argjson wall "$WALL" --arg psnr "$PSNR_AVG" \
'. += [{chunkSize:$cs, wallClockMs:$wall, psnrAvgDb:($psnr|tonumber)}]' \
"$ARTIFACT_DIR/results.json" > "$ARTIFACT_DIR/results.json.tmp" && \
echo " ✓ state=SUCCEEDED wall=${WALL_MS}ms psnr_avg=${PSNR_AVG}dB"
jq \
--arg protocol "$protocol" \
--argjson chunk_size "$chunk_size" \
--argjson wall_ms "$WALL_MS" \
--arg psnr "$PSNR_AVG" \
--arg encoded_sha "$ENCODED_SHA" \
--argjson encoded_bytes "$ENCODED_BYTES" \
'. += [{
planProtocol: $protocol,
chunkSize: $chunk_size,
wallClockMs: $wall_ms,
psnrAvgDb: ($psnr | tonumber),
encodedSha256: $encoded_sha,
encodedBytes: $encoded_bytes
}]' \
"$ARTIFACT_DIR/results.json" > "$ARTIFACT_DIR/results.json.tmp"
mv "$ARTIFACT_DIR/results.json.tmp" "$ARTIFACT_DIR/results.json"
if awk "BEGIN{exit !($PSNR_AVG < $PSNR_THRESHOLD)}"; then
echo " ✗ PSNR ${PSNR_AVG}dB below threshold ${PSNR_THRESHOLD}dB"
OVERALL_RC=5
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=5
fi
done
done
echo "→ Results:"; cat "$ARTIFACT_DIR/results.json" | jq .
exit $OVERALL_RC
canonicalize_output() {
local media_file="$1"
local output_prefix="$2"
ffmpeg -v error -i "$media_file" -map 0:v:0 -pix_fmt rgba \
-f framemd5 "$output_prefix.frames.md5"
grep -v '^#' "$output_prefix.frames.md5" > "$output_prefix.frames.data"
if ffprobe -v error -select_streams a:0 -show_entries stream=index \
-of csv=p=0 "$media_file" | grep -q .; then
ffmpeg -v error -i "$media_file" -map 0:a:0 -vn -ac 2 -ar 48000 \
-f hash -hash sha256 - > "$output_prefix.audio.sha256"
else
printf '%s\n' "NO_AUDIO" > "$output_prefix.audio.sha256"
fi
ffprobe -v error \
-show_entries \
format=duration:stream=codec_type,codec_name,width,height,pix_fmt,avg_frame_rate,r_frame_rate,nb_frames,sample_rate,channels,channel_layout \
-of json "$media_file" | jq -S . > "$output_prefix.probe.json"
}
if [ -n "${SEEN_PROTOCOLS[v1]:-}" ] && [ -n "${SEEN_PROTOCOLS[v2]:-}" ]; then
echo "→ Comparing v1 and v2 decoded outputs"
for chunk_size in "${SIZES[@]}"; do
V1_OUTPUT="$RENDER_DIR/v1-c$chunk_size-output.mp4"
V2_OUTPUT="$RENDER_DIR/v2-c$chunk_size-output.mp4"
if [ ! -f "$V1_OUTPUT" ] || [ ! -f "$V2_OUTPUT" ]; then
echo " ✗ c$chunk_size parity unavailable because one protocol did not render"
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
continue
fi
V1_PREFIX="$RENDER_DIR/v1-c$chunk_size-canonical"
V2_PREFIX="$RENDER_DIR/v2-c$chunk_size-canonical"
canonicalize_output "$V1_OUTPUT" "$V1_PREFIX"
canonicalize_output "$V2_OUTPUT" "$V2_PREFIX"
FRAMES_EQUAL=false
AUDIO_EQUAL=false
METADATA_EQUAL=false
cmp -s "$V1_PREFIX.frames.data" "$V2_PREFIX.frames.data" && FRAMES_EQUAL=true
cmp -s "$V1_PREFIX.audio.sha256" "$V2_PREFIX.audio.sha256" && AUDIO_EQUAL=true
cmp -s "$V1_PREFIX.probe.json" "$V2_PREFIX.probe.json" && METADATA_EQUAL=true
jq \
--argjson chunk_size "$chunk_size" \
--argjson frames_equal "$FRAMES_EQUAL" \
--argjson audio_equal "$AUDIO_EQUAL" \
--argjson metadata_equal "$METADATA_EQUAL" \
--arg v1_frame_sha "$(sha256sum "$V1_PREFIX.frames.data" | cut -d' ' -f1)" \
--arg v2_frame_sha "$(sha256sum "$V2_PREFIX.frames.data" | cut -d' ' -f1)" \
--arg v1_audio_sha "$(sed -n '1p' "$V1_PREFIX.audio.sha256")" \
--arg v2_audio_sha "$(sed -n '1p' "$V2_PREFIX.audio.sha256")" \
'. += [{
chunkSize: $chunk_size,
decodedFramesEqual: $frames_equal,
decodedAudioEqual: $audio_equal,
normalizedMetadataEqual: $metadata_equal,
v1FrameManifestSha256: $v1_frame_sha,
v2FrameManifestSha256: $v2_frame_sha,
v1AudioSha256: $v1_audio_sha,
v2AudioSha256: $v2_audio_sha
}]' \
"$ARTIFACT_DIR/parity.json" > "$ARTIFACT_DIR/parity.json.tmp"
mv "$ARTIFACT_DIR/parity.json.tmp" "$ARTIFACT_DIR/parity.json"
if [ "$FRAMES_EQUAL" = true ] && [ "$AUDIO_EQUAL" = true ] && [ "$METADATA_EQUAL" = true ]; then
echo " ✓ c$chunk_size decoded video, audio, and metadata match"
else
echo " ✗ c$chunk_size parity mismatch: frames=$FRAMES_EQUAL audio=$AUDIO_EQUAL metadata=$METADATA_EQUAL"
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
fi
done
fi
echo "→ Results"
jq . "$ARTIFACT_DIR/results.json"
if [ -n "${SEEN_PROTOCOLS[v1]:-}" ] && [ -n "${SEEN_PROTOCOLS[v2]:-}" ]; then
echo "→ Parity"
jq . "$ARTIFACT_DIR/parity.json"
fi
echo "→ Evidence: $ARTIFACT_DIR"
exit "$OVERALL_RC"
+1
View File
@@ -51,6 +51,7 @@
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
"package:codex-plugin": "node scripts/package-codex-plugin.mjs",
"upload:docs-images": "bash scripts/upload-docs-images.sh",
"prepare": "test -d .git && lefthook install || true"
},
+21 -1
View File
@@ -37,7 +37,7 @@ smoke flow; the SDK + CDK are the supported public surface for adopters.
│ pure functions over local paths
┌──────────────────────────────────────────────────────────────────┐
│ S3 bucket — plan tarball + per-chunk outputs + final mp4
│ S3 bucket — v1 plan tar or v2 manifest/blobs + chunks + output │
└──────────────────────────────────────────────────────────────────┘
```
@@ -45,6 +45,26 @@ The handler downloads inputs from S3 into `/tmp`, calls the OSS primitive,
uploads outputs back to S3, and returns a small JSON result that fits
inside Step Functions' history budget (under 200 bytes per chunk).
### Plan transport selection
`renderToLambda` defaults to the existing monolithic v1 plan transport.
Plan v2 is an explicit whole-render opt-in:
```ts
await renderToLambda({
// ...bucket, state machine, project, and config...
planProtocol: "v2",
});
```
V2 never overloads `PlanS3Uri`. The planner returns
`PlanV2ManifestS3Uri` and `PlanV2ArtifactS3Prefix`; chunk workers fetch
only manifest-selected chunk artifacts, while the assembler fetches its
own metadata and audio subset. Blobs are immutable SHA-256-addressed
objects, verified on upload and download, and the manifest is published
last. Unknown protocols and digest mismatches are terminal Step Functions
errors. Omit the selector—or use `"v1"`—to retain the prior wire contract.
## Chrome runtime
The package supports two Chromium sources:
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/aws-lambda",
"version": "0.7.65",
"version": "0.7.77",
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": {
"type": "git",
@@ -86,7 +86,8 @@
"constructs": "^10.3.0",
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"yaml": "^2.9.0"
},
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
_awaitBeforeDeadlineForTests,
_closeBrowserForProbeTests,
parseProbeArgs,
} from "./probe-beginframe.js";
describe("parseProbeArgs", () => {
it("defaults to the @sparticuz/chromium source", () => {
expect(parseProbeArgs([])).toEqual({});
});
it("accepts a standalone executable path", () => {
expect(parseProbeArgs(["--executable-path", "/opt/chrome/chrome-headless-shell"])).toEqual({
executablePath: "/opt/chrome/chrome-headless-shell",
});
});
it("accepts the equals form and resolves relative paths", () => {
expect(parseProbeArgs(["--executable-path=./chrome"])).toEqual({
executablePath: resolve("./chrome"),
});
});
it("loads exact production launch arguments from JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-probe-args-"));
const argsPath = join(dir, "args.json");
writeFileSync(argsPath, JSON.stringify(["--enable-begin-frame-control", "--no-sandbox"]));
try {
expect(parseProbeArgs(["--launch-args-json", argsPath])).toEqual({
launchArgs: ["--enable-begin-frame-control", "--no-sandbox"],
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects missing values and unknown arguments", () => {
expect(() => parseProbeArgs(["--executable-path"])).toThrow(
"--executable-path requires a path",
);
expect(() => parseProbeArgs(["--source", "chrome"])).toThrow("Unknown argument: --source");
expect(() => parseProbeArgs(["--launch-args-json"])).toThrow(
"--launch-args-json requires a path",
);
});
it("bounds a CDP operation that never resolves", async () => {
const never = new Promise<never>(() => {});
await expect(
_awaitBeforeDeadlineForTests(never, Date.now() + 25, "screenshot beginFrame"),
).rejects.toThrow("timeout during screenshot beginFrame");
});
it("force-kills and disconnects when graceful cleanup never resolves", async () => {
let killedWith: NodeJS.Signals | number | undefined;
let disconnected = false;
await _closeBrowserForProbeTests(
{
close: () => new Promise<never>(() => {}),
process: () => ({
kill: (signal) => {
killedWith = signal;
return true;
},
}),
disconnect: async () => {
disconnected = true;
},
},
25,
);
expect(killedWith).toBe("SIGKILL");
expect(disconnected).toBe(true);
});
});
+229 -34
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env tsx
// fallow-ignore-file code-duplication
/**
* BeginFrame regression guard for `@sparticuz/chromium`.
* BeginFrame regression guard for a Chromium executable.
*
* The load-bearing assumption of `@hyperframes/aws-lambda` is that the
* Chromium build shipped by `@sparticuz/chromium` honours CDP
* `HeadlessExperimental.beginFrame` with `screenshot: true`. This script
* boots that Chromium build (decompressing into `/tmp` per the library's
* runtime contract), navigates to a tiny static page, issues one
* `beginFrame` with a screenshot request, and asserts the response
* carries a PNG buffer.
* With no arguments, this boots the build shipped by `@sparticuz/chromium`
* (decompressing into `/tmp` per the library's runtime contract). Passing
* `--executable-path /path/to/chrome-headless-shell` probes an arbitrary
* executable instead; the GCP image build uses that form against the exact
* binary copied into the image.
*
* The script is the contract test, not a one-shot verification every
* release should run it inside the Docker container at
@@ -17,15 +16,18 @@
*
* Exits 0 on pass, 1 on fail. Run via:
*
* bun run --cwd packages/aws-lambda probe:beginframe # host
* bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like
* bun run --cwd packages/aws-lambda probe:beginframe
* bun run --cwd packages/aws-lambda probe:beginframe -- \
* --executable-path /opt/chrome/chrome-headless-shell
* bun run --cwd packages/aws-lambda probe:beginframe:docker
*/
import { mkdtempSync, promises as fs } from "node:fs";
import { mkdtempSync, promises as fs, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
interface ProbeResult {
export interface ProbeResult {
passed: boolean;
durationMs: number;
chromiumPath: string;
@@ -38,10 +40,140 @@ const PROBE_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
<style>html,body{margin:0;background:#173;color:#fff;font:48px/1 sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}</style>
</head><body><div id="x">hf-beginframe-probe</div></body></html>`;
const SCREENSHOT_ATTEMPTS = 10;
const PROBE_OPERATION_TIMEOUT_MS = 5000;
const PROBE_CLEANUP_TIMEOUT_MS = 250;
export interface ProbeOptions {
executablePath?: string;
/** Exact launch arguments to probe instead of the standalone default profile. */
launchArgs?: string[];
/** Test override for the renderer/CDP operation deadline. */
timeoutMs?: number;
}
// The CLI accepts paired and equals forms for two independent path options.
// fallow-ignore-next-line complexity
export function parseProbeArgs(args: string[]): ProbeOptions {
let executablePath: string | undefined;
let launchArgs: string[] | undefined;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--executable-path") {
const value = args[i + 1];
if (!value || value.startsWith("--")) {
throw new Error("--executable-path requires a path");
}
executablePath = resolve(value);
i += 1;
continue;
}
if (arg.startsWith("--executable-path=")) {
const value = arg.slice("--executable-path=".length);
if (!value) throw new Error("--executable-path requires a path");
executablePath = resolve(value);
continue;
}
if (arg === "--launch-args-json") {
const value = args[i + 1];
if (!value || value.startsWith("--")) {
throw new Error("--launch-args-json requires a path");
}
launchArgs = readLaunchArgs(value);
i += 1;
continue;
}
if (arg.startsWith("--launch-args-json=")) {
const value = arg.slice("--launch-args-json=".length);
if (!value) throw new Error("--launch-args-json requires a path");
launchArgs = readLaunchArgs(value);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return {
...(executablePath ? { executablePath } : {}),
...(launchArgs ? { launchArgs } : {}),
};
}
function readLaunchArgs(path: string): string[] {
const resolved = resolve(path);
const value: unknown = JSON.parse(readFileSync(resolved, "utf-8"));
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new Error(`--launch-args-json must contain a JSON string array: ${resolved}`);
}
return value;
}
async function awaitBeforeDeadline<T>(
operation: Promise<T>,
deadline: number,
label: string,
): Promise<T> {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error(`BeginFrame probe timeout before ${label}`);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`BeginFrame probe timeout during ${label}`)),
remainingMs,
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
/** Test-only export for the standalone probe's bounded-operation contract. */
export const _awaitBeforeDeadlineForTests = awaitBeforeDeadline;
interface ProbeBrowserCleanup {
close(): Promise<void>;
disconnect(): Promise<void>;
process(): { kill(signal?: NodeJS.Signals | number): boolean } | null;
}
async function settleWithin(operation: Promise<unknown>, timeoutMs: number): Promise<boolean> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation.then(
() => true,
() => false,
),
new Promise<false>((resolveTimeout) => {
timeout = setTimeout(() => resolveTimeout(false), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function closeBrowserForProbe(
browser: ProbeBrowserCleanup,
timeoutMs = PROBE_CLEANUP_TIMEOUT_MS,
): Promise<void> {
if (await settleWithin(browser.close(), timeoutMs)) return;
try {
browser.process()?.kill("SIGKILL");
} catch {
// Best effort; disconnect below still releases Puppeteer's transport.
}
await settleWithin(browser.disconnect(), timeoutMs);
}
/** Test-only export for bounded standalone-probe cleanup. */
export const _closeBrowserForProbeTests = closeBrowserForProbe;
async function main(): Promise<void> {
const start = Date.now();
const result = await probe();
const result = await probe(parseProbeArgs(process.argv.slice(2)));
result.durationMs = Date.now() - start;
console.log(JSON.stringify(result, null, 2));
if (!result.passed) {
@@ -49,12 +181,21 @@ async function main(): Promise<void> {
}
}
async function probe(): Promise<ProbeResult> {
// This intentionally linear contract owns launch, renderer setup, CDP
// validation, diagnostics, and cleanup in one fail-closed lifecycle.
// fallow-ignore-next-line complexity
export async function probe(options: ProbeOptions = {}): Promise<ProbeResult> {
let chromiumPath = "";
let tmpHtmlDir = "";
try {
let sourceArgs: string[] = [];
if (options.executablePath) {
chromiumPath = options.executablePath;
} else {
const { default: chromium } = await import("@sparticuz/chromium");
chromiumPath = await chromium.executablePath();
const args = chromium.args;
sourceArgs = chromium.args;
}
const puppeteer = await import("puppeteer-core");
@@ -63,7 +204,7 @@ async function probe(): Promise<ProbeResult> {
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
// an unguessable directory name so two concurrent probes on the same
// host don't collide and CodeQL's insecure-tempfile rule clears.
const tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
const htmlPath = join(tmpHtmlDir, "probe.html");
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
@@ -75,6 +216,11 @@ async function probe(): Promise<ProbeResult> {
// ("Chrome's beginFrame with `screenshot` param always reports
// hasDamage=true").
const beginFrameFlags = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
"--deterministic-mode",
"--enable-begin-frame-control",
"--disable-new-content-rendering-timeout",
@@ -89,55 +235,97 @@ async function probe(): Promise<ProbeResult> {
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
// Distributed Linux rendering explicitly uses software compositing to
// avoid stale transformed layers in SwiftShader (see browserManager).
"--disable-gpu-compositing",
];
const browser = await puppeteer.launch({
executablePath: chromiumPath,
headless: "shell",
args: [...args, ...beginFrameFlags],
args: options.launchArgs ?? [...sourceArgs, ...beginFrameFlags],
defaultViewport: { width: 800, height: 600 },
});
try {
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
const session = await page.createCDPSession();
await session.send("HeadlessExperimental.enable");
const timeoutMs = options.timeoutMs ?? PROBE_OPERATION_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
const page = await awaitBeforeDeadline(browser.newPage(), deadline, "newPage");
await awaitBeforeDeadline(
page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: timeoutMs }),
deadline,
"navigation",
);
const session = await awaitBeforeDeadline(
page.createCDPSession(),
deadline,
"CDP session creation",
);
await awaitBeforeDeadline(
session.send("HeadlessExperimental.enable"),
deadline,
"HeadlessExperimental.enable",
);
// Warm-up beginFrame with noDisplayUpdates: true — drives the
// compositor without producing a screenshot, matching how the engine
// primes a capture loop.
await session.send("HeadlessExperimental.beginFrame", {
await awaitBeforeDeadline(
session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 0,
interval: 33,
noDisplayUpdates: true,
});
const response = await session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 1000,
}),
deadline,
"warm-up beginFrame",
);
let hasDamage = false;
let bytes = Buffer.alloc(0);
let isPng = false;
let attempts = 0;
// A renderer-ready document can still need more than one controlled
// frame before it submits a screenshot surface. Chromium explicitly
// permits screenshotData to be absent during renderer initialization,
// so retry a small bounded sequence with monotonically increasing ticks.
for (attempts = 1; attempts <= SCREENSHOT_ATTEMPTS; attempts += 1) {
const response = await awaitBeforeDeadline(
session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 1000 + (attempts - 1) * 33,
interval: 33,
screenshot: { format: "png" },
});
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
}),
deadline,
`screenshot beginFrame attempt ${attempts}`,
);
hasDamage = response.hasDamage;
const screenshot = response.screenshotData ?? "";
const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
const isPng =
bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
isPng =
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47;
if (isPng) break;
await awaitBeforeDeadline(
new Promise((resolveDelay) => setTimeout(resolveDelay, 10)),
deadline,
`screenshot retry delay ${attempts}`,
);
}
return {
passed: isPng && bytes.length > 0,
durationMs: 0,
chromiumPath,
screenshotBytes: bytes.length,
hasDamage: response.hasDamage,
hasDamage,
detail: isPng
? "OK — BeginFrame returned a PNG buffer."
: `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${
? `OK — BeginFrame returned a PNG buffer after ${attempts} attempt(s).`
: `FAIL — BeginFrame returned ${bytes.length} bytes after ${SCREENSHOT_ATTEMPTS} ` +
`attempts, PNG signature ${
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
}`,
};
} finally {
await browser.close().catch(() => {});
await closeBrowserForProbe(browser);
}
} catch (err) {
return {
@@ -148,10 +336,17 @@ async function probe(): Promise<ProbeResult> {
hasDamage: false,
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
};
} finally {
if (tmpHtmlDir) {
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
}
}
}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
if (invokedPath === fileURLToPath(import.meta.url)) {
void main().catch((err) => {
console.error("[probe-beginframe] unexpected:", err);
process.exit(2);
});
}
@@ -20,11 +20,12 @@
*/
import { beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { parse as parseYaml } from "yaml";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
@@ -50,20 +51,35 @@ const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
// not at this level — we cover it separately in the contract test.
const EXPECTED_STATE_NAMES = [
"SelectPlanProtocol",
"Plan",
"PlanV2",
"BuildChunkList",
"AssertChunkCount",
"SelectWorkerProtocol",
"RenderChunks",
"RenderChunksV2",
"Assemble",
"AssembleV2",
"PlanProducedZeroChunks",
"UnsupportedPlanProtocol",
];
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
]);
@@ -128,7 +144,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("declares the state machine with the expected state names", () => {
const { definition } = SYNTHED;
expect(definition.StartAt).toBe("Plan");
expect(definition.StartAt).toBe("SelectPlanProtocol");
const actualStates = Object.keys(definition.States);
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
});
@@ -138,7 +154,7 @@ describe("HyperframesRenderStack — snapshot", () => {
const collected = new Set<string>();
// Plan + Assemble are top-level states; RenderChunk is nested inside
// the Map's Iterator definition.
const topLevelStates = ["Plan", "Assemble"] as const;
const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected);
}
@@ -150,6 +166,15 @@ describe("HyperframesRenderStack — snapshot", () => {
| undefined;
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStates.RenderChunk, collected);
const renderChunksV2 = definition.States.RenderChunksV2 as
| {
Iterator?: { States?: Record<string, unknown> };
ItemProcessor?: { States?: Record<string, unknown> };
}
| undefined;
const innerStatesV2 =
renderChunksV2?.Iterator?.States ?? renderChunksV2?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStatesV2.RenderChunkV2, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
expect({ error: expected, present: collected.has(expected) }).toEqual({
@@ -158,6 +183,71 @@ 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("routes video failures consistently across SAM/CDK and both plan protocols", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
const v1 = getV1TaskStates(definition);
const v2 = getV2TaskStates(definition);
for (const planState of [v1.Plan, v2.PlanV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(planState, errors);
expect(errors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
expect(errors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
}
for (const chunkState of [v1.RenderChunk, v2.RenderChunkV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(chunkState, errors);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
}
}
});
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 {
@@ -169,3 +259,81 @@ 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 getV1TaskStates(definition: {
States: Record<string, unknown>;
}): Record<"Plan" | "RenderChunk" | "Assemble", unknown> {
const renderChunks = requireRecord(definition.States.RenderChunks, "RenderChunks state");
const processor = isRecord(renderChunks.Iterator)
? renderChunks.Iterator
: requireRecord(renderChunks.ItemProcessor, "RenderChunks processor");
const innerStates = requireRecord(processor.States, "RenderChunks processor states");
return {
Plan: definition.States.Plan,
RenderChunk: innerStates.RenderChunk,
Assemble: definition.States.Assemble,
};
}
function 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,6 +200,14 @@ export class HyperframesRenderStack extends Construct {
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
];
@@ -208,13 +216,28 @@ export class HyperframesRenderStack extends Construct {
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
const NON_RETRYABLE_ASSEMBLE = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
@@ -227,6 +250,7 @@ export class HyperframesRenderStack extends Construct {
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
@@ -248,6 +272,35 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
PlanProtocol: "v2",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Payload.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
},
resultPath: "$.Plan",
});
planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
planV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
parameters: {
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
@@ -331,11 +384,100 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
PlanProtocol: "v2",
"ChunkIndex.$": "$.ChunkIndex",
"PlanV2ManifestS3Uri.$": "$.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
renderChunkV2Task.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunksV2.itemProcessor(renderChunkV2Task);
const assembleV2 = new tasks.LambdaInvoke(this, "AssembleV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
AudioS3Uri: null,
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
assembleV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
.when(
sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
renderChunksV2.next(assembleV2),
)
.otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderChunks.next(assemble))
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
.otherwise(planProducedZero);
return plan.next(buildChunkList).next(assertChunkCount);
plan.next(buildChunkList);
planV2.next(buildChunkList);
buildChunkList.next(assertChunkCount);
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(plan);
}
}
+76 -15
View File
@@ -25,6 +25,8 @@ export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/
/** Discriminator for the three roles the one Lambda image fulfills. */
export type LambdaAction = "plan" | "renderChunk" | "assemble";
/** Transport protocol selected for one complete distributed render. */
export type LambdaPlanProtocol = "v1" | "v2";
/**
* Top-level shape of any event the handler may receive.
@@ -42,7 +44,7 @@ export type LambdaEvent =
| { Input: LambdaEvent };
/** Activity A: produce a planDir, upload to S3. */
export interface PlanEvent {
interface PlanEventBase {
Action: "plan";
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
ProjectS3Uri: string;
@@ -52,17 +54,27 @@ export interface PlanEvent {
Config: SerializableDistributedRenderConfig;
}
/** Legacy/default plan transport. Absence is deliberately interpreted as v1. */
export interface PlanV1Event extends PlanEventBase {
PlanProtocol?: "v1";
}
/** Explicit opt-in to the content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase {
PlanProtocol: "v2";
}
export type PlanEvent = PlanV1Event | PlanV2Event;
/** Activity B: fetch planDir, render one chunk, upload result. */
export interface RenderChunkEvent {
interface RenderChunkEventBase {
Action: "renderChunk";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
/**
* `PlanResult.planHash` from the Plan invocation. The handler verifies
* this against the untarred planDir's `plan.json` before invoking the
* producer, throwing a typed `PLAN_HASH_MISMATCH` on divergence so the
* state machine routes it as non-retryable. Defense-in-depth the
* producer also re-checks internally.
* `PlanResult.planHash` from the Plan invocation. For v1, the handler
* verifies it against the untarred planDir's `plan.json`; for v2, it
* verifies it against the content-addressed manifest before invoking the
* producer. Divergence throws a typed `PLAN_HASH_MISMATCH` so the state
* machine routes it as non-retryable.
*/
PlanHash: string;
/** 0-based chunk index this invocation should render. */
@@ -73,11 +85,28 @@ export interface RenderChunkEvent {
Format: DistributedFormat;
}
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
export interface AssembleEvent {
Action: "assemble";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
/** Legacy/default chunk event. */
export interface RenderChunkV1Event extends RenderChunkEventBase {
PlanProtocol?: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
}
/**
* V2 chunk event. It intentionally cannot carry `PlanS3Uri`: the manifest
* describes the exact content-addressed artifacts needed by this chunk.
*/
export interface RenderChunkV2Event extends RenderChunkEventBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
export type RenderChunkEvent = RenderChunkV1Event | RenderChunkV2Event;
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
interface AssembleEventBase {
Action: "assemble";
/** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */
ChunkS3Uris: string[];
/** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */
@@ -98,12 +127,28 @@ export interface AssembleEvent {
Cfr?: boolean;
}
/** Legacy/default assemble event. */
export interface AssembleV1Event extends AssembleEventBase {
PlanProtocol?: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
}
/** V2 assemble event, scoped to manifest-declared assembler artifacts. */
export interface AssembleV2Event extends AssembleEventBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
PlanHash: string;
}
export type AssembleEvent = AssembleV1Event | AssembleV2Event;
// ── Result types — kept small to fit Step Functions history budgets ─────────
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
export interface PlanLambdaResult {
interface PlanLambdaResultBase {
Action: "plan";
PlanS3Uri: string;
PlanHash: string;
ChunkCount: number;
TotalFrames: number;
@@ -118,6 +163,20 @@ export interface PlanLambdaResult {
DurationMs: number;
}
/** Existing v1 result. Kept unchanged for wire compatibility. */
export interface PlanV1LambdaResult extends PlanLambdaResultBase {
PlanS3Uri: string;
}
/** V2 result. The two v2 locators are never aliases for `PlanS3Uri`. */
export interface PlanV2LambdaResult extends PlanLambdaResultBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
export type PlanLambdaResult = PlanV1LambdaResult | PlanV2LambdaResult;
/** Result of a `renderChunk` invocation. Sized ≤200 bytes per §2.4. */
export interface RenderChunkLambdaResult {
Action: "renderChunk";
@@ -125,6 +184,8 @@ export interface RenderChunkLambdaResult {
ChunkIndex: number;
Sha256: string;
FramesEncoded: number;
/** Effective engine mode after browser probing. Emitted by current handlers. */
CaptureMode?: "beginframe" | "screenshot" | "drawelement";
DurationMs: number;
}
+260 -8
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
/**
* Handler dispatch unit tests.
*
@@ -15,10 +16,21 @@
*/
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed";
import { dirname, join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
PlanVideosMetadataError,
type AssembleResult,
type ChunkResult,
type PlanResult,
type PlanV2ArtifactPublisher,
type PlanV2Manifest,
publishPlanV2FromV1,
} from "@hyperframes/producer/distributed";
import { recomputePlanHashFromPlanDir } from "../../producer/src/services/render/stages/freezePlan.js";
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
import { handler, unwrapEvent } from "./handler.js";
@@ -37,9 +49,13 @@ class FakeS3Client {
ops: FakeS3Op[] = [];
// Map S3 URIs → byte buffers the fake serves.
objects = new Map<string, Buffer>();
metadata = new Map<string, Record<string, string>>();
// Methods called by the real S3 transport — minimal surface so the
// handler's call sites don't need rewriting under test.
// This fake intentionally implements the complete S3 command matrix inline so
// handler tests exercise realistic state transitions without AWS.
// fallow-ignore-next-line complexity
async send(command: unknown): Promise<unknown> {
const op = command as { input: { Bucket: string; Key: string } } & {
constructor: { name: string };
@@ -54,20 +70,38 @@ class FakeS3Client {
const { Readable } = await import("node:stream");
return { Body: Readable.from([bytes]) };
}
if (cmdName === "HeadObjectCommand") {
const bytes = this.objects.get(uri);
if (!bytes) {
const error = new Error("not found") as Error & {
$metadata: { httpStatusCode: number };
};
error.name = "NotFound";
error.$metadata = { httpStatusCode: 404 };
throw error;
}
return {
ContentLength: bytes.length,
Metadata: this.metadata.get(uri),
};
}
if (cmdName === "PutObjectCommand") {
// Buffer the body so we can record how many bytes were uploaded; the
// handler's hot path streams from disk, but tests pin the count.
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
let bytes = 0;
const chunks: Buffer[] = [];
if (Buffer.isBuffer(body)) {
bytes = body.length;
chunks.push(body);
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
for await (const chunk of body as NodeJS.ReadableStream) {
bytes += (chunk as Buffer).length;
chunks.push(Buffer.from(chunk as Buffer));
}
}
this.ops.push({ kind: "upload", uri, bytes });
this.objects.set(uri, Buffer.alloc(bytes));
const bytes = Buffer.concat(chunks);
this.ops.push({ kind: "upload", uri, bytes: bytes.length });
this.objects.set(uri, bytes);
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
if (metadata) this.metadata.set(uri, metadata);
return {};
}
return {};
@@ -157,6 +191,7 @@ describe("handler dispatch", () => {
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return {
planDir,
planProtocol: CURRENT_PLAN_PROTOCOL,
planHash: "fakehash",
chunkCount: 4,
totalFrames: 720,
@@ -209,6 +244,55 @@ describe("handler dispatch", () => {
).toBe(true);
});
it("normalizes producer workflow codes to Step Functions error names", async () => {
for (const code of [
"PLAN_TOO_LARGE",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"INVALID_VIDEO_METADATA",
] as const) {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const terminal =
code === "INVALID_VIDEO_METADATA"
? new PlanVideosMetadataError("test invalid plan video metadata")
: Object.assign(new Error(`terminal: ${code}`), {
code,
name: "ProducerError",
});
await expect(
handler(
{
Action: "plan",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/terminal/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
},
{
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: mock(async () => {
throw terminal;
}) as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk: mock(async () => {
throw new Error("unused");
}) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble: mock(async () => {
throw new Error("unused");
}) as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
},
),
).rejects.toMatchObject({ name: code });
}
});
it("plan honors a pre-set PRODUCER_HEADLESS_SHELL_PATH instead of re-resolving Chrome", async () => {
// Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
// RIE smoke) seeds the path, handlePlan must not overwrite it.
@@ -224,6 +308,7 @@ describe("handler dispatch", () => {
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return {
planDir,
planProtocol: CURRENT_PLAN_PROTOCOL,
planHash: "fakehash",
chunkCount: 1,
totalFrames: 30,
@@ -299,6 +384,12 @@ describe("handler dispatch", () => {
framesEncoded: 240,
sha256: "0".repeat(64),
durationMs: 12345,
planHashMs: 1,
sessionBootMs: 2,
captureStageMs: 3,
encodeStageMs: 4,
workers: 1,
captureMode: "beginframe",
perfPath: outputChunkPath + ".perf.json",
};
},
@@ -338,6 +429,7 @@ describe("handler dispatch", () => {
expect(result.ChunkIndex).toBe(2);
expect(result.Sha256).toBe("0".repeat(64));
expect(result.FramesEncoded).toBe(240);
expect(result.CaptureMode).toBe("beginframe");
expect(renderChunkMock).toHaveBeenCalledTimes(1);
});
@@ -447,6 +539,142 @@ describe("handler dispatch", () => {
expect(assembleMock).toHaveBeenCalledTimes(1);
});
it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const planV2WithPublisherMock = mock(
async (
projectDir: string,
_config: unknown,
publisher: PlanV2ArtifactPublisher,
options: Readonly<{ stagingParentDir?: string }>,
): Promise<PlanV2Manifest> => {
const v1Dir = join(tmpRoot, `v1-${Date.now()}`);
makeMinimalV1PlanDir(v1Dir, true);
const manifest = await publishPlanV2FromV1(v1Dir, publisher);
expect(options.stagingParentDir).toBe(dirname(projectDir));
expect(existsSync(join(dirname(projectDir), "plan-v2"))).toBe(false);
return manifest;
},
);
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,
planHashMs: 0,
sessionBootMs: 0,
captureStageMs: 1,
encodeStageMs: 0,
workers: 1,
captureMode: "beginframe",
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,
planV2WithPublisher:
planV2WithPublisherMock as unknown as typeof import("@hyperframes/producer/distributed").planV2WithPublisher,
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 planUploads = s3.ops.filter((operation) => operation.kind === "upload");
expect(planUploads.at(-1)?.uri).toBe(planned.PlanV2ManifestS3Uri);
expect(
planUploads
.slice(0, -1)
.every((operation) => operation.uri.startsWith(`${planned.PlanV2ArtifactS3Prefix}/`)),
).toBe(true);
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");
expect(chunk.CaptureMode).toBe("beginframe");
const audioDigest = createHash("sha256").update("AAC").digest("hex");
const audioUri = `${planned.PlanV2ArtifactS3Prefix}/${audioDigest.slice(0, 2)}/${audioDigest}`;
expect(s3.ops.slice(beforeChunk).some((operation) => operation.uri === audioUri)).toBe(false);
await handler(
{
Action: "assemble",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash,
ChunkS3Uris: [chunk.ChunkS3Uri],
AudioS3Uri: null,
OutputS3Uri: "s3://bucket/renders/v2/output.mp4",
Format: "mp4",
},
deps,
);
expect(
s3.ops.some((operation) => operation.kind === "download" && operation.uri === audioUri),
).toBe(true);
});
it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot();
await expect(
@@ -568,3 +796,27 @@ async function makeMinimalPlanTar(): Promise<Buffer> {
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
return rf(tarPath);
}
function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
mkdirSync(join(dir, "meta"), { recursive: true });
mkdirSync(join(dir, "compiled"), { recursive: true });
writeFileSync(join(dir, "compiled", "index.html"), "<html>aws v2 fixture</html>");
const planJson = {
planHash: "a".repeat(64),
chunkCount: 1,
totalFrames: 30,
dimensions: { fpsNum: 30, fpsDen: 1, width: 640, height: 360, format: "mp4" },
ffmpegVersion: "6.0",
producerVersion: "test",
fontSnapshotSha: "font-snapshot-test",
};
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
writeFileSync(
join(dir, "meta", "chunks.json"),
JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]),
);
writeFileSync(join(dir, "meta", "encoder.json"), "{}");
if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC");
planJson.planHash = recomputePlanHashFromPlanDir(dir);
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
}
+298 -7
View File
@@ -18,10 +18,18 @@ import { S3Client } from "@aws-sdk/client-s3";
import {
assemble,
type AssembleResult,
type ChunkRenderer,
type ChunkResult,
type DistributedRenderConfig,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
plan,
planV2WithPublisher,
type PlanResult,
type PlanV2Artifact,
type PlanV2Manifest,
type PlanV2MaterializationTarget,
readPlanV2Manifest,
renderChunk,
} from "@hyperframes/producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js";
@@ -39,11 +47,13 @@ import type {
} from "./events.js";
import {
downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
parseS3Uri,
tarDirectory,
untarDirectory,
uploadFileToS3,
} from "./s3Transport.js";
import { S3PlanV2ArtifactPublisher } from "./s3PlanV2Publisher.js";
/**
* Lazily-constructed S3 client. Cached at module scope so warm Lambda
@@ -67,7 +77,8 @@ export interface HandlerDeps {
s3?: S3Client;
primitives?: {
plan: typeof plan;
renderChunk: typeof renderChunk;
planV2WithPublisher?: typeof planV2WithPublisher;
renderChunk: ChunkRenderer;
assemble: typeof assemble;
};
/** Override the per-invocation `/tmp` workdir root (defaults to Lambda's `/tmp`). */
@@ -109,6 +120,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
}
}
} catch (err) {
normalizeTerminalErrorName(err);
// Log before re-throwing so CloudWatch captures the structured
// error context alongside Lambda's default stack trace. Otherwise
// ops only sees the trace and has to correlate with execution
@@ -116,6 +128,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
logEvent({
event: "handler_error",
action: unwrapped.Action,
input: summarizeEvent(unwrapped),
message: err instanceof Error ? err.message : String(err),
name: err instanceof Error ? err.name : undefined,
});
@@ -123,6 +136,28 @@ 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 workflow-facing 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.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
) {
candidate.name = candidate.code;
}
}
/**
* Walk through Step Functions' Map-state and Task-state envelopes until
* the discriminated event is found.
@@ -178,6 +213,8 @@ function logEvent(payload: Record<string, unknown>): void {
* the routable fields (S3 URIs, chunk index, format) needed to triage
* a failure from CloudWatch.
*/
// Keep event variants together so logs share one redaction and summarization boundary.
// fallow-ignore-next-line complexity
function summarizeEvent(
event: PlanEvent | RenderChunkEvent | AssembleEvent,
): Record<string, unknown> {
@@ -186,18 +223,25 @@ function summarizeEvent(
return {
projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix,
planProtocol: event.PlanProtocol ?? "v1",
format: event.Config.format,
fps: event.Config.fps,
};
case "renderChunk":
return {
planS3Uri: event.PlanS3Uri,
planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkIndex: event.ChunkIndex,
format: event.Format,
};
case "assemble":
return {
planS3Uri: event.PlanS3Uri,
planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkCount: event.ChunkS3Uris.length,
hasAudio: event.AudioS3Uri !== null,
outputS3Uri: event.OutputS3Uri,
@@ -225,7 +269,12 @@ function primeRuntimeEnv(): void {
// ── Plan ────────────────────────────────────────────────────────────────────
// The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle.
// fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
if (event.PlanProtocol === "v2") {
return handlePlanV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.plan ?? plan;
@@ -297,12 +346,67 @@ 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?.planV2WithPublisher ?? planV2WithPublisher;
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");
try {
await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
await untarDirectory(projectArchive, projectDir);
const publisher = new S3PlanV2ArtifactPublisher({
s3,
planOutputS3Prefix: event.PlanOutputS3Prefix,
temporaryRoot: work,
});
const manifest: PlanV2Manifest = await primitive(projectDir, { ...event.Config }, publisher, {
stagingParentDir: work,
});
return {
Action: "plan",
PlanProtocol: "v2",
PlanV2ManifestS3Uri: publisher.manifestUri,
PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
PlanHash: manifest.planHash,
ChunkCount: manifest.chunkCount,
TotalFrames: manifest.totalFrames,
Fps: manifest.fps,
Width: manifest.width,
Height: manifest.height,
Format: manifest.format,
HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
AudioS3Uri: null,
FfmpegVersion: manifest.ffmpegVersion,
ProducerVersion: manifest.producerVersion,
DurationMs: Date.now() - started,
};
} finally {
cleanupDir(work);
}
}
// ── RenderChunk ─────────────────────────────────────────────────────────────
async function handleRenderChunk(
event: RenderChunkEvent,
deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleRenderChunkV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -357,6 +461,55 @@ async function handleRenderChunk(
ChunkIndex: event.ChunkIndex,
Sha256: result.sha256,
FramesEncoded: result.framesEncoded,
CaptureMode: result.captureMode,
DurationMs: Date.now() - started,
};
} finally {
cleanupDir(work);
}
}
// The v2 chunk handler deliberately keeps download, verified materialization,
// render, and upload in one lifecycle so cleanup and errors remain atomic.
// fallow-ignore-next-line complexity
async function handleRenderChunkV2(
event: 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,
CaptureMode: result.captureMode,
DurationMs: Date.now() - started,
};
} finally {
@@ -393,6 +546,9 @@ async function handleAssemble(
event: AssembleEvent,
deps?: HandlerDeps,
): Promise<AssembleLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleAssembleV2(event, deps);
}
const started = Date.now();
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.assemble ?? assemble;
@@ -442,6 +598,132 @@ 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]!);
}
}
const results = await Promise.allSettled(
Array.from({ length: Math.min(concurrency, values.length) }, () => worker()),
);
const failure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
// Do not reject while sibling workers may still be writing into invocation
// scratch. The caller removes that directory in `finally`; draining the pool
// first prevents late S3 streams from racing cleanup after another GET fails.
if (failure) throw failure.reason;
}
async function downloadChunkObjects(
s3: S3Client,
uris: string[],
@@ -479,16 +761,25 @@ async function downloadChunkObjects(
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Collect every S3 URI that the handler will touch for a given event. */
// This is an exhaustive event-union projection used only for safe log summaries.
// fallow-ignore-next-line complexity
function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] {
switch (event.Action) {
case "plan":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk":
return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
return event.PlanProtocol === "v2"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
: [event.PlanS3Uri, event.ChunkOutputS3Prefix];
case "assemble":
return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
(u): u is string => u != null,
);
return [
...(event.PlanProtocol === "v2"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
: [event.PlanS3Uri]),
...event.ChunkS3Uris,
event.OutputS3Uri,
event.AudioS3Uri,
].filter((u): u is string => u != null);
}
}
+15
View File
@@ -28,11 +28,20 @@ export {
type AssembleLambdaResult,
type LambdaAction,
type LambdaEvent,
type LambdaPlanProtocol,
type LambdaResult,
type PlanEvent,
type PlanLambdaResult,
type PlanV1Event,
type PlanV1LambdaResult,
type PlanV2Event,
type PlanV2LambdaResult,
type RenderChunkEvent,
type RenderChunkLambdaResult,
type RenderChunkV1Event,
type RenderChunkV2Event,
type AssembleV1Event,
type AssembleV2Event,
type SerializableDistributedRenderConfig,
} from "./events.js";
// `_setSparticuzChromiumForTests` is intentionally NOT re-exported from
@@ -47,13 +56,19 @@ export {
} from "./chromium.js";
export {
downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
formatS3Uri,
parseS3Uri,
type S3Location,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
uploadFileToS3,
} from "./s3Transport.js";
export {
S3PlanV2ArtifactPublisher,
type S3PlanV2ArtifactPublisherOptions,
} from "./s3PlanV2Publisher.js";
// ── Client-side SDK ─────────────────────────────────────────────────────────
export { deploySite, type DeploySiteOptions, type SiteHandle } from "./sdk/deploySite.js";
@@ -0,0 +1,229 @@
// fallow-ignore-file code-duplication complexity
import { afterEach, describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { S3PlanV2ArtifactPublisher } from "./s3PlanV2Publisher.js";
interface StoredObject {
readonly bytes: Buffer;
readonly sha256: string;
}
interface PutOperation {
readonly uri: string;
readonly ifNoneMatch?: string;
}
class FakeS3 {
readonly objects = new Map<string, StoredObject>();
readonly puts: PutOperation[] = [];
asClient(): import("@aws-sdk/client-s3").S3Client {
return this as unknown as import("@aws-sdk/client-s3").S3Client;
}
async send(command: unknown): Promise<unknown> {
// AWS command inputs are the runtime boundary exercised by this fake.
const value = command as unknown as {
readonly constructor: { readonly name: string };
readonly input: {
readonly Bucket: string;
readonly Key: string;
readonly Body?: NodeJS.ReadableStream;
readonly Metadata?: Record<string, string>;
readonly IfNoneMatch?: 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");
error.name = "NotFound";
Object.assign(error, { $metadata: { httpStatusCode: 404 } });
throw error;
}
return {
ContentLength: object.bytes.length,
Metadata: { sha256: object.sha256 },
};
}
if (value.constructor.name === "PutObjectCommand") {
if (value.input.IfNoneMatch === "*" && this.objects.has(uri)) {
const error = new Error("precondition failed");
error.name = "PreconditionFailed";
Object.assign(error, { $metadata: { httpStatusCode: 412 } });
throw error;
}
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.puts.push({ uri, ifNoneMatch: value.input.IfNoneMatch });
return {};
}
throw new Error(`unexpected command ${value.constructor.name}`);
}
}
const roots: string[] = [];
afterEach(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
roots.length = 0;
});
function makeSource(contents: string): {
readonly root: string;
readonly path: string;
readonly digest: string;
readonly sizeBytes: number;
} {
const root = mkdtempSync(join(tmpdir(), "hf-s3-plan-v2-publisher-"));
roots.push(root);
const path = join(root, "artifact.bin");
writeFileSync(path, contents);
return {
root,
path,
digest: createHash("sha256").update(contents).digest("hex"),
sizeBytes: statSync(path).size,
};
}
function manifestFor(digest: string, marker = "one"): string {
return JSON.stringify({
planHash: marker,
artifacts: [{ path: "compiled/index.html", sha256: digest, sizeBytes: 5 }],
});
}
describe("S3PlanV2ArtifactPublisher", () => {
it("trims an arbitrary trailing-slash run in linear time", () => {
const publisher = new S3PlanV2ArtifactPublisher({
s3: new FakeS3().asClient(),
planOutputS3Prefix: `s3://bucket/render${"/".repeat(10_000)}`,
});
expect(publisher.artifactPrefix).toBe("s3://bucket/render/v2/artifacts/sha256");
expect(publisher.manifestUri).toBe("s3://bucket/render/v2/manifest.json");
});
it("publishes immutable blobs before the fixed-key manifest", async () => {
const source = makeSource("hello");
const s3 = new FakeS3();
const artifactPrefix = "s3://bucket/render/v2/artifacts/sha256";
const manifestUri = "s3://bucket/render/v2/manifest.json";
const publisher = new S3PlanV2ArtifactPublisher({
s3: s3.asClient(),
planOutputS3Prefix: "s3://bucket/render",
temporaryRoot: source.root,
});
await publisher.putBlob({
sourcePath: source.path,
sha256: source.digest,
sizeBytes: source.sizeBytes,
});
const manifest = manifestFor(source.digest);
await publisher.commitManifest(manifest);
const blobUri = `${artifactPrefix}/${source.digest.slice(0, 2)}/${source.digest}`;
expect(s3.puts.map((operation) => operation.uri)).toEqual([blobUri, manifestUri]);
expect(s3.puts.every((operation) => operation.ifNoneMatch === "*")).toBe(true);
expect(s3.objects.get(manifestUri)?.bytes.toString("utf8")).toBe(manifest);
});
it("refuses to expose a manifest that references an unpublished digest", async () => {
const source = makeSource("hello");
const s3 = new FakeS3();
const manifestUri = "s3://bucket/render/v2/manifest.json";
const publisher = new S3PlanV2ArtifactPublisher({
s3: s3.asClient(),
planOutputS3Prefix: "s3://bucket/render",
temporaryRoot: source.root,
});
await expect(publisher.commitManifest(manifestFor(source.digest))).rejects.toMatchObject({
name: "PlanV2IntegrityError",
});
expect(s3.objects.has(manifestUri)).toBe(false);
});
it("rejects malformed digests before constructing an S3 object key", async () => {
const source = makeSource("hello");
const s3 = new FakeS3();
const publisher = new S3PlanV2ArtifactPublisher({
s3: s3.asClient(),
planOutputS3Prefix: "s3://bucket/render",
temporaryRoot: source.root,
});
await expect(
publisher.putBlob({
sourcePath: source.path,
sha256: "../outside-prefix",
sizeBytes: source.sizeBytes,
}),
).rejects.toMatchObject({ name: "PlanV2IntegrityError" });
expect(s3.puts).toHaveLength(0);
});
it("reuses matching objects and rejects a conflicting fixed-key manifest", async () => {
const source = makeSource("hello");
const s3 = new FakeS3();
const options = {
s3: s3.asClient(),
planOutputS3Prefix: "s3://bucket/render",
temporaryRoot: source.root,
};
const blob = {
sourcePath: source.path,
sha256: source.digest,
sizeBytes: source.sizeBytes,
};
const first = new S3PlanV2ArtifactPublisher(options);
await first.putBlob(blob);
await first.commitManifest(manifestFor(source.digest, "one"));
const retry = new S3PlanV2ArtifactPublisher(options);
await retry.putBlob(blob);
await retry.commitManifest(manifestFor(source.digest, "one"));
expect(s3.puts).toHaveLength(2);
const conflict = new S3PlanV2ArtifactPublisher(options);
await conflict.putBlob(blob);
await expect(conflict.commitManifest(manifestFor(source.digest, "two"))).rejects.toMatchObject({
name: "PLAN_ARTIFACT_DIGEST_MISMATCH",
});
expect(s3.puts).toHaveLength(2);
});
it("leaves durable remote CAS blobs intact when publication aborts", async () => {
const source = makeSource("hello");
const s3 = new FakeS3();
const publisher = new S3PlanV2ArtifactPublisher({
s3: s3.asClient(),
planOutputS3Prefix: "s3://bucket/render",
temporaryRoot: source.root,
});
const blob = {
sourcePath: source.path,
sha256: source.digest,
sizeBytes: source.sizeBytes,
};
await publisher.putBlob(blob);
await publisher.abort();
await publisher.abort();
expect(s3.objects.size).toBe(1);
await expect(publisher.putBlob(blob)).rejects.toMatchObject({
name: "PlanV2IntegrityError",
});
});
});
@@ -0,0 +1,137 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { S3Client } from "@aws-sdk/client-s3";
import {
PlanV2IntegrityError,
type PlanV2ArtifactPublisher,
type PlanV2PublishBlob,
} from "@hyperframes/producer/distributed";
import { parseS3Uri, uploadContentAddressedFileToS3 } from "./s3Transport.js";
export interface S3PlanV2ArtifactPublisherOptions {
readonly s3: S3Client;
/** Validated render output prefix from which all v2 object keys are derived. */
readonly planOutputS3Prefix: string;
/** Planner-local scratch parent for the small manifest upload file. */
readonly temporaryRoot?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertSha256(value: unknown, label: string): string {
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
throw new PlanV2IntegrityError(`${label} must be a lowercase SHA-256 digest`);
}
return value;
}
function manifestDigests(manifestBytes: string): ReadonlySet<string> {
let value: unknown;
try {
value = JSON.parse(manifestBytes);
} catch {
throw new PlanV2IntegrityError("S3 publisher received invalid manifest JSON");
}
if (!isRecord(value) || !Array.isArray(value.artifacts)) {
throw new PlanV2IntegrityError("S3 publisher manifest requires an artifacts array");
}
return new Set(
value.artifacts.map((artifact, index) => {
if (!isRecord(artifact)) {
throw new PlanV2IntegrityError(`S3 publisher artifacts[${index}] must be an object`);
}
return assertSha256(artifact.sha256, `S3 publisher artifacts[${index}].sha256`);
}),
);
}
function trimTrailingSlash(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
return value.slice(0, end);
}
/**
* Manifest-last S3 implementation of the producer's plan-v2 publication seam.
*
* Blobs stream from the planner's private frozen directory directly to S3.
* Successfully uploaded or safely reused digests are tracked so a manifest
* cannot become visible before all of its references are durable.
*/
export class S3PlanV2ArtifactPublisher implements PlanV2ArtifactPublisher {
readonly artifactPrefix: string;
readonly manifestUri: string;
readonly #s3: S3Client;
readonly #temporaryRoot: string;
readonly #publishedDigests = new Set<string>();
#state: "open" | "committed" | "aborted" = "open";
constructor(options: Readonly<S3PlanV2ArtifactPublisherOptions>) {
const outputPrefix = `${trimTrailingSlash(options.planOutputS3Prefix)}/v2`;
parseS3Uri(outputPrefix);
this.#s3 = options.s3;
this.artifactPrefix = `${outputPrefix}/artifacts/sha256`;
this.manifestUri = `${outputPrefix}/manifest.json`;
this.#temporaryRoot = options.temporaryRoot ?? tmpdir();
mkdirSync(this.#temporaryRoot, { recursive: true });
}
async putBlob(blob: Readonly<PlanV2PublishBlob>): Promise<void> {
this.#assertOpen("publish a blob");
const digest = assertSha256(blob.sha256, "S3 published blob sha256");
const sourceSize = statSync(blob.sourcePath).size;
if (sourceSize !== blob.sizeBytes) {
throw new PlanV2IntegrityError(
`S3 published blob size changed for ${digest}: expected ${blob.sizeBytes}, got ${sourceSize}`,
);
}
const uri = `${this.artifactPrefix}/${digest.slice(0, 2)}/${digest}`;
await uploadContentAddressedFileToS3(this.#s3, blob.sourcePath, uri, digest);
this.#publishedDigests.add(digest);
}
async commitManifest(manifestBytes: string): Promise<void> {
this.#assertOpen("commit a manifest");
for (const digest of manifestDigests(manifestBytes)) {
if (!this.#publishedDigests.has(digest)) {
throw new PlanV2IntegrityError(
`cannot commit S3 manifest before referenced blob is durable: ${digest}`,
);
}
}
const manifestDigest = createHash("sha256").update(manifestBytes, "utf8").digest("hex");
const stagingDir = mkdtempSync(join(this.#temporaryRoot, "hf-plan-v2-manifest-"));
const manifestPath = join(stagingDir, "manifest.json");
try {
writeFileSync(manifestPath, manifestBytes, "utf8");
await uploadContentAddressedFileToS3(
this.#s3,
manifestPath,
this.manifestUri,
manifestDigest,
"application/json",
);
this.#state = "committed";
} finally {
rmSync(stagingDir, { recursive: true, force: true });
}
}
async abort(): Promise<void> {
if (this.#state === "open") this.#state = "aborted";
// Remote CAS blobs are immutable and may already be reused by a retry.
// Without a committed manifest they are unreachable and expire under the
// render bucket's intermediate-object lifecycle policy.
}
#assertOpen(operation: string): void {
if (this.#state !== "open") {
throw new PlanV2IntegrityError(`cannot ${operation} after publisher is ${this.#state}`);
}
}
}
+158 -1
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
/**
* Unit tests for the S3 URI parser + tar helpers. Real S3 network calls
* are covered by the dispatch tests in `handler.test.ts` via a fake
@@ -8,7 +9,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatS3Uri, parseS3Uri, tarDirectory, untarDirectory } from "./s3Transport.js";
import {
downloadS3ObjectToFileVerified,
formatS3Uri,
parseS3Uri,
sha256File,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
} from "./s3Transport.js";
let scratchRoot: string;
@@ -91,3 +100,151 @@ describe("tar round-trip", () => {
expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
});
});
describe("content-addressed v2 artifacts", () => {
it("uploads once and reuses an object with matching digest metadata", async () => {
const source = join(scratchRoot, "artifact-upload.bin");
writeFileSync(source, "immutable bytes");
const digest = await sha256File(source);
const s3 = new ContentAddressedFakeS3();
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe(
"uploaded",
);
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe("reused");
expect(s3.putCount).toBe(1);
});
it("refuses to overwrite an immutable key with conflicting metadata", async () => {
const source = join(scratchRoot, "artifact-conflict.bin");
writeFileSync(source, "expected bytes");
const digest = await sha256File(source);
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
const s3 = new ContentAddressedFakeS3();
s3.objects.set(uri, {
bytes: Buffer.from("same length!!"),
sha256: "0".repeat(64),
});
await expect(
uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
expect(s3.putCount).toBe(0);
});
it("reuses a matching object won by a concurrent conditional create", async () => {
const source = join(scratchRoot, "artifact-race.bin");
writeFileSync(source, "race-safe bytes");
const digest = await sha256File(source);
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
const s3 = new ContentAddressedFakeS3();
s3.raceOnNextPut = { bytes: Buffer.from("race-safe bytes"), sha256: digest };
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe("reused");
expect(s3.putCount).toBe(0);
});
it("rejects a conflicting object won by a concurrent conditional create", async () => {
const source = join(scratchRoot, "artifact-race-conflict.bin");
writeFileSync(source, "race-safe bytes");
const digest = await sha256File(source);
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
const s3 = new ContentAddressedFakeS3();
s3.raceOnNextPut = {
bytes: Buffer.alloc(Buffer.byteLength("race-safe bytes"), "x"),
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;
raceOnNextPut: { bytes: Buffer; sha256: string } | undefined;
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") {
if (this.raceOnNextPut) {
this.objects.set(uri, this.raceOnNextPut);
this.raceOnNextPut = undefined;
if (value.input.Body && "destroy" in value.input.Body) {
value.input.Body.destroy();
}
const error = new Error("precondition failed");
error.name = "PreconditionFailed";
Object.assign(error, { $metadata: { httpStatusCode: 412 } });
throw error;
}
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}`);
}
}
+158 -1
View File
@@ -25,9 +25,15 @@ import {
rmSync,
statSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import { GetObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import * as tar from "tar";
/** Parsed `s3://bucket/key` URI. */
@@ -75,6 +81,26 @@ export async function downloadS3ObjectToFile(
await pipeline(body, createWriteStream(destPath));
}
/** Download and verify an immutable plan-v2 artifact before materialization. */
export async function downloadS3ObjectToFileVerified(
client: S3Client,
uri: string,
destPath: string,
expectedSha256: string,
): Promise<void> {
assertSha256(expectedSha256);
await downloadS3ObjectToFile(client, uri, destPath);
const actual = await sha256File(destPath);
if (actual !== expectedSha256) {
rmSync(destPath, { force: true });
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
}
}
/**
* Upload a local file's contents to an S3 URI using a streaming
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
@@ -104,6 +130,137 @@ 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;
const existing = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
if (existing === "matching") return "reused";
if (existing === "conflict") throwImmutableObjectConflict(uri);
const body = createReadStream(localPath);
try {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
ContentLength: size,
Metadata: { sha256: expectedSha256 },
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
// HEAD followed by an unconditional PUT can overwrite a conflicting
// object published by a concurrent planner. Conditional create makes
// immutable CAS and fixed-key manifest publication race-safe.
IfNoneMatch: "*",
}),
);
return "uploaded";
} catch (error) {
if (!isS3PreconditionFailed(error)) throw error;
const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
if (raced === "matching") return "reused";
if (raced === "conflict") throwImmutableObjectConflict(uri);
// The winning object was deleted between the conditional failure and
// verification. Preserve the service error so the orchestrator may retry.
throw error;
} finally {
// A failed conditional request may reject before consuming the stream.
// Explicit teardown avoids retaining the source descriptor on a warm
// Lambda planner.
body.destroy();
}
}
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)}`,
);
}
}
type ContentAddressedObjectState = "missing" | "matching" | "conflict";
async function inspectContentAddressedObject(
client: S3Client,
bucket: string,
key: string,
expectedSize: number,
expectedSha256: string,
): Promise<ContentAddressedObjectState> {
try {
const existing = await client.send(
new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" }),
);
return existing.ContentLength === expectedSize && existing.Metadata?.sha256 === expectedSha256
? "matching"
: "conflict";
} catch (error) {
if (isS3NotFound(error)) return "missing";
throw error;
}
}
function throwImmutableObjectConflict(uri: string): never {
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;
}
function isS3NotFound(error: unknown): boolean {
if (!isRecord(error)) return false;
const metadata = isRecord(error.$metadata) ? error.$metadata : undefined;
return (
error.name === "NotFound" || error.name === "NoSuchKey" || metadata?.httpStatusCode === 404
);
}
function isS3PreconditionFailed(error: unknown): boolean {
if (!isRecord(error)) return false;
const metadata = isRecord(error.$metadata) ? error.$metadata : undefined;
return error.name === "PreconditionFailed" || metadata?.httpStatusCode === 412;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Pack a directory into a `.tar.gz` at `destTarball`. Uses the `tar` npm
* package (pure JS over `node:zlib`) rather than spawning a system tar
@@ -201,6 +201,30 @@ describe("getRenderProgress", () => {
expect(progress.endedAt).not.toBeNull();
});
it("recognizes v2 chunk and assemble state names", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("PlanV2"),
lambdaSucceeded({ Action: "plan", TotalFrames: 30 }),
stateEntered("RenderChunkV2"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 30 }),
stateEntered("AssembleV2"),
lambdaSucceeded({ Action: "assemble", FramesEncoded: 30 }),
stateExited("AssembleV2", {
Output: { OutputS3Uri: "s3://b/v2.mp4", FileSize: 321 },
}),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(30);
expect(progress.overallProgress).toBe(1);
expect(progress.outputFile).toEqual({ s3Uri: "s3://b/v2.mp4", bytes: 321 });
});
it("computes cost from observed billed duration", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
@@ -251,7 +251,7 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda
// result, so we re-extract them here from the state exit's
// own output rather than relying on the Lambda payload.
if (ev.stateExitedEventDetails?.name === "Assemble") {
if (isAssembleState(ev.stateExitedEventDetails?.name)) {
assembleComplete = true;
const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
if (exitPayload && typeof exitPayload === "object") {
@@ -350,12 +350,20 @@ function applyPayloadFrameCounts(
currentLambdaState: string | null,
bump: (delta: number) => void,
): void {
if (currentLambdaState !== "RenderChunk") return;
if (!isRenderChunkState(currentLambdaState)) return;
if (!payload || typeof payload !== "object") return;
const obj = payload as Record<string, unknown>;
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
}
function isRenderChunkState(name: string | null | undefined): boolean {
return name === "RenderChunk" || name === "RenderChunkV2";
}
function isAssembleState(name: string | null | undefined): boolean {
return name === "Assemble" || name === "AssembleV2";
}
/**
* Lambda success payloads from our handler include `DurationMs` the
* wall-clock the handler observed. We use it as a best-effort proxy
@@ -89,9 +89,27 @@ describe("renderToLambda", () => {
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig,
PlanProtocol: "v1",
});
});
it("opts the complete execution into plan protocol v2 explicitly", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
executionName: "smoke-v2",
planProtocol: "v2",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(sfn.starts[0]?.input).toMatchObject({ PlanProtocol: "v2" });
});
it("derives the file extension from config.format", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
@@ -20,7 +20,7 @@
import { randomUUID } from "node:crypto";
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
import type { S3Client } from "@aws-sdk/client-s3";
import type { SerializableDistributedRenderConfig } from "../events.js";
import type { LambdaPlanProtocol, SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatS3Uri } from "../s3Transport.js";
import { deploySite, type SiteHandle } from "./deploySite.js";
@@ -37,6 +37,11 @@ export interface RenderToLambdaOptions {
siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/**
* Distributed plan transport. Defaults to `"v1"` for backwards
* compatibility; v2 is always an explicit whole-render opt-in.
*/
planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
bucketName: string;
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
@@ -110,6 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
PlanOutputS3Prefix: planOutputS3Prefix,
OutputS3Uri: outputS3Uri,
Config: opts.config,
PlanProtocol: opts.planProtocol ?? "v1",
};
// Reject oversize input client-side. Step Functions Standard caps the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.7.65",
"version": "0.7.77",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"license": "Apache-2.0",
"repository": {
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AUDIT_SEEK_OPTIONS,
DENSE_GEOMETRY_SEEK_OPTIONS,
captureRegionCrop,
clampCropRegion,
installPageFunctionGuard,
@@ -374,3 +376,15 @@ describe("installPageFunctionGuard", () => {
expect(shim(marker)).toBe(marker);
});
});
describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => {
it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => {
// Dense pass must not inherit AUDIT's post-seek waits — geometry is valid synchronously after setTime, and waits multiply by sample count.
expect(DENSE_GEOMETRY_SEEK_OPTIONS.animationFrameSettle).toBe("none");
expect(DENSE_GEOMETRY_SEEK_OPTIONS.waitForFontsMs).toBe(0);
expect(DENSE_GEOMETRY_SEEK_OPTIONS.settleMs).toBe(0);
// AUDIT (the full-settle path used by the base grid) must still carry the waits.
expect(AUDIT_SEEK_OPTIONS.animationFrameSettle).toBe("double");
expect(AUDIT_SEEK_OPTIONS.settleMs).toBeGreaterThan(0);
});
});
@@ -18,6 +18,14 @@ export const AUDIT_SEEK_OPTIONS = {
settleMs: 120,
} as const;
// Geometry-only seek for the dense content_overlap grid: getBoundingClientRect is valid synchronously after setTime, so drop all post-seek waits (rAF/font/sleep) that would multiply across the dense grid.
export const DENSE_GEOMETRY_SEEK_OPTIONS = {
...AUDIT_SEEK_OPTIONS,
animationFrameSettle: "none",
waitForFontsMs: 0,
settleMs: 0,
} as const;
export interface SeekCompositionTimelineOptions {
fallbackToBridgeAndTimelines?: boolean;
waitForPreferredSeekTargetMs?: number;
+6
View File
@@ -35,6 +35,12 @@ describe("CLI command registration", () => {
);
});
it("registers media-treatment as the only treatment authoring command", () => {
const loaders = commandLoaderBlock();
expect(loaders).toContain('"media-treatment"');
expect(loaders).not.toContain('"color-grading"');
});
// A command actively reconciling skills (`skills check`/`skills update`)
// must not also nudge the user to go reconcile skills — that nudge is
// either redundant (it just ran) or misleading (a stale cached count from
+2
View File
@@ -152,6 +152,8 @@ const commandLoaders = {
events: () => import("./commands/events.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
"media-treatment": () =>
import("./commands/media-treatment.js").then((m) => m.mediaTreatmentCommand),
"grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
compare: () => import("./commands/compare.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
+94
View File
@@ -147,8 +147,12 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
seek: vi.fn(async (_time: number) => undefined),
seekGeometry: vi.fn(async (_time: number) => undefined),
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
collectOverlap: vi.fn(async (_time: number) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectRotationSample: vi.fn(async (_time: number) => []),
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
collectGeometryCandidates: vi.fn(async () => []),
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
@@ -1137,6 +1141,57 @@ describe("frame-check flag grammar", () => {
});
expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=4px")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=2garbage")).toThrow("Invalid --frame-check");
});
});
describe("layout flag grammar", () => {
it("parses proseCoverageFloor and rejects malformed specs", async () => {
const { parseLayout } = await import("./check.js");
expect(parseLayout(undefined)).toBeUndefined();
expect(parseLayout("proseCoverageFloor=0.05")).toEqual({ proseCoverageFloor: 0.05 });
expect(parseLayout("proseCoverageFloor=0")).toEqual({ proseCoverageFloor: 0 });
expect(parseLayout("proseCoverageFloor=1")).toEqual({ proseCoverageFloor: 1 });
expect(() => parseLayout(true)).toThrow("Invalid --layout");
expect(() => parseLayout("")).toThrow("Invalid --layout");
expect(() => parseLayout("bogus=1")).toThrow("Invalid --layout");
expect(() => parseLayout("proseCoverageFloor=-0.1")).toThrow("Invalid --layout");
expect(() => parseLayout("proseCoverageFloor=1.1")).toThrow("Invalid --layout");
expect(() => parseLayout("proseCoverageFloor=0.05garbage")).toThrow("Invalid --layout");
expect(() => parseLayout("proseCoverageFloor=0.1%")).toThrow("Invalid --layout");
expect(() => parseLayout("proseCoverageFloor=")).toThrow("Invalid --layout");
});
it("threads --layout into the check pipeline options", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline,
withMeta: (value) => value,
});
await runCommand(command, {
rawArgs: ["--json", "--layout", "proseCoverageFloor=0.05"],
});
expect(runPipeline).toHaveBeenCalledWith(
PROJECT,
expect.objectContaining({
layout: { proseCoverageFloor: 0.05 },
}),
);
});
it("forwards layout options into driver.collectLayout", async () => {
const collectLayout = vi.fn(async (_time: number, _tolerance: number, _layout?: unknown) => []);
await runScenario(fakeDriver({ collectLayout }), { layout: { proseCoverageFloor: 0.05 } });
expect(collectLayout).toHaveBeenCalled();
expect(collectLayout).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), {
proseCoverageFloor: 0.05,
});
});
});
@@ -1341,3 +1396,42 @@ describe("contrast candidate round-trip", () => {
expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/);
});
});
describe("dense motion-overlap re-sampling", () => {
// Collision lives inside (3.5, 4.5), a gap the sparse base grid seeks past; only the 8fps dense pass observes it.
const inBetweenGridWindow = (time: number): boolean => time >= 3.6 && time <= 4.4;
it("detects a content_overlap that occurs ONLY between two sparse grid samples", async () => {
const driver = fakeDriver({
// Sparse base grid sees nothing at any base sample time.
collectLayout: vi.fn(async (_time: number) => []),
// The transient exists only strictly between base samples 3.5 and 4.5.
collectOverlap: vi.fn(async (time: number) =>
inBetweenGridWindow(time)
? [layoutIssue("warning", { time, code: "content_overlap" })]
: [],
),
});
const { report } = await runScenario(driver);
expect(driver.collectOverlap).toHaveBeenCalled();
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
// Held ~750ms across the dense grid (>= the 500ms floor) -> promoted.
expect(report.layout.errorCount).toBeGreaterThan(0);
});
it("runs the dense pass even when sparse fingerprints are identical (aliased motion)", async () => {
// Aliased motion has identical fingerprints yet still collides between samples — the false-negative the removed gate caused.
const driver = fakeDriver({
collectLayoutGeometry: vi.fn(async () => "static"),
collectLayout: vi.fn(async (_time: number) => []),
collectOverlap: vi.fn(async (time: number) =>
inBetweenGridWindow(time)
? [layoutIssue("warning", { time, code: "content_overlap" })]
: [],
),
});
const { report } = await runScenario(driver);
expect(driver.collectOverlap).toHaveBeenCalled();
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
});
});
+58 -5
View File
@@ -16,7 +16,7 @@ import {
type CheckReport,
type CheckSection,
} from "../utils/checkPipeline.js";
import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js";
import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js";
export const examples: Example[] = [
["Run the full verification gate", "hyperframes check"],
@@ -120,6 +120,10 @@ export function createCheckCommand(
description:
'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune',
},
layout: {
type: "string",
description: 'Layout knobs: "proseCoverageFloor=0.05" (01; default 0.15).',
},
},
async run({ args }) {
const asJson = args.json === true;
@@ -168,6 +172,7 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
snapshots: args.snapshots === true,
captionZone: parseCaptionZone(args["caption-zone"]),
frameCheck: parseFrameCheck(args["frame-check"]),
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined,
};
}
@@ -176,6 +181,8 @@ const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]
const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
const LAYOUT_FIELDS = new Set(["proseCoverageFloor"]);
// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol
// options survive the migration instead of being silently dropped by a
// boolean flag (bare --frame-check keeps today's defaults).
@@ -206,8 +213,8 @@ function parseFrameCheckFields(value: string): Map<string, string> {
function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const tol = Number.parseFloat(raw);
if (!Number.isFinite(tol) || tol < 0) throw frameCheckError();
const tol = parseNumberStrict(raw);
if (tol === null || tol < 0) throw frameCheckError();
return tol;
}
@@ -217,6 +224,52 @@ function frameCheckError(): Error {
);
}
/** Parse `--layout "proseCoverageFloor=0.05"` (semicolon-separated key=value, like caption-zone). */
export function parseLayout(value: unknown): LayoutOptions | undefined {
if (value === undefined || value === null || value === false) return undefined;
if (value === true || value === "") throw layoutError();
if (typeof value !== "string") throw layoutError();
const fields = parseLayoutFields(value);
const proseCoverageFloor = parseProseCoverageFloor(fields.get("proseCoverageFloor"));
if (proseCoverageFloor === undefined) throw layoutError();
return { proseCoverageFloor };
}
function parseLayoutFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const trimmed = part.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) throw layoutError();
const key = trimmed.slice(0, separator).trim();
const entry = trimmed.slice(separator + 1).trim();
if (!LAYOUT_FIELDS.has(key) || fields.has(key)) throw layoutError();
fields.set(key, entry);
}
return fields;
}
function parseProseCoverageFloor(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const floor = parseNumberStrict(raw);
if (floor === null || floor < 0 || floor > 1) throw layoutError();
return floor;
}
function layoutError(): Error {
return new Error(
'Invalid --layout: use "proseCoverageFloor=0.05" with a fraction from 0 to 1 (inclusive)',
);
}
/** Reject trailing garbage that Number.parseFloat would silently accept (`4px`, `0.05abc`). */
function parseNumberStrict(raw: string): number | null {
if (raw === "") return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
if (value === undefined || value === null) return undefined;
const fields = parseCaptionFields(captionZoneString(value));
@@ -279,8 +332,8 @@ function requiredCaptionFraction(fields: Map<string, string>, key: string): numb
function captionFraction(value: string | undefined): number | null {
if (value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null;
const parsed = parseNumberStrict(value);
return parsed !== null && parsed >= 0 && parsed <= 1 ? parsed : null;
}
function captionSeverity(value: string | undefined): "error" | "warning" | undefined {
@@ -28,4 +28,20 @@ describe("Send-to guide fidelity contract", () => {
expect(GUIDE).not.toContain("lossy by nature");
expect(GUIDE).not.toContain("content match the brief exactly");
});
// Pricing is LLM-facing contract too: the guide once labeled Enhance "the paid step", which
// teaches Claude the wrong billing boundary. The shipped model (heygen-server
// magic_edit/logic/usage_limits.py) is: import + enhance turns free; only Render is billed —
// FREE accounts get 3 renders/month, paid plans 20 credits/rendered-minute. Pin the concept,
// not an exact sentence, and block the retired Enhance-as-paid wording from returning.
it("identifies Enhance as free and Render as the paid/billed step, with the tiered contract", () => {
expect(GUIDE).toContain("Enhance turns are free");
expect(GUIDE).toContain("Render is the paid step");
expect(GUIDE).toContain("3 renders per month");
expect(GUIDE).toContain("20 credits per rendered minute");
});
it("does not restore the retired 'Enhance ... paid step' wording", () => {
expect(GUIDE).not.toContain("HeyGen media. This is the paid step");
});
});
@@ -73,3 +73,56 @@ describe("media-use TTS documentation", () => {
expect(captions).toContain("heygen-tts.mjs");
});
});
describe("media treatment routing documentation", () => {
it("routes vague composition-media feedback to the canonical workflow", () => {
const router = read("skills", "hyperframes", "SKILL.md");
const mediaUse = read("skills", "media-use", "SKILL.md");
const treatments = read("skills", "media-use", "references", "media-treatments.md");
expect(router).toContain("dark/flat/boring footage");
expect(router).toContain("`/media-use`");
expect(mediaUse).toContain("references/media-treatments.md");
expect(mediaUse).toContain("`hyperframes media-treatment`");
expect(treatments).toContain("Persist pixel settings with `hyperframes media-treatment`");
expect(treatments).toContain("apply to the entire selected real `<img>` or");
expect(treatments).toContain("external segmentation/tracking tool");
});
it("keeps discovery progressive and verification visual", () => {
const treatments = read("skills", "media-use", "references", "media-treatments.md");
const recipes = read("skills", "media-use", "references", "media-treatment-recipes.md");
expect(treatments).toContain("hyperframes media-treatment --capabilities --json");
expect(treatments).toContain("--capability <id>");
expect(treatments).toContain("Recipes are optional macros");
expect(recipes).toContain("optional tested seeds");
expect(treatments).toContain("hyperframes add <name> --dir <project>");
expect(treatments).toContain("snapshots/treatment-before/contact-sheet.jpg");
expect(treatments).toMatch(/Do not report visual\s+quality from command success alone/);
});
it("indexes calibrated treatment recipes without making them mandatory", () => {
const treatments = read("skills", "media-use", "references", "media-treatments.md");
const recipes = read("skills", "media-use", "references", "media-treatment-recipes.md");
for (const heading of [
"Monochrome Screen Print",
"Engraved Illustration",
"Crosshatched Sketch",
"CRT Display",
]) {
expect(treatments).toContain(`\`${heading}\``);
expect(recipes).toContain(`## ${heading}`);
}
});
it("places the media-treatment discovery gate in new project instructions", () => {
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
const template = read("packages", "cli", "src", "templates", "_shared", file);
expect(template).toContain("Changing how real footage or images look or reveal?");
expect(template).toContain("Load `/media-use`");
expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays");
}
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { buildTelemetryJoinKeys } from "./feedback.js";
describe("buildTelemetryJoinKeys", () => {
it("emits fid + tid and omits renders when the ring is empty", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "feedback-uuid",
anonymousId: "install-uuid",
});
expect(keys).toBe("fid=feedback-uuid tid=install-uuid");
});
it("appends recent render ids newest-last with a ! marking failed renders", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "f",
anonymousId: "t",
recentRenders: [
{ id: "render-a", at: "2026-07-21T00:00:00Z", ok: true },
{ id: "render-b", at: "2026-07-21T01:00:00Z", ok: false },
],
});
expect(keys).toBe("fid=f tid=t renders=render-a,render-b!");
});
it("stays within the backend env cap for a full ring of uuid render ids", () => {
const uuid = "01234567-89ab-cdef-0123-456789abcdef";
const keys = buildTelemetryJoinKeys({
feedbackId: uuid,
anonymousId: uuid,
recentRenders: Array.from({ length: 5 }, (_, i) => ({
id: uuid,
at: "2026-07-21T00:00:00Z",
ok: i % 2 === 0,
})),
});
// submitFeedback caps env at 500 chars; the doctor summary consumes ~100.
expect(keys.length).toBeLessThan(400);
});
});
+43 -2
View File
@@ -1,4 +1,5 @@
import { failCommand } from "../utils/commandResult.js";
import { randomUUID } from "node:crypto";
import { resolve } from "node:path";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
@@ -7,6 +8,7 @@ import type { Example } from "./_examples.js";
import { trackRenderFeedback } from "../telemetry/events.js";
import { shouldTrack, flush } from "../telemetry/client.js";
import { getDoctorSummary } from "../telemetry/feedback.js";
import { readConfig, type RecentRenderRecord } from "../telemetry/config.js";
import { publishProjectArchive } from "../utils/publishProject.js";
import { submitFeedback } from "../utils/submitFeedback.js";
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
@@ -28,6 +30,27 @@ function normalizeComment(raw?: string): string | undefined {
return raw || undefined;
}
/**
* Compact PostHog join keys appended to the environment string that rides
* along with the forwarded report (and therefore lands verbatim in the wild
* feedback channel): `fid` = this submission's PostHog `cli_render_feedback`
* `feedback_id`; `tid` = the install's telemetry distinct_id; `renders` =
* recent `render_job_id`s (newest last, `!` suffix = the render failed).
* Together they turn a wild report into an exact telemetry lookup instead of
* a hardware-fingerprint hunt.
*/
export function buildTelemetryJoinKeys(input: {
feedbackId: string;
anonymousId: string;
recentRenders?: RecentRenderRecord[];
}): string {
const parts = [`fid=${input.feedbackId}`, `tid=${input.anonymousId}`];
if (input.recentRenders?.length) {
parts.push(`renders=${input.recentRenders.map((r) => `${r.id}${r.ok ? "" : "!"}`).join(",")}`);
}
return parts.join(" ");
}
function printIssueConsent(dir: string): void {
console.log();
console.log(
@@ -170,6 +193,18 @@ export default defineCommand({
const comment = normalizeComment(args.comment);
const doctorSummary = await getDoctorSummary();
// Join keys tying this report to the install's PostHog rows — see
// buildTelemetryJoinKeys. Appended to the env string so they surface in
// the forwarded report; mirrored as structured props on the PostHog event.
const feedbackId = randomUUID();
const config = readConfig();
const joinKeys = buildTelemetryJoinKeys({
feedbackId,
anonymousId: config.anonymousId,
recentRenders: config.recentRenders,
});
const envWithJoinKeys = doctorSummary ? `${doctorSummary} ${joinKeys}` : joinKeys;
// Soft-warn (never blocks) when the comment for a non-clean report is
// missing the mandated reproduction-packet markers. Prints before the
// submission ack so the reporter sees the nudge while their run is fresh.
@@ -177,13 +212,19 @@ export default defineCommand({
// The standalone command runs separately from `render`, so it has no real
// elapsed time to report. Omit it rather than recording a fake duration.
trackRenderFeedback({ rating, comment, doctorSummary });
trackRenderFeedback({
rating,
comment,
doctorSummary,
feedbackId,
recentRenderIds: config.recentRenders?.map((r) => r.id),
});
await flush();
// Ack first so the user isn't kept waiting on the best-effort forward (which
// is bounded to a few seconds and never surfaces an error either way).
console.log(c.dim("Thanks for the feedback!"));
await submitFeedback({ rating, comment, cliVersion: VERSION, env: doctorSummary });
await submitFeedback({ rating, comment, cliVersion: VERSION, env: envWithJoinKeys });
if (args["file-issue"] === true) {
await fileGithubIssue({
+18 -1
View File
@@ -556,6 +556,7 @@ async function scaffoldProject(
durationSeconds?: number,
tailwind = false,
resolution?: CanvasResolution,
authoringSkill?: string,
): Promise<void> {
mkdirSync(destDir, { recursive: true });
@@ -588,10 +589,17 @@ async function scaffoldProject(
// Write hyperframes.json so `hyperframes add` knows which registry to use
// and where to drop block/component files. Overwritten only if absent.
// When the scaffolding workflow declared itself via --skill, stamp the owning
// skill here so every later render of this project is attributed to it.
if (!existsSync(resolve(destDir, "hyperframes.json"))) {
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
await import("../utils/projectConfig.js");
writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
const { normalizeSkillSlug } = await import("../telemetry/skill.js");
const skill = normalizeSkillSlug(authoringSkill);
writeProjectConfig(
destDir,
skill ? { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill } : DEFAULT_PROJECT_CONFIG,
);
}
writeDefaultPackageJson(destDir, name);
@@ -728,6 +736,13 @@ export default defineCommand({
description:
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
},
skill: {
type: "string",
description:
"Owning authoring workflow slug (e.g. product-launch-video). Stamped into " +
"hyperframes.json so every render of this project is attributed to it on " +
"anonymous telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
},
},
async run({ args }) {
if (args.template !== undefined) {
@@ -898,6 +913,7 @@ export default defineCommand({
videoDuration,
tailwind,
resolutionPreset,
args.skill,
);
} catch (err) {
console.error(
@@ -1112,6 +1128,7 @@ export default defineCommand({
videoDuration,
tailwind,
resolutionPreset,
args.skill,
);
if (!isBundled) {
spin.stop(c.success(`Downloaded ${templateId}`));
+333 -48
View File
@@ -935,7 +935,8 @@
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
// once a real share of it is covered — see `occludedTextIssue`.
const ATOMIC_LABEL_MAX_CHARS = 16;
const PROSE_COVERAGE_FLOOR = 0.15;
// Default prose floor — callers may lower via auditLayout({ proseCoverageFloor }).
const DEFAULT_PROSE_COVERAGE_FLOOR = 0.15;
function isAtomicLabel(text) {
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
@@ -995,12 +996,8 @@
return false;
}
// Catches the blind spot the overflow checks miss: text that fits its box
// perfectly but is covered by a later sibling/overlay. An atomic label
// (short, no whitespace) flags at any coverage; ordinary prose only flags
// once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
// cover on a paragraph is usually a styling artifact, not a reading defect.
function occludedTextIssue(element, time) {
// text_occluded: atomic labels flag at any hit; prose needs coveredFraction >= proseCoverageFloor (default 0.15).
function occludedTextIssue(element, time, proseCoverageFloor) {
if (hasAllowOcclusionFlag(element)) return null;
if (!hasVisibleTextInk(element)) return null;
const textRect = textRectFor(element, true);
@@ -1012,7 +1009,7 @@
textRects.length > 0 ? textRects : [textRect],
);
if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
if (!isAtomicLabel(text) && coveredFraction < proseCoverageFloor) return null;
return {
code: "text_occluded",
severity: "error",
@@ -1198,6 +1195,7 @@
return issues;
}
// Soft prior only — the counterfactual attach test (below) is what makes detachment a finding.
const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
@@ -1207,14 +1205,16 @@
return `${element.id || ""} ${className}`;
}
// Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
function pathScreenEndpoints(svg, path) {
if (
typeof path.getTotalLength !== "function" ||
typeof path.getPointAtLength !== "function" ||
typeof path.getScreenCTM !== "function" ||
typeof svg.createSVGPoint !== "function"
) {
function isConnectorPath(svg, path) {
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
return (
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
);
}
/** Raw `d`-space endpoints (no CTM) — the mapping authors use when they paste screen coords into `d`. */
function pathUserEndpoints(path) {
if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") {
return null;
}
let total;
@@ -1224,6 +1224,20 @@
return null;
}
if (!Number.isFinite(total) || total <= 0) return null;
const start = path.getPointAtLength(0);
const end = path.getPointAtLength(total);
return { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } };
}
// Screen endpoints via getScreenCTM (viewBox, preserveAspectRatio, group transforms).
function pathScreenEndpoints(svg, path, user) {
if (
!user ||
typeof path.getScreenCTM !== "function" ||
typeof svg.createSVGPoint !== "function"
) {
return null;
}
const matrix = path.getScreenCTM();
if (!matrix) return null;
const toScreen = (local) => {
@@ -1233,10 +1247,7 @@
const mapped = point.matrixTransform(matrix);
return { x: mapped.x, y: mapped.y };
};
return {
start: toScreen(path.getPointAtLength(0)),
end: toScreen(path.getPointAtLength(total)),
};
return { start: toScreen(user.start), end: toScreen(user.end) };
}
function distanceToRect(point, rect) {
@@ -1246,6 +1257,7 @@
}
// Solid, compact elements a connector could plausibly anchor to.
// Both tiers keep `element` so attachment identity is stable across containment vs near-miss.
function connectorAnchorRects(root, rootRect) {
const compact = [];
const painted = [];
@@ -1261,42 +1273,57 @@
if (area < 400) continue;
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
if (area <= rootArea * 0.15) compact.push(rect);
if (area <= rootArea * 0.15) compact.push({ rect, element });
}
return { compact, painted };
}
function isConnectorPath(svg, path) {
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
return (
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
);
}
// A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
// min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
// Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach.
function connectorDetachmentIssues(root, rootRect, time) {
const issues = [];
let anchors = null;
// Attach near-miss tolerance (screen px). Separate from the closed-glyph chord floor.
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
const MIN_CONNECTOR_CHORD_PX = 8;
for (const svg of Array.from(root.querySelectorAll("svg"))) {
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
if (!isConnectorPath(svg, path)) continue;
const endpoints = pathScreenEndpoints(svg, path);
if (!endpoints) continue;
const user = pathUserEndpoints(path);
const rendered = pathScreenEndpoints(svg, path, user);
if (!user || !rendered) continue;
// Closed/glyph paths collapse to one point — compare in screen px (not user units).
const renderedChord = Math.hypot(
rendered.end.x - rendered.start.x,
rendered.end.y - rendered.start.y,
);
if (renderedChord < MIN_CONNECTOR_CHORD_PX) continue;
if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
if (anchors.compact.length < 2) return issues;
const attached = (point) =>
anchors.painted.some(
(anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
if (attached(endpoints.start) || attached(endpoints.end)) continue;
// Stable DOM identity across painted (inside) and compact (near-miss) tiers.
const attachmentKey = (point) => {
for (const anchor of anchors.painted) {
if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) {
return anchor.element;
}
}
for (const anchor of anchors.compact) {
if (distanceToRect(point, anchor.rect) <= threshold) return anchor.element;
}
return null;
};
const attached = (point) => attachmentKey(point) !== null;
// Half-attached as drawn is allowed; only full render-miss proceeds.
if (attached(rendered.start) || attached(rendered.end)) continue;
// Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels.
const userStartKey = attachmentKey(user.start);
const userEndKey = attachmentKey(user.end);
if (!userStartKey || !userEndKey || userStartKey === userEndKey) continue;
const gap = Math.round(
Math.min(
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))),
),
);
issues.push({
@@ -1305,17 +1332,17 @@
time,
selector: selectorFor(path),
containerSelector: selectorFor(svg),
message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
message: `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`,
rect: toRect({
left: Math.min(endpoints.start.x, endpoints.end.x),
top: Math.min(endpoints.start.y, endpoints.end.y),
right: Math.max(endpoints.start.x, endpoints.end.x),
bottom: Math.max(endpoints.start.y, endpoints.end.y),
width: Math.abs(endpoints.end.x - endpoints.start.x),
height: Math.abs(endpoints.end.y - endpoints.start.y),
left: Math.min(rendered.start.x, rendered.end.x),
top: Math.min(rendered.start.y, rendered.end.y),
right: Math.max(rendered.start.x, rendered.end.x),
bottom: Math.max(rendered.start.y, rendered.end.y),
width: Math.abs(rendered.end.x - rendered.start.x),
height: Math.abs(rendered.end.y - rendered.start.y),
}),
fixHint:
"Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
"Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage.",
});
}
}
@@ -1390,6 +1417,10 @@
const time = options && typeof options.time === "number" ? options.time : 0;
const tolerance =
options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2;
const proseCoverageFloor =
options && typeof options.proseCoverageFloor === "number"
? Math.min(1, Math.max(0, options.proseCoverageFloor))
: DEFAULT_PROSE_COVERAGE_FLOOR;
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
@@ -1407,7 +1438,7 @@
const clipped = clippedTextIssue(element, time, tolerance);
if (clipped) issues.push(clipped);
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
const occluded = occludedTextIssue(element, time);
const occluded = occludedTextIssue(element, time, proseCoverageFloor);
if (occluded) issues.push(occluded);
const invisible = invisibleTextIssue(element, time);
if (invisible) issues.push(invisible);
@@ -1425,6 +1456,16 @@
return issues;
};
// Reruns only the overlap detector (same threshold, no new surface) on a fine grid for the dense motion re-sampling pass.
window.__hyperframesOverlapAudit = function auditOverlap(options) {
const time = options && typeof options.time === "number" ? options.time : 0;
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
return contentOverlapIssues(root, time);
};
// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
// fingerprint of every visible element's box + opacity, in DOM order. Node
// calls this once per seeked grid point and compares the strings across the
@@ -1480,4 +1521,248 @@
}
return parts.join("|");
};
// Rotation-pivot sampling (rotation_pivot_drift). Per sample, report every
// rotatable candidate's bbox center, size, and current rotation angle. Node
// accumulates these across the seek grid and, after the run, flags any
// element that spins (angle varies) while its bbox CENTER drifts — the
// signature of a wrong transformOrigin/svgOrigin (spokes swinging off-axis
// instead of spinning in place). Single frame can't tell spin from pivot
// drift, so this is a cross-sample finder, not a per-sample one.
function rotationAngleDeg(transform) {
if (!transform || transform === "none") return null;
const match = transform.match(/matrix(3d)?\(([^)]+)\)/);
if (!match) return null;
const values = match[2].split(",").map((part) => Number.parseFloat(part));
// matrix(a,b,c,d,e,f) → a=values[0], b=values[1]. matrix3d shares the same
// leading two entries for the in-plane 2D rotation component.
const a = values[0];
const b = values[1];
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
return (Math.atan2(b, a) * 180) / Math.PI;
}
window.__hyperframesRotationSample = function collectRotationSample() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const samples = [];
// Cap the candidate set so a pathological composition can't blow up the
// per-sample payload; transformed elements above a minimum area only.
const CANDIDATE_CAP = 200;
for (const element of Array.from(root.querySelectorAll("*"))) {
if (samples.length >= CANDIDATE_CAP) break;
// Intended orbits/satellites opt out — their bbox center is SUPPOSED to
// travel, so a drift finding there is a false positive.
if (element.closest("[data-layout-allow-orbit]")) continue;
if (!isVisibleElement(element, 0.05)) continue;
const angle = rotationAngleDeg(getComputedStyle(element).transform);
if (angle === null) continue; // identity / untransformed — not a candidate
const box = element.getBoundingClientRect();
if (box.width * box.height <= 400) continue;
samples.push({
selector: selectorFor(element),
cx: round(box.left + box.width / 2),
cy: round(box.top + box.height / 2),
w: round(box.width),
h: round(box.height),
angle: round(angle),
});
}
return samples;
};
// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
// center orbits either way), so this records two MATERIAL points on each
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
// rendered transform is honored regardless of svgOrigin/transform-origin — and
// the dial's static hub (the point shared by the most non-rotating circles).
// The pipeline fits a rotation to the material-point trajectories to recover
// the real center-of-rotation and flags it when it drifts off that hub.
function ctmRotationDeg(ctm) {
if (!ctm) return null;
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
}
function ctmScale(ctm) {
return Math.hypot(ctm.a, ctm.b);
}
function mapPoint(svg, ctm, x, y) {
const point = svg.createSVGPoint();
point.x = x;
point.y = y;
const mapped = point.matrixTransform(ctm);
return { x: mapped.x, y: mapped.y };
}
// Walks up to (and including) the composition root, NOT just the owner <svg>:
// an element spun by a div ancestor above its svg must not be mistaken for a
// static hub anchor (else a lone rotating arc becomes its own dial center).
function hasRotatedAncestor(element, root) {
let node = element;
while (node) {
const angle = rotationAngleDeg(getComputedStyle(node).transform);
if (angle !== null && Math.abs(angle) > 1) return true;
if (node === root) break;
node = node.parentElement;
}
return false;
}
// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
// string (no import across the puppeteer boundary), so the Kåsa math is
// intentionally duplicated per-language. Any change must land in both copies.
function fitCirclePoints(points) {
const count = points.length;
if (count < 3) return null;
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
let suu = 0,
svv = 0,
suv = 0,
suuu = 0,
svvv = 0,
suvv = 0,
svuu = 0;
for (const point of points) {
const u = point.x - meanX;
const v = point.y - meanY;
suu += u * u;
svv += v * v;
suv += u * v;
suuu += u * u * u;
svvv += v * v * v;
suvv += u * v * v;
svuu += v * u * u;
}
const det = suu * svv - suv * suv;
if (Math.abs(det) < 1e-6) return null;
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
const cx = uc + meanX;
const cy = vc + meanY;
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
let squaredError = 0;
for (const point of points) {
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
squaredError += delta * delta;
}
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
}
// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
// the largest static, near-circular path and recover its arc center.
function arcHubForSvg(svg, root) {
let best = null;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (hasRotatedAncestor(path, root)) continue;
if (typeof path.getTotalLength !== "function") continue;
const total = path.getTotalLength();
if (total < 200) continue;
const ctm = path.getScreenCTM();
if (!ctm) continue;
const points = [];
for (let i = 0; i <= 16; i++) {
const local = path.getPointAtLength((total * i) / 16);
points.push(mapPoint(svg, ctm, local.x, local.y));
}
const fit = fitCirclePoints(points);
if (!fit || fit.radius < 40) continue;
if (fit.residual > 0.05 * fit.radius) continue;
if (!best || fit.radius > best.radius) best = fit;
}
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
}
function dialHubForSvg(svg, root) {
const centers = [];
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
if (hasRotatedAncestor(circle, root)) continue;
const ctm = circle.getScreenCTM();
if (!ctm) continue;
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
const center = mapPoint(svg, ctm, cx, cy);
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
centers.push({ x: center.x, y: center.y, radius });
}
let best = null;
for (const anchor of centers) {
const cluster = centers.filter(
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
);
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
}
if (best && best.cluster.length >= 2) {
const count = best.cluster.length;
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
return { hx, hy, hr, count };
}
return arcHubForSvg(svg, root);
}
window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const samples = [];
const hubCache = new Map();
const CANDIDATE_CAP = 60;
for (const element of Array.from(
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
)) {
if (samples.length >= CANDIDATE_CAP) break;
const svg = element.ownerSVGElement;
if (!svg || typeof element.getBBox !== "function") continue;
if (element.closest("[data-layout-allow-orbit]")) continue;
if (!isVisibleElement(element, 0.05)) continue;
const ctm = element.getScreenCTM();
const angle = ctmRotationDeg(ctm);
if (ctm === null || angle === null) continue;
let bbox;
try {
bbox = element.getBBox();
} catch {
continue;
}
const long = Math.max(bbox.width, bbox.height);
const short = Math.min(bbox.width, bbox.height);
if (short <= 0 || long / short < 3 || long < 40) continue;
const vertical = bbox.height >= bbox.width;
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
const a = vertical
? mapPoint(svg, ctm, midMajor, bbox.y)
: mapPoint(svg, ctm, bbox.x, midMajor);
const b = vertical
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
let hub = hubCache.get(svg);
if (hub === undefined) {
hub = dialHubForSvg(svg, root);
hubCache.set(svg, hub);
}
samples.push({
selector: selectorFor(element),
ax: round(a.x),
ay: round(a.y),
bx: round(b.x),
by: round(b.y),
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
angle: round(angle),
hx: hub ? round(hub.hx) : null,
hy: hub ? round(hub.hy) : null,
hr: hub ? round(hub.hr) : null,
hubCount: hub ? hub.count : 0,
});
}
return samples;
};
})();
@@ -847,8 +847,8 @@ describe("layout-audit.browser coordinate-frame findings", () => {
// The marker tip path is skipped outright; only the detached line reports.
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" });
expect(issues[0]?.message).toContain("drawn into an SVG with a different origin");
expect(issues[0]?.fixHint).toContain("Subtract the SVG's own rect");
expect(issues[0]?.message).toContain("user-space coordinates would attach");
expect(issues[0]?.fixHint).toContain("invert getScreenCTM");
});
it("skips svgs and paths without connector intent", () => {
@@ -877,6 +877,254 @@ describe("layout-audit.browser coordinate-frame findings", () => {
// "knowledge-overflow" contains conn-family substrings only across word boundaries — no match.
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
// Counterfactual: decorative paths miss anchors both as rendered and as user-as-screen → not the frame bug.
it("skips decorative arrow/flow paths whose user-space coords would not attach either", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="arrow-l" class="arrow">
<path id="arrow-glyph" d="M70 20 L10 20" marker-end="url(#tip)" />
</svg>
<svg id="decor"><path id="flow-line" class="flow-line" d="M-100 200 L2020 880" /></svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"arrow-l": rect({ left: 100, top: 500, width: 80, height: 40 }),
decor: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
installConnectorGeometry({ e: 100, f: 500 });
// Full-bleed decor SVG uses identity translate so user-as-screen == rendered (still off-canvas).
for (const path of Array.from(document.querySelectorAll("#decor path"))) {
Object.defineProperty(path, "getScreenCTM", {
value: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
});
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
Object.defineProperty(path, "getPointAtLength", {
value: (length: number) => (length === 0 ? { x: -100, y: 200 } : { x: 2020, y: 880 }),
});
}
const decorSvg = document.getElementById("decor");
if (decorSvg) {
Object.defineProperty(decorSvg, "createSVGPoint", {
value: () => ({
x: 0,
y: 0,
matrixTransform(m: { a: number; b: number; c: number; d: number; e: number; f: number }) {
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
},
}),
});
}
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
// Same DOM node via painted-inside + compact-near-miss must share one identity (not p0 vs c0).
it("skips same-anchor cross-tier arrows that only graze one node", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="arrow-svg" class="arrow">
<path id="cross-tier" d="M 980 580 L 1080 580" marker-end="url(#tip)" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"arrow-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
// Raw start inside #n1; raw end just outside #n1 but within attach tolerance — one element.
installConnectorGeometry({ e: 80, f: 227 });
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
// One raw endpoint on a node is not the paste-into-`d` bug (decorative arrow / partial aim).
it("skips one-ended decorative arrows when only one user endpoint attaches", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="arrow-svg" class="arrow">
<path id="one-ended" d="M 980 580 L 200 100" marker-end="url(#tip)" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"arrow-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
// CTM offset moves both rendered ends off anchors; raw start sits in #n1, raw end in empty space.
installConnectorGeometry({ e: 80, f: 227 });
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
// Scaled viewBox: user chord can be <32 while screen chord is hundreds of px — must not skip.
it("flags foreign-frame connectors when user-space chord is short but screen chord is long", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="scaled-svg" viewBox="0 0 192 108">
<path id="short-user" class="connector" d="M 100 58 L 140 58" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
// Non-overlapping anchors so both user endpoints hit distinct keys.
n1: rect({ left: 70, top: 40, width: 50, height: 40 }),
n2: rect({ left: 125, top: 40, width: 50, height: 40 }),
"scaled-svg": rect({ left: 0, top: 0, width: 1920, height: 1080 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
// 10× viewBox scale: user chord 30 (< old 32px gate) → screen chord 300.
const path = document.getElementById("short-user");
const svg = document.getElementById("scaled-svg");
const matrix = { a: 10, b: 0, c: 0, d: 10, e: 0, f: 0 };
const prop = { configurable: true, writable: true };
if (path) {
Object.defineProperty(path, "getTotalLength", { ...prop, value: () => 30 });
Object.defineProperty(path, "getPointAtLength", {
...prop,
value: (length: number) => (length === 0 ? { x: 100, y: 58 } : { x: 140, y: 58 }),
});
Object.defineProperty(path, "getScreenCTM", { ...prop, value: () => matrix });
}
if (svg) {
Object.defineProperty(svg, "createSVGPoint", {
...prop,
value: () => ({
x: 0,
y: 0,
matrixTransform(m: typeof matrix) {
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
},
}),
});
}
installAuditScript();
const issues = runAudit().filter((issue) => issue.code === "connector_detached");
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ selector: "#short-user" });
});
// Closed glyph: rendered chord ~0 — not a two-ended frame bug even if the point sits on a node.
it("skips closed filled glyphs whose user-space endpoints collapse", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="arrow-svg" class="arrow">
<path id="main-arrow" d="M10 10 L90 10 L50 90 Z" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"arrow-svg": rect({ left: 0, top: 0, width: 1920, height: 1080 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
// Closed path: start≈end in user space (and after CTM).
for (const path of Array.from(document.querySelectorAll("#main-arrow"))) {
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
Object.defineProperty(path, "getPointAtLength", {
value: () => ({ x: 980, y: 580 }),
});
Object.defineProperty(path, "getScreenCTM", {
value: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
});
}
const svg = document.getElementById("arrow-svg");
if (svg) {
Object.defineProperty(svg, "createSVGPoint", {
value: () => ({
x: 0,
y: 0,
matrixTransform(m: { a: number; b: number; c: number; d: number; e: number; f: number }) {
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
},
}),
});
}
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
// Correct inverse-CTM authoring: rendered attaches → never flag, even with an offset SVG.
it("skips connectors whose rendered endpoints already attach", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="connector-svg">
<path id="anchored-only" class="connector-line" d="M 900 353 L 300 53" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"connector-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
installConnectorGeometry({ e: 80, f: 227 });
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
});
describe("layout-audit.browser content overlap", () => {
@@ -1538,6 +1786,26 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("flags ~0.07 prose when proseCoverageFloor is lowered to 0.05", () => {
const issues = auditCoverageScene({
text: "This paragraph is long enough to read as ordinary prose, not a label.",
hitCount: 2,
proseCoverageFloor: 0.05,
});
const occluded = issues.find((issue) => issue.code === "text_occluded");
expect(occluded).toBeDefined();
expect(occluded?.coveredFraction).toBe(0.07);
});
it("still flags an atomic label at ~0.07 when proseCoverageFloor is 0.05", () => {
const issues = auditCoverageScene({
text: "SUBSCRIBE",
hitCount: 2,
proseCoverageFloor: 0.05,
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
});
it("flags prose once coverage clears the 0.15 floor", () => {
// 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor.
const issues = auditCoverageScene({
@@ -1794,6 +2062,7 @@ function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number
function auditCoverageScene(options: {
text: string;
hitCount: number;
proseCoverageFloor?: number;
}): ReturnType<typeof runAudit> {
const textRect = { left: 200, top: 500, width: 600, height: 80 };
document.body.innerHTML = `
@@ -1817,7 +2086,11 @@ function auditCoverageScene(options: {
return document.getElementById(isHit ? "overlay" : "headline");
};
installAuditScript();
return runAudit();
return runAudit(
options.proseCoverageFloor === undefined
? undefined
: { proseCoverageFloor: options.proseCoverageFloor },
);
}
function auditOcclusionScene(options: {
@@ -1954,10 +2227,12 @@ interface CtmTranslate {
}
// happy-dom has no SVG geometry APIs; endpoints come from the path's `d`, the CTM is a pure translate.
function installConnectorGeometry(translate: CtmTranslate): void {
function installConnectorGeometry(translate: CtmTranslate, root: ParentNode = document): void {
const matrix = { a: 1, b: 0, c: 0, d: 1, e: translate.e, f: translate.f };
for (const svg of Array.from(document.querySelectorAll("svg"))) {
const prop = { configurable: true, writable: true };
for (const svg of Array.from(root.querySelectorAll("svg"))) {
Object.defineProperty(svg, "createSVGPoint", {
...prop,
value: () => ({
x: 0,
y: 0,
@@ -1970,11 +2245,12 @@ function installConnectorGeometry(translate: CtmTranslate): void {
const numbers = (path.getAttribute("d")?.match(/-?\d*\.?\d+/g) || []).map(Number);
const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 };
const end = { x: numbers[numbers.length - 2] ?? 0, y: numbers[numbers.length - 1] ?? 0 };
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
Object.defineProperty(path, "getTotalLength", { ...prop, value: () => 100 });
Object.defineProperty(path, "getPointAtLength", {
...prop,
value: (length: number) => (length === 0 ? start : end),
});
Object.defineProperty(path, "getScreenCTM", { value: () => matrix });
Object.defineProperty(path, "getScreenCTM", { ...prop, value: () => matrix });
}
}
}
@@ -2067,13 +2343,17 @@ interface AuditIssue {
coveredFraction?: number;
}
function runAudit(): AuditIssue[] {
function runAudit(options?: { proseCoverageFloor?: number }): AuditIssue[] {
const audit = (
window as unknown as {
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
__hyperframesLayoutAudit: (options: {
time: number;
tolerance: number;
proseCoverageFloor?: number;
}) => AuditIssue[];
}
).__hyperframesLayoutAudit;
return audit({ time: 1, tolerance: 2 });
return audit({ time: 1, tolerance: 2, ...options });
}
function selectedRangeElement(selected: Node | null): Element | null {
@@ -0,0 +1,20 @@
import {
analyzeMediaGrade,
type MediaTreatmentAnalysis,
} from "@hyperframes/core/media-grade-analyzer";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
interface CliMediaTreatmentAnalysis extends Omit<MediaTreatmentAnalysis, "adjust"> {
suggestedPatch: { adjust: MediaTreatmentAnalysis["adjust"] };
}
export function analyzeMediaTreatment(mediaPath: string): CliMediaTreatmentAnalysis {
const ffmpegPath = findFFmpeg();
const ffprobePath = findFFprobe();
if (!ffmpegPath || !ffprobePath) {
throw new Error(`FFmpeg and ffprobe are required. ${getFFmpegInstallHint()}`);
}
const analysis = analyzeMediaGrade(mediaPath, { ffmpegPath, ffprobePath });
const { adjust, ...evidence } = analysis;
return { ...evidence, suggestedPatch: { adjust } };
}
@@ -0,0 +1,421 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { runCommand } from "citty";
import { describe, expect, it, vi } from "vitest";
import {
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
getHfColorGradingCapabilities,
} from "@hyperframes/core";
import {
applyMediaTreatmentToHtml,
getMediaTreatmentCapabilityDetail,
getMediaTreatmentCapabilityOverview,
mediaTreatmentCommand,
resolveMediaTreatmentSource,
} from "./media-treatment.js";
import { CliRuntimeError } from "../utils/commandResult.js";
const VIDEO = `<!doctype html><html><body><video id="hero" src="hero.mp4"></video></body></html>`;
describe("applyMediaTreatmentToHtml", () => {
it("provides a concise first-hop overview of the complete treatment surface", () => {
const overview = getMediaTreatmentCapabilityOverview();
expect(overview.families.find(({ id }) => id === "correction")).not.toHaveProperty("items");
expect(overview.families.find(({ id }) => id === "grading")).toMatchObject({
label: "Color Grading",
});
expect(overview.families.find(({ id }) => id === "art")).not.toHaveProperty("items");
expect(overview.families.find(({ id }) => id === "overlays")).toMatchObject({
owner: "registry",
});
expect(overview.families.some(({ id }) => id === "looks" || id === "treatments")).toBe(false);
const discoveredEffects = ["essentials", "retro-glitch", "print", "art"].flatMap((family) => {
const detail = getMediaTreatmentCapabilityDetail(family);
return (
typeof detail === "object" &&
detail !== null &&
"effects" in detail &&
Array.isArray(detail.effects)
? detail.effects
: []
).map((effect) =>
typeof effect === "object" && effect && "id" in effect ? effect.id : null,
);
});
expect(discoveredEffects.sort()).toEqual([...HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS].sort());
expect(JSON.stringify(overview).length).toBeLessThan(3_000);
});
it("returns focused controls and apply data for one capability", () => {
expect(getMediaTreatmentCapabilityDetail("kuwahara")).toMatchObject({
id: "kuwahara",
family: "art",
renderLane: "multipass",
apply: { effects: { kuwahara: 1 } },
animation: {
property: expect.objectContaining({ path: "effects.kuwahara" }),
initial: expect.stringContaining("--hf-color-grading-kuwahara"),
tween: expect.stringContaining("timeline.to"),
},
});
expect(getMediaTreatmentCapabilityDetail("retro-glitch")).toMatchObject({
id: "retro-glitch",
effects: expect.arrayContaining([expect.objectContaining({ id: "chromaBleed" })]),
});
expect(getMediaTreatmentCapabilityDetail("deep-sea")).toMatchObject({
id: "deep-sea",
apply: { palette: expect.arrayContaining(["#0a1628"]) },
});
expect(getMediaTreatmentCapabilityDetail("exposure")).toMatchObject({
id: "exposure",
family: "correction",
animation: {
property: expect.objectContaining({ path: "adjust.exposure" }),
},
});
expect(getMediaTreatmentCapabilityDetail("vignette")).toMatchObject({
id: "vignette",
family: "finishing",
control: expect.objectContaining({ key: "vignette" }),
});
expect(getMediaTreatmentCapabilityDetail("wheels")).toMatchObject({
contract: expect.objectContaining({ zones: ["shadows", "midtones", "highlights"] }),
});
expect(getMediaTreatmentCapabilityDetail("curves")).toMatchObject({
contract: expect.objectContaining({ channels: ["master", "red", "green", "blue"] }),
});
const hueCurves = getMediaTreatmentCapabilityDetail("hue-curves");
const serializedHueCurves = JSON.stringify(hueCurves);
expect(serializedHueCurves).toContain('"maxPoints":16');
expect(serializedHueCurves).toContain('"key":"hueVsHue"');
expect(serializedHueCurves).toContain('"key":"hueVsSaturation"');
expect(serializedHueCurves).toContain('"key":"hueVsLuma"');
expect(getMediaTreatmentCapabilityDetail("secondary")).toMatchObject({
contract: expect.objectContaining({
max: 4,
saturation: expect.objectContaining({ relation: "min < max" }),
luma: expect.objectContaining({ relation: "min < max" }),
}),
});
expect(getMediaTreatmentCapabilityDetail("grading")).toMatchObject({
order: [
"adjust",
"wheels",
"curves",
"hueCurves",
"secondaries",
"lut",
"details",
"effects",
],
});
expect(getMediaTreatmentCapabilityDetail("scopes")).toMatchObject({
command: expect.stringContaining("--analyze"),
});
});
it("rejects unknown capability lookups", () => {
expect(() => getMediaTreatmentCapabilityDetail("make-it-cinematic")).toThrow(
/Unknown media-treatment capability/,
);
expect(() => getMediaTreatmentCapabilityDetail("__proto__")).toThrow(
/Unknown media-treatment capability/,
);
});
it("exposes enough canonical metadata to assemble a custom treatment", () => {
const capabilities = getHfColorGradingCapabilities();
expect(capabilities.targetTags).toEqual(["img", "video"]);
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.apply).toMatchObject({
kuwahara: 1,
kuwaharaRadius: 1 / 7,
});
expect(capabilities.animatable.find(({ path }) => path === "effects.blur")?.name).toBe(
"--hf-color-grading-blur",
);
});
it("normalizes and persists a grading payload on real media", () => {
const result = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: { preset: "warm-daylight", intensity: 0.8 },
});
expect(result.changed).toBe(true);
expect(result.tag).toBe("video");
expect(result.value).toContain('"preset":"warm-daylight"');
expect(result.value).toContain('"intensity":0.8');
expect(result.html).toContain("data-color-grading=");
});
it("normalizes and persists advanced grading on real media", () => {
const result = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: {
wheels: { shadows: { hue: 205, amount: 0.08, level: 0.02 } },
curves: {
master: [
[0, 0],
[0.5, 0.55],
[1, 1],
],
},
hueCurves: {
hueVsSaturation: [
[180, 0],
[210, 0.15],
[240, 0],
],
},
secondaries: [
{
key: {
hue: { center: 215, range: 25, softness: 10 },
saturation: { min: 0.2, max: 1, softness: 0.08 },
luma: { min: 0.1, max: 0.9, softness: 0.08 },
},
correction: { saturation: 0.15, luma: 0.03 },
},
],
},
});
expect(result.changed).toBe(true);
expect(result.value).toContain('"wheels"');
expect(result.value).toContain('"curves"');
expect(result.value).toContain('"hueCurves"');
expect(result.value).toContain('"secondaries"');
});
it("persists a disabled secondary without treating it as an active grade", () => {
const result = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: {
secondaries: [
{
enabled: false,
key: { hue: { center: 215, range: 25 } },
correction: { saturation: 0.15 },
},
],
},
});
expect(result.changed).toBe(true);
expect(result.value).toContain('"enabled":false');
expect(result.value).toContain('"secondaries"');
});
it("resolves nested composition media through the shared project-root contract", () => {
const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-assets-"));
const escapedAsset = join(project, "..", `${basename(project)}-escape.mp4`);
mkdirSync(join(project, "capture"), { recursive: true });
mkdirSync(join(project, "assets"), { recursive: true });
writeFileSync(join(project, "capture", "talking-head.mp4"), "");
writeFileSync(join(project, "assets", "photo.webp"), "");
writeFileSync(join(project, "assets", "My Clip.mp4"), "");
writeFileSync(escapedAsset, "");
try {
expect(
resolveMediaTreatmentSource(
project,
"compositions/scene.html",
"../capture/talking-head.mp4?v=1#frame",
),
).toBe(join(project, "capture/talking-head.mp4"));
expect(
resolveMediaTreatmentSource(project, "compositions/scene.html", "assets/photo.webp"),
).toBe(join(project, "assets/photo.webp"));
expect(
resolveMediaTreatmentSource(
project,
"compositions/scene.html",
"/assets/My%20Clip.mp4?v=1",
),
).toBe(join(project, "assets/My Clip.mp4"));
expect(() =>
resolveMediaTreatmentSource(
project,
"compositions/scene.html",
"https://example.com/a.mp4",
),
).toThrow(/local project asset/);
expect(() => resolveMediaTreatmentSource(project, "compositions/scene.html", "#")).toThrow(
/local project asset/,
);
expect(() =>
resolveMediaTreatmentSource(project, "compositions/scene.html", "missing.mp4"),
).toThrow(/Media file not found/);
expect(() =>
resolveMediaTreatmentSource(
project,
"compositions/scene.html",
`../../${basename(escapedAsset)}`,
),
).toThrow(/Media file not found/);
} finally {
rmSync(project, { recursive: true, force: true });
rmSync(escapedAsset, { force: true });
}
});
it("merges a validated patch and reports the stored before and after payloads", () => {
const initial = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: {
adjust: { exposure: 0.1 },
details: { grain: 0.2 },
},
});
const patched = applyMediaTreatmentToHtml(initial.html, {
selector: "#hero",
grading: { adjust: { shadows: 0.08 } },
});
expect(patched.before).toMatchObject({
adjust: { exposure: 0.1 },
details: { grain: 0.2 },
});
expect(patched.after).toMatchObject({
adjust: { exposure: 0.1, shadows: 0.08 },
details: { grain: 0.2 },
});
const repeated = applyMediaTreatmentToHtml(patched.html, {
selector: "#hero",
grading: { adjust: { shadows: 0.08 } },
});
expect(repeated.changed).toBe(false);
expect(repeated.html).toBe(patched.html);
expect(repeated.after).toEqual(repeated.before);
});
it("preserves unresolved variable references for runtime resolution", () => {
const wholeGrade = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: "$interviewGrade",
});
expect(wholeGrade.value).toBe("$interviewGrade");
const nested = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: { adjust: { exposure: "$interviewExposure" } },
});
expect(JSON.parse(nested.value ?? "{}")).toMatchObject({
adjust: { exposure: "$interviewExposure" },
});
const storedVariable = VIDEO.replace(" src=", ` data-color-grading="$interviewGrade" src=`);
expect(() =>
applyMediaTreatmentToHtml(storedVariable, {
selector: "#hero",
grading: { adjust: { exposure: 0.1 } },
}),
).toThrow(/Cannot merge.*unresolved whole-grade variable/);
});
it("requires an unambiguous media target", () => {
const source = `<img class="media" src="a.png"><img class="media" src="b.png">`;
expect(() =>
applyMediaTreatmentToHtml(source, { selector: ".media", grading: { preset: "neutral" } }),
).toThrow(/matched 2 elements/);
const result = applyMediaTreatmentToHtml(source, {
selector: ".media",
selectorIndex: 1,
grading: { preset: "warm-daylight" },
});
expect((result.html.match(/data-color-grading/g) ?? []).length).toBe(1);
});
it("persists grading inside composition templates", () => {
const source = `<template><video id="hero" src="hero.mp4"></video></template>`;
const result = applyMediaTreatmentToHtml(source, {
selector: "#hero",
grading: { preset: "warm-daylight" },
});
expect(result.changed).toBe(true);
expect(result.html).toContain("data-color-grading=");
});
it("rejects non-media elements", () => {
expect(() =>
applyMediaTreatmentToHtml(`<div id="hero"></div>`, {
selector: "#hero",
grading: { preset: "warm-daylight" },
}),
).toThrow(/requires an <img> or <video>/);
});
it("rejects unknown keys instead of silently dropping agent mistakes", () => {
expect(() =>
applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: { adjustments: { exposure: -0.45 }, effects: { dither: 1 } },
}),
).toThrow(/grading.*adjustments/i);
expect(() =>
applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
grading: { effects: { dithering: 1 } },
}),
).toThrow(/effects.*dithering/i);
});
it("requires --apply for --grading while keeping --clear explicit", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-"));
const file = join(project, "index.html");
const grading = '{"adjust":{"exposure":0.1}}';
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
writeFileSync(file, VIDEO);
try {
await expect(
runCommand(mediaTreatmentCommand, {
rawArgs: ["--project", project, "--selector", "#hero", "--grading", grading],
}),
).rejects.toThrow(CliRuntimeError);
expect(error).toHaveBeenLastCalledWith(expect.stringContaining("--grading requires --apply"));
expect(readFileSync(file, "utf8")).toBe(VIDEO);
await runCommand(mediaTreatmentCommand, {
rawArgs: ["--project", project, "--selector", "#hero", "--grading", grading, "--apply"],
});
expect(readFileSync(file, "utf8")).toContain("data-color-grading");
await runCommand(mediaTreatmentCommand, {
rawArgs: ["--project", project, "--selector", "#hero", "--clear"],
});
expect(readFileSync(file, "utf8")).not.toContain("data-color-grading");
} finally {
log.mockRestore();
error.mockRestore();
rmSync(project, { recursive: true, force: true });
}
});
it("clears both explicit and normalized no-op grading", () => {
const graded = VIDEO.replace(" src=", ` data-color-grading='{"preset":"warm-daylight"}' src=`);
expect(
applyMediaTreatmentToHtml(graded, { selector: "#hero", clear: true }).html,
).not.toContain("data-color-grading");
expect(
applyMediaTreatmentToHtml(graded, { selector: "#hero", grading: { preset: "neutral" } }).html,
).not.toContain("data-color-grading");
});
it("does not report or serialize a no-op clear because unrelated HTML formatting differs", () => {
const source = `<!doctype html><html><head><meta charset="utf-8" /></head><body><video id="hero" src="hero.mp4"></video></body></html>`;
const result = applyMediaTreatmentToHtml(source, { selector: "#hero", clear: true });
expect(result.changed).toBe(false);
expect(result.html).toBe(source);
});
});
@@ -0,0 +1,751 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import {
HF_COLOR_GRADING_ATTR,
getHfColorGradingCapabilities,
hasHfColorGradingAuthoredValues,
isPathInside,
normalizeHfColorGrading,
serializeHfColorGrading,
} from "@hyperframes/core";
import {
isColorGradingVariableRef,
validateColorGradingContract,
} from "@hyperframes/parsers/color-grading-contract";
import {
cleanAssetUrl,
isRemoteOrInlineUrl,
resolveExistingLocalAsset,
} from "@hyperframes/parsers/asset-resolution";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation";
import { defineCommand } from "citty";
import { parseHTML } from "linkedom";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { failCommand } from "../utils/commandResult.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { readOptionalString } from "../utils/pathArgs.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
import { analyzeMediaTreatment } from "./media-treatment-analysis.js";
export function getMediaTreatmentCapabilityOverview() {
const capabilities = getHfColorGradingCapabilities();
const family = (id: string, label: string, description: string) => ({
id,
label,
description,
});
return {
version: capabilities.version,
targetTags: capabilities.targetTags,
colorSpace: capabilities.colorSpace,
families: [
family("correction", "Adjust", "Fix exposure, tonal balance, color casts, and saturation."),
family(
"grading",
"Color Grading",
"Shape tonal color with wheels, RGB and hue curves, and selective HSL correction.",
),
family(
"presets",
"Presets",
"Apply a tested starting point, then tune only when the source or intent requires it.",
),
family("finishing", "Finish", "Shape vignette and deterministic film grain."),
...capabilities.effectFamilies,
family(
"palettes",
"Palettes",
"Reusable two-to-six-color palettes for compatible art effects.",
),
family(
"animation",
"Animation",
"Seek-safe CSS properties that registered GSAP timelines may animate.",
),
family("lut", "Custom LUT", "Apply a user-owned 3D .cube LUT."),
{
id: "overlays",
label: "Overlays",
description: "Install authored HUD, light-leak, flash, or freeze-frame overlay blocks.",
owner: "registry",
},
],
discovery: {
detail: "Use --capability <family-or-item> for exact controls and examples.",
full: "Use --all only for tooling or exhaustive inspection.",
},
};
}
export function getMediaTreatmentCapabilityDetail(id: string): unknown {
const capabilities = getHfColorGradingCapabilities();
const animation = (path: string) => {
const property = capabilities.animatable.find((candidate) => candidate.path === path);
if (!property) return null;
return {
property,
initial: `style="${property.name}: <start>"`,
tween: `timeline.to("<selector>", { "${property.name}": <end>, duration: <seconds> })`,
rules: [
"Author the initial value inline on the media element.",
"Use finite keyframes on a paused timeline registered in window.__timelines.",
"Do not use a frame-zero set, timers, random values, or onUpdate callbacks.",
],
};
};
const effect = capabilities.effects.find(({ key }) => key === id);
if (effect) {
return {
id,
family: effect.family,
label: effect.label,
description: effect.description,
renderLane: effect.renderLane,
supportsPalette: effect.supportsPalette,
apply: { effects: effect.apply },
controls: effect.controls,
animation: animation(`effects.${id}`),
};
}
const effectFamily = capabilities.effectFamilies.find((family) => family.id === id);
if (effectFamily) {
return {
...effectFamily,
effects: capabilities.effects
.filter((candidate) => candidate.family === id)
.map((candidate) => ({
id: candidate.key,
label: candidate.label,
description: candidate.description,
renderLane: candidate.renderLane,
supportsPalette: candidate.supportsPalette,
animatable: capabilities.animatable.some(
({ path }) => path === `effects.${candidate.key}`,
),
})),
};
}
const adjustment = capabilities.adjustments.find(({ key }) => key === id);
if (adjustment) {
return {
id,
family: "correction",
description: `Adjust ${id} within the canonical correction range.`,
control: adjustment,
apply: { adjust: { [id]: adjustment.identity } },
animation: animation(`adjust.${id}`),
};
}
const finishing = capabilities.finishing.find(({ key }) => key === id);
if (finishing) {
return {
id,
family: "finishing",
description: `Adjust ${id} within the canonical finishing range.`,
control: finishing,
apply: { details: { [id]: finishing.identity } },
};
}
const preset = capabilities.presets.find((candidate) => candidate.id === id);
if (preset) {
return {
id: preset.id,
label: preset.label,
description: "Tested built-in media preset.",
apply: { preset: preset.id, intensity: preset.intensity },
};
}
const palette = capabilities.palettes.find((candidate) => candidate.id === id);
if (palette) return { ...palette, apply: { palette: palette.colors } };
const details = {
grading: {
id,
description:
"Shape media after primary correction with wheels, curves, HSL secondaries, and an optional user-owned LUT.",
order: [
"adjust",
"wheels",
"curves",
"hueCurves",
"secondaries",
"lut",
"details",
"effects",
],
discover: ["wheels", "curves", "hue-curves", "secondary", "scopes", "lut"],
},
correction: {
id,
description: "Fix exposure, tonal balance, color casts, and saturation.",
controls: capabilities.adjustments,
},
wheels: {
id,
description: "Shadow, midtone, and highlight color wheels.",
contract: capabilities.wheels,
apply: {
wheels: {
shadows: { hue: 205, amount: 0.08, level: 0 },
highlights: { hue: 35, amount: 0.06, level: 0 },
},
},
},
curves: {
id,
description: "Master and per-channel curves using normalized [input, output] points.",
contract: capabilities.curves,
apply: {
curves: {
master: [
[0, 0],
[0.25, 0.2],
[0.75, 0.82],
[1, 1],
],
},
},
},
"hue-curves": {
id,
description: "Hue-selective curves using [hueDegrees, delta] points.",
contract: capabilities.hueCurves,
apply: {
hueCurves: {
hueVsSaturation: [
[180, 0],
[210, 0.15],
[240, 0],
],
},
},
},
secondary: {
id,
description: "Up to four ordered HSL selections with bounded color correction.",
contract: capabilities.secondaries,
apply: {
secondaries: [
{
key: {
hue: { center: 215, range: 25, softness: 10 },
saturation: { min: 0.2, max: 1, softness: 0.08 },
luma: { min: 0.1, max: 0.9, softness: 0.08 },
},
correction: { saturation: 0.15, luma: 0.03 },
},
],
},
},
scopes: {
id,
description:
"Deterministic source measurements for agent decisions; visual scopes remain a Studio display.",
command:
"hyperframes media-treatment --file compositions/scene.html --selector '#hero' --analyze --json",
output: [
"source color metadata and HDR/LOG warnings",
"luma percentile and clipping evidence",
"bounded suggested primary correction",
],
},
presets: {
id,
description: "Tested starting points for color and stylized media effects.",
presets: capabilities.presets,
},
finishing: {
id,
description: "Vignette and deterministic film-grain controls.",
controls: capabilities.finishing,
},
palettes: {
id,
description: "Named palettes plus the custom palette contract.",
contract: capabilities.palette,
palettes: capabilities.palettes,
},
animation: {
id,
description: "Seek-safe CSS properties for registered GSAP timelines.",
properties: capabilities.animatable,
},
lut: {
id,
description: "User-owned 3D .cube LUT support.",
contract: capabilities.lut,
},
overlays: {
id,
description: "Authored overlay blocks owned by the HyperFrames Registry.",
discover: "hyperframes catalog",
apply: "hyperframes add <overlay> --dir <project> --no-clipboard --json",
},
} as const;
const detail = Object.hasOwn(details, id) ? Reflect.get(details, id) : undefined;
if (detail) return detail;
throw new Error(`Unknown media-treatment capability: ${id}`);
}
export const examples: Example[] = [
[
"Discover the complete treatment surface without loading every control",
`hyperframes media-treatment --capabilities --json`,
],
[
"Inspect one relevant effect in detail",
`hyperframes media-treatment --capability kuwahara --json`,
],
[
"Inspect the exhaustive machine-readable catalog",
`hyperframes media-treatment --capabilities --all --json`,
],
[
"Apply a resolved treatment to one media element",
`hyperframes media-treatment --selector '#hero' --grading '{"preset":"skin-soft","intensity":0.6}' --apply`,
],
[
"Preview the exact mutation without writing",
`hyperframes media-treatment --file compositions/scene.html --selector 'video' --grading '{"preset":"warm-daylight"}' --apply --dry-run --json`,
],
[
"Measure one local media source before choosing a correction",
`hyperframes media-treatment --selector '#hero' --analyze --json`,
],
["Remove a treatment", `hyperframes media-treatment --selector '#hero' --clear`],
];
interface ApplyMediaTreatmentOptions {
selector: string;
selectorIndex?: number;
grading?: unknown;
clear?: boolean;
}
interface ApplyMediaTreatmentResult {
html: string;
changed: boolean;
tag: "img" | "video";
value: string | null;
before: unknown;
after: unknown;
}
function parseSourceDocument(source: string): Document {
if (/<!doctype|<html[\s>]/i.test(source)) return parseHTML(source).document;
return parseHTML(`<!DOCTYPE html><html><body>${source}</body></html>`).document;
}
function assertKnownGradingShape(value: unknown): void {
const issue = validateColorGradingContract(value)[0];
if (issue) {
const hint = issue.hint ? ` ${issue.hint}` : "";
throw new Error(`Invalid color-grading ${issue.path}: ${issue.message}.${hint}`);
}
}
function containsColorGradingVariableRef(value: unknown): boolean {
if (isColorGradingVariableRef(value)) return true;
if (Array.isArray(value)) return value.some(containsColorGradingVariableRef);
if (typeof value !== "object" || value === null) return false;
return Object.values(value).some(containsColorGradingVariableRef);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseStoredGrading(raw: string | null): unknown {
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function mergeGradingPatch(current: unknown, patch: unknown): unknown {
if (!isRecord(current) || !isRecord(patch)) return patch;
const merged = { ...current };
for (const [key, value] of Object.entries(patch)) {
merged[key] =
isRecord(value) && isRecord(merged[key]) ? mergeGradingPatch(merged[key], value) : value;
}
return merged;
}
function serializeGradingPatch(before: unknown, patch: unknown): string | null {
assertKnownGradingShape(patch);
if (isColorGradingVariableRef(before) && isRecord(patch)) {
throw new Error("Cannot merge a grading patch into an unresolved whole-grade variable");
}
const current =
typeof before === "string" && !isColorGradingVariableRef(before) ? { preset: before } : before;
const grading = mergeGradingPatch(current, patch);
assertKnownGradingShape(grading);
if (containsColorGradingVariableRef(grading)) {
return typeof grading === "string" ? grading.trim() : JSON.stringify(grading);
}
const normalized = normalizeHfColorGrading(grading);
if (!normalized) throw new Error("--grading must be valid HyperFrames color-grading JSON");
return hasHfColorGradingAuthoredValues(normalized) ? serializeHfColorGrading(normalized) : null;
}
function queryIncludingTemplates(root: Document | Element, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll(selector));
if (matches.length > 0) return matches;
for (const template of root.querySelectorAll("template")) {
const nested = queryIncludingTemplates(template, selector);
if (nested.length > 0) return nested;
}
return [];
}
function selectMediaElement(
source: string,
selector: string,
selectorIndex?: number,
): { element: Element; selectorIndex: number; tag: "img" | "video" } {
const document = parseSourceDocument(source);
let matches: Element[];
try {
matches = queryIncludingTemplates(document, selector);
} catch {
throw new Error(`Invalid selector: ${selector}`);
}
if (matches.length === 0) throw new Error(`Selector did not match: ${selector}`);
if (selectorIndex === undefined && matches.length > 1) {
throw new Error(
`Selector matched ${matches.length} elements; use a unique selector or --selector-index`,
);
}
const resolvedIndex = selectorIndex ?? 0;
const element = matches[resolvedIndex];
if (!element) {
throw new Error(`--selector-index ${resolvedIndex} is outside ${matches.length} matches`);
}
const tag = element.tagName.toLowerCase();
if (tag !== "img" && tag !== "video") {
throw new Error(`Color grading requires an <img> or <video>; selector matched <${tag}>`);
}
return { element, selectorIndex: resolvedIndex, tag };
}
export function applyMediaTreatmentToHtml(
source: string,
options: ApplyMediaTreatmentOptions,
): ApplyMediaTreatmentResult {
const { element, selectorIndex, tag } = selectMediaElement(
source,
options.selector,
options.selectorIndex,
);
const before = parseStoredGrading(element.getAttribute(HF_COLOR_GRADING_ATTR));
const value = options.clear ? null : serializeGradingPatch(before, options.grading);
const changed = element.getAttribute(HF_COLOR_GRADING_ATTR) !== value;
const after = parseStoredGrading(value);
if (!changed) return { html: source, changed: false, tag, value, before, after };
const patched = patchElementInHtml(source, { selector: options.selector, selectorIndex }, [
{ type: "attribute", property: HF_COLOR_GRADING_ATTR, value },
]);
if (!patched.matched) throw new Error(`Could not persist selector: ${options.selector}`);
return { html: patched.html, changed: true, tag, value, before, after };
}
function parseSelectorIndex(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new Error("--selector-index must be a non-negative integer");
}
return value;
}
function mediaSourceForElement(element: Element): string {
const src =
element.getAttribute("src") ??
(element.tagName.toLowerCase() === "video"
? element.querySelector("source")?.getAttribute("src")
: null);
if (!src) throw new Error("Selected media has no analyzable src");
return src;
}
export function resolveMediaTreatmentSource(
projectDir: string,
compositionFile: string,
source: string,
): string {
const sourceUrl = source.trim();
if (!sourceUrl) throw new Error("Selected media has no analyzable local src");
if (isRemoteOrInlineUrl(sourceUrl)) {
throw new Error(
"Media analysis requires a local project asset; freeze remote media with media-use first",
);
}
const cleanSource = cleanAssetUrl(sourceUrl);
if (!cleanSource) throw new Error("Selected media has no analyzable local src");
const projectRelative = cleanSource.startsWith("/")
? cleanSource
: rewriteAssetPath(compositionFile, cleanSource);
const asset = resolveExistingLocalAsset(projectDir, projectRelative);
if (!asset) throw new Error(`Media file not found: ${source}`);
return asset.resolved;
}
function parseGrading(raw: string | undefined, apply: boolean, clear: boolean): unknown {
if (clear) {
if (raw !== undefined || apply) {
throw new Error("Use either --apply with --grading or --clear, not both");
}
return undefined;
}
if (!apply) {
if (raw !== undefined) throw new Error("--grading requires --apply");
throw new Error("Use --apply with --grading <json> or --clear");
}
if (raw === undefined) throw new Error("--apply requires --grading <json>");
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`Could not parse --grading JSON: ${normalizeErrorMessage(error)}`);
}
}
function mutationVerb(action: "apply" | "clear", changed: boolean, dryRun: boolean): string {
if (dryRun) return `Would ${action}`;
if (!changed) return action === "apply" ? "Already applied" : "Already clear";
return action === "apply" ? "Applied" : "Cleared";
}
interface MediaTreatmentCommandArgs {
capabilities?: boolean;
capability?: string;
all?: boolean;
project?: string;
file?: string;
selector?: string;
"selector-index"?: string;
grading?: string;
apply?: boolean;
analyze?: boolean;
clear?: boolean;
"dry-run"?: boolean;
json?: boolean;
}
function runCapabilityQuery(args: MediaTreatmentCommandArgs): void {
const capability = readOptionalString(args.capability);
const hasMutationOption = [
readOptionalString(args.selector),
readOptionalString(args.grading),
args.clear,
args["dry-run"],
args.apply,
args.analyze,
].some(Boolean);
if (hasMutationOption) throw new Error("--capabilities cannot be combined with mutation options");
if (args.all === true && capability)
throw new Error("Use either --all or --capability, not both");
let capabilities: unknown = getMediaTreatmentCapabilityOverview();
if (args.all === true) capabilities = getHfColorGradingCapabilities();
else if (capability) capabilities = getMediaTreatmentCapabilityDetail(capability);
console.log(JSON.stringify(withMeta({ ok: true, capabilities }), null, 2));
}
function resolveMutationFile(args: MediaTreatmentCommandArgs) {
const project = resolveProject(readOptionalString(args.project));
const fileArg = readOptionalString(args.file) ?? "index.html";
const filePath = resolve(project.dir, fileArg);
if (!isPathInside(filePath, project.dir) || !filePath.toLowerCase().endsWith(".html")) {
throw new Error("--file must be an HTML file inside the project");
}
if (!existsSync(filePath)) throw new Error(`Composition file not found: ${fileArg}`);
return { project, filePath };
}
function analyzeTarget(args: MediaTreatmentCommandArgs) {
const { project, filePath } = resolveMutationFile(args);
const selector = readOptionalString(args.selector);
if (!selector) throw new Error("--selector is required");
if (
readOptionalString(args.grading) ||
args.clear === true ||
args.apply === true ||
args["dry-run"] === true
) {
throw new Error("--analyze cannot be combined with mutation options");
}
const selectorIndex = parseSelectorIndex(readOptionalString(args["selector-index"]));
const { element, tag } = selectMediaElement(
readFileSync(filePath, "utf8"),
selector,
selectorIndex,
);
const source = mediaSourceForElement(element);
const compositionFile = relative(project.dir, filePath).split("\\").join("/");
const mediaPath = resolveMediaTreatmentSource(project.dir, compositionFile, source);
return {
ok: true,
action: "analyze",
file: compositionFile || "index.html",
selector,
selectorIndex: selectorIndex ?? 0,
tag,
media: source,
...analyzeMediaTreatment(mediaPath),
};
}
function prepareMutation(args: MediaTreatmentCommandArgs) {
const { project, filePath } = resolveMutationFile(args);
const selector = readOptionalString(args.selector);
if (!selector) throw new Error("--selector is required");
const clear = args.clear === true;
const apply = args.apply === true;
const selectorIndex = parseSelectorIndex(readOptionalString(args["selector-index"]));
const result = applyMediaTreatmentToHtml(readFileSync(filePath, "utf8"), {
selector,
selectorIndex,
grading: parseGrading(readOptionalString(args.grading), apply, clear),
clear,
});
const dryRun = args["dry-run"] === true;
if (result.changed && !dryRun) writeFileSync(filePath, result.html);
const action: "clear" | "apply" = result.value === null ? "clear" : "apply";
return {
action,
result,
selector,
payload: {
ok: true,
action,
file: relative(project.dir, filePath) || "index.html",
selector,
selectorIndex: selectorIndex ?? 0,
tag: result.tag,
changed: result.changed,
dryRun,
before: result.before,
after: result.after,
},
};
}
function isCapabilityQuery(args: MediaTreatmentCommandArgs): boolean {
return (
args.capabilities === true || Boolean(readOptionalString(args.capability)) || args.all === true
);
}
function printAnalysis(args: MediaTreatmentCommandArgs): void {
const result = analyzeTarget(args);
if (args.json === true) {
console.log(JSON.stringify(withMeta(result), null, 2));
return;
}
console.log(`${c.success("◇")} Analyzed ${c.accent(result.selector)}`);
for (const diagnosis of result.diagnosis) console.log(` ${diagnosis}`);
for (const warning of result.warnings) console.log(` ${c.warn(warning)}`);
console.log(` suggested patch: ${JSON.stringify(result.suggestedPatch)}`);
}
function printMutation(args: MediaTreatmentCommandArgs): void {
const { action, result, selector, payload } = prepareMutation(args);
if (args.json === true) {
console.log(JSON.stringify(withMeta(payload), null, 2));
return;
}
const verb = mutationVerb(action, result.changed, payload.dryRun);
console.log(`${c.success("◇")} ${verb} media treatment on ${c.accent(selector)}`);
}
function printFailure(error: unknown, json: boolean): void {
const message = normalizeErrorMessage(error);
if (json) console.log(JSON.stringify(withMeta({ ok: false, error: message })));
else console.error(`${c.error("✗")} ${message}`);
failCommand();
}
export const mediaTreatmentCommand = defineCommand({
meta: {
name: "media-treatment",
description: "Discover, apply, or clear deterministic media treatments",
},
args: {
capabilities: {
type: "boolean",
description: "Print a concise agent-readable capability overview",
default: false,
},
capability: {
type: "string",
description: "Inspect one family, control, effect, preset, or palette",
},
all: {
type: "boolean",
description: "Print the exhaustive capability catalog",
default: false,
},
project: { type: "string", description: "Project directory (default: cwd)" },
file: {
type: "string",
description: "Composition file relative to project (default: index.html)",
},
selector: {
type: "string",
description: "Unique CSS selector for one <img> or <video>",
},
"selector-index": {
type: "string",
description: "Zero-based match index when the selector is not unique",
},
grading: { type: "string", description: "Canonical color-grading JSON patch" },
apply: {
type: "boolean",
description: "Apply the validated grading patch (explicit agent form)",
default: false,
},
analyze: {
type: "boolean",
description: "Measure selected local media and suggest a bounded primary correction",
default: false,
},
clear: { type: "boolean", description: "Remove color grading from the target", default: false },
"dry-run": {
type: "boolean",
description: "Validate and report without writing",
default: false,
},
json: { type: "boolean", description: "Output an agent-friendly JSON result", default: false },
},
run({ args }) {
try {
if (isCapabilityQuery(args)) return runCapabilityQuery(args);
if (args.analyze === true) {
printAnalysis(args);
return;
}
printMutation(args);
} catch (error) {
printFailure(error, args.json === true);
}
},
});
+24 -29
View File
@@ -1,5 +1,5 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -123,6 +123,20 @@ vi.mock("../telemetry/config.js", () => ({
configState.cache = { ...configState.disk };
return { ...configState.disk };
}),
recordRecentRender: vi.fn((id: string, ok: boolean) => {
// Mirrors the real ring update (readConfigFresh → append, cap 5 → write)
// against the mock's disk state, so a render's recent-renders write is
// modeled like every other config mutation here. Fixed timestamp keeps it
// deterministic (tests never assert on `at`).
const disk = configState.disk as Record<string, unknown>;
const ring = [
...((disk.recentRenders as unknown[]) ?? []),
{ id, at: "2026-01-01T00:00:00Z", ok },
];
const next = { ...disk, recentRenders: ring.slice(-5) };
configState.disk = next;
configState.cache = { ...next };
}),
writeConfig: vi.fn((config: Record<string, unknown>) => {
configState.writeConfigCalls.push({ ...config });
if (configState.failWrites > 0) {
@@ -167,26 +181,19 @@ vi.mock("../utils/orphanCleanup.js", () => ({
}),
}));
// Collect the heavy render module once, after Vitest has hoisted the mocks
// above. Keeping this import out of a hook means parallel monorepo contention
// cannot turn module collection into a `beforeAll` timeout.
const renderModule = await import("./render.js");
describe("renderLocal browser GPU config", () => {
const savedEnv = new Map<string, string | undefined>();
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
// cold-loads a heavy module graph (core + engine + producer, incl. linkedom),
// slow under the parallel monorepo run — the generous hook timeout that
// absorbs that contention now lives in vitest.config.ts (shared by all CLI
// suites). Importing once in `beforeAll` keeps every test fast and isolated.
let renderLocal: typeof import("./render.js").renderLocal;
let resolveBrowserGpuForCli: typeof import("./render.js").resolveBrowserGpuForCli;
let renderLintContinuationHint: typeof import("./render.js").renderLintContinuationHint;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
beforeAll(async () => {
({
const {
renderLocal,
resolveBrowserGpuForCli,
renderLintContinuationHint,
__resetDeParallelRouterTrialStateForTests: resetTrialState,
} = await import("./render.js"));
});
} = renderModule;
it("points strict warning-only renders to --strict-all", () => {
expect(renderLintContinuationHint(true)).toContain("--strict-all");
@@ -699,15 +706,9 @@ describe("renderLocal browser GPU config", () => {
});
describe("renderLocal — DE parallel-router CLI trial", () => {
let renderLocal: typeof import("./render.js").renderLocal;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
const { renderLocal, __resetDeParallelRouterTrialStateForTests: resetTrialState } = renderModule;
const savedEnv = new Map<string, string | undefined>();
beforeAll(async () => {
({ renderLocal, __resetDeParallelRouterTrialStateForTests: resetTrialState } =
await import("./render.js"));
});
beforeEach(() => {
producerState.createdJobs = [];
producerState.executeImpl = async () => undefined;
@@ -1096,13 +1097,7 @@ describe("renderLocal — DE parallel-router CLI trial", () => {
});
describe("checkRenderResolutionPreflight", () => {
let checkRenderResolutionPreflight: typeof import("./render.js").checkRenderResolutionPreflight;
// Cold-imports render.js (heavy graph); the generous hook timeout for parallel
// CI contention lives in vitest.config.ts. See the note above.
beforeAll(async () => {
({ checkRenderResolutionPreflight } = await import("./render.js"));
});
const { checkRenderResolutionPreflight } = renderModule;
// Dims must be read the same way the producer's compiler reads them:
// `data-width` / `data-height` on the `[data-composition-id]` root.
+31 -5
View File
@@ -1,8 +1,9 @@
import { failCommand, requestCliExit } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests.
@@ -61,7 +62,12 @@ import {
trackRenderObservation,
} from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { readConfigFresh, writeConfig, type HyperframesConfig } from "../telemetry/config.js";
import {
readConfigFresh,
recordRecentRender,
writeConfig,
type HyperframesConfig,
} from "../telemetry/config.js";
import { shouldTrack } from "../telemetry/client.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
import { bytesToMb } from "../telemetry/system.js";
@@ -346,6 +352,9 @@ export default defineCommand({
// Keep the transport adapter thin: each phase has one ownership boundary.
async run({ args }) {
const plan = createRenderPlan(args);
// Teach the project its owning skill from an explicit --skill so every
// later flag-less render (re-render, `npm run render`, batch) inherits it.
seedProjectAuthoringSkill(plan.project.dir, args.skill);
await presentRenderPlan(plan);
await executeRenderPlan(plan, {
renderDocker,
@@ -553,9 +562,12 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s
const dockerfilePath = resolveDockerfilePath();
// Copy Dockerfile to a temp build context so docker build has a clean context
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
// Copy Dockerfile to a temp build context so docker build has a clean context.
// mkdtempSync (not a `Date.now()`-derived name) so the path is unpredictable
// and created 0o700 by the kernel — a guessable temp dir in a world-writable
// tmpdir is pre-creatable by another local user, who could then swap in their
// own Dockerfile or symlink the path (CodeQL js/insecure-temporary-file).
const tmpDir = mkdtempSync(join(tmpdir(), "hyperframes-docker-"));
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
// Platform is now derived from the host arch (see resolveDockerPlatform).
@@ -1339,6 +1351,9 @@ function handleRenderError(
...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(),
});
// Failed renders join the recent-renders ring too — a bug report filed via
// `hyperframes feedback` is MOST likely to be about a failed render.
if (job?.id) recordRecentRender(job.id, false);
if (options.throwOnError) {
throw new Error(message);
}
@@ -1376,6 +1391,9 @@ function trackRenderMetrics(
options: RenderOptions,
docker: boolean,
): void {
// Successful render → recent-renders ring, so a later `hyperframes
// feedback` can attach this render's telemetry id to the report.
recordRecentRender(job.id, true);
const perf = job.perfSummary;
const compositionDurationMs = perf
? Math.round(perf.compositionDurationSeconds * 1000)
@@ -1393,6 +1411,13 @@ function trackRenderMetrics(
fps: fpsToNumber(options.fps),
quality: options.quality,
workers: options.workers ?? perf?.workers,
workersBoundBy: perf?.workerSizing?.boundBy,
workersCpuBased: perf?.workerSizing?.cpuBasedWorkers,
workersMemoryBased: perf?.workerSizing?.memoryBasedWorkers,
workersHeapBased: perf?.workerSizing?.heapBasedWorkers,
workersFrameBased: perf?.workerSizing?.frameBasedWorkers,
workersHeapLimitMb: perf?.workerSizing?.heapLimitMb,
workersExceedHeapAdvisory: perf?.workerSizing?.exceedsHeapAdvisory,
docker,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
@@ -1411,6 +1436,7 @@ function trackRenderMetrics(
deParallelRouter: perf?.drawElement?.parallelRouter,
dePreRouterWorkers: perf?.drawElement?.preRouterWorkers,
deGateReason: perf?.drawElement?.gateReason,
gpuRenderer: perf?.drawElement?.gpuRenderer,
deWorkerEncode: perf?.drawElement?.workerEncode,
deVerifyArmed: perf?.drawElement?.verifyArmed,
deVerifyChecked: perf?.drawElement?.verifyChecked,
@@ -81,4 +81,22 @@ describe("createRenderPlan", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("OFF");
});
it("attributes a flag-less render to the skill persisted in hyperframes.json", () => {
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ authoringSkill: "product-launch-video" }),
);
const plan = createRenderPlan({ dir: projectDir });
expect(plan.authoringSkill).toBe("product-launch-video");
});
it("lets an explicit --skill flag override the persisted project owner", () => {
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ authoringSkill: "product-launch-video" }),
);
const plan = createRenderPlan({ dir: projectDir, skill: "motion-graphics" });
expect(plan.authoringSkill).toBe("motion-graphics");
});
});
+8 -2
View File
@@ -28,6 +28,7 @@ import {
resolveDefaultFpsArg,
} from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js";
import { loadProjectConfig } from "../../utils/projectConfig.js";
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
@@ -192,9 +193,14 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
}
const quality = qualityRaw as RenderQuality;
const authoringSkill = normalizeSkillSlug(args.skill);
// Attribution resolves the explicit --skill flag first, then falls back to
// the owning skill persisted in hyperframes.json — so re-renders, batch
// renders, and `npm run render` (which never re-pass the flag) stay
// attributed to the workflow that created the project.
const flagSkill = normalizeSkillSlug(args.skill);
const authoringSkill = flagSkill ?? loadProjectConfig(project.dir).authoringSkill;
const invalidAuthoringSkill =
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill
typeof args.skill === "string" && args.skill.trim() !== "" && !flagSkill
? args.skill
: undefined;
+4
View File
@@ -42,6 +42,10 @@ const GROUPS: Group[] = [
["inspect", "Inspect rendered visual layout across the timeline"],
["keyframes", "Inspect keyframes and render onion-shot diagnostics"],
["snapshot", "Capture key frames as PNG screenshots for visual verification"],
[
"media-treatment",
"Discover, apply, or clear deterministic media treatments on one media element",
],
[
"grade-compare",
"Render candidate color grades onto a reference frame as one labeled comparison PNG",
@@ -1,6 +1,8 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundleToSingleHtml } from "@hyperframes/core/compiler";
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
@@ -29,7 +31,7 @@ function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): s
return missing;
}
describe("registry block manifests", () => {
describe("registry blocks", () => {
it("installs every local script referenced by a block composition", () => {
const missing: string[] = [];
@@ -48,4 +50,18 @@ describe("registry block manifests", () => {
expect(missing).toEqual([]);
});
it("keeps the Camcorder HUD seekable inside a differently named host composition", async () => {
const bundled = await bundleToSingleHtml(resolve(blocksDir, "camcorder-hud"), {
entryFile: "demo.html",
});
const { document } = parseHTML(bundled);
const demo = document.getElementById("camcorder-hud-demo");
const hud = document.getElementById("ch-demo-overlay");
expect(demo?.getAttribute("data-composition-id")).toBe("camcorder-hud-demo");
expect(hud?.getAttribute("data-composition-id")).toBe("camcorder-hud");
expect(hud?.hasAttribute("data-composition-src")).toBe(false);
expect(bundled).toContain('var __hfTimelineCompId = "camcorder-hud";');
});
});
@@ -26,9 +26,7 @@ async function invalidInstallableMedia(entryName: string): Promise<string[]> {
isSubComposition: true,
});
for (const finding of result.findings) {
if (finding.code !== "media_in_subcomposition" && finding.code !== "media_missing_src") {
continue;
}
if (finding.code !== "media_missing_src") continue;
invalidMedia.push(`${entryName}/${file.path}: ${finding.code}`);
}
}
+13 -128
View File
@@ -1,43 +1,21 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readConfig, writeConfig } from "./config.js";
import { VERSION } from "../version.js";
import { c } from "../ui/colors.js";
import { diag } from "../ui/diagnostics.js";
import { isDevMode } from "../utils/env.js";
import { getSystemMeta } from "./system.js";
// This is a public project API key — safe to embed in client-side code.
// It only allows writing events, not reading data.
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_TIMEOUT_MS = 5_000;
import { enqueue, POSTHOG_API_KEY, type EventProperties } from "./transport.js";
// ---------------------------------------------------------------------------
// Lightweight PostHog client — uses the HTTP batch API directly to avoid
// pulling in the full posthog-node SDK and its dependencies.
// All calls are fire-and-forget with a hard timeout.
// CLI-facing telemetry policy: opt-out checks, system-metadata enrichment, and
// the first-run disclosure notice. The reliability-critical delivery layer
// (the event queue, `flush()`, and the exit-time `flushSync()`) lives in
// transport.ts. `flush` / `flushSync` are re-exported here so existing callers
// (events.ts, index.ts, the cli.ts exit handlers) keep importing from
// `./client.js` unchanged.
// ---------------------------------------------------------------------------
interface EventProperties {
[key: string]: string | number | boolean | null | undefined;
}
interface QueuedEvent {
// Client-generated event id. PostHog dedupes on it, so an event that gets
// sent by an interrupted flush() AND re-sent by the exit-time flushSync()
// fallback still counts once.
uuid: string;
event: string;
properties: EventProperties;
timestamp: string;
// Override for the batch distinct_id. Defaults to the install's anonymousId.
// Used to attribute server-side studio renders to the browser user who
// triggered them, so the render funnel is joinable across processes.
distinctId?: string;
}
let eventQueue: QueuedEvent[] = [];
export { flush, flushSync } from "./transport.js";
let telemetryEnabled: boolean | null = null;
@@ -71,6 +49,8 @@ export function shouldTrack(): boolean {
/**
* Queue a telemetry event. Non-blocking, fail-silent.
* Enriches the event with system metadata, then hands it to the transport
* queue (which stamps the dedup uuid + timestamp).
*/
export function trackEvent(
event: string,
@@ -80,11 +60,9 @@ export function trackEvent(
if (!shouldTrack()) return;
const sys = getSystemMeta();
eventQueue.push({
uuid: randomUUID(),
enqueue(
event,
distinctId,
properties: {
{
...properties,
cli_version: VERSION,
os: process.platform,
@@ -107,101 +85,8 @@ export function trackEvent(
term_program: sys.term_program ?? undefined,
agent_env_hints: sys.agent_env_hints ?? undefined,
},
timestamp: new Date().toISOString(),
});
}
/**
* Serialize events into a PostHog `/batch/` payload string. Pure the queue
* is untouched, so callers decide when events count as delivered.
*
* Each event carries its client-generated `uuid`, which PostHog treats as the
* event id re-sending the same event is idempotent, not a duplicate.
*
* $ip:null tells PostHog not to record the request IP for any of these events.
* Server-side "Discard client IP data" is also enabled in project settings.
*/
function buildPayload(events: readonly QueuedEvent[]): string | null {
if (events.length === 0) return null;
const config = readConfig();
const batch = events.map((e) => ({
uuid: e.uuid,
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: e.distinctId ?? config.anonymousId,
timestamp: e.timestamp,
}));
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
}
/**
* Flush all queued events to PostHog via async HTTP POST.
* Call sites: the `beforeExit` hook in cli.ts (normal exit), eager sends right
* after high-value events (trackRenderComplete / trackRenderError), and the
* `events` beacon command, which awaits delivery before its process exits.
*
* Events are only removed from the queue once the request has completed.
* The old drain-first version silently lost the whole batch whenever the
* process died with the fetch in flight which is the NORMAL exit path for
* `render`: an agent pipe closing triggers the EPIPE `process.exit(0)`, and
* error paths call `process.exit(1)` directly, both killing the in-flight
* request that `beforeExit` had just started. Keeping the queue intact until
* delivery lets the exit-time flushSync() child (which survives the parent)
* re-send anything unconfirmed; event uuids make that re-send idempotent.
*/
export async function flush(): Promise<void> {
// Copy, not alias — events queued while the request is in flight must not
// be swept into the "delivered" set below.
const snapshot = eventQueue.slice();
const payload = buildPayload(snapshot);
if (payload == null) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
try {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json", Connection: "close" },
body: payload,
signal: controller.signal,
});
// Delivered — forget exactly what was sent (events queued while the
// request was in flight stay for the next flush).
const sent = new Set(snapshot);
eventQueue = eventQueue.filter((e) => !sent.has(e));
} catch {
// Silently ignore — telemetry must never break the CLI. The events stay
// queued so the exit-time flushSync() fallback can still deliver them.
} finally {
clearTimeout(timeout);
}
}
/**
* Fire-and-forget flush for use in the `exit` event handler.
* Spawns a detached child process that sends the HTTP request independently,
* so the parent process exits immediately without waiting.
*/
export function flushSync(): void {
const payload = buildPayload(eventQueue);
if (payload == null) return;
eventQueue = [];
try {
const child = spawn(
process.execPath,
[
"-e",
`fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`,
],
{ detached: true, stdio: "ignore" },
distinctId,
);
// Let the parent exit without waiting for the child
child.unref();
} catch {
// Silently ignore
}
}
/**
+45
View File
@@ -85,6 +85,39 @@ export interface HyperframesConfig {
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
*/
deParallelRouterTrialRenderCount?: number;
/**
* Ring of the last few local renders (newest last). `hyperframes feedback`
* attaches these ids which are the `render_job_id` /
* `observability_render_job_id` on this install's PostHog events to the
* feedback it submits, so a wild bug report can be joined to the exact
* telemetry rows of the renders it describes.
*/
recentRenders?: RecentRenderRecord[];
}
/** One entry in {@link HyperframesConfig.recentRenders}. */
export interface RecentRenderRecord {
/** The render job id (`RenderJob.id` — the telemetry `render_job_id`). */
id: string;
/** ISO timestamp of when the render finished. */
at: string;
/** Whether the render completed successfully. */
ok: boolean;
}
/** Ring size for {@link HyperframesConfig.recentRenders}. */
const MAX_RECENT_RENDERS = 5;
/**
* Append a finished render to the recent-renders ring (newest last, capped).
* Fresh read-modify-write like the trial counters narrows, but does not
* eliminate, lost updates against a concurrent CLI process.
*/
export function recordRecentRender(id: string, ok: boolean): void {
const config = readConfigFresh();
const ring = [...(config.recentRenders ?? []), { id, at: new Date().toISOString(), ok }];
config.recentRenders = ring.slice(-MAX_RECENT_RENDERS);
writeConfig(config);
}
const DEFAULT_CONFIG: HyperframesConfig = {
@@ -142,6 +175,18 @@ export function readConfig(): HyperframesConfig {
typeof parsed.deParallelRouterTrialRenderCount === "number"
? parsed.deParallelRouterTrialRenderCount
: undefined,
recentRenders: Array.isArray(parsed.recentRenders)
? parsed.recentRenders
.filter(
(r): r is RecentRenderRecord =>
typeof r === "object" &&
r !== null &&
typeof (r as RecentRenderRecord).id === "string" &&
typeof (r as RecentRenderRecord).at === "string" &&
typeof (r as RecentRenderRecord).ok === "boolean",
)
.slice(-MAX_RECENT_RENDERS)
: undefined,
};
cachedConfig = config;
+55 -2
View File
@@ -197,6 +197,59 @@ describe("render telemetry events", () => {
expect(flush).toHaveBeenCalledTimes(2);
});
// The enforcement decision for the advisory heap budget reads these fleet
// props (see computeWorkerSizing) — a silent drop in the summary→event hop
// would invalidate that decision without anyone noticing.
it("carries every worker-sizing provenance prop on render_complete", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "high",
docker: false,
gpu: false,
workers: 6,
workersBoundBy: "max_workers",
workersCpuBased: 16,
workersMemoryBased: 8,
workersHeapBased: 4,
workersFrameBased: 24,
workersHeapLimitMb: 4096,
workersExceedHeapAdvisory: true,
});
expect(trackEvent).toHaveBeenCalledWith(
"render_complete",
expect.objectContaining({
workers: 6,
workers_bound_by: "max_workers",
workers_cpu_based: 16,
workers_memory_based: 8,
workers_heap_based: 4,
workers_frame_based: 24,
workers_heap_limit_mb: 4096,
workers_exceed_heap_advisory: true,
}),
undefined,
);
});
it("ties feedback to its report and recent renders via feedback_id + recent_render_ids", () => {
trackRenderFeedback({
rating: 3,
comment: "hook scene blank",
feedbackId: "feedback-uuid",
recentRenderIds: ["render-a", "render-b"],
});
expect(trackEvent).toHaveBeenCalledWith(
"cli_render_feedback",
expect.objectContaining({
feedback_id: "feedback-uuid",
recent_render_ids: "render-a,render-b",
}),
);
});
it("redacts paths and URL query strings from render error messages", () => {
trackRenderError({
fps: 30,
@@ -462,7 +515,7 @@ describe("trackRenderFeedback", () => {
const [, props] = trackEvent.mock.calls[0] as [string, Record<string, unknown>];
expect(props).not.toHaveProperty("render_duration_ms");
expect(props.$survey_response).toBe(4);
expect(props.rating).toBe(4);
expect(props.rating_scale).toBe(10);
});
@@ -470,7 +523,7 @@ describe("trackRenderFeedback", () => {
trackRenderFeedback({ rating: 5, renderDurationMs: 6000 });
expect(trackEvent).toHaveBeenCalledWith(
"survey sent",
"cli_render_feedback",
expect.objectContaining({ render_duration_ms: 6000 }),
);
});
+45 -4
View File
@@ -73,6 +73,7 @@ export interface RenderObservabilityTelemetryPayload {
captureDeWorkerInversion?: string;
captureDePreInversionWorkers?: number;
captureDeParallelRouter?: string;
captureDeGpuRenderer?: string;
captureDePreRouterWorkers?: number;
captureDeSelfVerifyFallback?: boolean;
captureDeFallbackReason?: string;
@@ -130,6 +131,7 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
de_worker_inversion: props.captureDeWorkerInversion,
de_pre_inversion_workers: props.captureDePreInversionWorkers,
de_parallel_router: props.captureDeParallelRouter,
gpu_renderer: props.captureDeGpuRenderer,
de_pre_router_workers: props.captureDePreRouterWorkers,
de_self_verify_fallback: props.captureDeSelfVerifyFallback,
de_fallback_reason: props.captureDeFallbackReason,
@@ -171,6 +173,17 @@ export function trackRenderComplete(
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string;
workers?: number;
// Worker auto-sizing provenance (RenderPerfSummary.workerSizing). Answers
// "why N workers?" fleet-wide, and validates the advisory per-worker heap
// budget before it's enforced (field OOM: 6 auto workers on a 24GB/4GB-heap
// machine — see computeWorkerSizing in @hyperframes/engine).
workersBoundBy?: string;
workersCpuBased?: number;
workersMemoryBased?: number;
workersHeapBased?: number;
workersFrameBased?: number;
workersHeapLimitMb?: number;
workersExceedHeapAdvisory?: boolean;
docker: boolean;
gpu: boolean;
// Static-frame dedup outcome (opt-out HF_STATIC_DEDUP=false). Undefined on
@@ -194,6 +207,8 @@ export function trackRenderComplete(
deParallelRouter?: string;
dePreRouterWorkers?: number;
deGateReason?: string;
/** Low-cardinality GPU bucket from DE session init (`<backend>/<vendor>`, e.g. `d3d11/nvidia`). */
gpuRenderer?: string;
deWorkerEncode?: boolean;
deVerifyArmed?: number;
deVerifyChecked?: number;
@@ -267,6 +282,13 @@ export function trackRenderComplete(
quality: props.quality,
authoring_skill: props.authoringSkill,
workers: props.workers,
workers_bound_by: props.workersBoundBy,
workers_cpu_based: props.workersCpuBased,
workers_memory_based: props.workersMemoryBased,
workers_heap_based: props.workersHeapBased,
workers_frame_based: props.workersFrameBased,
workers_heap_limit_mb: props.workersHeapLimitMb,
workers_exceed_heap_advisory: props.workersExceedHeapAdvisory,
docker: props.docker,
gpu: props.gpu,
static_dedup_enabled: props.staticDedupEnabled,
@@ -284,6 +306,7 @@ export function trackRenderComplete(
de_parallel_router: props.deParallelRouter,
de_pre_router_workers: props.dePreRouterWorkers,
de_gate_reason: props.deGateReason,
gpu_renderer: props.gpuRenderer,
de_worker_encode: props.deWorkerEncode,
de_verify_armed: props.deVerifyArmed,
de_verify_checked: props.deVerifyChecked,
@@ -380,6 +403,10 @@ export function trackRenderError(
peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb,
...powerStateFields(),
// gpu_renderer arrives via renderObservabilityEventProperties below:
// on the failure path perfSummary is never built, so live capture
// observability is the only source. Backend attribution matters MOST
// here — a win32 D3D11 crash is what the rollout is watching for.
...renderObservabilityEventProperties(props),
},
props.distinctId,
@@ -633,14 +660,28 @@ export function trackRenderFeedback(props: {
renderDurationMs?: number;
comment?: string;
doctorSummary?: string;
/**
* Join key shared with the forwarded feedback report (Slack/backend): the
* same uuid rides in the report's env string as `fid=…`, so a wild report
* resolves to exactly one PostHog `cli_render_feedback` event and vice versa.
*/
feedbackId?: string;
/** render_job_id values of this install's recent renders (newest last). */
recentRenderIds?: string[];
}): void {
trackEvent("survey sent", {
$survey_id: "render_satisfaction",
$survey_response: props.rating,
// Plain product event, not a PostHog survey response: nothing here is served
// by the surveys product (no survey definition, no targeting, no popover).
trackEvent("cli_render_feedback", {
rating: props.rating,
rating_scale: FEEDBACK_RATING_SCALE,
...(props.comment ? { $survey_response_2: props.comment } : {}),
...(props.comment ? { comment: props.comment } : {}),
...(props.renderDurationMs !== undefined ? { render_duration_ms: props.renderDurationMs } : {}),
...(props.doctorSummary ? { doctor_summary: props.doctorSummary } : {}),
...(props.feedbackId ? { feedback_id: props.feedbackId } : {}),
// Comma-joined: EventProperties values are scalars only.
...(props.recentRenderIds?.length
? { recent_render_ids: props.recentRenderIds.join(",") }
: {}),
});
}
@@ -43,6 +43,7 @@ export function renderObservabilityTelemetryPayload(
captureDeWorkerInversion: capture.deWorkerInversion,
captureDePreInversionWorkers: capture.dePreInversionWorkers,
captureDeParallelRouter: capture.deParallelRouter,
captureDeGpuRenderer: capture.deGpuRenderer,
captureDePreRouterWorkers: capture.dePreRouterWorkers,
captureDeSelfVerifyFallback: capture.deSelfVerifyFallback,
captureDeFallbackReason: capture.deFallbackReason,
+150
View File
@@ -0,0 +1,150 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readConfig } from "./config.js";
// This is a public project API key — safe to embed in client-side code.
// It only allows writing events, not reading data.
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_TIMEOUT_MS = 5_000;
// ---------------------------------------------------------------------------
// Lightweight PostHog transport — talks to the HTTP batch API directly to
// avoid pulling in the full posthog-node SDK and its dependencies. Owns the
// in-memory event queue and the two delivery paths: the async `flush()` used
// during a live process, and the exit-time `flushSync()` that hands the queue
// to a detached child which outlives the parent.
//
// This is the reliability-critical layer — telemetry must never break the CLI,
// and events must survive the render command's abrupt `process.exit()` teardown
// (see `flush()` for the exit-race that made this subtle). The CLI-facing policy
// (opt-out, system-metadata enrichment, first-run notice) lives in client.ts.
// ---------------------------------------------------------------------------
export interface EventProperties {
[key: string]: string | number | boolean | null | undefined;
}
interface QueuedEvent {
// Client-generated event id. PostHog dedupes on it, so an event that gets
// sent by an interrupted flush() AND re-sent by the exit-time flushSync()
// fallback still counts once.
uuid: string;
event: string;
properties: EventProperties;
timestamp: string;
// Override for the batch distinct_id. Defaults to the install's anonymousId.
// Used to attribute server-side studio renders to the browser user who
// triggered them, so the render funnel is joinable across processes.
distinctId?: string;
}
let eventQueue: QueuedEvent[] = [];
/**
* Append an event to the in-memory queue, stamping it with a client-generated
* `uuid` (PostHog's dedup key) and an ISO timestamp. Non-blocking; the caller
* is responsible for enrichment (system metadata, cli_version, ).
*/
export function enqueue(event: string, properties: EventProperties, distinctId?: string): void {
eventQueue.push({
uuid: randomUUID(),
event,
distinctId,
properties,
timestamp: new Date().toISOString(),
});
}
/**
* Serialize events into a PostHog `/batch/` payload string. Pure the queue
* is untouched, so callers decide when events count as delivered.
*
* Each event carries its client-generated `uuid`, which PostHog treats as the
* event id re-sending the same event is idempotent, not a duplicate.
*
* $ip:null tells PostHog not to record the request IP for any of these events.
* Server-side "Discard client IP data" is also enabled in project settings.
*/
function buildPayload(events: readonly QueuedEvent[]): string | null {
if (events.length === 0) return null;
const config = readConfig();
const batch = events.map((e) => ({
uuid: e.uuid,
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: e.distinctId ?? config.anonymousId,
timestamp: e.timestamp,
}));
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
}
/**
* Flush all queued events to PostHog via async HTTP POST.
* Call sites: the `beforeExit` hook in cli.ts (normal exit), eager sends right
* after high-value events (trackRenderComplete / trackRenderError), and the
* `events` beacon command, which awaits delivery before its process exits.
*
* Events are only removed from the queue once the request has completed.
* The old drain-first version silently lost the whole batch whenever the
* process died with the fetch in flight which is the NORMAL exit path for
* `render`: an agent pipe closing triggers the EPIPE `process.exit(0)`, and
* error paths call `process.exit(1)` directly, both killing the in-flight
* request that `beforeExit` had just started. Keeping the queue intact until
* delivery lets the exit-time flushSync() child (which survives the parent)
* re-send anything unconfirmed; event uuids make that re-send idempotent.
*/
export async function flush(): Promise<void> {
// Copy, not alias — events queued while the request is in flight must not
// be swept into the "delivered" set below.
const snapshot = eventQueue.slice();
const payload = buildPayload(snapshot);
if (payload == null) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
try {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json", Connection: "close" },
body: payload,
signal: controller.signal,
});
// Delivered — forget exactly what was sent (events queued while the
// request was in flight stay for the next flush).
const sent = new Set(snapshot);
eventQueue = eventQueue.filter((e) => !sent.has(e));
} catch {
// Silently ignore — telemetry must never break the CLI. The events stay
// queued so the exit-time flushSync() fallback can still deliver them.
} finally {
clearTimeout(timeout);
}
}
/**
* Fire-and-forget flush for use in the `exit` event handler.
* Spawns a detached child process that sends the HTTP request independently,
* so the parent process exits immediately without waiting.
*/
export function flushSync(): void {
const payload = buildPayload(eventQueue);
if (payload == null) return;
eventQueue = [];
try {
const child = spawn(
process.execPath,
[
"-e",
`fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`,
],
{ detached: true, stdio: "ignore" },
);
// Let the parent exit without waiting for the child
child.unref();
} catch {
// Silently ignore
}
}

Some files were not shown because too many files have changed in this diff Show More