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
+57 -13
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 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. 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 is the clock**: all beat times come from `vo-words.json`; a VO regen re-opens
every seam. 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 ### 5 · Build
Follow `references/build-spec.md` exactly: brand tokens + fonts (bundled in 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 → order: `ledger.json` (all ordinary seams cut-the-curve LEFT) → seam-stamp →
internal beats on VO words → seam-gate verify. 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) ### 6 · Gates (all green before presenting)
1. `bun run --cwd packages/cli hyperframes check` (or the installed 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 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, frames from the MP4 (`ffmpeg -ss <t> … -frames:v 1`): captions present,
background video not black, no tiny/frozen frames. 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 ## Project layout
@@ -136,15 +178,17 @@ projects/active/weekly-changelog-<range>/
## Anti-patterns ## Anti-patterns
| Don't | Instead | | Don't | Instead |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Bullet-point slides for UI changes | Mock the surface acting out the change | | Bullet-point slides for UI changes | Mock the surface acting out the change |
| Fake UI for un-representable items | Honest checklist scene | | Fake UI for un-representable items | Honest checklist scene |
| Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard | | Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard |
| Phonetic spellings in captions | Captions always render the display layer | | Phonetic spellings in captions | Captions always render the display layer |
| Guessing an unknown term's pronunciation | Ask, then grow the lexicon | | Guessing an unknown term's pronunciation | Ask, then grow the lexicon |
| Speaking every changelog item | ≤3 per theme; the digest link carries the rest | | Speaking every changelog item | ≤3 per theme; the digest link carries the rest |
| Green accents everywhere | One green moment per scene (#5ef17c) | | Green accents everywhere | One green moment per scene (#5ef17c) |
| Starting from a prior video's index.html | Step 0 — copy `examples/master-skeleton.html` from this skill into `project/index.html`, always | | 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 | | 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 | | 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); .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); } 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 */ /* 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; #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: 25px; z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 32px;
letter-spacing: .01em; color: rgba(245,246,244,.92); 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); text-shadow: 0 2px 14px rgba(0,0,0,.85), 0 0 3px rgba(0,0,0,.6);
white-space: nowrap; pointer-events: none; } white-space: nowrap; pointer-events: none; }
.cap-phrase { position: absolute; left: 0; right: 0; } .cap-phrase { position: absolute; left: 0; right: 0; }
@@ -76,7 +76,7 @@ otherwise) and must stay flat 2D (no 3D ancestors).
y ∈ [288, 944]. y ∈ [288, 944].
- Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule, - Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule,
mono URL chip, tag line. Fade all + chrome ~0.5s before end. 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) ## Seams + internal life (doctrine mechanics)
@@ -105,7 +105,7 @@ transcript) before the captions are trusted.
## Caption rail (rendering) ## Caption rail (rendering)
Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line, Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line,
bottom-center (top: 1002px on 1080-square), TT Norms Pro 500 25px, bottom-center (top: 990px, height: 52px on 1080-square), TT Norms Pro 500 32px,
ink .92, soft dark text-shadow, words fading in (0.12s) on their timestamps, 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 ~80px phrase swaps as sets. Keep critical small text out of the bottom ~100px
center span; everything else may run under the rail. center span; everything else may run under the rail.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "hyperframes", "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.", "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": { "author": {
"name": "HeyGen", "name": "HeyGen",
"email": "hyperframes@heygen.com", "email": "hyperframes@heygen.com",
+57 -13
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 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. 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 is the clock**: all beat times come from `vo-words.json`; a VO regen re-opens
every seam. 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 ### 5 · Build
Follow `references/build-spec.md` exactly: brand tokens + fonts (bundled in 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 → order: `ledger.json` (all ordinary seams cut-the-curve LEFT) → seam-stamp →
internal beats on VO words → seam-gate verify. 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) ### 6 · Gates (all green before presenting)
1. `bun run --cwd packages/cli hyperframes check` (or the installed 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 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, frames from the MP4 (`ffmpeg -ss <t> … -frames:v 1`): captions present,
background video not black, no tiny/frozen frames. 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 ## Project layout
@@ -136,15 +178,17 @@ projects/active/weekly-changelog-<range>/
## Anti-patterns ## Anti-patterns
| Don't | Instead | | Don't | Instead |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Bullet-point slides for UI changes | Mock the surface acting out the change | | Bullet-point slides for UI changes | Mock the surface acting out the change |
| Fake UI for un-representable items | Honest checklist scene | | Fake UI for un-representable items | Honest checklist scene |
| Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard | | Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard |
| Phonetic spellings in captions | Captions always render the display layer | | Phonetic spellings in captions | Captions always render the display layer |
| Guessing an unknown term's pronunciation | Ask, then grow the lexicon | | Guessing an unknown term's pronunciation | Ask, then grow the lexicon |
| Speaking every changelog item | ≤3 per theme; the digest link carries the rest | | Speaking every changelog item | ≤3 per theme; the digest link carries the rest |
| Green accents everywhere | One green moment per scene (#5ef17c) | | Green accents everywhere | One green moment per scene (#5ef17c) |
| Starting from a prior video's index.html | Step 0 — copy `examples/master-skeleton.html` from this skill into `project/index.html`, always | | 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 | | 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 | | 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); .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); } 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 */ /* 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; #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: 25px; z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 32px;
letter-spacing: .01em; color: rgba(245,246,244,.92); 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); text-shadow: 0 2px 14px rgba(0,0,0,.85), 0 0 3px rgba(0,0,0,.6);
white-space: nowrap; pointer-events: none; } white-space: nowrap; pointer-events: none; }
.cap-phrase { position: absolute; left: 0; right: 0; } .cap-phrase { position: absolute; left: 0; right: 0; }
@@ -76,7 +76,7 @@ otherwise) and must stay flat 2D (no 3D ancestors).
y ∈ [288, 944]. y ∈ [288, 944].
- Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule, - Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule,
mono URL chip, tag line. Fade all + chrome ~0.5s before end. 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) ## Seams + internal life (doctrine mechanics)
@@ -105,7 +105,7 @@ transcript) before the captions are trusted.
## Caption rail (rendering) ## Caption rail (rendering)
Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line, Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line,
bottom-center (top: 1002px on 1080-square), TT Norms Pro 500 25px, bottom-center (top: 990px, height: 52px on 1080-square), TT Norms Pro 500 32px,
ink .92, soft dark text-shadow, words fading in (0.12s) on their timestamps, 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 ~80px phrase swaps as sets. Keep critical small text out of the bottom ~100px
center span; everything else may run under the rail. center span; everything else may run under the rail.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "hyperframes", "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.", "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": { "author": {
"name": "HeyGen", "name": "HeyGen",
"email": "hyperframes@heygen.com", "email": "hyperframes@heygen.com",
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "hyperframes", "name": "hyperframes",
"displayName": "HyperFrames by HeyGen", "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.", "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": { "author": {
"name": "HeyGen", "name": "HeyGen",
"email": "hyperframes@heygen.com" "email": "hyperframes@heygen.com"
+30
View File
@@ -4,6 +4,7 @@
"packages/producer/src/**/*.test.ts", "packages/producer/src/**/*.test.ts",
"packages/aws-lambda/src/**/*.test.ts", "packages/aws-lambda/src/**/*.test.ts",
"packages/gcp-cloud-run/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.ts",
"packages/producer/src/regression-harness-distributed.test.ts", "packages/producer/src/regression-harness-distributed.test.ts",
"packages/producer/src/regression-harness-lambda-local.ts", "packages/producer/src/regression-harness-lambda-local.ts",
@@ -397,6 +398,18 @@
// require intrusive middleware changes beyond this PR's scope. // require intrusive middleware changes beyond this PR's scope.
"minLines": 6, "minLines": 6,
"ignore": [ "ignore": [
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
// extracting a shared cloud abstraction would couple independent packages.
"packages/aws-lambda/src/handler.ts",
"packages/aws-lambda/src/s3Transport.ts",
// 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 // sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two // and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them. // optional fields here, but the line shift makes fallow re-flag them.
@@ -593,6 +606,23 @@
// makes non-trivial. // makes non-trivial.
"packages/studio-server/src/helpers/screenshotClip.ts", "packages/studio-server/src/helpers/screenshotClip.ts",
"packages/studio/vite.browser.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": { "health": {
+50
View File
@@ -34,6 +34,8 @@ jobs:
code: ${{ steps.filter.outputs.code }} code: ${{ steps.filter.outputs.code }}
cli: ${{ steps.filter.outputs.cli }} cli: ${{ steps.filter.outputs.cli }}
skills: ${{ steps.filter.outputs.skills }} skills: ${{ steps.filter.outputs.skills }}
codex_plugin: ${{ steps.filter.outputs.codex_plugin }}
gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }}
steps: steps:
# Force git-based change detection instead of the pull_request REST API. # Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles # The API path can fail the whole workflow on transient listFiles
@@ -68,6 +70,40 @@ jobs:
- "scripts/check-skill-mirror.mjs" - "scripts/check-skill-mirror.mjs"
- "package.json" - "package.json"
- ".github/workflows/ci.yml" - ".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: build:
name: Build name: Build
@@ -339,6 +375,20 @@ jobs:
- name: Verify .claude/skills/ and .agents/skills/ are byte-identical - name: Verify .claude/skills/ and .agents/skills/ are byte-identical
run: node scripts/check-skill-mirror.mjs 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: cli-npx-shim:
name: "CLI: npx shim (${{ matrix.os }})" name: "CLI: npx shim (${{ matrix.os }})"
needs: changes needs: changes
+17 -26
View File
@@ -29,6 +29,7 @@ jobs:
timeout-minutes: 2 timeout-minutes: 2
outputs: outputs:
code: ${{ steps.filter.outputs.code }} code: ${{ steps.filter.outputs.code }}
matrix: ${{ steps.shards.outputs.matrix }}
steps: steps:
# Force git-based change detection instead of the pull_request REST API. # Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles # The API path can fail the whole workflow on transient listFiles
@@ -46,6 +47,14 @@ jobs:
- "packages/producer/**" - "packages/producer/**"
- "packages/engine/**" - "packages/engine/**"
- "Dockerfile*" - "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: preflight:
name: Preflight (lint + format) name: Preflight (lint + format)
@@ -64,30 +73,10 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: true fail-fast: true
matrix: # Bin-packed at run time from packages/producer/tests/shard-schedule.json.
# Shards are bin-packed by measured per-test duration (LPT heuristic on # To rebalance, refresh the timings in that file — no YAML edit needed.
# CI run 25893372795) so each row carries ~15-16 min of work. When a # To change shard count, set "shardCount" there.
# new fixture lands, drop it into the currently-lightest shard or matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
# 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"
steps: steps:
- name: Checkout (with LFS) - name: Checkout (with LFS)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -124,15 +113,17 @@ jobs:
# concurrent exports cannot exhaust the Actions cache service. # concurrent exports cannot exhaust the Actions cache service.
cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max,scope=regression-test-image' || '' }} 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: | run: |
echo "Shard: ${{ matrix.shard }}" echo "Shard: ${{ matrix.shard }}"
echo "Args: ${{ matrix.args }}" echo "Mode: ${{ matrix.mode }}"
echo "Args: ${{ matrix.args }}"
docker run --rm \ docker run --rm \
--security-opt seccomp=unconfined \ --security-opt seccomp=unconfined \
--shm-size=4g \ --shm-size=4g \
-v ${{ github.workspace }}/packages/producer/tests:/app/packages/producer/tests \ -v ${{ github.workspace }}/packages/producer/tests:/app/packages/producer/tests \
hyperframes-producer:test \ hyperframes-producer:test \
--mode=${{ matrix.mode }} \
${{ matrix.args }} ${{ matrix.args }}
- name: Upload failure artifacts - 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 COPY packages/sdk-playground/package.json packages/sdk-playground/package.json
RUN bun install --frozen-lockfile 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/parsers/ packages/parsers/
COPY packages/lint/ packages/lint/ COPY packages/lint/ packages/lint/
COPY packages/studio-server/ packages/studio-server/ COPY packages/studio-server/ packages/studio-server/
COPY packages/core/ packages/core/ 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 # Build workspace packages so "node" export conditions resolve to built dist
RUN bun run --filter '@hyperframes/{parsers,lint,studio-server}' build \ 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) # Build core runtime artifacts (needed by renderer)
RUN bun run --filter @hyperframes/core build:hyperframes-runtime:modular 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) # Generate embedded font data (deterministicFonts.ts imports this at runtime)
RUN cd packages/producer && bunx tsx scripts/generate-font-data.ts 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. 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 ### Router
| Skill | Use when | | Skill | Use when |
+16 -13
View File
@@ -23,7 +23,7 @@
}, },
"packages/aws-lambda": { "packages/aws-lambda": {
"name": "@hyperframes/aws-lambda", "name": "@hyperframes/aws-lambda",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0",
@@ -43,6 +43,7 @@
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"yaml": "^2.9.0",
}, },
"peerDependencies": { "peerDependencies": {
"aws-cdk-lib": "^2.130.0", "aws-cdk-lib": "^2.130.0",
@@ -55,7 +56,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@hyperframes/cli", "name": "@hyperframes/cli",
"version": "0.7.60", "version": "0.7.71",
"bin": { "bin": {
"hyperframes": "./bin/hyperframes.mjs", "hyperframes": "./bin/hyperframes.mjs",
}, },
@@ -106,7 +107,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@hyperframes/core", "name": "@hyperframes/core",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.5", "@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*", "@hyperframes/lint": "workspace:*",
@@ -131,7 +132,7 @@
}, },
"packages/engine": { "packages/engine": {
"name": "@hyperframes/engine", "name": "@hyperframes/engine",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^", "@hyperframes/core": "workspace:^",
@@ -150,7 +151,7 @@
}, },
"packages/gcp-cloud-run": { "packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run", "name": "@hyperframes/gcp-cloud-run",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.14.0", "@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0", "@google-cloud/workflows": "^4.2.0",
@@ -166,16 +167,18 @@
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"yaml": "^2.9.0",
}, },
}, },
"packages/lint": { "packages/lint": {
"name": "@hyperframes/lint", "name": "@hyperframes/lint",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.4",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
@@ -187,7 +190,7 @@
}, },
"packages/parsers": { "packages/parsers": {
"name": "@hyperframes/parsers", "name": "@hyperframes/parsers",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@babel/parser": "^7.27.0", "@babel/parser": "^7.27.0",
"acorn": "^8.17.0", "acorn": "^8.17.0",
@@ -207,7 +210,7 @@
}, },
"packages/player": { "packages/player": {
"name": "@hyperframes/player", "name": "@hyperframes/player",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
}, },
@@ -222,7 +225,7 @@
}, },
"packages/producer": { "packages/producer": {
"name": "@hyperframes/producer", "name": "@hyperframes/producer",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@fontsource/archivo-black": "^5.2.8", "@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7", "@fontsource/eb-garamond": "^5.2.7",
@@ -267,7 +270,7 @@
}, },
"packages/sdk": { "packages/sdk": {
"name": "@hyperframes/sdk", "name": "@hyperframes/sdk",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*", "@hyperframes/parsers": "workspace:*",
@@ -297,7 +300,7 @@
}, },
"packages/shader-transitions": { "packages/shader-transitions": {
"name": "@hyperframes/shader-transitions", "name": "@hyperframes/shader-transitions",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
}, },
@@ -309,7 +312,7 @@
}, },
"packages/studio": { "packages/studio": {
"name": "@hyperframes/studio", "name": "@hyperframes/studio",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -357,7 +360,7 @@
}, },
"packages/studio-server": { "packages/studio-server": {
"name": "@hyperframes/studio-server", "name": "@hyperframes/studio-server",
"version": "0.7.60", "version": "0.7.71",
"dependencies": { "dependencies": {
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "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`. */} {/* 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 <Update
label="HyperFrames v0.7.65" label="HyperFrames v0.7.65"
description="Released - 2026-07-21" 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 compositions/hero.html
<html data-composition-variables='[ <html data-composition-variables='[
{"id":"gradingPreset","type":"enum","label":"Preset","default":"natural-lift", {"id":"gradingPreset","type":"enum","label":"Preset","default":"clean-studio",
"options":[{"value":"natural-lift","label":"Natural Lift"},{"value":"warm-daylight","label":"Warm Daylight"}]}, "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":"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":"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} {"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/authentication",
"guides/video-components", "guides/video-components",
"guides/color-grading", "guides/color-grading",
"guides/media-effects",
"guides/media-overlays",
"guides/html-in-canvas", "guides/html-in-canvas",
"guides/website-to-video", "guides/website-to-video",
"guides/figma", "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. 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.** 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. 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. 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. Your job is step 1: a composition that imports cleanly and is a strong on-brand starting point.
+262 -163
View File
@@ -1,193 +1,187 @@
--- ---
title: Color Grading 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 | Stage | Purpose | HyperFrames tools |
| Capability | Status | Notes |
| --- | --- | --- | | --- | --- | --- |
| Studio Color Grading panel | Supported | Appears on selected `<video>` and `<img>` elements. | | Correct | Fix exposure, contrast, tonal balance, and casts | Adjust controls and source analysis |
| Manual controls | Supported | Exposure, contrast, highlights, shadows, white point, black point, warmth, tint, vibrance, saturation. | | Grade | Shape color and mood | Tonal wheels, RGB curves, hue curves, and HSL color selections |
| Presets | Supported | Named HyperFrames presets backed by shader settings, not bundled third-party LUT packs. | | Apply a look | Start from a tested style or an existing external look | Presets and custom 3D `.cube` LUTs |
| Custom LUT upload | Supported | Project-local 3D `.cube` LUT files with strength control. | | Finish | Add restrained optical texture | Vignette and grain |
| Finishing | Supported | Vignette and grain, with advanced settings behind the settings icon. | | Stylize | Transform the pixels beyond normal grading | [Media Effects](/guides/media-effects) |
| Effects | Supported | Blur and pixelate on the selected media surface. | | Dress | Add an authored HUD, flash, light leak, or freeze-frame layer | [Media Overlays](/guides/media-overlays) |
| 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. |
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 | <Tabs>
| --- | --- | --- | <Tab title="Studio">
| 1080p SDR video | Yes | Best default path. Good for most uploaded/generated MP4/WebM/MOV media that browsers can decode. | Select a real `<img>` or `<video>` element to open Color Grading in the
| 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. | Design panel.
| 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. |
## 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 npx hyperframes media-treatment \
<video --project . \
id="hero-video" --file compositions/interview.html \
src="assets/hero.mp4" --selector '#interview' \
data-start="0" --grading '{"adjust":{"highlights":-0.08,"shadows":0.06},"wheels":{"midtones":{"hue":32,"amount":0.05}}}' \
data-duration="6" --apply \
muted --json
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>
```
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> To reuse a grade in Studio, use **Copy grade to**, choose **Current file media**
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. or **All project media**, then click **Apply**. Project-wide copy refuses
</Note> 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 reliable path. Remote media requires compatible CORS
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. headers. Color Grading does not process captions, text, SVG, arbitrary DOM, or
</Note> CSS background images.
## Data Shape ## Professional Controls
```json ### Color Wheels
{
"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"
}
```
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 ### RGB Curves
<img
src="assets/product.jpg"
data-color-grading='{
"lut":{"src":"assets/luts/product-pop.cube","intensity":0.7}
}'
/>
```
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. Curve points are normalized `[input, output]` pairs. Resolved curves contain
- 3D cube LUTs up to `LUT_3D_SIZE 64` are supported. input endpoints `0` and `1`; HyperFrames infers missing endpoints, and the
- 1D `.cube` LUTs and mixed 1D+3D LUT files are not supported yet. 216-point limit includes those inferred points. An S-curve increases contrast;
- Supported headers include common `DOMAIN_MIN` / `DOMAIN_MAX` and DaVinci/IRIDAS-style `LUT_3D_INPUT_RANGE`. lifting the lower-left region raises shadows; channel curves can build
- LUTs are not universal. A LUT looks correct only when the source footage roughly matches the LUT's expected input color space. split-tone or cast-removal adjustments.
- 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.
## 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 | | Histogram | Overall distribution from dark to bright |
| Use a look from another editor | Custom 3D `.cube` LUT | | Waveform | Brightness by horizontal image position |
| Add polish to a product shot | Vignette, subtle grain, contrast, vibrance | | RGB Parade | Channel balance and clipped individual channels |
| Blur or pixelate selected media | Effects inside Color Grading | | Vectorscope | Hue direction and saturation |
| 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 |
## 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 HyperFrames ships tested shader-setting presets, not bundled LUT files:
Apply a clean studio preset to assets/interview.mp4, reduce highlights slightly,
lift shadows, add subtle vignette, and keep captions ungraded. | 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 ```html index.html
<video <video
@@ -199,26 +193,131 @@ Expected markup:
playsinline playsinline
data-color-grading='{ data-color-grading='{
"preset":"clean-studio", "preset":"clean-studio",
"intensity":0.8, "intensity":0.85,
"adjust":{"highlights":-0.08,"shadows":0.08}, "adjust":{
"details":{"vignette":0.08} "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> ></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}> <CardGroup cols={2}>
<Card title="Remove Background" icon="scissors" href="/guides/remove-background"> <Card title="Media Effects" icon="wand-magic-sparkles" href="/guides/media-effects">
Create transparent video/image cutouts for presenter and product overlays. Apply and animate shader-based optical, retro, print, and art treatments.
</Card> </Card>
<Card title="Rendering" icon="film" href="/guides/rendering#transparent-background"> <Card title="Media Overlays" icon="layer-group" href="/guides/media-overlays">
Render transparent overlays and final MP4/WebM/MOV outputs. Add editable HUD, flash, light-leak, and freeze-frame composition layers.
</Card> </Card>
<Card title="HDR Rendering" icon="sun" href="/guides/hdr"> <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>
<Card title="4K Rendering" icon="up-right-and-down-left-from-center" href="/guides/4k-rendering"> <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> </Card>
</CardGroup> </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 ### CLI feedback
Event: `cli_render_feedback`
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| `$survey_id` | `render_satisfaction` | | `rating` | Raw rating (010) |
| `$survey_response` | Raw rating (010) |
| `rating_scale` | `10` for the current recommendation scale | | `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 | | `render_duration_ms` | Time the render took in milliseconds |
| `doctor_summary` | System context (see below) | | `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 ### Studio feedback
Event: `studio_feedback`
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| `$survey_id` | `studio_experience` | | `rating` | Raw rating (010) |
| `$survey_response` | Raw rating (010) |
| `rating_scale` | `10` for the current recommendation scale | | `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` | | `source` | `studio` |
| `doctor_summary` | Browser context (platform, screen, CPU cores, device memory, network type) | | `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`) - 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`) - 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`) - Capture key frames as PNG screenshots (`snapshot`)
- Discover, analyze, and apply media grading/effects (`media-treatment`)
- Check your environment for missing dependencies (`doctor`) - Check your environment for missing dependencies (`doctor`)
**Use a different package if you want to:** **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. Runs multiple render configurations (varying fps, quality, and worker count) and compares timing and file size for each.
</Tab> </Tab>
<Tab title="Utilities"> <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` ### `doctor`
Check your environment for required dependencies: 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-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-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-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-width` | div | On compositions | Composition width in pixels. |
| `data-height` | div | On compositions | Composition height in pixels. | | `data-height` | div | On compositions | Composition height in pixels. |
@@ -162,6 +163,34 @@ Common sizes:
</Accordion> </Accordion>
</AccordionGroup> </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 ## Relative Timing
Reference another clip's ID in `data-start` to mean "start when that clip ends": 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." "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. - 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. - 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. - 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: - Use the existing canonical remediation:
- Missing: `curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth` - Missing: install the [HeyGen CLI](https://developers.heygen.com/cli), then run `heygen auth login --oauth`
- Outdated: `heygen update` - 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 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. - 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). 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`. */} {/* 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", "ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/", "PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4", "OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
"PlanProtocol": "v1",
"Config": { "Config": {
"fps": 30, "fps": 30,
"width": 1920, "width": 1920,
@@ -91,6 +92,9 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`. the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
`PlanProtocol` may be `"v1"` or `"v2"`; absent defaults to v1. V2 uses
separate manifest and content-addressed artifact locators throughout the
workflow and never places a v2 object in `PlanS3Uri`.
## Local invocation ## Local invocation
@@ -119,13 +123,14 @@ the architecture works on a deployed Lambda — use the local smoke
script: script:
```bash ```bash
# All defaults (mp4-h264-sdr fixture, chunk counts 2/4/8, PSNR >= 40 dB). # Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
./scripts/smoke.sh ./scripts/smoke.sh
# Customised: # Customised:
./scripts/smoke.sh \ ./scripts/smoke.sh \
--fixture mp4-h264-sdr \ --fixture mp4-h264-sdr \
--chunk-counts 2,4,8,16 \ --chunk-counts 2,4,8,16 \
--plan-protocol both \
--psnr-threshold 40 \ --psnr-threshold 40 \
--reserved-concurrency 8 --reserved-concurrency 8
@@ -141,7 +146,21 @@ per-run stack name, renders the fixture at each chunk count via the
Step Functions state machine, PSNR-compares against the in-process Step Functions state machine, PSNR-compares against the in-process
baseline (which is git-LFS tracked under baseline (which is git-LFS tracked under
`packages/producer/tests/distributed/<fixture>/output/`), captures `packages/producer/tests/distributed/<fixture>/output/`), captures
per-execution Step Functions history, and tears the stack down. per-execution Step Functions history, and tears the stack down. Use
`--plan-protocol both` to run v1 and v2 through the same deployed Lambda
package and baseline. Each v1/v2 pair is also gated directly on per-chunk
hashes from Step Functions history, normalized decoded RGBA frame hashes,
decoded 48 kHz stereo s16le PCM hashes and byte counts, normalized stream
metadata, and duration. Encoded MP4 SHA equality is reported but is
informational unless `--require-encoded-sha-equal` is set. The script
assigns unique function/state-machine names, uses a
dedicated temporary SAM artifact bucket, and removes render objects,
retained buckets, the implicit Lambda log group, and deployment artifacts
on teardown. Suspended-version buckets are purged in 1,000-entry batches,
including concrete versions, null versions, and delete markers. It then
verifies that the stack, both buckets, Lambda, state-machine, and both log
groups are absent; an otherwise-successful run fails if cleanup cannot be
proven.
**Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a **Wall-clock methodology caveat (`eval.sh` only).** `eval.sh` reports a
local-vs-Lambda "speedup" column. The local timing includes `bun` + local-vs-Lambda "speedup" column. The local timing includes `bun` +
@@ -162,9 +181,11 @@ spend is roughly $0.10-$0.20 per pass before S3 transfer. Lower
Outputs land under `<repo-root>/lambda-smoke-artifacts/`: Outputs land under `<repo-root>/lambda-smoke-artifacts/`:
- `results.json``chunkCount × wallClockMs × psnrAvgDb` - `results.json``planProtocol × chunkCount × wallClockMs × psnrAvgDb`
- `renders/N<N>-output.mp4` — each rendered chunk count - `semantic-comparisons.json` — direct v1/v2 semantic gate results
- `renders/N<N>-history.json` — full Step Functions execution history - `renders/<protocol>-N<N>-output.mp4` — each rendered variant
- `renders/<protocol>-N<N>-history.json` — full Step Functions execution history
- `renders/v1-v2-N<N>.*` — normalized frame hashes, ffprobe metadata, and comparison JSON
Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`, Prerequisites: `aws` (v2), `sam` (≥ 1.100), `bun` (≥ 1.3), `ffmpeg`,
`jq`, `zip`. AWS credentials come from the standard resolution chain `jq`, `zip`. AWS credentials come from the standard resolution chain
@@ -0,0 +1,11 @@
{
"Action": "assemble",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkS3Uris": ["s3://example-bucket/renders/sample/chunks/0000.mp4"],
"AudioS3Uri": null,
"OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
"Format": "mp4"
}
@@ -0,0 +1,15 @@
{
"Action": "plan",
"PlanProtocol": "v2",
"ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": {
"fps": 30,
"width": 1920,
"height": 1080,
"format": "mp4",
"chunkSize": 240,
"maxParallelChunks": 8,
"runtimeCap": "lambda"
}
}
@@ -0,0 +1,10 @@
{
"Action": "renderChunk",
"PlanProtocol": "v2",
"PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
"PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Format": "mp4"
}
+181
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"
+337 -44
View File
@@ -18,17 +18,20 @@
# - sam (AWS SAM CLI, >= 1.100) # - sam (AWS SAM CLI, >= 1.100)
# - bun (>= 1.3, to build the handler ZIP) # - bun (>= 1.3, to build the handler ZIP)
# - ffmpeg (system or built-in; PSNR computation) # - ffmpeg (system or built-in; PSNR computation)
# - ffprobe (normalized stream metadata + duration)
# - jq # - jq
# - sha256sum + cmp
# - zip # - zip
# #
# Inputs (flags or env vars): # Inputs (flags or env vars):
# --fixture <name> (default: mp4-h264-sdr) # --fixture <name> (default: mp4-h264-sdr)
# --chunk-counts <list> (default: 2,4,8) # --chunk-counts <list> (default: 2,4,8)
# --psnr-threshold <db> (default: 40) # --psnr-threshold <db> (default: fixture meta.json minPsnr)
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>) # --stack-name <name> (default: hyperframes-lambda-smoke-<unique-run-id>)
# --region <region> (default: $AWS_REGION or us-east-1) # --region <region> (default: $AWS_REGION or us-east-1)
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS # --profile <name> (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain) # default profile resolution chain)
# --plan-protocol <v1|v2|both> (default: v1)
# --keep-stack (skip `sam delete` at the end) # --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one) # --skip-build (skip the ZIP rebuild; use the existing one)
# #
@@ -51,6 +54,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SAM_DIR="$SCRIPT_DIR/.." SAM_DIR="$SCRIPT_DIR/.."
# shellcheck source=./_semantic-compare.sh
source "$SCRIPT_DIR/_semantic-compare.sh"
# shellcheck source=./_s3-purge.sh
source "$SCRIPT_DIR/_s3-purge.sh"
# shellcheck source=./_smoke-config.sh
source "$SCRIPT_DIR/_smoke-config.sh"
# shellcheck source=./_aws-isolation.sh
source "$SCRIPT_DIR/_aws-isolation.sh"
# ── Defaults ────────────────────────────────────────────────────────────── # ── Defaults ──────────────────────────────────────────────────────────────
FIXTURE="${FIXTURE:-mp4-h264-sdr}" FIXTURE="${FIXTURE:-mp4-h264-sdr}"
@@ -63,19 +74,25 @@ CHUNK_COUNTS="${CHUNK_COUNTS:-2,4,8}"
# than the in-process baseline (Debian-bookworm-slim's apt ffmpeg + # than the in-process baseline (Debian-bookworm-slim's apt ffmpeg +
# Puppeteer-managed chrome-headless-shell). Expected drift across those # Puppeteer-managed chrome-headless-shell). Expected drift across those
# environments is ~3 dB on simple fixtures, more on font-heavy ones. # environments is ~3 dB on simple fixtures, more on font-heavy ones.
# The gate defaults to 40 dB to absorb that drift; tighten it via # The gate defaults to the fixture's own `meta.json.minPsnr`, which is
# --psnr-threshold for a stricter check. # calibrated for that content/runtime boundary. Override it via
PSNR_THRESHOLD="${PSNR_THRESHOLD:-40}" # --psnr-threshold (or PSNR_THRESHOLD) for a stricter experiment.
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}" PSNR_THRESHOLD="${PSNR_THRESHOLD-}"
SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}" AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}" AWS_PROFILE="${AWS_PROFILE:-}"
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}"
KEEP_STACK="false" KEEP_STACK="false"
SKIP_BUILD="false" SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
# Lambda Map-state concurrency cap. 16 fans out the chunks aggressively # Lambda Map-state concurrency cap. 16 fans out the chunks aggressively
# at the cost of a higher peak Lambda bill. Drop to 2-4 for cheaper runs; # at the cost of a higher peak Lambda bill. Drop to 2-4 for cheaper runs;
# raise as far as your AWS account's regional concurrency quota allows. # raise as far as your AWS account's regional concurrency quota allows.
RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}" RESERVED_CONCURRENCY="${RESERVED_CONCURRENCY:-16}"
ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts" ARTIFACT_DIR="$REPO_ROOT/lambda-smoke-artifacts"
PROJECT_NAME=""
SAM_DEPLOY_BUCKET=""
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -89,12 +106,14 @@ output against the in-process baseline, and tears the stack down.
Flags: Flags:
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr) --fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8) --chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
--psnr-threshold <db> PSNR floor in dB for visual equivalence (default: 40) --psnr-threshold <db> PSNR floor (default: fixture meta.json minPsnr)
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>) --stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
--region <region> AWS region (default: $AWS_REGION or us-east-1) --region <region> AWS region (default: $AWS_REGION or us-east-1)
--profile <name> AWS profile (default: $AWS_PROFILE) --profile <name> AWS profile (default: $AWS_PROFILE)
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1)
--reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16) --reserved-concurrency <N> Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later) --keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
--skip-build reuse existing dist/handler.zip --skip-build reuse existing dist/handler.zip
-h, --help show this help and exit -h, --help show this help and exit
@@ -105,7 +124,8 @@ Cost notes:
per run before S3 PUT/GET. Set --reserved-concurrency lower for per run before S3 PUT/GET. Set --reserved-concurrency lower for
cost-conscious accounts. cost-conscious accounts.
Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3), ffmpeg, jq, zip. Required tools on PATH: aws (v2), sam (>= 1.100), bun (>= 1.3),
ffmpeg, ffprobe, jq, sha256sum, cmp, zip.
EOF EOF
} }
@@ -118,7 +138,9 @@ while [ $# -gt 0 ]; do
--stack-name) STACK_NAME="$2"; shift 2 ;; --stack-name) STACK_NAME="$2"; shift 2 ;;
--region) AWS_REGION="$2"; shift 2 ;; --region) AWS_REGION="$2"; shift 2 ;;
--profile) AWS_PROFILE="$2"; shift 2 ;; --profile) AWS_PROFILE="$2"; shift 2 ;;
--plan-protocol) PLAN_PROTOCOL="$2"; shift 2 ;;
--keep-stack) KEEP_STACK="true"; shift ;; --keep-stack) KEEP_STACK="true"; shift ;;
--require-encoded-sha-equal) REQUIRE_ENCODED_SHA_EQUAL="true"; shift ;;
--skip-build) SKIP_BUILD="true"; shift ;; --skip-build) SKIP_BUILD="true"; shift ;;
--reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;; --reserved-concurrency) RESERVED_CONCURRENCY="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
@@ -126,6 +148,12 @@ while [ $# -gt 0 ]; do
esac esac
done done
if [ "$PLAN_PROTOCOL" != "v1" ] && [ "$PLAN_PROTOCOL" != "v2" ] && [ "$PLAN_PROTOCOL" != "both" ]; then
echo "ERROR: --plan-protocol must be v1, v2, or both." >&2
exit 1
fi
PROJECT_NAME=$(hf_derive_project_name "$STACK_NAME")
# Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the # Export AWS_REGION + AWS_PROFILE so `aws` and `sam` inherit them via the
# standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's # standard env-var chain. AWS_PROFILE may be empty — that lets the CLI's
# default resolution (env → ~/.aws/config → IMDS) take over without us # default resolution (env → ~/.aws/config → IMDS) take over without us
@@ -143,6 +171,62 @@ fi
# ── Cleanup helper (defined early so the failure paths below can call it) ─ # ── Cleanup helper (defined early so the failure paths below can call it) ─
BUCKET="" BUCKET=""
STATE_MACHINE_ARN=""
verify_absent_api() {
local label="$1" absent_pattern="$2"
shift 2
local output_file status
output_file=$(mktemp)
if "$@" >"$output_file" 2>&1; then
leaks+=("$label")
rm -f "$output_file"
return
else
status=$?
fi
if ! grep -Eiq "$absent_pattern" "$output_file"; then
local detail
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:$label:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_log_group_absent() {
local log_group="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws logs describe-log-groups \
--log-group-name-prefix "$log_group" \
--query "logGroups[?logGroupName=='$log_group'].logGroupName" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("log-group:$log_group")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:log-group:$log_group:exit=$status:$detail")
fi
rm -f "$output_file"
}
verify_state_machine_name_absent() {
local state_machine_name="$1" output_file output status detail
output_file=$(mktemp)
if output=$(aws stepfunctions list-state-machines \
--query "stateMachines[?name=='$state_machine_name'].stateMachineArn" \
--output text 2>"$output_file"); then
if [ -n "$output" ]; then
leaks+=("state-machine-name:$state_machine_name:$output")
fi
else
status=$?
detail=$(tr '\n' ' ' < "$output_file" | cut -c1-240)
leaks+=("verification-error:state-machine-name:$state_machine_name:exit=$status:$detail")
fi
rm -f "$output_file"
}
cleanup_and_exit() { cleanup_and_exit() {
local exit_code="${1:-0}" local exit_code="${1:-0}"
@@ -157,24 +241,135 @@ cleanup_and_exit() {
fi fi
else else
echo "→ Tearing down stack $STACK_NAME" echo "→ Tearing down stack $STACK_NAME"
if [ -n "$BUCKET" ]; then local cleanup_identity_ok=true
aws s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true local stack_cleanup_allowed=false
aws s3 rb "s3://$BUCKET" --force >/dev/null 2>&1 || true local ownership_status=""
local discovery_errors=()
if ! aws sts get-caller-identity >/dev/null; then
cleanup_identity_ok=false
echo "ERROR: AWS identity check failed before cleanup; absence cannot be trusted" >&2
fi fi
(cd "$SAM_DIR" && sam delete \ if [ "$cleanup_identity_ok" = true ]; then
--stack-name "$STACK_NAME" \ if ownership_status=$(hf_stack_ownership_status "$STACK_NAME" "$SMOKE_RUN_ID"); then
--no-prompts) >/dev/null 2>&1 || true if [ "$ownership_status" = "owned" ]; then
stack_cleanup_allowed=true
else
echo "→ Stack is absent; skipping stack-scoped destructive cleanup"
fi
else
discovery_errors+=("verification-error:cloudformation-stack-ownership")
fi
fi
if [ "$stack_cleanup_allowed" = true ]; then
local discovered
if discovered=$(hf_discover_stack_resources "$STACK_NAME"); then
if [ -z "$BUCKET" ]; then
BUCKET=$(jq -r '.renderBucket' <<<"$discovered")
fi
if [ -z "$STATE_MACHINE_ARN" ]; then
STATE_MACHINE_ARN=$(jq -r '.stateMachineArn' <<<"$discovered")
fi
else
discovery_errors+=("verification-error:cloudformation-resource-discovery")
fi
if [ -n "$BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$BUCKET"; then
echo "WARN: failed to purge/delete retained render bucket s3://$BUCKET" >&2
fi
fi
if ! (cd "$SAM_DIR" && sam delete \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--no-prompts); then
echo "WARN: sam delete failed for $STACK_NAME" >&2
fi
if ! aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"; then
echo "WARN: CloudFormation did not confirm stack deletion for $STACK_NAME" >&2
fi
if aws logs describe-log-groups \
--log-group-name-prefix "/aws/lambda/${PROJECT_NAME}-render" \
--query "logGroups[?logGroupName=='/aws/lambda/${PROJECT_NAME}-render'].logGroupName" \
--output text | grep -q .; then
if ! aws logs delete-log-group --log-group-name "/aws/lambda/${PROJECT_NAME}-render"; then
echo "WARN: failed to delete Lambda log group" >&2
fi
fi
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
if ! hf_delete_s3_bucket_completely "$SAM_DEPLOY_BUCKET"; then
echo "WARN: failed to purge/delete SAM deployment bucket s3://$SAM_DEPLOY_BUCKET" >&2
fi
fi
local leaks=()
if [ "${#discovery_errors[@]}" -gt 0 ]; then
leaks+=("${discovery_errors[@]}")
fi
if [ "$cleanup_identity_ok" != true ]; then
leaks+=("verification-error:aws-identity-unavailable")
fi
verify_absent_api "cloudformation-stack:$STACK_NAME" \
"does not exist" \
aws cloudformation describe-stacks --stack-name "$STACK_NAME"
if [ -n "$BUCKET" ]; then
verify_absent_api "render-bucket:s3://$BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$BUCKET"
fi
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
verify_absent_api "sam-bucket:s3://$SAM_DEPLOY_BUCKET" \
"404|Not Found|NoSuchBucket" \
aws s3api head-bucket --bucket "$SAM_DEPLOY_BUCKET"
fi
verify_absent_api "lambda-function:${PROJECT_NAME}-render" \
"ResourceNotFoundException|Function not found" \
aws lambda get-function --function-name "${PROJECT_NAME}-render"
if [ -n "$STATE_MACHINE_ARN" ]; then
verify_absent_api "state-machine:$STATE_MACHINE_ARN" \
"StateMachineDoesNotExist|does not exist" \
aws stepfunctions describe-state-machine --state-machine-arn "$STATE_MACHINE_ARN"
fi
verify_state_machine_name_absent "${PROJECT_NAME}-render"
local lambda_log="/aws/lambda/${PROJECT_NAME}-render"
local states_log="/aws/states/${PROJECT_NAME}-render"
verify_log_group_absent "$lambda_log"
verify_log_group_absent "$states_log"
if [ "${#leaks[@]}" -gt 0 ]; then
echo "ERROR: AWS cleanup verification found leaked resources:" >&2
printf ' - %s\n' "${leaks[@]}" >&2
if [ "$exit_code" -eq 0 ]; then
exit_code=7
fi
else
echo "→ Cleanup verified: no scoped AWS resources remain"
fi
mkdir -p "$ARTIFACT_DIR"
local cleanup_lines
cleanup_lines=$(mktemp)
if [ "${#leaks[@]}" -gt 0 ]; then
printf '%s\n' "${leaks[@]}" > "$cleanup_lines"
fi
jq -Rn \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg checkedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson originalExitCode "${1:-0}" \
'{
stackName: $stackName,
projectName: $projectName,
checkedAt: $checkedAt,
originalExitCode: $originalExitCode,
leaks: [inputs | select(length > 0)],
cleanupVerified: false
} | .cleanupVerified = (.leaks | length == 0)' \
< "$cleanup_lines" > "$ARTIFACT_DIR/cleanup-verification.json"
rm -f "$cleanup_lines"
fi fi
exit "$exit_code" exit "$exit_code"
} }
# Trap unexpected failures (set -e trips, SIGINT, etc.) so we don't leak
# the deployed stack + bucket on a non-routed error path. Explicit
# cleanup_and_exit calls disarm the trap first so teardown runs once.
trap 'cleanup_and_exit $?' EXIT
# ── Pre-flight checks ───────────────────────────────────────────────────── # ── Pre-flight checks ─────────────────────────────────────────────────────
for cmd in aws sam bun ffmpeg jq zip; do for cmd in aws sam bun ffmpeg ffprobe jq zip sha256sum cmp; do
if ! command -v "$cmd" >/dev/null 2>&1; then if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: '$cmd' not found on PATH." >&2 echo "ERROR: '$cmd' not found on PATH." >&2
exit 1 exit 1
@@ -203,6 +398,25 @@ if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
exit 1 exit 1
fi fi
# This check runs before cleanup is armed or any resource is created.
echo "→ Pre-flight: proving exact AWS resource names are unused"
if ! hf_assert_deploy_isolation "$STACK_NAME" "$PROJECT_NAME"; then
echo "ERROR: refusing to reuse or clean resources not created by this run." >&2
exit 1
fi
# Arm cleanup before atomically reserving the stack name. If another smoke run
# wins the create-stack race, ownership verification prevents this run from
# touching it. If this run wins, every later destructive action requires the
# same ownership tag.
trap 'cleanup_and_exit $?' EXIT
echo "→ Pre-flight: atomically reserving stack name for this smoke run"
if ! hf_reserve_smoke_stack "$STACK_NAME" "$SMOKE_RUN_ID"; then
echo "ERROR: could not reserve stack name; another run may have won the race." >&2
cleanup_and_exit 1
fi
mkdir -p "$ARTIFACT_DIR/renders" mkdir -p "$ARTIFACT_DIR/renders"
# ── 1. Build the handler ZIP ────────────────────────────────────────────── # ── 1. Build the handler ZIP ──────────────────────────────────────────────
@@ -223,21 +437,29 @@ echo "→ SAM validate"
(cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION") (cd "$SAM_DIR" && sam validate --lint --region "$AWS_REGION")
echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)" echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
# ProjectName is intentionally NOT set to $STACK_NAME — the template # Use a per-run resource prefix and deployment bucket. The template has
# uses ProjectName only for the function/state-machine human-facing # explicit FunctionName/StateMachineName properties, so leaving ProjectName
# names, and forcing it long here doesn't help. The BucketName is # at its default makes concurrent smoke stacks overwrite/collide. A dedicated
# auto-generated by CloudFormation per stack so concurrent smoke runs # SAM bucket also lets teardown remove every object created by this run.
# don't collide. Pass --region explicitly here even though ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# AWS_DEFAULT_REGION is set, so a stray samconfig.toml in the working SAM_DEPLOY_BUCKET=$(hf_sam_deploy_bucket_name "$ACCOUNT_ID" "$AWS_REGION" "$SMOKE_RUN_ID")
# directory can't override the script's choice. if [ "$AWS_REGION" = "us-east-1" ]; then
aws s3api create-bucket --bucket "$SAM_DEPLOY_BUCKET" >/dev/null
else
aws s3api create-bucket \
--bucket "$SAM_DEPLOY_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_REGION" >/dev/null
fi
if ! (cd "$SAM_DIR" && sam deploy \ if ! (cd "$SAM_DIR" && sam deploy \
--stack-name "$STACK_NAME" \ --stack-name "$STACK_NAME" \
--region "$AWS_REGION" \ --region "$AWS_REGION" \
--resolve-s3 \ --s3-bucket "$SAM_DEPLOY_BUCKET" \
--capabilities CAPABILITY_IAM \ --capabilities CAPABILITY_IAM \
--no-confirm-changeset \ --no-confirm-changeset \
--no-fail-on-empty-changeset \ --no-fail-on-empty-changeset \
--tags "HyperframesSmokeRun=$SMOKE_RUN_ID" \
--parameter-overrides \ --parameter-overrides \
"ProjectName=$PROJECT_NAME" \
ChromeSource=sparticuz \ ChromeSource=sparticuz \
"ReservedConcurrency=$RESERVED_CONCURRENCY"); then "ReservedConcurrency=$RESERVED_CONCURRENCY"); then
echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2 echo "ERROR: sam deploy failed; tearing down rollback'd stack..." >&2
@@ -254,6 +476,27 @@ STATE_MACHINE_ARN=$(aws cloudformation describe-stacks \
--query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \ --query "Stacks[0].Outputs[?OutputKey=='RenderStateMachineArn'].OutputValue" \
--output text) --output text)
echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN" echo "→ Stack outputs: bucket=$BUCKET state_machine=$STATE_MACHINE_ARN"
jq -n \
--arg stackName "$STACK_NAME" \
--arg projectName "$PROJECT_NAME" \
--arg region "$AWS_REGION" \
--arg renderBucket "$BUCKET" \
--arg samDeployBucket "$SAM_DEPLOY_BUCKET" \
--arg lambdaFunction "${PROJECT_NAME}-render" \
--arg stateMachineArn "$STATE_MACHINE_ARN" \
--arg lambdaLogGroup "/aws/lambda/${PROJECT_NAME}-render" \
--arg statesLogGroup "/aws/states/${PROJECT_NAME}-render" \
'{
stackName: $stackName,
projectName: $projectName,
region: $region,
renderBucket: $renderBucket,
samDeployBucket: $samDeployBucket,
lambdaFunction: $lambdaFunction,
stateMachineArn: $stateMachineArn,
lambdaLogGroup: $lambdaLogGroup,
statesLogGroup: $statesLogGroup
}' > "$ARTIFACT_DIR/aws-resource-scope.json"
# ── 4. Upload fixture as a project tarball ──────────────────────────────── # ── 4. Upload fixture as a project tarball ────────────────────────────────
# tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not # tar.gz (not zip): Lambda's Node 22 base image ships GNU `tar` but not
@@ -267,6 +510,7 @@ rm -rf "$TMP_ARCHIVE"
# ── 5. Render at each chunk count ───────────────────────────────────────── # ── 5. Render at each chunk count ─────────────────────────────────────────
FIXTURE_META="$FIXTURE_DIR/meta.json" FIXTURE_META="$FIXTURE_DIR/meta.json"
PSNR_THRESHOLD=$(hf_resolve_psnr_threshold "$PSNR_THRESHOLD" "$FIXTURE_META")
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META") BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META") BASE_W=$(jq -r '.renderConfig.width // 640' "$FIXTURE_META")
BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META") BASE_H=$(jq -r '.renderConfig.height // 360' "$FIXTURE_META")
@@ -275,13 +519,20 @@ RESULTS_JSON="$ARTIFACT_DIR/results.json"
echo "[]" > "$RESULTS_JSON" echo "[]" > "$RESULTS_JSON"
IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS" IFS=',' read -ra COUNTS <<< "$CHUNK_COUNTS"
if [ "$PLAN_PROTOCOL" = "both" ]; then
PROTOCOLS=(v1 v2)
else
PROTOCOLS=("$PLAN_PROTOCOL")
fi
for PROTOCOL in "${PROTOCOLS[@]}"; do
for N in "${COUNTS[@]}"; do for N in "${COUNTS[@]}"; do
EXEC_NAME="smoke-N$N-$(date +%s)" EXEC_NAME="smoke-$PROTOCOL-N$N-$(date +%s)"
OUTPUT_KEY="renders/$EXEC_NAME/output.mp4" OUTPUT_KEY="renders/$EXEC_NAME/output.mp4"
INPUT_JSON=$(jq -n \ INPUT_JSON=$(jq -n \
--arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \ --arg project "s3://$BUCKET/projects/$FIXTURE.tar.gz" \
--arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \ --arg prefix "s3://$BUCKET/renders/$EXEC_NAME/" \
--arg output "s3://$BUCKET/$OUTPUT_KEY" \ --arg output "s3://$BUCKET/$OUTPUT_KEY" \
--arg protocol "$PROTOCOL" \
--argjson n "$N" \ --argjson n "$N" \
--argjson fps "$BASE_FPS" \ --argjson fps "$BASE_FPS" \
--argjson w "$BASE_W" \ --argjson w "$BASE_W" \
@@ -290,6 +541,7 @@ for N in "${COUNTS[@]}"; do
ProjectS3Uri: $project, ProjectS3Uri: $project,
PlanOutputS3Prefix: $prefix, PlanOutputS3Prefix: $prefix,
OutputS3Uri: $output, OutputS3Uri: $output,
PlanProtocol: $protocol,
Config: { Config: {
fps: $fps, fps: $fps,
width: $w, width: $w,
@@ -301,7 +553,7 @@ for N in "${COUNTS[@]}"; do
}') }')
echo echo
echo "================== N=$N ==================" echo "================== protocol=$PROTOCOL N=$N =================="
echo "$INPUT_JSON" | jq . echo "$INPUT_JSON" | jq .
START_MS=$(date +%s%3N) START_MS=$(date +%s%3N)
@@ -323,21 +575,21 @@ for N in "${COUNTS[@]}"; do
WALL_MS=$((END_MS - START_MS)) WALL_MS=$((END_MS - START_MS))
if [ "$STATUS" != "SUCCEEDED" ]; then if [ "$STATUS" != "SUCCEEDED" ]; then
echo "ERROR: N=$N execution did not succeed ($STATUS)." >&2 echo "ERROR: protocol=$PROTOCOL N=$N execution did not succeed ($STATUS)." >&2
aws stepfunctions describe-execution \ aws stepfunctions describe-execution \
--execution-arn "$EXEC_ARN" \ --execution-arn "$EXEC_ARN" \
> "$ARTIFACT_DIR/renders/N$N-execution.json" > "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-execution.json"
aws stepfunctions get-execution-history \ aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 200 \ --execution-arn "$EXEC_ARN" --max-results 200 \
> "$ARTIFACT_DIR/renders/N$N-history.json" || true > "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json" || true
cleanup_and_exit 4 cleanup_and_exit 4
fi fi
aws stepfunctions get-execution-history \ aws stepfunctions get-execution-history \
--execution-arn "$EXEC_ARN" --max-results 1000 --output json \ --execution-arn "$EXEC_ARN" --max-results 1000 --output json \
> "$ARTIFACT_DIR/renders/N$N-history.json" > "$ARTIFACT_DIR/renders/$PROTOCOL-N$N-history.json"
OUTPUT_LOCAL="$ARTIFACT_DIR/renders/N$N-output.mp4" OUTPUT_LOCAL="$ARTIFACT_DIR/renders/$PROTOCOL-N$N-output.mp4"
aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL" aws s3 cp "s3://$BUCKET/$OUTPUT_KEY" "$OUTPUT_LOCAL"
PSNR_LOG=$(mktemp) PSNR_LOG=$(mktemp)
@@ -363,34 +615,75 @@ for N in "${COUNTS[@]}"; do
' "$PSNR_LOG") ' "$PSNR_LOG")
rm -f "$PSNR_LOG" rm -f "$PSNR_LOG"
echo "N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB" echo "protocol=$PROTOCOL N=$N wall=${WALL_MS}ms psnr=${PSNR_AVG} dB"
jq --argjson n "$N" \ jq --argjson n "$N" \
--argjson wall "$WALL_MS" \ --argjson wall "$WALL_MS" \
--arg psnr "$PSNR_AVG" \ --arg psnr "$PSNR_AVG" \
'. += [{chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/N\($n)-output.mp4", history: "renders/N\($n)-history.json"}]' \ --arg protocol "$PROTOCOL" \
'. += [{planProtocol: $protocol, chunkCount: $n, wallClockMs: $wall, psnrAvgDb: ($psnr|tonumber), output: "renders/\($protocol)-N\($n)-output.mp4", history: "renders/\($protocol)-N\($n)-history.json"}]' \
"$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON" "$RESULTS_JSON" > "$RESULTS_JSON.tmp" && mv "$RESULTS_JSON.tmp" "$RESULTS_JSON"
done done
done
# ── 6. Gate on PSNR threshold ───────────────────────────────────────────── # ── 6. Direct v1 ↔ v2 semantic equivalence ────────────────────────────────
SEMANTIC_FAILED=0
SEMANTIC_RESULTS_JSON="$ARTIFACT_DIR/semantic-comparisons.json"
echo "[]" > "$SEMANTIC_RESULTS_JSON"
if [ "$PLAN_PROTOCOL" = "both" ]; then
for N in "${COUNTS[@]}"; do
V1_OUTPUT="$ARTIFACT_DIR/renders/v1-N$N-output.mp4"
V2_OUTPUT="$ARTIFACT_DIR/renders/v2-N$N-output.mp4"
V1_HISTORY="$ARTIFACT_DIR/renders/v1-N$N-history.json"
V2_HISTORY="$ARTIFACT_DIR/renders/v2-N$N-history.json"
SEMANTIC_PREFIX="$ARTIFACT_DIR/renders/v1-v2-N$N"
COMPARE_STATUS=0
if hf_compare_render_semantics \
"$V1_OUTPUT" "$V2_OUTPUT" "$SEMANTIC_PREFIX" "$V1_HISTORY" "$V2_HISTORY"; then
echo "PASS: v1/v2 semantic equivalence at N=$N"
else
COMPARE_STATUS=$?
if [ ! -f "${SEMANTIC_PREFIX}.json" ]; then
jq -n --argjson status "$COMPARE_STATUS" \
'{semanticEqual: false, comparisonError: true, comparisonExitCode: $status}' \
> "${SEMANTIC_PREFIX}.json"
fi
echo "FAIL: v1/v2 semantic comparison at N=$N exited $COMPARE_STATUS (see ${SEMANTIC_PREFIX}.json)" >&2
SEMANTIC_FAILED=$((SEMANTIC_FAILED + 1))
fi
jq --argjson n "$N" --slurpfile comparison "${SEMANTIC_PREFIX}.json" \
'. += [($comparison[0] + {chunkCount: $n})]' \
"$SEMANTIC_RESULTS_JSON" > "$SEMANTIC_RESULTS_JSON.tmp" &&
mv "$SEMANTIC_RESULTS_JSON.tmp" "$SEMANTIC_RESULTS_JSON"
jq -r '" chunks=\(.chunks.equal // "error") decoded-video=\(.video.equal // "error") audio=\(.audio.equal // "error") metadata=\(.metadata.equal // "error") duration=\(.duration.equal // "error") encoded-sha=\(.encoded.equal // "error") (informational unless gated)"' \
"${SEMANTIC_PREFIX}.json"
done
fi
# ── 7. Gate on baseline PSNR threshold ────────────────────────────────────
FAILED=0 FAILED=0
while read -r row; do while read -r row; do
N=$(echo "$row" | jq -r .chunkCount) N=$(echo "$row" | jq -r .chunkCount)
PROTOCOL=$(echo "$row" | jq -r .planProtocol)
P=$(echo "$row" | jq -r .psnrAvgDb) P=$(echo "$row" | jq -r .psnrAvgDb)
if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then if awk -v p="$P" -v t="$PSNR_THRESHOLD" 'BEGIN{exit !(p<t)}'; then
echo "FAIL: N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2 echo "FAIL: protocol=$PROTOCOL N=$N PSNR=$P dB below threshold $PSNR_THRESHOLD" >&2
FAILED=$((FAILED + 1)) FAILED=$((FAILED + 1))
fi fi
done < <(jq -c '.[]' "$RESULTS_JSON") done < <(jq -c '.[]' "$RESULTS_JSON")
# ── 7. Summary ──────────────────────────────────────────────────────────── # ── 8. Summary ────────────────────────────────────────────────────────────
echo echo
echo "================ RESULTS ================" echo "================ RESULTS ================"
printf '%-10s %-12s %-10s\n' "ChunkCount" "WallMs" "PSNR (dB)" printf '%-10s %-10s %-12s %-10s\n' "Protocol" "ChunkCount" "WallMs" "PSNR (dB)"
jq -r '.[] | [.chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \ jq -r '.[] | [.planProtocol, .chunkCount, .wallClockMs, .psnrAvgDb] | @tsv' "$RESULTS_JSON" \
| awk -F'\t' '{printf "%-10s %-12s %-10s\n", $1, $2, $3}' | awk -F'\t' '{printf "%-10s %-10s %-12s %-10s\n", $1, $2, $3, $4}'
echo echo
echo "Artifacts: $ARTIFACT_DIR" echo "Artifacts: $ARTIFACT_DIR"
if [ "$SEMANTIC_FAILED" -gt 0 ]; then
echo "FAILED ($SEMANTIC_FAILED v1/v2 semantic mismatches)" >&2
cleanup_and_exit 6
fi
if [ "$FAILED" -gt 0 ]; then if [ "$FAILED" -gt 0 ]; then
echo "FAILED ($FAILED renders below PSNR threshold)" >&2 echo "FAILED ($FAILED renders below PSNR threshold)" >&2
cleanup_and_exit 5 cleanup_and_exit 5
+195 -2
View File
@@ -158,6 +158,7 @@ Resources:
# Lambda's Node 22 runtime sets these by default; explicit for # Lambda's Node 22 runtime sets these by default; explicit for
# clarity + so users can override during local SAM invoke. # clarity + so users can override during local SAM invoke.
TMPDIR: /tmp TMPDIR: /tmp
HYPERFRAMES_RENDER_BUCKET: !Ref RenderBucket
Policies: Policies:
- S3CrudPolicy: - S3CrudPolicy:
BucketName: !Ref RenderBucket BucketName: !Ref RenderBucket
@@ -207,8 +208,27 @@ Resources:
# compound into a multi-hour execution. The longest legitimate # compound into a multi-hour execution. The longest legitimate
# render observed in PR 880's eval was ~3 minutes. # render observed in PR 880's eval was ~3 minutes.
TimeoutSeconds: 3600 TimeoutSeconds: 3600
StartAt: Plan StartAt: SelectPlanProtocol
States: States:
SelectPlanProtocol:
Type: Choice
Choices:
- Variable: $.PlanProtocol
StringEquals: v2
Next: PlanV2
- Variable: $.PlanProtocol
StringEquals: v1
Next: Plan
- Variable: $.PlanProtocol
IsPresent: true
Next: UnsupportedPlanProtocol
Default: Plan
UnsupportedPlanProtocol:
Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED
Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1).
Plan: Plan:
Type: Task Type: Task
Resource: arn:aws:states:::lambda:invoke Resource: arn:aws:states:::lambda:invoke
@@ -220,6 +240,7 @@ Resources:
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix" PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config" Config.$: "$.Config"
ResultSelector: ResultSelector:
PlanProtocol: v1
PlanS3Uri.$: "$.Payload.PlanS3Uri" PlanS3Uri.$: "$.Payload.PlanS3Uri"
PlanHash.$: "$.Payload.PlanHash" PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount" ChunkCount.$: "$.Payload.ChunkCount"
@@ -239,6 +260,12 @@ Resources:
- BROWSER_GPU_NOT_SOFTWARE - BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED - FONT_FETCH_FAILED
- PLAN_TOO_LARGE - PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0 MaxAttempts: 0
- ErrorEquals: [States.ALL] - ErrorEquals: [States.ALL]
@@ -248,6 +275,52 @@ Resources:
MaxDelaySeconds: 60 MaxDelaySeconds: 60
Next: BuildChunkList Next: BuildChunkList
PlanV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: plan
PlanProtocol: v2
ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
ResultSelector:
PlanProtocol: v2
PlanV2ManifestS3Uri.$: "$.Payload.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Payload.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Payload.PlanHash"
ChunkCount.$: "$.Payload.ChunkCount"
Format.$: "$.Payload.Format"
HasAudio.$: "$.Payload.HasAudio"
ResultPath: $.Plan
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- FONT_FETCH_FAILED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- 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: BuildChunkList:
# Translate ChunkCount into an array `[0, 1, ..., N-1]` so the # Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
# Map state below has something to iterate. Range is the # Map state below has something to iterate. Range is the
@@ -269,7 +342,7 @@ Resources:
Choices: Choices:
- Variable: $.Plan.ChunkCount - Variable: $.Plan.ChunkCount
NumericGreaterThan: 0 NumericGreaterThan: 0
Next: RenderChunks Next: SelectWorkerProtocol
Default: PlanProducedZeroChunks Default: PlanProducedZeroChunks
PlanProducedZeroChunks: PlanProducedZeroChunks:
@@ -277,6 +350,14 @@ Resources:
Error: PLAN_TOO_LARGE Error: PLAN_TOO_LARGE
Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation. Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.
SelectWorkerProtocol:
Type: Choice
Choices:
- Variable: $.Plan.PlanProtocol
StringEquals: v2
Next: RenderChunksV2
Default: RenderChunks
RenderChunks: RenderChunks:
Type: Map Type: Map
ItemsPath: $.Iterator.ChunkIndexes ItemsPath: $.Iterator.ChunkIndexes
@@ -320,6 +401,12 @@ Resources:
- FFMPEG_VERSION_MISMATCH - FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH - PLAN_HASH_MISMATCH
- BROWSER_GPU_NOT_SOFTWARE - BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0 MaxAttempts: 0
- ErrorEquals: [States.ALL] - ErrorEquals: [States.ALL]
IntervalSeconds: 2 IntervalSeconds: 2
@@ -356,6 +443,112 @@ Resources:
- FFMPEG_VERSION_MISMATCH - FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH - PLAN_HASH_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
IntervalSeconds: 2
MaxAttempts: 4
BackoffRate: 2
MaxDelaySeconds: 60
End: true
RenderChunksV2:
Type: Map
ItemsPath: $.Iterator.ChunkIndexes
ItemSelector:
ChunkIndex.$: "$$.Map.Item.Value"
PlanV2ManifestS3Uri.$: "$.Plan.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.Plan.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.Plan.PlanHash"
ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Format.$: "$.Plan.Format"
MaxConcurrencyPath: $.Plan.ChunkCount
ResultPath: $.Chunks
ItemProcessor:
ProcessorConfig:
Mode: INLINE
StartAt: RenderChunkV2
States:
RenderChunkV2:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: renderChunk
PlanProtocol: v2
ChunkIndex.$: "$.ChunkIndex"
PlanV2ManifestS3Uri.$: "$.PlanV2ManifestS3Uri"
PlanV2ArtifactS3Prefix.$: "$.PlanV2ArtifactS3Prefix"
PlanHash.$: "$.PlanHash"
ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
Format.$: "$.Format"
ResultSelector:
ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
ChunkIndex.$: "$.Payload.ChunkIndex"
Sha256.$: "$.Payload.Sha256"
Retry:
- ErrorEquals:
- FFMPEG_VERSION_MISMATCH
- PLAN_HASH_MISMATCH
- S3_URI_NOT_ALLOWED
- BROWSER_GPU_NOT_SOFTWARE
- PLAN_TOO_LARGE
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- 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 MaxAttempts: 0
- ErrorEquals: [States.ALL] - ErrorEquals: [States.ALL]
IntervalSeconds: 2 IntervalSeconds: 2
+110 -25
View File
@@ -1,51 +1,136 @@
# Google Cloud Run example # 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 ## Layout
``` ```text
scripts/smoke.sh Real-GCP smoke: build → deploy render → PSNR → destroy scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup
sample-events/ Example request bodies for the Cloud Run handler sample-events/ v1 and v2 handler request examples
(plan.json, render-chunk.json, assemble.json)
``` ```
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 ## Prerequisites
- `gcloud` authenticated, with a project that has **billing enabled** - `gcloud` authenticated to a project with billing enabled
- `terraform` ( 1.5), `docker`, `ffmpeg`, `jq` on PATH - `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 ## Run the smoke
V1 remains the safe default:
```bash ```bash
# Renders the mp4-h264-sdr fixture through the workflow and PSNR-compares it ./scripts/smoke.sh \
# against the in-process baseline, then tears the stack down. --project YOUR_GCP_PROJECT \
./scripts/smoke.sh --project YOUR_GCP_PROJECT --region us-central1 --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
``` ```
Outputs land in `scripts/gcp-smoke-artifacts/`: `results.json` Explicitly run v1/v2 end-to-end parity at one or more chunk sizes:
(`chunkSize × wallClockMs × psnrAvgDb`), the rendered MP4s, and each
workflow execution's describe output. ```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 ## Test the handler locally
The sample events exercise the same body shape Cloud Workflows sends. With the The sample events mirror the request bodies sent by Cloud Workflows:
container running locally (`PORT=8080`) and credentials that can reach a GCS
bucket, you can drive a single action:
```bash ```bash
# V1
curl -sX POST localhost:8080/ \ curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
--data @sample-events/plan.json | jq . --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` Replace `PROJECT`, locator placeholders, and plan hashes with values returned
with real values from a prior `plan` response. 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", "Action": "assemble",
"PlanProtocol": "v1",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"ChunkGcsUris": [ "ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4", "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", "Action": "plan",
"PlanProtocol": "v1",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz", "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" } "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", "Action": "renderChunk",
"PlanProtocol": "v1",
"PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
"PlanHash": "REPLACE_WITH_PLAN_HASH", "PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0, "ChunkIndex": 0,
+634 -187
View File
@@ -1,239 +1,686 @@
#!/usr/bin/env bash #!/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 # The default is intentionally v1-only. Plan protocol v2 must be opted into
# container, pushes it to Artifact Registry, applies the Terraform module at # explicitly with --protocols v1,v2. Every invocation derives a unique,
# packages/gcp-cloud-run/terraform to your project, renders a fixture # length-safe resource prefix and uses an isolated Terraform working directory
# composition through the Cloud Workflows definition, PSNR-compares the # and state file. Cleanup verifies every owned resource is absent and fails
# output against the in-process baseline, and tears the stack down. # closed on API/authentication errors.
# #
# Usage: # Usage:
# ./smoke.sh --project <gcp-project> # ./smoke.sh --project <gcp-project>
# ./smoke.sh --project p --fixture mp4-h264-sdr --chunk-sizes 15,30 # ./smoke.sh --project p --protocols v1,v2 --chunk-sizes 15,30
# ./smoke.sh --project p --keep-stack # ./smoke.sh --project p --owner james-plan-v2 --keep-stack
# #
# Required tools on PATH: # Required tools:
# - gcloud (authenticated; the target project must have billing enabled) # gcloud, terraform (>= 1.5), ffmpeg, ffprobe, jq, tar, sha256sum
# - 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
# #
# Exit codes: # Exit codes:
# 0 all good 1 arg/pre-flight 2 build/push 3 terraform apply # 0 success 1 arguments/pre-flight
# 4 a render failed 5 PSNR below threshold # 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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TF_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform" TF_SOURCE_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform"
# ── Defaults ──────────────────────────────────────────────────────────────
PROJECT="${GCP_PROJECT:-}" PROJECT="${GCP_PROJECT:-}"
REGION="${GCP_REGION:-us-central1}" REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}" FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}" CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}" 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 KEEP_STACK=0
SKIP_BUILD=0 SKIP_BUILD=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--project) PROJECT="$2"; shift 2 ;; --project) PROJECT="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;; --region) REGION="$2"; shift 2 ;;
--fixture) FIXTURE="$2"; shift 2 ;; --fixture) FIXTURE="$2"; shift 2 ;;
--chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;; --chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;; --psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--repo) AR_REPO="$2"; shift 2 ;; --protocols) PROTOCOLS="$2"; shift 2 ;;
--keep-stack) KEEP_STACK=1; shift ;; --owner) OWNER="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;; --repo) AR_REPO="$2"; AR_REPO_WAS_EXPLICIT=1; shift 2 ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;; --image) EXPLICIT_IMAGE="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;; --keep-stack) KEEP_STACK=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; exit 1 ;;
esac esac
done done
[ -n "$PROJECT" ] || { echo "ERROR: --project (or \$GCP_PROJECT) is required" >&2; exit 1; } [ -n "$PROJECT" ] || {
for tool in gcloud terraform docker ffmpeg jq; do echo "ERROR: --project (or GCP_PROJECT) is required" >&2
command -v "$tool" >/dev/null || { echo "ERROR: $tool not on PATH" >&2; exit 1; } 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 done
FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE" FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE"
FIXTURE_META="$FIXTURE_DIR/meta.json" FIXTURE_META="$FIXTURE_DIR/meta.json"
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4" BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
[ -d "$FIXTURE_DIR/src" ] || { echo "ERROR: fixture src missing: $FIXTURE_DIR/src" >&2; exit 1; } [ -d "$FIXTURE_DIR/src" ] || {
[ -f "$BASELINE_MP4" ] || { echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2; exit 1; } 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" BUCKET="$STACK_NAME-render-$PROJECT"
mkdir -p "$ARTIFACT_DIR/renders" 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 ────────────────────────────────────────────────────────── verify_absent() {
echo "→ Enabling required APIs (idempotent)" local label="$1"
gcloud services enable \ shift
run.googleapis.com workflows.googleapis.com workflowexecutions.googleapis.com \ local error_file="$ARTIFACT_DIR/cleanup-${label//[^a-zA-Z0-9]/-}.stderr"
artifactregistry.googleapis.com cloudbuild.googleapis.com monitoring.googleapis.com \ if "$@" >/dev/null 2>"$error_file"; then
--project "$PROJECT" >/dev/null echo "$label still exists" >&2
return 1
fi
if is_not_found "$error_file"; then
echo "$label absent"
return 0
fi
echo " ✗ could not verify $label absence (API/auth error):" >&2
sed -n '1,12p' "$error_file" >&2
return 1
}
# ── 2. Build + push the render image ──────────────────────────────────────── verify_service_account_absent() {
IMAGE_TXT="$ARTIFACT_DIR/image.txt" local label="$1"
if [ "$SKIP_BUILD" -eq 1 ] && [ -f "$IMAGE_TXT" ]; then local email="$2"
IMAGE="$(cat "$IMAGE_TXT")" local error_file="$ARTIFACT_DIR/cleanup-${label//[^a-zA-Z0-9]/-}.stderr"
echo "→ Reusing image $IMAGE" local matches
else if ! matches="$(gcloud iam service-accounts list \
gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 || \ --project "$PROJECT" \
gcloud artifacts repositories create "$AR_REPO" --repository-format docker \ --filter "email:$email" \
--location "$REGION" --project "$PROJECT" >/dev/null --format 'value(email)' 2>"$error_file")"; then
TAG="$(date +%Y%m%d-%H%M%S)" echo " ✗ could not verify $label absence (API/auth error):" >&2
IMAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/hyperframes-render:$TAG" sed -n '1,12p' "$error_file" >&2
echo "→ Building + pushing $IMAGE via Cloud Build" return 1
# The Dockerfile lives at packages/gcp-cloud-run/Dockerfile, not the repo fi
# root, so we drive the build with an inline cloudbuild config rather than if [ -n "$matches" ]; then
# `--tag` (which assumes a root Dockerfile). echo "$label still exists" >&2
CB_CONFIG="$ARTIFACT_DIR/cloudbuild.yaml" return 1
cat > "$CB_CONFIG" <<EOF fi
steps: echo "$label absent"
- 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"
fi
# ── 3. terraform apply ────────────────────────────────────────────────────── delete_owned_image() {
# The google provider authenticates via Application Default Credentials. If local error_file="$ARTIFACT_DIR/cleanup-image-delete.stderr"
# ADC isn't configured (common on a box set up with only `gcloud auth login`), [ "$CREATED_IMAGE" -eq 1 ] || return 0
# fall back to a short-lived access token from the active gcloud account. echo "→ Deleting owner-scoped test image package $IMAGE_PACKAGE"
if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then if gcloud artifacts docker images delete "$IMAGE_PACKAGE" --delete-tags --quiet \
echo "→ ADC not configured; using a gcloud access token for Terraform" --project "$PROJECT" >/dev/null 2>"$error_file"; then
export GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)" return 0
export GOOGLE_PROJECT="$PROJECT" fi
fi if is_not_found "$error_file"; then
echo "→ terraform apply" return 0
terraform -chdir="$TF_DIR" init -input=false >/dev/null fi
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \ sed -n '1,12p' "$error_file" >&2
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ return 1
|| { echo "ERROR: terraform apply failed" >&2; exit 3; } }
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"
cleanup() { cleanup() {
if [ "$KEEP_STACK" -eq 0 ]; then local original_rc=$?
echo "→ terraform destroy" local cleanup_rc=0
# Apply force_destroy=true into state FIRST. Terraform reads the bucket's trap - EXIT
# force_destroy from prior state during the destroy step, so a destroy set +e
# alone can't flip it; a quick apply updates the attribute, then destroy
# can empty + remove the (scratch) bucket. if [ "$KEEP_STACK" -eq 1 ]; then
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \ echo "→ --keep-stack set; retaining this invocation's stack, image, and repository."
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ echo " Owner: $OWNER ($OWNER_HASH)"
-var "bucket_force_destroy=true" >/dev/null 2>&1 || true echo " Isolated Terraform state: $TF_WORK_DIR/terraform.tfstate"
terraform -chdir="$TF_DIR" destroy -input=false -auto-approve \ 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"
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \ echo " Then run the matching terraform destroy command with the same variables."
-var "bucket_force_destroy=true" || true exit "$original_rc"
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"
fi 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 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" SITE_TAR="$ARTIFACT_DIR/project.tar.gz"
tar -czf "$SITE_TAR" -C "$FIXTURE_DIR/src" . 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 gcloud storage cp "$SITE_TAR" "$PROJECT_GCS" --project "$PROJECT" >/dev/null
echo "→ Uploaded fixture to $PROJECT_GCS" echo "→ Uploaded fixture to $PROJECT_GCS"
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META") BASE_FPS="$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")"
META_CHUNK=$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META") META_CHUNK="$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META")"
[ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}" [ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}"
echo "[]" > "$ARTIFACT_DIR/results.json"
OVERALL_RC=0
IFS=',' read -ra SIZES <<< "$CHUNK_SIZES" IFS=',' read -ra SIZES <<< "$CHUNK_SIZES"
for CS in "${SIZES[@]}"; do for chunk_size in "${SIZES[@]}"; do
RENDER_ID="hf-smoke-c${CS}-$(date +%s)" [[ "$chunk_size" =~ ^[1-9][0-9]*$ ]] || {
OUT_GCS="gs://$BUCKET/renders/$RENDER_ID/output.mp4" echo "ERROR: invalid positive integer chunk size '$chunk_size'" >&2
ARG=$(jq -n \ exit 1
--arg svc "$SERVICE_URL" \ }
--arg proj "$PROJECT_GCS" \
--arg prefix "gs://$BUCKET/renders/$RENDER_ID/" \
--arg out "$OUT_GCS" \
--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}}')
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.
STATE="ACTIVE"
while [ "$STATE" = "ACTIVE" ] || [ "$STATE" = "QUEUED" ]; do
sleep 5
STATE=$(gcloud workflows executions describe "$EXEC" --location "$REGION" \
--project "$PROJECT" --format='value(state)')
done
END_MS=$(date +%s%3N)
WALL=$((END_MS - START_MS))
gcloud workflows executions describe "$EXEC" --location "$REGION" --project "$PROJECT" \
--format=json > "$ARTIFACT_DIR/renders/c$CS-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
continue
fi
OUT_LOCAL="$ARTIFACT_DIR/renders/c$CS-output.mp4"
gcloud storage cp "$OUT_GCS" "$OUT_LOCAL" --project "$PROJECT" >/dev/null
# PSNR vs the in-process baseline.
PSNR_LOG="$ARTIFACT_DIR/renders/c$CS-psnr.log"
ffmpeg -y -i "$OUT_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")
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" && \
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
fi
done done
echo "→ Results:"; cat "$ARTIFACT_DIR/results.json" | jq . jq -n '[]' > "$ARTIFACT_DIR/results.json"
exit $OVERALL_RC jq -n '[]' > "$ARTIFACT_DIR/parity.json"
OVERALL_RC=0
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 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 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 "$EXECUTION" \
--location "$REGION" \
--project "$PROJECT" \
--format='value(state)')"
done
END_MS="$(date +%s%3N)"
WALL_MS=$((END_MS - START_MS))
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' "$EXECUTION_JSON" | head -c 800
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=4
continue
fi
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
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")"
ENCODED_SHA="$(sha256sum "$OUTPUT_LOCAL" | cut -d' ' -f1)"
ENCODED_BYTES="$(wc -c < "$OUTPUT_LOCAL" | tr -d ' ')"
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" -ne 0 ] || OVERALL_RC=5
fi
done
done
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'", "test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts", "generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-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", "upload:docs-images": "bash scripts/upload-docs-images.sh",
"prepare": "test -d .git && lefthook install || true" "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 │ pure functions over local paths
┌──────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────┐
│ S3 bucket — plan tarball + per-chunk outputs + final mp4 │ S3 bucket — v1 plan tar or v2 manifest/blobs + chunks + output │
└──────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────┘
``` ```
@@ -45,6 +45,26 @@ The handler downloads inputs from S3 into `/tmp`, calls the OSS primitive,
uploads outputs back to S3, and returns a small JSON result that fits uploads outputs back to S3, and returns a small JSON result that fits
inside Step Functions' history budget (under 200 bytes per chunk). inside Step Functions' history budget (under 200 bytes per chunk).
### Plan transport selection
`renderToLambda` defaults to the existing monolithic v1 plan transport.
Plan v2 is an explicit whole-render opt-in:
```ts
await renderToLambda({
// ...bucket, state machine, project, and config...
planProtocol: "v2",
});
```
V2 never overloads `PlanS3Uri`. The planner returns
`PlanV2ManifestS3Uri` and `PlanV2ArtifactS3Prefix`; chunk workers fetch
only manifest-selected chunk artifacts, while the assembler fetches its
own metadata and audio subset. Blobs are immutable SHA-256-addressed
objects, verified on upload and download, and the manifest is published
last. Unknown protocols and digest mismatches are terminal Step Functions
errors. Omit the selector—or use `"v1"`—to retain the prior wire contract.
## Chrome runtime ## Chrome runtime
The package supports two Chromium sources: The package supports two Chromium sources:
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hyperframes/aws-lambda", "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.", "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -86,7 +86,8 @@
"constructs": "^10.3.0", "constructs": "^10.3.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"yaml": "^2.9.0"
}, },
"peerDependencies": { "peerDependencies": {
"aws-cdk-lib": "^2.130.0", "aws-cdk-lib": "^2.130.0",
@@ -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);
});
});
+246 -51
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env tsx #!/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 * With no arguments, this boots the build shipped by `@sparticuz/chromium`
* Chromium build shipped by `@sparticuz/chromium` honours CDP * (decompressing into `/tmp` per the library's runtime contract). Passing
* `HeadlessExperimental.beginFrame` with `screenshot: true`. This script * `--executable-path /path/to/chrome-headless-shell` probes an arbitrary
* boots that Chromium build (decompressing into `/tmp` per the library's * executable instead; the GCP image build uses that form against the exact
* runtime contract), navigates to a tiny static page, issues one * binary copied into the image.
* `beginFrame` with a screenshot request, and asserts the response
* carries a PNG buffer.
* *
* The script is the contract test, not a one-shot verification every * The script is the contract test, not a one-shot verification every
* release should run it inside the Docker container at * release should run it inside the Docker container at
@@ -17,15 +16,18 @@
* *
* Exits 0 on pass, 1 on fail. Run via: * 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
* bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like * 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 { 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; passed: boolean;
durationMs: number; durationMs: number;
chromiumPath: string; chromiumPath: string;
@@ -38,10 +40,140 @@ const PROBE_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title> <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> <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>`; </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> { async function main(): Promise<void> {
const start = Date.now(); const start = Date.now();
const result = await probe(); const result = await probe(parseProbeArgs(process.argv.slice(2)));
result.durationMs = Date.now() - start; result.durationMs = Date.now() - start;
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
if (!result.passed) { 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 chromiumPath = "";
let tmpHtmlDir = "";
try { try {
const { default: chromium } = await import("@sparticuz/chromium"); let sourceArgs: string[] = [];
chromiumPath = await chromium.executablePath(); if (options.executablePath) {
const args = chromium.args; chromiumPath = options.executablePath;
} else {
const { default: chromium } = await import("@sparticuz/chromium");
chromiumPath = await chromium.executablePath();
sourceArgs = chromium.args;
}
const puppeteer = await import("puppeteer-core"); const puppeteer = await import("puppeteer-core");
@@ -63,7 +204,7 @@ async function probe(): Promise<ProbeResult> {
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives // Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
// an unguessable directory name so two concurrent probes on the same // an unguessable directory name so two concurrent probes on the same
// host don't collide and CodeQL's insecure-tempfile rule clears. // 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"); const htmlPath = join(tmpHtmlDir, "probe.html");
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8"); 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 // ("Chrome's beginFrame with `screenshot` param always reports
// hasDamage=true"). // hasDamage=true").
const beginFrameFlags = [ const beginFrameFlags = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
"--deterministic-mode", "--deterministic-mode",
"--enable-begin-frame-control", "--enable-begin-frame-control",
"--disable-new-content-rendering-timeout", "--disable-new-content-rendering-timeout",
@@ -89,55 +235,97 @@ async function probe(): Promise<ProbeResult> {
"--use-gl=angle", "--use-gl=angle",
"--use-angle=swiftshader", "--use-angle=swiftshader",
"--enable-unsafe-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({ const browser = await puppeteer.launch({
executablePath: chromiumPath, executablePath: chromiumPath,
headless: "shell", headless: "shell",
args: [...args, ...beginFrameFlags], args: options.launchArgs ?? [...sourceArgs, ...beginFrameFlags],
defaultViewport: { width: 800, height: 600 }, defaultViewport: { width: 800, height: 600 },
}); });
try { try {
const page = await browser.newPage(); const timeoutMs = options.timeoutMs ?? PROBE_OPERATION_TIMEOUT_MS;
await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 }); const deadline = Date.now() + timeoutMs;
const session = await page.createCDPSession(); const page = await awaitBeforeDeadline(browser.newPage(), deadline, "newPage");
await session.send("HeadlessExperimental.enable"); 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 // Warm-up beginFrame with noDisplayUpdates: true — drives the
// compositor without producing a screenshot, matching how the engine // compositor without producing a screenshot, matching how the engine
// primes a capture loop. // primes a capture loop.
await session.send("HeadlessExperimental.beginFrame", { await awaitBeforeDeadline(
frameTimeTicks: 0, session.send("HeadlessExperimental.beginFrame", {
interval: 33, frameTimeTicks: 0,
noDisplayUpdates: true, interval: 33,
}); noDisplayUpdates: true,
const response = await session.send("HeadlessExperimental.beginFrame", { }),
frameTimeTicks: 1000, deadline,
interval: 33, "warm-up beginFrame",
screenshot: { format: "png" }, );
}); let hasDamage = false;
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {}); let bytes = Buffer.alloc(0);
const screenshot = response.screenshotData ?? ""; let isPng = false;
const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0); let attempts = 0;
const isPng = // A renderer-ready document can still need more than one controlled
bytes.length >= 8 && // frame before it submits a screenshot surface. Chromium explicitly
bytes[0] === 0x89 && // permits screenshotData to be absent during renderer initialization,
bytes[1] === 0x50 && // so retry a small bounded sequence with monotonically increasing ticks.
bytes[2] === 0x4e && for (attempts = 1; attempts <= SCREENSHOT_ATTEMPTS; attempts += 1) {
bytes[3] === 0x47; const response = await awaitBeforeDeadline(
session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 1000 + (attempts - 1) * 33,
interval: 33,
screenshot: { format: "png" },
}),
deadline,
`screenshot beginFrame attempt ${attempts}`,
);
hasDamage = response.hasDamage;
const screenshot = response.screenshotData ?? "";
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 { return {
passed: isPng && bytes.length > 0, passed: isPng && bytes.length > 0,
durationMs: 0, durationMs: 0,
chromiumPath, chromiumPath,
screenshotBytes: bytes.length, screenshotBytes: bytes.length,
hasDamage: response.hasDamage, hasDamage,
detail: isPng detail: isPng
? "OK — BeginFrame returned a PNG buffer." ? `OK — BeginFrame returned a PNG buffer after ${attempts} attempt(s).`
: `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${ : `FAIL — BeginFrame returned ${bytes.length} bytes after ${SCREENSHOT_ATTEMPTS} ` +
`attempts, PNG signature ${
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>" bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
}`, }`,
}; };
} finally { } finally {
await browser.close().catch(() => {}); await closeBrowserForProbe(browser);
} }
} catch (err) { } catch (err) {
return { return {
@@ -148,10 +336,17 @@ async function probe(): Promise<ProbeResult> {
hasDamage: false, hasDamage: false,
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`, detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
}; };
} finally {
if (tmpHtmlDir) {
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
}
} }
} }
void main().catch((err) => { const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
console.error("[probe-beginframe] unexpected:", err); if (invokedPath === fileURLToPath(import.meta.url)) {
process.exit(2); 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 { beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib"; import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions"; import { Template } from "aws-cdk-lib/assertions";
import { parse as parseYaml } from "yaml";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js"; import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on // CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
@@ -50,20 +51,35 @@ const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`, // `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
// not at this level — we cover it separately in the contract test. // not at this level — we cover it separately in the contract test.
const EXPECTED_STATE_NAMES = [ const EXPECTED_STATE_NAMES = [
"SelectPlanProtocol",
"Plan", "Plan",
"PlanV2",
"BuildChunkList", "BuildChunkList",
"AssertChunkCount", "AssertChunkCount",
"SelectWorkerProtocol",
"RenderChunks", "RenderChunks",
"RenderChunksV2",
"Assemble", "Assemble",
"AssembleV2",
"PlanProducedZeroChunks", "PlanProducedZeroChunks",
"UnsupportedPlanProtocol",
]; ];
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"FFMPEG_VERSION_MISMATCH", "FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH", "PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE", "BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED", "FONT_FETCH_FAILED",
"PLAN_TOO_LARGE", "PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError", "ChromeBinaryUnavailableError",
]); ]);
@@ -128,7 +144,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("declares the state machine with the expected state names", () => { it("declares the state machine with the expected state names", () => {
const { definition } = SYNTHED; const { definition } = SYNTHED;
expect(definition.StartAt).toBe("Plan"); expect(definition.StartAt).toBe("SelectPlanProtocol");
const actualStates = Object.keys(definition.States); const actualStates = Object.keys(definition.States);
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort()); expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
}); });
@@ -138,7 +154,7 @@ describe("HyperframesRenderStack — snapshot", () => {
const collected = new Set<string>(); const collected = new Set<string>();
// Plan + Assemble are top-level states; RenderChunk is nested inside // Plan + Assemble are top-level states; RenderChunk is nested inside
// the Map's Iterator definition. // the Map's Iterator definition.
const topLevelStates = ["Plan", "Assemble"] as const; const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
for (const stateName of topLevelStates) { for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected); collectNonRetryableErrors(definition.States[stateName], collected);
} }
@@ -150,6 +166,15 @@ describe("HyperframesRenderStack — snapshot", () => {
| undefined; | undefined;
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {}; const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStates.RenderChunk, collected); collectNonRetryableErrors(innerStates.RenderChunk, collected);
const renderChunksV2 = definition.States.RenderChunksV2 as
| {
Iterator?: { States?: Record<string, unknown> };
ItemProcessor?: { States?: Record<string, unknown> };
}
| undefined;
const innerStatesV2 =
renderChunksV2?.Iterator?.States ?? renderChunksV2?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStatesV2.RenderChunkV2, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) { for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
expect({ error: expected, present: collected.has(expected) }).toEqual({ expect({ error: expected, present: collected.has(expected) }).toEqual({
@@ -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 { 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", "BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED", "FONT_FETCH_FAILED",
"PLAN_TOO_LARGE", "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", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError", "ChromeBinaryUnavailableError",
]; ];
@@ -208,13 +216,28 @@ export class HyperframesRenderStack extends Construct {
"PLAN_HASH_MISMATCH", "PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED", "S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE", "BROWSER_GPU_NOT_SOFTWARE",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError", "ChromeBinaryUnavailableError",
]; ];
const NON_RETRYABLE_ASSEMBLE = [ const NON_RETRYABLE_ASSEMBLE = [
"FFMPEG_VERSION_MISMATCH", "FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH", "PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED", "S3_URI_NOT_ALLOWED",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError", "ChromeBinaryUnavailableError",
]; ];
@@ -227,6 +250,7 @@ export class HyperframesRenderStack extends Construct {
"Config.$": "$.Config", "Config.$": "$.Config",
}), }),
resultSelector: { resultSelector: {
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Payload.PlanS3Uri", "PlanS3Uri.$": "$.Payload.PlanS3Uri",
"PlanHash.$": "$.Payload.PlanHash", "PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount", "ChunkCount.$": "$.Payload.ChunkCount",
@@ -248,6 +272,35 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60), maxDelay: Duration.seconds(60),
}); });
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
PlanProtocol: "v2",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Payload.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
},
resultPath: "$.Plan",
});
planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
planV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const buildChunkList = new sfn.Pass(this, "BuildChunkList", { const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
parameters: { parameters: {
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)", "ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
@@ -331,11 +384,100 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60), maxDelay: Duration.seconds(60),
}); });
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
PlanProtocol: "v2",
"ChunkIndex.$": "$.ChunkIndex",
"PlanV2ManifestS3Uri.$": "$.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
renderChunkV2Task.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunksV2.itemProcessor(renderChunkV2Task);
const assembleV2 = new tasks.LambdaInvoke(this, "AssembleV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
AudioS3Uri: null,
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
assembleV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
.when(
sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
renderChunksV2.next(assembleV2),
)
.otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount") const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderChunks.next(assemble)) .when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
.otherwise(planProducedZero); .otherwise(planProducedZero);
return plan.next(buildChunkList).next(assertChunkCount); plan.next(buildChunkList);
planV2.next(buildChunkList);
buildChunkList.next(assertChunkCount);
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(plan);
} }
} }
+76 -15
View File
@@ -25,6 +25,8 @@ export type { SerializableDistributedRenderConfig } from "@hyperframes/producer/
/** Discriminator for the three roles the one Lambda image fulfills. */ /** Discriminator for the three roles the one Lambda image fulfills. */
export type LambdaAction = "plan" | "renderChunk" | "assemble"; export type LambdaAction = "plan" | "renderChunk" | "assemble";
/** Transport protocol selected for one complete distributed render. */
export type LambdaPlanProtocol = "v1" | "v2";
/** /**
* Top-level shape of any event the handler may receive. * Top-level shape of any event the handler may receive.
@@ -42,7 +44,7 @@ export type LambdaEvent =
| { Input: LambdaEvent }; | { Input: LambdaEvent };
/** Activity A: produce a planDir, upload to S3. */ /** Activity A: produce a planDir, upload to S3. */
export interface PlanEvent { interface PlanEventBase {
Action: "plan"; Action: "plan";
/** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */ /** S3 URI pointing at a `tar -czf`-archived project directory (`s3://bucket/key.tar.gz`). */
ProjectS3Uri: string; ProjectS3Uri: string;
@@ -52,17 +54,27 @@ export interface PlanEvent {
Config: SerializableDistributedRenderConfig; Config: SerializableDistributedRenderConfig;
} }
/** Legacy/default plan transport. Absence is deliberately interpreted as v1. */
export interface PlanV1Event extends PlanEventBase {
PlanProtocol?: "v1";
}
/** Explicit opt-in to the content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase {
PlanProtocol: "v2";
}
export type PlanEvent = PlanV1Event | PlanV2Event;
/** Activity B: fetch planDir, render one chunk, upload result. */ /** Activity B: fetch planDir, render one chunk, upload result. */
export interface RenderChunkEvent { interface RenderChunkEventBase {
Action: "renderChunk"; Action: "renderChunk";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
/** /**
* `PlanResult.planHash` from the Plan invocation. The handler verifies * `PlanResult.planHash` from the Plan invocation. For v1, the handler
* this against the untarred planDir's `plan.json` before invoking the * verifies it against the untarred planDir's `plan.json`; for v2, it
* producer, throwing a typed `PLAN_HASH_MISMATCH` on divergence so the * verifies it against the content-addressed manifest before invoking the
* state machine routes it as non-retryable. Defense-in-depth the * producer. Divergence throws a typed `PLAN_HASH_MISMATCH` so the state
* producer also re-checks internally. * machine routes it as non-retryable.
*/ */
PlanHash: string; PlanHash: string;
/** 0-based chunk index this invocation should render. */ /** 0-based chunk index this invocation should render. */
@@ -73,11 +85,28 @@ export interface RenderChunkEvent {
Format: DistributedFormat; Format: DistributedFormat;
} }
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */ /** Legacy/default chunk event. */
export interface AssembleEvent { export interface RenderChunkV1Event extends RenderChunkEventBase {
Action: "assemble"; PlanProtocol?: "v1";
/** S3 URI of the plan tar produced by a PlanEvent invocation. */ /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string; PlanS3Uri: string;
}
/**
* V2 chunk event. It intentionally cannot carry `PlanS3Uri`: the manifest
* describes the exact content-addressed artifacts needed by this chunk.
*/
export interface RenderChunkV2Event extends RenderChunkEventBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
export type RenderChunkEvent = RenderChunkV1Event | RenderChunkV2Event;
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
interface AssembleEventBase {
Action: "assemble";
/** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */ /** S3 URIs of every chunk, ordered by chunk index. Length must equal `chunkCount`. */
ChunkS3Uris: string[]; ChunkS3Uris: string[];
/** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */ /** S3 URI of the planDir's `audio.aac` if the composition has audio; `null` otherwise. */
@@ -98,12 +127,28 @@ export interface AssembleEvent {
Cfr?: boolean; Cfr?: boolean;
} }
/** Legacy/default assemble event. */
export interface AssembleV1Event extends AssembleEventBase {
PlanProtocol?: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
}
/** V2 assemble event, scoped to manifest-declared assembler artifacts. */
export interface AssembleV2Event extends AssembleEventBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
PlanHash: string;
}
export type AssembleEvent = AssembleV1Event | AssembleV2Event;
// ── Result types — kept small to fit Step Functions history budgets ───────── // ── Result types — kept small to fit Step Functions history budgets ─────────
/** Result of a `plan` invocation. Carries enough to size the Map(N) state. */ /** Result of a `plan` invocation. Carries enough to size the Map(N) state. */
export interface PlanLambdaResult { interface PlanLambdaResultBase {
Action: "plan"; Action: "plan";
PlanS3Uri: string;
PlanHash: string; PlanHash: string;
ChunkCount: number; ChunkCount: number;
TotalFrames: number; TotalFrames: number;
@@ -118,6 +163,20 @@ export interface PlanLambdaResult {
DurationMs: number; DurationMs: number;
} }
/** Existing v1 result. Kept unchanged for wire compatibility. */
export interface PlanV1LambdaResult extends PlanLambdaResultBase {
PlanS3Uri: string;
}
/** V2 result. The two v2 locators are never aliases for `PlanS3Uri`. */
export interface PlanV2LambdaResult extends PlanLambdaResultBase {
PlanProtocol: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
export type PlanLambdaResult = PlanV1LambdaResult | PlanV2LambdaResult;
/** Result of a `renderChunk` invocation. Sized ≤200 bytes per §2.4. */ /** Result of a `renderChunk` invocation. Sized ≤200 bytes per §2.4. */
export interface RenderChunkLambdaResult { export interface RenderChunkLambdaResult {
Action: "renderChunk"; Action: "renderChunk";
@@ -125,6 +184,8 @@ export interface RenderChunkLambdaResult {
ChunkIndex: number; ChunkIndex: number;
Sha256: string; Sha256: string;
FramesEncoded: number; FramesEncoded: number;
/** Effective engine mode after browser probing. Emitted by current handlers. */
CaptureMode?: "beginframe" | "screenshot" | "drawelement";
DurationMs: number; DurationMs: number;
} }
+260 -8
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
/** /**
* Handler dispatch unit tests. * Handler dispatch unit tests.
* *
@@ -15,10 +16,21 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed"; 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 type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
import { handler, unwrapEvent } from "./handler.js"; import { handler, unwrapEvent } from "./handler.js";
@@ -37,9 +49,13 @@ class FakeS3Client {
ops: FakeS3Op[] = []; ops: FakeS3Op[] = [];
// Map S3 URIs → byte buffers the fake serves. // Map S3 URIs → byte buffers the fake serves.
objects = new Map<string, Buffer>(); objects = new Map<string, Buffer>();
metadata = new Map<string, Record<string, string>>();
// Methods called by the real S3 transport — minimal surface so the // Methods called by the real S3 transport — minimal surface so the
// handler's call sites don't need rewriting under test. // handler's call sites don't need rewriting under test.
// This fake intentionally implements the complete S3 command matrix inline so
// handler tests exercise realistic state transitions without AWS.
// fallow-ignore-next-line complexity
async send(command: unknown): Promise<unknown> { async send(command: unknown): Promise<unknown> {
const op = command as { input: { Bucket: string; Key: string } } & { const op = command as { input: { Bucket: string; Key: string } } & {
constructor: { name: string }; constructor: { name: string };
@@ -54,20 +70,38 @@ class FakeS3Client {
const { Readable } = await import("node:stream"); const { Readable } = await import("node:stream");
return { Body: Readable.from([bytes]) }; return { Body: Readable.from([bytes]) };
} }
if (cmdName === "HeadObjectCommand") {
const bytes = this.objects.get(uri);
if (!bytes) {
const error = new Error("not found") as Error & {
$metadata: { httpStatusCode: number };
};
error.name = "NotFound";
error.$metadata = { httpStatusCode: 404 };
throw error;
}
return {
ContentLength: bytes.length,
Metadata: this.metadata.get(uri),
};
}
if (cmdName === "PutObjectCommand") { if (cmdName === "PutObjectCommand") {
// Buffer the body so we can record how many bytes were uploaded; the // Buffer the body so we can record how many bytes were uploaded; the
// handler's hot path streams from disk, but tests pin the count. // handler's hot path streams from disk, but tests pin the count.
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body; const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
let bytes = 0; const chunks: Buffer[] = [];
if (Buffer.isBuffer(body)) { if (Buffer.isBuffer(body)) {
bytes = body.length; chunks.push(body);
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") { } else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
for await (const chunk of body as NodeJS.ReadableStream) { for await (const chunk of body as NodeJS.ReadableStream) {
bytes += (chunk as Buffer).length; chunks.push(Buffer.from(chunk as Buffer));
} }
} }
this.ops.push({ kind: "upload", uri, bytes }); const bytes = Buffer.concat(chunks);
this.objects.set(uri, Buffer.alloc(bytes)); this.ops.push({ kind: "upload", uri, bytes: bytes.length });
this.objects.set(uri, bytes);
const metadata = (command as { input: { Metadata?: Record<string, string> } }).input.Metadata;
if (metadata) this.metadata.set(uri, metadata);
return {}; return {};
} }
return {}; return {};
@@ -157,6 +191,7 @@ describe("handler dispatch", () => {
writeFileSync(join(planDir, "meta", "chunks.json"), "[]"); writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return { return {
planDir, planDir,
planProtocol: CURRENT_PLAN_PROTOCOL,
planHash: "fakehash", planHash: "fakehash",
chunkCount: 4, chunkCount: 4,
totalFrames: 720, totalFrames: 720,
@@ -209,6 +244,55 @@ describe("handler dispatch", () => {
).toBe(true); ).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 () => { it("plan honors a pre-set PRODUCER_HEADLESS_SHELL_PATH instead of re-resolving Chrome", async () => {
// Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local // Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
// RIE smoke) seeds the path, handlePlan must not overwrite it. // RIE smoke) seeds the path, handlePlan must not overwrite it.
@@ -224,6 +308,7 @@ describe("handler dispatch", () => {
writeFileSync(join(planDir, "meta", "chunks.json"), "[]"); writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return { return {
planDir, planDir,
planProtocol: CURRENT_PLAN_PROTOCOL,
planHash: "fakehash", planHash: "fakehash",
chunkCount: 1, chunkCount: 1,
totalFrames: 30, totalFrames: 30,
@@ -299,6 +384,12 @@ describe("handler dispatch", () => {
framesEncoded: 240, framesEncoded: 240,
sha256: "0".repeat(64), sha256: "0".repeat(64),
durationMs: 12345, durationMs: 12345,
planHashMs: 1,
sessionBootMs: 2,
captureStageMs: 3,
encodeStageMs: 4,
workers: 1,
captureMode: "beginframe",
perfPath: outputChunkPath + ".perf.json", perfPath: outputChunkPath + ".perf.json",
}; };
}, },
@@ -338,6 +429,7 @@ describe("handler dispatch", () => {
expect(result.ChunkIndex).toBe(2); expect(result.ChunkIndex).toBe(2);
expect(result.Sha256).toBe("0".repeat(64)); expect(result.Sha256).toBe("0".repeat(64));
expect(result.FramesEncoded).toBe(240); expect(result.FramesEncoded).toBe(240);
expect(result.CaptureMode).toBe("beginframe");
expect(renderChunkMock).toHaveBeenCalledTimes(1); expect(renderChunkMock).toHaveBeenCalledTimes(1);
}); });
@@ -447,6 +539,142 @@ describe("handler dispatch", () => {
expect(assembleMock).toHaveBeenCalledTimes(1); expect(assembleMock).toHaveBeenCalledTimes(1);
}); });
it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const 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 () => { it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot(); const tmpRoot = makeTmpRoot();
await expect( await expect(
@@ -568,3 +796,27 @@ async function makeMinimalPlanTar(): Promise<Buffer> {
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]); await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
return rf(tarPath); return rf(tarPath);
} }
function makeMinimalV1PlanDir(dir: string, withAudio: boolean): void {
mkdirSync(join(dir, "meta"), { recursive: true });
mkdirSync(join(dir, "compiled"), { recursive: true });
writeFileSync(join(dir, "compiled", "index.html"), "<html>aws v2 fixture</html>");
const planJson = {
planHash: "a".repeat(64),
chunkCount: 1,
totalFrames: 30,
dimensions: { fpsNum: 30, fpsDen: 1, width: 640, height: 360, format: "mp4" },
ffmpegVersion: "6.0",
producerVersion: "test",
fontSnapshotSha: "font-snapshot-test",
};
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
writeFileSync(
join(dir, "meta", "chunks.json"),
JSON.stringify([{ index: 0, startFrame: 0, endFrame: 30 }]),
);
writeFileSync(join(dir, "meta", "encoder.json"), "{}");
if (withAudio) writeFileSync(join(dir, "audio.aac"), "AAC");
planJson.planHash = recomputePlanHashFromPlanDir(dir);
writeFileSync(join(dir, "plan.json"), JSON.stringify(planJson));
}
+298 -7
View File
@@ -18,10 +18,18 @@ import { S3Client } from "@aws-sdk/client-s3";
import { import {
assemble, assemble,
type AssembleResult, type AssembleResult,
type ChunkRenderer,
type ChunkResult, type ChunkResult,
type DistributedRenderConfig, type DistributedRenderConfig,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
plan, plan,
planV2WithPublisher,
type PlanResult, type PlanResult,
type PlanV2Artifact,
type PlanV2Manifest,
type PlanV2MaterializationTarget,
readPlanV2Manifest,
renderChunk, renderChunk,
} from "@hyperframes/producer/distributed"; } from "@hyperframes/producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js"; import { resolveChromeExecutablePath } from "./chromium.js";
@@ -39,11 +47,13 @@ import type {
} from "./events.js"; } from "./events.js";
import { import {
downloadS3ObjectToFile, downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
parseS3Uri, parseS3Uri,
tarDirectory, tarDirectory,
untarDirectory, untarDirectory,
uploadFileToS3, uploadFileToS3,
} from "./s3Transport.js"; } from "./s3Transport.js";
import { S3PlanV2ArtifactPublisher } from "./s3PlanV2Publisher.js";
/** /**
* Lazily-constructed S3 client. Cached at module scope so warm Lambda * Lazily-constructed S3 client. Cached at module scope so warm Lambda
@@ -67,7 +77,8 @@ export interface HandlerDeps {
s3?: S3Client; s3?: S3Client;
primitives?: { primitives?: {
plan: typeof plan; plan: typeof plan;
renderChunk: typeof renderChunk; planV2WithPublisher?: typeof planV2WithPublisher;
renderChunk: ChunkRenderer;
assemble: typeof assemble; assemble: typeof assemble;
}; };
/** Override the per-invocation `/tmp` workdir root (defaults to Lambda's `/tmp`). */ /** 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) { } catch (err) {
normalizeTerminalErrorName(err);
// Log before re-throwing so CloudWatch captures the structured // Log before re-throwing so CloudWatch captures the structured
// error context alongside Lambda's default stack trace. Otherwise // error context alongside Lambda's default stack trace. Otherwise
// ops only sees the trace and has to correlate with execution // ops only sees the trace and has to correlate with execution
@@ -116,6 +128,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
logEvent({ logEvent({
event: "handler_error", event: "handler_error",
action: unwrapped.Action, action: unwrapped.Action,
input: summarizeEvent(unwrapped),
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
name: err instanceof Error ? err.name : undefined, name: err instanceof Error ? err.name : undefined,
}); });
@@ -123,6 +136,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 * Walk through Step Functions' Map-state and Task-state envelopes until
* the discriminated event is found. * 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 * the routable fields (S3 URIs, chunk index, format) needed to triage
* a failure from CloudWatch. * a failure from CloudWatch.
*/ */
// Keep event variants together so logs share one redaction and summarization boundary.
// fallow-ignore-next-line complexity
function summarizeEvent( function summarizeEvent(
event: PlanEvent | RenderChunkEvent | AssembleEvent, event: PlanEvent | RenderChunkEvent | AssembleEvent,
): Record<string, unknown> { ): Record<string, unknown> {
@@ -186,18 +223,25 @@ function summarizeEvent(
return { return {
projectS3Uri: event.ProjectS3Uri, projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix, planOutputS3Prefix: event.PlanOutputS3Prefix,
planProtocol: event.PlanProtocol ?? "v1",
format: event.Config.format, format: event.Config.format,
fps: event.Config.fps, fps: event.Config.fps,
}; };
case "renderChunk": case "renderChunk":
return { return {
planS3Uri: event.PlanS3Uri, planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkIndex: event.ChunkIndex, chunkIndex: event.ChunkIndex,
format: event.Format, format: event.Format,
}; };
case "assemble": case "assemble":
return { return {
planS3Uri: event.PlanS3Uri, planProtocol: event.PlanProtocol ?? "v1",
...(event.PlanProtocol === "v2"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkCount: event.ChunkS3Uris.length, chunkCount: event.ChunkS3Uris.length,
hasAudio: event.AudioS3Uri !== null, hasAudio: event.AudioS3Uri !== null,
outputS3Uri: event.OutputS3Uri, outputS3Uri: event.OutputS3Uri,
@@ -225,7 +269,12 @@ function primeRuntimeEnv(): void {
// ── Plan ──────────────────────────────────────────────────────────────────── // ── 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> { async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLambdaResult> {
if (event.PlanProtocol === "v2") {
return handlePlanV2(event, deps);
}
const started = Date.now(); const started = Date.now();
const s3 = deps?.s3 ?? getS3Client(); const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.plan ?? plan; const primitive = deps?.primitives?.plan ?? plan;
@@ -297,12 +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 ───────────────────────────────────────────────────────────── // ── RenderChunk ─────────────────────────────────────────────────────────────
async function handleRenderChunk( async function handleRenderChunk(
event: RenderChunkEvent, event: RenderChunkEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<RenderChunkLambdaResult> { ): Promise<RenderChunkLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleRenderChunkV2(event, deps);
}
const started = Date.now(); const started = Date.now();
const s3 = deps?.s3 ?? getS3Client(); const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.renderChunk ?? renderChunk; const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -357,6 +461,55 @@ async function handleRenderChunk(
ChunkIndex: event.ChunkIndex, ChunkIndex: event.ChunkIndex,
Sha256: result.sha256, Sha256: result.sha256,
FramesEncoded: result.framesEncoded, 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, DurationMs: Date.now() - started,
}; };
} finally { } finally {
@@ -393,6 +546,9 @@ async function handleAssemble(
event: AssembleEvent, event: AssembleEvent,
deps?: HandlerDeps, deps?: HandlerDeps,
): Promise<AssembleLambdaResult> { ): Promise<AssembleLambdaResult> {
if (event.PlanProtocol === "v2") {
return handleAssembleV2(event, deps);
}
const started = Date.now(); const started = Date.now();
const s3 = deps?.s3 ?? getS3Client(); const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.assemble ?? assemble; const primitive = deps?.primitives?.assemble ?? assemble;
@@ -442,6 +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( async function downloadChunkObjects(
s3: S3Client, s3: S3Client,
uris: string[], uris: string[],
@@ -479,16 +761,25 @@ async function downloadChunkObjects(
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
/** Collect every S3 URI that the handler will touch for a given event. */ /** Collect every S3 URI that the handler will touch for a given event. */
// This is an exhaustive event-union projection used only for safe log summaries.
// fallow-ignore-next-line complexity
function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] { function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] {
switch (event.Action) { switch (event.Action) {
case "plan": case "plan":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix]; return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk": case "renderChunk":
return [event.PlanS3Uri, event.ChunkOutputS3Prefix]; return event.PlanProtocol === "v2"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
: [event.PlanS3Uri, event.ChunkOutputS3Prefix];
case "assemble": case "assemble":
return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter( return [
(u): u is string => u != null, ...(event.PlanProtocol === "v2"
); ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
: [event.PlanS3Uri]),
...event.ChunkS3Uris,
event.OutputS3Uri,
event.AudioS3Uri,
].filter((u): u is string => u != null);
} }
} }
+15
View File
@@ -28,11 +28,20 @@ export {
type AssembleLambdaResult, type AssembleLambdaResult,
type LambdaAction, type LambdaAction,
type LambdaEvent, type LambdaEvent,
type LambdaPlanProtocol,
type LambdaResult, type LambdaResult,
type PlanEvent, type PlanEvent,
type PlanLambdaResult, type PlanLambdaResult,
type PlanV1Event,
type PlanV1LambdaResult,
type PlanV2Event,
type PlanV2LambdaResult,
type RenderChunkEvent, type RenderChunkEvent,
type RenderChunkLambdaResult, type RenderChunkLambdaResult,
type RenderChunkV1Event,
type RenderChunkV2Event,
type AssembleV1Event,
type AssembleV2Event,
type SerializableDistributedRenderConfig, type SerializableDistributedRenderConfig,
} from "./events.js"; } from "./events.js";
// `_setSparticuzChromiumForTests` is intentionally NOT re-exported from // `_setSparticuzChromiumForTests` is intentionally NOT re-exported from
@@ -47,13 +56,19 @@ export {
} from "./chromium.js"; } from "./chromium.js";
export { export {
downloadS3ObjectToFile, downloadS3ObjectToFile,
downloadS3ObjectToFileVerified,
formatS3Uri, formatS3Uri,
parseS3Uri, parseS3Uri,
type S3Location, type S3Location,
tarDirectory, tarDirectory,
untarDirectory, untarDirectory,
uploadContentAddressedFileToS3,
uploadFileToS3, uploadFileToS3,
} from "./s3Transport.js"; } from "./s3Transport.js";
export {
S3PlanV2ArtifactPublisher,
type S3PlanV2ArtifactPublisherOptions,
} from "./s3PlanV2Publisher.js";
// ── Client-side SDK ───────────────────────────────────────────────────────── // ── Client-side SDK ─────────────────────────────────────────────────────────
export { deploySite, type DeploySiteOptions, type SiteHandle } from "./sdk/deploySite.js"; 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 * 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 * 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 { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { formatS3Uri, parseS3Uri, tarDirectory, untarDirectory } from "./s3Transport.js"; import {
downloadS3ObjectToFileVerified,
formatS3Uri,
parseS3Uri,
sha256File,
tarDirectory,
untarDirectory,
uploadContentAddressedFileToS3,
} from "./s3Transport.js";
let scratchRoot: string; let scratchRoot: string;
@@ -91,3 +100,151 @@ describe("tar round-trip", () => {
expect(existsSync(join(destDir, "stale.txt"))).toBe(false); expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
}); });
}); });
describe("content-addressed v2 artifacts", () => {
it("uploads once and reuses an object with matching digest metadata", async () => {
const source = join(scratchRoot, "artifact-upload.bin");
writeFileSync(source, "immutable bytes");
const digest = await sha256File(source);
const s3 = new ContentAddressedFakeS3();
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe(
"uploaded",
);
expect(await uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest)).toBe("reused");
expect(s3.putCount).toBe(1);
});
it("refuses to overwrite an immutable key with conflicting metadata", async () => {
const source = join(scratchRoot, "artifact-conflict.bin");
writeFileSync(source, "expected bytes");
const digest = await sha256File(source);
const uri = `s3://bucket/v2/artifacts/sha256/${digest.slice(0, 2)}/${digest}`;
const s3 = new ContentAddressedFakeS3();
s3.objects.set(uri, {
bytes: Buffer.from("same length!!"),
sha256: "0".repeat(64),
});
await expect(
uploadContentAddressedFileToS3(s3.asClient(), source, uri, digest),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
expect(s3.putCount).toBe(0);
});
it("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, rmSync,
statSync, statSync,
} from "node:fs"; } from "node:fs";
import { createHash } from "node:crypto";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { GetObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import * as tar from "tar"; import * as tar from "tar";
/** Parsed `s3://bucket/key` URI. */ /** Parsed `s3://bucket/key` URI. */
@@ -75,6 +81,26 @@ export async function downloadS3ObjectToFile(
await pipeline(body, createWriteStream(destPath)); await pipeline(body, createWriteStream(destPath));
} }
/** Download and verify an immutable plan-v2 artifact before materialization. */
export async function downloadS3ObjectToFileVerified(
client: S3Client,
uri: string,
destPath: string,
expectedSha256: string,
): Promise<void> {
assertSha256(expectedSha256);
await downloadS3ObjectToFile(client, uri, destPath);
const actual = await sha256File(destPath);
if (actual !== expectedSha256) {
rmSync(destPath, { force: true });
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
}
}
/** /**
* Upload a local file's contents to an S3 URI using a streaming * Upload a local file's contents to an S3 URI using a streaming
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the * `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
@@ -104,6 +130,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 * Pack a directory into a `.tar.gz` at `destTarball`. Uses the `tar` npm
* package (pure JS over `node:zlib`) rather than spawning a system tar * package (pure JS over `node:zlib`) rather than spawning a system tar
@@ -201,6 +201,30 @@ describe("getRenderProgress", () => {
expect(progress.endedAt).not.toBeNull(); expect(progress.endedAt).not.toBeNull();
}); });
it("recognizes v2 chunk and assemble state names", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("PlanV2"),
lambdaSucceeded({ Action: "plan", TotalFrames: 30 }),
stateEntered("RenderChunkV2"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 30 }),
stateEntered("AssembleV2"),
lambdaSucceeded({ Action: "assemble", FramesEncoded: 30 }),
stateExited("AssembleV2", {
Output: { OutputS3Uri: "s3://b/v2.mp4", FileSize: 321 },
}),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(30);
expect(progress.overallProgress).toBe(1);
expect(progress.outputFile).toEqual({ s3Uri: "s3://b/v2.mp4", bytes: 321 });
});
it("computes cost from observed billed duration", async () => { it("computes cost from observed billed duration", async () => {
const sfn = new FakeSFN(); const sfn = new FakeSFN();
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]]; sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
@@ -251,7 +251,7 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda // ResultSelector pulls FileSize + OutputS3Uri from the Lambda
// result, so we re-extract them here from the state exit's // result, so we re-extract them here from the state exit's
// own output rather than relying on the Lambda payload. // own output rather than relying on the Lambda payload.
if (ev.stateExitedEventDetails?.name === "Assemble") { if (isAssembleState(ev.stateExitedEventDetails?.name)) {
assembleComplete = true; assembleComplete = true;
const exitPayload = parseJson(ev.stateExitedEventDetails?.output); const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
if (exitPayload && typeof exitPayload === "object") { if (exitPayload && typeof exitPayload === "object") {
@@ -350,12 +350,20 @@ function applyPayloadFrameCounts(
currentLambdaState: string | null, currentLambdaState: string | null,
bump: (delta: number) => void, bump: (delta: number) => void,
): void { ): void {
if (currentLambdaState !== "RenderChunk") return; if (!isRenderChunkState(currentLambdaState)) return;
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
const obj = payload as Record<string, unknown>; const obj = payload as Record<string, unknown>;
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded); if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
} }
function isRenderChunkState(name: string | null | undefined): boolean {
return name === "RenderChunk" || name === "RenderChunkV2";
}
function isAssembleState(name: string | null | undefined): boolean {
return name === "Assemble" || name === "AssembleV2";
}
/** /**
* Lambda success payloads from our handler include `DurationMs` the * Lambda success payloads from our handler include `DurationMs` the
* wall-clock the handler observed. We use it as a best-effort proxy * wall-clock the handler observed. We use it as a best-effort proxy
@@ -89,9 +89,27 @@ describe("renderToLambda", () => {
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/", PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4", OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig, Config: baseConfig,
PlanProtocol: "v1",
}); });
}); });
it("opts the complete execution into plan protocol v2 explicitly", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
executionName: "smoke-v2",
planProtocol: "v2",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(sfn.starts[0]?.input).toMatchObject({ PlanProtocol: "v2" });
});
it("derives the file extension from config.format", async () => { it("derives the file extension from config.format", async () => {
const sfn = new FakeSFN(); const sfn = new FakeSFN();
const s3 = new FakeS3(); const s3 = new FakeS3();
@@ -20,7 +20,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn"; import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
import type { S3Client } from "@aws-sdk/client-s3"; import type { S3Client } from "@aws-sdk/client-s3";
import type { SerializableDistributedRenderConfig } from "../events.js"; import type { LambdaPlanProtocol, SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js"; import { formatExtension } from "../formatExtension.js";
import { formatS3Uri } from "../s3Transport.js"; import { formatS3Uri } from "../s3Transport.js";
import { deploySite, type SiteHandle } from "./deploySite.js"; import { deploySite, type SiteHandle } from "./deploySite.js";
@@ -37,6 +37,11 @@ export interface RenderToLambdaOptions {
siteHandle?: SiteHandle; siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig; config: SerializableDistributedRenderConfig;
/**
* Distributed plan transport. Defaults to `"v1"` for backwards
* compatibility; v2 is always an explicit whole-render opt-in.
*/
planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */ /** S3 bucket from the SAM stack output (`RenderBucketName`). */
bucketName: string; bucketName: string;
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */ /** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
@@ -110,6 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
PlanOutputS3Prefix: planOutputS3Prefix, PlanOutputS3Prefix: planOutputS3Prefix,
OutputS3Uri: outputS3Uri, OutputS3Uri: outputS3Uri,
Config: opts.config, Config: opts.config,
PlanProtocol: opts.planProtocol ?? "v1",
}; };
// Reject oversize input client-side. Step Functions Standard caps the // Reject oversize input client-side. Step Functions Standard caps the
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hyperframes/cli", "name": "@hyperframes/cli",
"version": "0.7.65", "version": "0.7.77",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions", "description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
AUDIT_SEEK_OPTIONS,
DENSE_GEOMETRY_SEEK_OPTIONS,
captureRegionCrop, captureRegionCrop,
clampCropRegion, clampCropRegion,
installPageFunctionGuard, installPageFunctionGuard,
@@ -374,3 +376,15 @@ describe("installPageFunctionGuard", () => {
expect(shim(marker)).toBe(marker); 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, settleMs: 120,
} as const; } 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 { export interface SeekCompositionTimelineOptions {
fallbackToBridgeAndTimelines?: boolean; fallbackToBridgeAndTimelines?: boolean;
waitForPreferredSeekTargetMs?: number; 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`) // A command actively reconciling skills (`skills check`/`skills update`)
// must not also nudge the user to go reconcile skills — that nudge is // 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 // 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), events: () => import("./commands/events.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default), validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.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), "grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
compare: () => import("./commands/compare.js").then((m) => m.default), compare: () => import("./commands/compare.js").then((m) => m.default),
capture: () => import("./commands/capture.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 })), getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
seek: vi.fn(async (_time: number) => undefined), seek: vi.fn(async (_time: number) => undefined),
seekGeometry: vi.fn(async (_time: number) => undefined),
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
collectOverlap: vi.fn(async (_time: number) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectRotationSample: vi.fn(async (_time: number) => []),
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
collectGeometryCandidates: vi.fn(async () => []), collectGeometryCandidates: vi.fn(async () => []),
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => 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("bogus=1")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=-2")).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\)/); 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 CheckReport,
type CheckSection, type CheckSection,
} from "../utils/checkPipeline.js"; } 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[] = [ export const examples: Example[] = [
["Run the full verification gate", "hyperframes check"], ["Run the full verification gate", "hyperframes check"],
@@ -120,6 +120,10 @@ export function createCheckCommand(
description: 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', '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 }) { async run({ args }) {
const asJson = args.json === true; const asJson = args.json === true;
@@ -168,6 +172,7 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
snapshots: args.snapshots === true, snapshots: args.snapshots === true,
captionZone: parseCaptionZone(args["caption-zone"]), captionZone: parseCaptionZone(args["caption-zone"]),
frameCheck: parseFrameCheck(args["frame-check"]), frameCheck: parseFrameCheck(args["frame-check"]),
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined, 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 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 // 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 // options survive the migration instead of being silently dropped by a
// boolean flag (bare --frame-check keeps today's defaults). // 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 { function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined; if (raw === undefined) return undefined;
const tol = Number.parseFloat(raw); const tol = parseNumberStrict(raw);
if (!Number.isFinite(tol) || tol < 0) throw frameCheckError(); if (tol === null || tol < 0) throw frameCheckError();
return tol; 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 { function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
if (value === undefined || value === null) return undefined; if (value === undefined || value === null) return undefined;
const fields = parseCaptionFields(captionZoneString(value)); 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 { function captionFraction(value: string | undefined): number | null {
if (value === undefined || value === "") return null; if (value === undefined || value === "") return null;
const parsed = Number(value); const parsed = parseNumberStrict(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; return parsed !== null && parsed >= 0 && parsed <= 1 ? parsed : null;
} }
function captionSeverity(value: string | undefined): "error" | "warning" | undefined { 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("lossy by nature");
expect(GUIDE).not.toContain("content match the brief exactly"); 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"); 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 { failCommand } from "../utils/commandResult.js";
import { randomUUID } from "node:crypto";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import * as clack from "@clack/prompts"; import * as clack from "@clack/prompts";
@@ -7,6 +8,7 @@ import type { Example } from "./_examples.js";
import { trackRenderFeedback } from "../telemetry/events.js"; import { trackRenderFeedback } from "../telemetry/events.js";
import { shouldTrack, flush } from "../telemetry/client.js"; import { shouldTrack, flush } from "../telemetry/client.js";
import { getDoctorSummary } from "../telemetry/feedback.js"; import { getDoctorSummary } from "../telemetry/feedback.js";
import { readConfig, type RecentRenderRecord } from "../telemetry/config.js";
import { publishProjectArchive } from "../utils/publishProject.js"; import { publishProjectArchive } from "../utils/publishProject.js";
import { submitFeedback } from "../utils/submitFeedback.js"; import { submitFeedback } from "../utils/submitFeedback.js";
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js"; import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
@@ -28,6 +30,27 @@ function normalizeComment(raw?: string): string | undefined {
return raw || 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 { function printIssueConsent(dir: string): void {
console.log(); console.log();
console.log( console.log(
@@ -170,6 +193,18 @@ export default defineCommand({
const comment = normalizeComment(args.comment); const comment = normalizeComment(args.comment);
const doctorSummary = await getDoctorSummary(); 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 // Soft-warn (never blocks) when the comment for a non-clean report is
// missing the mandated reproduction-packet markers. Prints before the // missing the mandated reproduction-packet markers. Prints before the
// submission ack so the reporter sees the nudge while their run is fresh. // 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 // The standalone command runs separately from `render`, so it has no real
// elapsed time to report. Omit it rather than recording a fake duration. // 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(); await flush();
// Ack first so the user isn't kept waiting on the best-effort forward (which // 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). // is bounded to a few seconds and never surfaces an error either way).
console.log(c.dim("Thanks for the feedback!")); 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) { if (args["file-issue"] === true) {
await fileGithubIssue({ await fileGithubIssue({
+18 -1
View File
@@ -556,6 +556,7 @@ async function scaffoldProject(
durationSeconds?: number, durationSeconds?: number,
tailwind = false, tailwind = false,
resolution?: CanvasResolution, resolution?: CanvasResolution,
authoringSkill?: string,
): Promise<void> { ): Promise<void> {
mkdirSync(destDir, { recursive: true }); mkdirSync(destDir, { recursive: true });
@@ -588,10 +589,17 @@ async function scaffoldProject(
// Write hyperframes.json so `hyperframes add` knows which registry to use // Write hyperframes.json so `hyperframes add` knows which registry to use
// and where to drop block/component files. Overwritten only if absent. // 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"))) { if (!existsSync(resolve(destDir, "hyperframes.json"))) {
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } = const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
await import("../utils/projectConfig.js"); 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); writeDefaultPackageJson(destDir, name);
@@ -728,6 +736,13 @@ export default defineCommand({
description: 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).", "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 }) { async run({ args }) {
if (args.template !== undefined) { if (args.template !== undefined) {
@@ -898,6 +913,7 @@ export default defineCommand({
videoDuration, videoDuration,
tailwind, tailwind,
resolutionPreset, resolutionPreset,
args.skill,
); );
} catch (err) { } catch (err) {
console.error( console.error(
@@ -1112,6 +1128,7 @@ export default defineCommand({
videoDuration, videoDuration,
tailwind, tailwind,
resolutionPreset, resolutionPreset,
args.skill,
); );
if (!isBundled) { if (!isBundled) {
spin.stop(c.success(`Downloaded ${templateId}`)); 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 // (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
// once a real share of it is covered — see `occludedTextIssue`. // once a real share of it is covered — see `occludedTextIssue`.
const ATOMIC_LABEL_MAX_CHARS = 16; 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) { function isAtomicLabel(text) {
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text); return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
@@ -995,12 +996,8 @@
return false; return false;
} }
// Catches the blind spot the overflow checks miss: text that fits its box // text_occluded: atomic labels flag at any hit; prose needs coveredFraction >= proseCoverageFloor (default 0.15).
// perfectly but is covered by a later sibling/overlay. An atomic label function occludedTextIssue(element, time, proseCoverageFloor) {
// (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) {
if (hasAllowOcclusionFlag(element)) return null; if (hasAllowOcclusionFlag(element)) return null;
if (!hasVisibleTextInk(element)) return null; if (!hasVisibleTextInk(element)) return null;
const textRect = textRectFor(element, true); const textRect = textRectFor(element, true);
@@ -1012,7 +1009,7 @@
textRects.length > 0 ? textRects : [textRect], textRects.length > 0 ? textRects : [textRect],
); );
if (!occluder) return null; if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null; if (!isAtomicLabel(text) && coveredFraction < proseCoverageFloor) return null;
return { return {
code: "text_occluded", code: "text_occluded",
severity: "error", severity: "error",
@@ -1198,6 +1195,7 @@
return issues; 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_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern"; const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
@@ -1207,14 +1205,16 @@
return `${element.id || ""} ${className}`; return `${element.id || ""} ${className}`;
} }
// Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms. function isConnectorPath(svg, path) {
function pathScreenEndpoints(svg, path) { if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
if ( return (
typeof path.getTotalLength !== "function" || CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
typeof path.getPointAtLength !== "function" || );
typeof path.getScreenCTM !== "function" || }
typeof svg.createSVGPoint !== "function"
) { /** 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; return null;
} }
let total; let total;
@@ -1224,6 +1224,20 @@
return null; return null;
} }
if (!Number.isFinite(total) || total <= 0) 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(); const matrix = path.getScreenCTM();
if (!matrix) return null; if (!matrix) return null;
const toScreen = (local) => { const toScreen = (local) => {
@@ -1233,10 +1247,7 @@
const mapped = point.matrixTransform(matrix); const mapped = point.matrixTransform(matrix);
return { x: mapped.x, y: mapped.y }; return { x: mapped.x, y: mapped.y };
}; };
return { return { start: toScreen(user.start), end: toScreen(user.end) };
start: toScreen(path.getPointAtLength(0)),
end: toScreen(path.getPointAtLength(total)),
};
} }
function distanceToRect(point, rect) { function distanceToRect(point, rect) {
@@ -1246,6 +1257,7 @@
} }
// Solid, compact elements a connector could plausibly anchor to. // 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) { function connectorAnchorRects(root, rootRect) {
const compact = []; const compact = [];
const painted = []; const painted = [];
@@ -1261,42 +1273,57 @@
if (area < 400) continue; if (area < 400) continue;
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints. // 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 (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 }; return { compact, painted };
} }
function isConnectorPath(svg, path) { // Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach.
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.
function connectorDetachmentIssues(root, rootRect, time) { function connectorDetachmentIssues(root, rootRect, time) {
const issues = []; const issues = [];
let anchors = null; 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 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"))) { for (const svg of Array.from(root.querySelectorAll("svg"))) {
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue; if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
for (const path of Array.from(svg.querySelectorAll("path"))) { for (const path of Array.from(svg.querySelectorAll("path"))) {
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
if (!isConnectorPath(svg, path)) continue; if (!isConnectorPath(svg, path)) continue;
const endpoints = pathScreenEndpoints(svg, path); const user = pathUserEndpoints(path);
if (!endpoints) continue; 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 === null) anchors = connectorAnchorRects(root, rootRect);
if (anchors.compact.length < 2) return issues; if (anchors.compact.length < 2) return issues;
const attached = (point) => // Stable DOM identity across painted (inside) and compact (near-miss) tiers.
anchors.painted.some( const attachmentKey = (point) => {
(anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0, for (const anchor of anchors.painted) {
) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold); if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) {
if (attached(endpoints.start) || attached(endpoints.end)) continue; 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( const gap = Math.round(
Math.min( Math.min(
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))), Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))), Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))),
), ),
); );
issues.push({ issues.push({
@@ -1305,17 +1332,17 @@
time, time,
selector: selectorFor(path), selector: selectorFor(path),
containerSelector: selectorFor(svg), 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({ rect: toRect({
left: Math.min(endpoints.start.x, endpoints.end.x), left: Math.min(rendered.start.x, rendered.end.x),
top: Math.min(endpoints.start.y, endpoints.end.y), top: Math.min(rendered.start.y, rendered.end.y),
right: Math.max(endpoints.start.x, endpoints.end.x), right: Math.max(rendered.start.x, rendered.end.x),
bottom: Math.max(endpoints.start.y, endpoints.end.y), bottom: Math.max(rendered.start.y, rendered.end.y),
width: Math.abs(endpoints.end.x - endpoints.start.x), width: Math.abs(rendered.end.x - rendered.start.x),
height: Math.abs(endpoints.end.y - endpoints.start.y), height: Math.abs(rendered.end.y - rendered.start.y),
}), }),
fixHint: 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 time = options && typeof options.time === "number" ? options.time : 0;
const tolerance = const tolerance =
options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2; 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 = const root =
document.querySelector("[data-composition-id][data-width][data-height]") || document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") || document.querySelector("[data-composition-id]") ||
@@ -1407,7 +1438,7 @@
const clipped = clippedTextIssue(element, time, tolerance); const clipped = clippedTextIssue(element, time, tolerance);
if (clipped) issues.push(clipped); if (clipped) issues.push(clipped);
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance)); issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
const occluded = occludedTextIssue(element, time); const occluded = occludedTextIssue(element, time, proseCoverageFloor);
if (occluded) issues.push(occluded); if (occluded) issues.push(occluded);
const invisible = invisibleTextIssue(element, time); const invisible = invisibleTextIssue(element, time);
if (invisible) issues.push(invisible); if (invisible) issues.push(invisible);
@@ -1425,6 +1456,16 @@
return issues; 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 // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
// fingerprint of every visible element's box + opacity, in DOM order. Node // 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 // calls this once per seeked grid point and compares the strings across the
@@ -1480,4 +1521,248 @@
} }
return parts.join("|"); 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. // The marker tip path is skipped outright; only the detached line reports.
expect(issues).toHaveLength(1); expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" }); expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" });
expect(issues[0]?.message).toContain("drawn into an SVG with a different origin"); expect(issues[0]?.message).toContain("user-space coordinates would attach");
expect(issues[0]?.fixHint).toContain("Subtract the SVG's own rect"); expect(issues[0]?.fixHint).toContain("invert getScreenCTM");
}); });
it("skips svgs and paths without connector intent", () => { 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. // "knowledge-overflow" contains conn-family substrings only across word boundaries — no match.
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); 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", () => { 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); 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", () => { it("flags prose once coverage clears the 0.15 floor", () => {
// 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor. // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor.
const issues = auditCoverageScene({ const issues = auditCoverageScene({
@@ -1794,6 +2062,7 @@ function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number
function auditCoverageScene(options: { function auditCoverageScene(options: {
text: string; text: string;
hitCount: number; hitCount: number;
proseCoverageFloor?: number;
}): ReturnType<typeof runAudit> { }): ReturnType<typeof runAudit> {
const textRect = { left: 200, top: 500, width: 600, height: 80 }; const textRect = { left: 200, top: 500, width: 600, height: 80 };
document.body.innerHTML = ` document.body.innerHTML = `
@@ -1817,7 +2086,11 @@ function auditCoverageScene(options: {
return document.getElementById(isHit ? "overlay" : "headline"); return document.getElementById(isHit ? "overlay" : "headline");
}; };
installAuditScript(); installAuditScript();
return runAudit(); return runAudit(
options.proseCoverageFloor === undefined
? undefined
: { proseCoverageFloor: options.proseCoverageFloor },
);
} }
function auditOcclusionScene(options: { 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. // 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 }; 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", { Object.defineProperty(svg, "createSVGPoint", {
...prop,
value: () => ({ value: () => ({
x: 0, x: 0,
y: 0, y: 0,
@@ -1970,11 +2245,12 @@ function installConnectorGeometry(translate: CtmTranslate): void {
const numbers = (path.getAttribute("d")?.match(/-?\d*\.?\d+/g) || []).map(Number); const numbers = (path.getAttribute("d")?.match(/-?\d*\.?\d+/g) || []).map(Number);
const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 }; const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 };
const end = { x: numbers[numbers.length - 2] ?? 0, y: numbers[numbers.length - 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", { Object.defineProperty(path, "getPointAtLength", {
...prop,
value: (length: number) => (length === 0 ? start : end), 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; coveredFraction?: number;
} }
function runAudit(): AuditIssue[] { function runAudit(options?: { proseCoverageFloor?: number }): AuditIssue[] {
const audit = ( const audit = (
window as unknown as { window as unknown as {
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[]; __hyperframesLayoutAudit: (options: {
time: number;
tolerance: number;
proseCoverageFloor?: number;
}) => AuditIssue[];
} }
).__hyperframesLayoutAudit; ).__hyperframesLayoutAudit;
return audit({ time: 1, tolerance: 2 }); return audit({ time: 1, tolerance: 2, ...options });
} }
function selectedRangeElement(selected: Node | null): Element | null { 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);
}
},
});
+28 -33
View File
@@ -1,5 +1,5 @@
// fallow-ignore-file code-duplication // 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 { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -123,6 +123,20 @@ vi.mock("../telemetry/config.js", () => ({
configState.cache = { ...configState.disk }; configState.cache = { ...configState.disk };
return { ...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>) => { writeConfig: vi.fn((config: Record<string, unknown>) => {
configState.writeConfigCalls.push({ ...config }); configState.writeConfigCalls.push({ ...config });
if (configState.failWrites > 0) { 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", () => { describe("renderLocal browser GPU config", () => {
const savedEnv = new Map<string, string | undefined>(); const savedEnv = new Map<string, string | undefined>();
// Pre-resolve once. The first dynamic `import("./render.js")` in this file const {
// cold-loads a heavy module graph (core + engine + producer, incl. linkedom), renderLocal,
// slow under the parallel monorepo run — the generous hook timeout that resolveBrowserGpuForCli,
// absorbs that contention now lives in vitest.config.ts (shared by all CLI renderLintContinuationHint,
// suites). Importing once in `beforeAll` keeps every test fast and isolated. __resetDeParallelRouterTrialStateForTests: resetTrialState,
let renderLocal: typeof import("./render.js").renderLocal; } = renderModule;
let resolveBrowserGpuForCli: typeof import("./render.js").resolveBrowserGpuForCli;
let renderLintContinuationHint: typeof import("./render.js").renderLintContinuationHint;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
beforeAll(async () => {
({
renderLocal,
resolveBrowserGpuForCli,
renderLintContinuationHint,
__resetDeParallelRouterTrialStateForTests: resetTrialState,
} = await import("./render.js"));
});
it("points strict warning-only renders to --strict-all", () => { it("points strict warning-only renders to --strict-all", () => {
expect(renderLintContinuationHint(true)).toContain("--strict-all"); expect(renderLintContinuationHint(true)).toContain("--strict-all");
@@ -699,15 +706,9 @@ describe("renderLocal browser GPU config", () => {
}); });
describe("renderLocal — DE parallel-router CLI trial", () => { describe("renderLocal — DE parallel-router CLI trial", () => {
let renderLocal: typeof import("./render.js").renderLocal; const { renderLocal, __resetDeParallelRouterTrialStateForTests: resetTrialState } = renderModule;
let resetTrialState: typeof import("./render.js").__resetDeParallelRouterTrialStateForTests;
const savedEnv = new Map<string, string | undefined>(); const savedEnv = new Map<string, string | undefined>();
beforeAll(async () => {
({ renderLocal, __resetDeParallelRouterTrialStateForTests: resetTrialState } =
await import("./render.js"));
});
beforeEach(() => { beforeEach(() => {
producerState.createdJobs = []; producerState.createdJobs = [];
producerState.executeImpl = async () => undefined; producerState.executeImpl = async () => undefined;
@@ -1096,13 +1097,7 @@ describe("renderLocal — DE parallel-router CLI trial", () => {
}); });
describe("checkRenderResolutionPreflight", () => { describe("checkRenderResolutionPreflight", () => {
let checkRenderResolutionPreflight: typeof import("./render.js").checkRenderResolutionPreflight; const { checkRenderResolutionPreflight } = renderModule;
// 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"));
});
// Dims must be read the same way the producer's compiler reads them: // Dims must be read the same way the producer's compiler reads them:
// `data-width` / `data-height` on the `[data-composition-id]` root. // `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 { failCommand, requestCliExit } from "../utils/commandResult.js";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import type { Example } from "./_examples.js"; 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 { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { presentRenderPlan } from "./render/present.js"; import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js"; import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests. // Test-only seams retained at the command boundary for render behavior tests.
@@ -61,7 +62,12 @@ import {
trackRenderObservation, trackRenderObservation,
} from "../telemetry/events.js"; } from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.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 { shouldTrack } from "../telemetry/client.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js"; import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
import { bytesToMb } from "../telemetry/system.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. // Keep the transport adapter thin: each phase has one ownership boundary.
async run({ args }) { async run({ args }) {
const plan = createRenderPlan(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 presentRenderPlan(plan);
await executeRenderPlan(plan, { await executeRenderPlan(plan, {
renderDocker, renderDocker,
@@ -553,9 +562,12 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s
const dockerfilePath = resolveDockerfilePath(); const dockerfilePath = resolveDockerfilePath();
// Copy Dockerfile to a temp build context so docker build has a clean context // Copy Dockerfile to a temp build context so docker build has a clean context.
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`); // mkdtempSync (not a `Date.now()`-derived name) so the path is unpredictable
mkdirSync(tmpDir, { recursive: true }); // 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)); writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
// Platform is now derived from the host arch (see resolveDockerPlatform). // Platform is now derived from the host arch (see resolveDockerPlatform).
@@ -1339,6 +1351,9 @@ function handleRenderError(
...renderJobObservabilityTelemetryPayload(job), ...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(), ...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) { if (options.throwOnError) {
throw new Error(message); throw new Error(message);
} }
@@ -1376,6 +1391,9 @@ function trackRenderMetrics(
options: RenderOptions, options: RenderOptions,
docker: boolean, docker: boolean,
): void { ): 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 perf = job.perfSummary;
const compositionDurationMs = perf const compositionDurationMs = perf
? Math.round(perf.compositionDurationSeconds * 1000) ? Math.round(perf.compositionDurationSeconds * 1000)
@@ -1393,6 +1411,13 @@ function trackRenderMetrics(
fps: fpsToNumber(options.fps), fps: fpsToNumber(options.fps),
quality: options.quality, quality: options.quality,
workers: options.workers ?? perf?.workers, 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, docker,
gpu: options.gpu, gpu: options.gpu,
authoringSkill: options.authoringSkill, authoringSkill: options.authoringSkill,
@@ -1411,6 +1436,7 @@ function trackRenderMetrics(
deParallelRouter: perf?.drawElement?.parallelRouter, deParallelRouter: perf?.drawElement?.parallelRouter,
dePreRouterWorkers: perf?.drawElement?.preRouterWorkers, dePreRouterWorkers: perf?.drawElement?.preRouterWorkers,
deGateReason: perf?.drawElement?.gateReason, deGateReason: perf?.drawElement?.gateReason,
gpuRenderer: perf?.drawElement?.gpuRenderer,
deWorkerEncode: perf?.drawElement?.workerEncode, deWorkerEncode: perf?.drawElement?.workerEncode,
deVerifyArmed: perf?.drawElement?.verifyArmed, deVerifyArmed: perf?.drawElement?.verifyArmed,
deVerifyChecked: perf?.drawElement?.verifyChecked, deVerifyChecked: perf?.drawElement?.verifyChecked,
@@ -81,4 +81,22 @@ describe("createRenderPlan", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" }); const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("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, resolveDefaultFpsArg,
} from "../../utils/renderArgs.js"; } from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js"; import { normalizeSkillSlug } from "../../telemetry/skill.js";
import { loadProjectConfig } from "../../utils/projectConfig.js";
const VALID_QUALITY = new Set(["draft", "standard", "high"]); const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const; 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 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 = const invalidAuthoringSkill =
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill typeof args.skill === "string" && args.skill.trim() !== "" && !flagSkill
? args.skill ? args.skill
: undefined; : undefined;
+4
View File
@@ -42,6 +42,10 @@ const GROUPS: Group[] = [
["inspect", "Inspect rendered visual layout across the timeline"], ["inspect", "Inspect rendered visual layout across the timeline"],
["keyframes", "Inspect keyframes and render onion-shot diagnostics"], ["keyframes", "Inspect keyframes and render onion-shot diagnostics"],
["snapshot", "Capture key frames as PNG screenshots for visual verification"], ["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", "grade-compare",
"Render candidate color grades onto a reference frame as one labeled comparison PNG", "Render candidate color grades onto a reference frame as one labeled comparison PNG",
@@ -1,6 +1,8 @@
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { bundleToSingleHtml } from "@hyperframes/core/compiler";
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks"); const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
@@ -29,7 +31,7 @@ function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): s
return missing; return missing;
} }
describe("registry block manifests", () => { describe("registry blocks", () => {
it("installs every local script referenced by a block composition", () => { it("installs every local script referenced by a block composition", () => {
const missing: string[] = []; const missing: string[] = [];
@@ -48,4 +50,18 @@ describe("registry block manifests", () => {
expect(missing).toEqual([]); 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, isSubComposition: true,
}); });
for (const finding of result.findings) { for (const finding of result.findings) {
if (finding.code !== "media_in_subcomposition" && finding.code !== "media_missing_src") { if (finding.code !== "media_missing_src") continue;
continue;
}
invalidMedia.push(`${entryName}/${file.path}: ${finding.code}`); invalidMedia.push(`${entryName}/${file.path}: ${finding.code}`);
} }
} }
+14 -129
View File
@@ -1,43 +1,21 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readConfig, writeConfig } from "./config.js"; import { readConfig, writeConfig } from "./config.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { diag } from "../ui/diagnostics.js"; import { diag } from "../ui/diagnostics.js";
import { isDevMode } from "../utils/env.js"; import { isDevMode } from "../utils/env.js";
import { getSystemMeta } from "./system.js"; import { getSystemMeta } from "./system.js";
import { enqueue, POSTHOG_API_KEY, type EventProperties } from "./transport.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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Lightweight PostHog client — uses the HTTP batch API directly to avoid // CLI-facing telemetry policy: opt-out checks, system-metadata enrichment, and
// pulling in the full posthog-node SDK and its dependencies. // the first-run disclosure notice. The reliability-critical delivery layer
// All calls are fire-and-forget with a hard timeout. // (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 { export { flush, flushSync } from "./transport.js";
[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[] = [];
let telemetryEnabled: boolean | null = null; let telemetryEnabled: boolean | null = null;
@@ -71,6 +49,8 @@ export function shouldTrack(): boolean {
/** /**
* Queue a telemetry event. Non-blocking, fail-silent. * 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( export function trackEvent(
event: string, event: string,
@@ -80,11 +60,9 @@ export function trackEvent(
if (!shouldTrack()) return; if (!shouldTrack()) return;
const sys = getSystemMeta(); const sys = getSystemMeta();
eventQueue.push({ enqueue(
uuid: randomUUID(),
event, event,
distinctId, {
properties: {
...properties, ...properties,
cli_version: VERSION, cli_version: VERSION,
os: process.platform, os: process.platform,
@@ -107,101 +85,8 @@ export function trackEvent(
term_program: sys.term_program ?? undefined, term_program: sys.term_program ?? undefined,
agent_env_hints: sys.agent_env_hints ?? undefined, agent_env_hints: sys.agent_env_hints ?? undefined,
}, },
timestamp: new Date().toISOString(), distinctId,
}); );
}
/**
* 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
}
} }
/** /**
+45
View File
@@ -85,6 +85,39 @@ export interface HyperframesConfig {
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`. * `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
*/ */
deParallelRouterTrialRenderCount?: number; 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 = { const DEFAULT_CONFIG: HyperframesConfig = {
@@ -142,6 +175,18 @@ export function readConfig(): HyperframesConfig {
typeof parsed.deParallelRouterTrialRenderCount === "number" typeof parsed.deParallelRouterTrialRenderCount === "number"
? parsed.deParallelRouterTrialRenderCount ? parsed.deParallelRouterTrialRenderCount
: undefined, : 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; cachedConfig = config;
+55 -2
View File
@@ -197,6 +197,59 @@ describe("render telemetry events", () => {
expect(flush).toHaveBeenCalledTimes(2); 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", () => { it("redacts paths and URL query strings from render error messages", () => {
trackRenderError({ trackRenderError({
fps: 30, fps: 30,
@@ -462,7 +515,7 @@ describe("trackRenderFeedback", () => {
const [, props] = trackEvent.mock.calls[0] as [string, Record<string, unknown>]; const [, props] = trackEvent.mock.calls[0] as [string, Record<string, unknown>];
expect(props).not.toHaveProperty("render_duration_ms"); expect(props).not.toHaveProperty("render_duration_ms");
expect(props.$survey_response).toBe(4); expect(props.rating).toBe(4);
expect(props.rating_scale).toBe(10); expect(props.rating_scale).toBe(10);
}); });
@@ -470,7 +523,7 @@ describe("trackRenderFeedback", () => {
trackRenderFeedback({ rating: 5, renderDurationMs: 6000 }); trackRenderFeedback({ rating: 5, renderDurationMs: 6000 });
expect(trackEvent).toHaveBeenCalledWith( expect(trackEvent).toHaveBeenCalledWith(
"survey sent", "cli_render_feedback",
expect.objectContaining({ render_duration_ms: 6000 }), expect.objectContaining({ render_duration_ms: 6000 }),
); );
}); });
+45 -4
View File
@@ -73,6 +73,7 @@ export interface RenderObservabilityTelemetryPayload {
captureDeWorkerInversion?: string; captureDeWorkerInversion?: string;
captureDePreInversionWorkers?: number; captureDePreInversionWorkers?: number;
captureDeParallelRouter?: string; captureDeParallelRouter?: string;
captureDeGpuRenderer?: string;
captureDePreRouterWorkers?: number; captureDePreRouterWorkers?: number;
captureDeSelfVerifyFallback?: boolean; captureDeSelfVerifyFallback?: boolean;
captureDeFallbackReason?: string; captureDeFallbackReason?: string;
@@ -130,6 +131,7 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
de_worker_inversion: props.captureDeWorkerInversion, de_worker_inversion: props.captureDeWorkerInversion,
de_pre_inversion_workers: props.captureDePreInversionWorkers, de_pre_inversion_workers: props.captureDePreInversionWorkers,
de_parallel_router: props.captureDeParallelRouter, de_parallel_router: props.captureDeParallelRouter,
gpu_renderer: props.captureDeGpuRenderer,
de_pre_router_workers: props.captureDePreRouterWorkers, de_pre_router_workers: props.captureDePreRouterWorkers,
de_self_verify_fallback: props.captureDeSelfVerifyFallback, de_self_verify_fallback: props.captureDeSelfVerifyFallback,
de_fallback_reason: props.captureDeFallbackReason, de_fallback_reason: props.captureDeFallbackReason,
@@ -171,6 +173,17 @@ export function trackRenderComplete(
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */ /** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string; authoringSkill?: string;
workers?: number; 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; docker: boolean;
gpu: boolean; gpu: boolean;
// Static-frame dedup outcome (opt-out HF_STATIC_DEDUP=false). Undefined on // Static-frame dedup outcome (opt-out HF_STATIC_DEDUP=false). Undefined on
@@ -194,6 +207,8 @@ export function trackRenderComplete(
deParallelRouter?: string; deParallelRouter?: string;
dePreRouterWorkers?: number; dePreRouterWorkers?: number;
deGateReason?: string; deGateReason?: string;
/** Low-cardinality GPU bucket from DE session init (`<backend>/<vendor>`, e.g. `d3d11/nvidia`). */
gpuRenderer?: string;
deWorkerEncode?: boolean; deWorkerEncode?: boolean;
deVerifyArmed?: number; deVerifyArmed?: number;
deVerifyChecked?: number; deVerifyChecked?: number;
@@ -267,6 +282,13 @@ export function trackRenderComplete(
quality: props.quality, quality: props.quality,
authoring_skill: props.authoringSkill, authoring_skill: props.authoringSkill,
workers: props.workers, 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, docker: props.docker,
gpu: props.gpu, gpu: props.gpu,
static_dedup_enabled: props.staticDedupEnabled, static_dedup_enabled: props.staticDedupEnabled,
@@ -284,6 +306,7 @@ export function trackRenderComplete(
de_parallel_router: props.deParallelRouter, de_parallel_router: props.deParallelRouter,
de_pre_router_workers: props.dePreRouterWorkers, de_pre_router_workers: props.dePreRouterWorkers,
de_gate_reason: props.deGateReason, de_gate_reason: props.deGateReason,
gpu_renderer: props.gpuRenderer,
de_worker_encode: props.deWorkerEncode, de_worker_encode: props.deWorkerEncode,
de_verify_armed: props.deVerifyArmed, de_verify_armed: props.deVerifyArmed,
de_verify_checked: props.deVerifyChecked, de_verify_checked: props.deVerifyChecked,
@@ -380,6 +403,10 @@ export function trackRenderError(
peak_memory_mb: props.peakMemoryMb, peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb, memory_free_mb: props.memoryFreeMb,
...powerStateFields(), ...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), ...renderObservabilityEventProperties(props),
}, },
props.distinctId, props.distinctId,
@@ -633,14 +660,28 @@ export function trackRenderFeedback(props: {
renderDurationMs?: number; renderDurationMs?: number;
comment?: string; comment?: string;
doctorSummary?: 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 { }): void {
trackEvent("survey sent", { // Plain product event, not a PostHog survey response: nothing here is served
$survey_id: "render_satisfaction", // by the surveys product (no survey definition, no targeting, no popover).
$survey_response: props.rating, trackEvent("cli_render_feedback", {
rating: props.rating,
rating_scale: FEEDBACK_RATING_SCALE, 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.renderDurationMs !== undefined ? { render_duration_ms: props.renderDurationMs } : {}),
...(props.doctorSummary ? { doctor_summary: props.doctorSummary } : {}), ...(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, captureDeWorkerInversion: capture.deWorkerInversion,
captureDePreInversionWorkers: capture.dePreInversionWorkers, captureDePreInversionWorkers: capture.dePreInversionWorkers,
captureDeParallelRouter: capture.deParallelRouter, captureDeParallelRouter: capture.deParallelRouter,
captureDeGpuRenderer: capture.deGpuRenderer,
captureDePreRouterWorkers: capture.dePreRouterWorkers, captureDePreRouterWorkers: capture.dePreRouterWorkers,
captureDeSelfVerifyFallback: capture.deSelfVerifyFallback, captureDeSelfVerifyFallback: capture.deSelfVerifyFallback,
captureDeFallbackReason: capture.deFallbackReason, 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