The lazy media preloading refactor split bindMediaMetadataListeners into
two loops: bind all listeners first, then preload all elements. This
changed when metadata listeners fire relative to .load(), shifting
timeline duration hydration and causing 3 transition frames to render at
a slightly different state in the style-9-prod regression test.
Move eager preload back inside the per-element binding loop so listener
attachment and .load() happen in the same iteration, matching the
original ordering. Lazy-mode demotion stays in a separate block after
mediaPreloader.refresh() since it needs the full clip list.
mediaPreloader.refresh() was called unconditionally, setting lazy=true
for compositions with ≥6 clips even in render mode. player.seek then
called preloadAroundTime() which evicted clips via src clearing,
destroying buffered data needed for frame-accurate capture.
Skip refresh() when __HF_EXPORT_RENDER_SEEK_CONFIG is set so isLazy()
stays false and the preloader is completely inert during renders.
- Player: _adoptIframeMedia now skips media with preload="metadata" or
"none", preventing parent-frame proxies from bypassing the preloader.
MutationObserver extended to watch preload attribute changes so proxies
are created just-in-time when the preloader promotes a clip.
- init.ts: lazy-mode demotion loop skips elements with data-preload-eager,
letting power users keep specific clips eagerly buffered.
- mediaPreloader: reads window.__HF_LAZY_PRELOAD_THRESHOLD as an override,
falling back to the default 6.
- Add onActivation callback to MediaPreloadManager; wired to
postRuntimeDiagnosticOnce in init.ts for observability
- Document LAZY_THRESHOLD rationale (why 6) and MAX_PROMOTED
defense-in-depth semantics
- Add render-mode bypass contract test (isLazy with exactly 6 clips)
- Add onActivation tests: fires once on lazy activation, skips below
threshold, deduplicates across refreshes
Three root-cause fixes for the lazy media preloading feature:
1. Untimed media orphaned at preload="metadata": the else branch in
bindMediaMetadataListeners demoted ALL media elements, but the
mediaPreloader only manages timed clips (data-start). Untimed media
(background audio, ambient loops) got stuck at metadata forever.
Now only timed elements are demoted.
2. Monotonic promotion with no eviction: once promoted, clips stayed
at preload="auto" forever. Scrubbing through the full timeline
promoted everything, bringing back the OOM crash. Added LRU eviction
with MAX_PROMOTED=5 — when clips leave the preload window, their src
is cleared and load() called to release buffered data per MDN. On
re-entry, the original src is restored.
3. Metadata preload without load(): setting preload="metadata" alone
doesn't guarantee the metadata fetch in Chrome Lite mode or Firefox
with media.preload.default=0. Now load() is called after demotion
to ensure el.duration is populated for timeline computation.
Also adds exact-boundary tests for LAZY_THRESHOLD=6 and eviction
coverage (evict on scrub, src restoration, MAX_PROMOTED cap, load()
called on eviction).
Wire the MediaPreloadManager into init.ts:
- Detect render mode via __HF_EXPORT_RENDER_SEEK_CONFIG (keeps eager preload)
- Gate bindMediaMetadataListeners: lazy mode sets preload="metadata",
eager mode keeps preload="auto" (unchanged for small compositions)
- Advance preload window in the timeline poll tick loop
- Call preloadAroundTime on seek for instant buffering at seek target
Studio Player.tsx: hasUnloadedAssets now skips elements with
preload!="auto" so deferred clips don't block the loading overlay.
Compositions with many large video files (e.g., 6GB across 20 clips) crash
the browser because the runtime eagerly sets preload="auto" + .load() on
every media element at startup. All files buffer simultaneously, exhausting
memory.
Add a MediaPreloadManager that gates preloading based on playhead position:
- Activates when a composition has ≥6 timed media elements
- Only preloads clips within a 10-second lookahead window (or next 2 clips)
- Far-away clips stay at preload="metadata" (resolves duration without
downloading data)
- Advances the window on each transport tick and immediately on seek
- Render mode (window.__HF_EXPORT_RENDER_SEEK_CONFIG) keeps eager preload
for deterministic frame capture
- Small compositions (<6 clips) keep eager preload — no behavior change
Studio's hasUnloadedAssets now skips elements with preload!="auto", so
deferred clips don't block the loading overlay.
The four existing presets only cover 16:9 (landscape) and 9:16 (portrait)
aspect ratios. A 1080×1080 square comp had nowhere to land at any scale:
"Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K
mapped to a landscape/portrait preset whose aspect ratio mismatched, which
the producer's resolveDeviceScaleFactor validator rejects with
"does not match the aspect ratio of the composition".
Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS
in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation
extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array
automatically, so the producer's validator, the render API route, and
the CLI `--resolution` flag pick the new presets up without further
changes.
- core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the
htmlParser to recognize `data-resolution="square|square-4k"` and to
infer square from equal width/height (vs. the prior "square defaults
to portrait" tie-breaker).
- studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors;
collapse isPortraitComp into a 3-way `compAspect` helper so
resolveResolution returns the square preset for square comps.
- cli: update --resolution help text on `init` and `render` to mention
the new presets.
- tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor
suite (returns 1 for square→square, 2 for square→square-4k, rejects
landscape preset on square comp), update the htmlParser test that
previously pinned the "square→portrait" tiebreaker.
- Hoist duplicated test mock helpers (createMockAudioContext / setupTransport /
mockBuffer / mockEl) from the two describe blocks to module scope.
- Drop redundant math-derivation comments in schedulePlayback; the dedicated
rate-aware tests are the canonical proof.
- Tighten setRate JSDoc.
- Add no-op guard in setRate when the new rate equals the current rate, so a
duplicate set-playback-rate postMessage doesn't re-anchor or walk active
sources for nothing.
- Add a regression test for the no-op guard, and strengthen the clamp test
to schedule at rate=2 first so the clamp-to-1 assertion is non-vacuous.
WebAudioTransport scheduled AudioBufferSourceNodes with the implicit
default playbackRate of 1, so non-1x transport rates desynced visuals
from audio: GSAP timelines, the transport clock, and native <video>
all sped up while WebAudio-routed <audio> clips kept playing at 1x.
- schedulePlayback now accepts a rate, sets sourceNode.playbackRate,
and scales the future-clip start delay by the rate (the in-progress
buffer offset stays elapsed + mediaStart, which is rate-independent).
- New setRate() updates active sources in place and rebases the
getTime() reference frame so the audio-master clock stays continuous
across mid-playback rate changes.
- Runtime onSetPlaybackRate now forwards into webAudio.setRate, and
player.play() schedules each clip with state.playbackRate.
Fixes#713
The property delegation on window.__player used Object.defineProperty
with only a getter, causing "Cannot set property renderSeek which has
only a getter" when Studio's motion-wrapping code tried to reassign
__player.renderSeek with a wrapped version. This cascaded into an
infinite error loop making the timeline unusable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.
- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
the user at the rational form, since `29.97` and `30000/1001` round
to different framerates inside ffmpeg
Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).
Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
count, frame-index → time)
Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.
Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
Clips whose compositionStart is ahead of the current timeline position
were starting immediately because sourceNode.start() always received
when=0. Use the AudioContext scheduling API to defer future clips:
sourceNode.start(ctx.currentTime + delay, mediaStart).
Closes#674
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.
Architecture:
TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
▲
AudioContext.currentTime (~21µs) ← WebAudio active
OR
audio.currentTime (~33ms) ← HTMLMediaElement fallback
OR
performance.now() (~1ms) ← no audio
Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)
Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).
Also fixes: CDN script HTML error responses in validate (pre-existing).
54 tests across clock, clock-drift, webAudioTransport, and media.
Closes#668
After hf#641 inlined the runtime IIFE into every bundle, lint tools
inspecting bundled output (including Abhay's c2v eval) started flagging
empty `catch {}` blocks across the runtime. The source had explanatory
comments inside, but esbuild's minifier strips them — the IIFE ships
~10 visible patterns of `}catch{}` and consumers' linters fire on each.
Each empty catch is intentional best-effort error swallowing —
postMessage to a parent frame that may not exist, `media.play()` /
`pause()` that throw under autoplay restrictions, timeline `seek()` on
a disposed timeline, anime.js / lottie feature detection on hosts that
don't load those libraries, etc. The right behaviour stays "tried,
didn't work, move on", but doing it visibly improves three things:
- lint clean: helper call is a real statement; no `no-empty` warnings
survive minification
- debuggable: flip `window.__hfDebug = true` in DevTools to see every
swallow site with `console.debug` (silent in prod by default)
- observable: studio / embeddings can install
`window.__hf.onSwallowed = handler` to collect runtime swallow
events without polluting the page console
Implementation: `packages/core/src/runtime/diagnostics.ts` exports
`swallow(label, err?)`. 41 catch sites across 12 runtime files
converted via mechanical pass (auto-generated `runtime.<module>.siteN`
labels — labels can be tightened site-by-site as a follow-up; the
shape of the change is what matters here).
Verification:
- core 674/674 (incl. 6 new diagnostics tests covering silent default,
__hfDebug logging, legacy __HYPERFRAMES_DEBUG flag, handler hook,
handler-throws-doesn't-recurse, both-active)
- typecheck clean
- format / lint clean
- runtime IIFE rebuilds successfully (`bun run build:hyperframes-runtime`)
Refs Abhay's c2v eval — bundler artefacts now lint-clean with the
runtime body inlined.
* feat(player): add volume/mute controls to the player
Adds a mute toggle button and volume slider to the controls bar,
positioned between the time display and speed selector. The slider
expands on hover for a compact default footprint.
- `volume` attribute/property (0–1, clamped) with `volumechange` event
- `muted` attribute now syncs to the controls UI (icon updates)
- Three volume icons: high, low, muted — updates reactively
- Volume forwarded to parent-frame audio proxies and iframe runtime
via `set-volume` postMessage control
- 9 new tests covering volume clamping, events, controls rendering,
mute toggle, and iframe message forwarding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(player): wire set-volume through runtime bridge + address review feedback
Addresses the blocker from PR review: the iframe runtime now handles
`set-volume` messages end-to-end (types → bridge → init → media sync).
Runtime side:
- Add `set-volume` to RuntimeBridgeControlAction union
- Add `volume` field to RuntimeBridgeControlMessage
- Handle `set-volume` in bridge.ts with [0,1] clamping
- Store bridgeVolume in RuntimeState, apply to media elements
- syncRuntimeMedia composes userVolume × clip author volume
Player side:
- Muted toggle now dispatches `volumechange` (HTML5 spec compliance)
- Volume slider auto-unmutes when scrubbed above 0 while muted
- Touch support on volume slider (touchstart/move/end)
Tests: 5 new (3 bridge, 2 media)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity
- Volume slider: role="slider", aria-label, aria-valuemin/max/now,
tabindex=0, arrow key support (5% steps, auto-unmutes)
- Volume=0 unmuted now shows low-volume icon instead of muted icon
- Fix clipVolume divergence: init.ts uses Number.isFinite() matching
media.ts semantics (preserves data-volume="0")
- 3 new tests: ARIA attributes, volumechange on mute, icon collision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime): clear play guard after hard seek to prevent audio desync on scrub
When scrubbing the timeline during playback, syncRuntimeMedia detects
the offset jump and hard-seeks the media element. But the in-flight
play() guard (playRequested WeakSet) from the previous play() call
prevented the next sync tick from re-issuing play() — leaving the
element paused at the new position for 50-150ms while the GSAP timeline
continued advancing. This caused audible audio desync after every scrub.
Fix: clear playRequested on the element after a hard seek so the very
next sync tick can re-issue play().
Also adds a lint rule (video_audio_double_source) that catches
compositions where an unmuted <video> and a separate <audio> point to
the same source — a pattern that causes double playback at runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime): detect failed seeks past MP3 buffer and force full fetch
Root cause: streaming MP3 with preload="metadata" only buffers the first
~15 seconds. Seeking past the buffered range silently fails — currentTime
stays at 0 while the timeline advances, causing permanent audio desync
that only a page refresh fixes.
Three changes:
1. Move preload="auto" enforcement to run for ALL active elements on
every sync tick (not just during play). This catches elements whose
preload was overridden after init.ts set it.
2. After a hard seek, check if currentTime actually reached the target.
If not (drift > 0.5s), call load() once to force the browser to
fully fetch the media and build a complete seek index.
3. Clear the load-retry guard when the clip leaves its active window
so re-entry can retry if needed.
Reproduced on hyperframes.dev Hermes launch video: vo.mp3 buffered to
15.96s, seeking to 20s failed silently. bg-music.wav (fully buffered)
was unaffected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per CodeQL's `js/bad-tag-filter` recommendation, replace the regex-based
`<script>` body extraction with a `parseHTML` + `querySelectorAll`
walk. The rule explicitly says "use a parser library" — and linkedom
is already imported in this file, so the diff is small.
This eliminates the regex entirely, so the rule can no longer fire on
this site (instead of chasing whitespace / case / trailing-content
edge cases one at a time).
CodeQL still flagged `</script\s*>` as too narrow — the rule wants
tolerance for `</script\t\n bar>` (HTML parser treats trailing content
in a close tag as part of the tag). Switched to `</script[^>]*>` for
full coverage.
The bundler still always emits the canonical `</script>`; this is
test-side hardening, not a runtime fix.
CodeQL's `js/bad-tag-filter` rule flagged `</script>` as too strict —
`</script >` (with whitespace before `>`) is valid HTML and would slip
past the matcher. Changed to `</script\s*>` for full defense-in-depth.
The bundler always emits the canonical form, so no real-traffic miss —
this is hardening the test's parse-loop, not fixing a downstream bug.
Addresses CodeQL alert on #641.
CodeQL flagged the inline `<script>...</script>` regex as case-sensitive,
which would miss `<SCRIPT>` tags. The bundler always emits lowercase, so
this is a defense-in-depth fix matching the `/i` flag already used by the
sibling regexes in this file (lines 37 & 75).
Addresses CodeQL review on #641.
Per @vai-bot's review on hf#641:
Important #1: dead `src=""` substitution sites
=============================================
Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.
Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.
Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
is "inline" (matches the self-contained-bundle promise the function name
makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
substitution code (regex never matches the new inlined-runtime shape).
Important #2: joinJsChunks ASI hazard
======================================
The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.
Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.
Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.
New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.
Verification
============
- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity
eval against the bundled output. Each is independently small; they share a single
PR because they're all artifacts of the bundler-output shape.
1. Empty `src=""` runtime placeholder (real bug)
`htmlBundler.ts:injectInterceptor` emitted
`<script data-hyperframes-preview-runtime="1" src=""></script>`
when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the
page URL itself; Chrome flags this as an infinite-fetch hazard. Three other
consumers (studioServer, validate, snapshot) post-process the placeholder to
substitute either a real URL or an inlined body — `bundleToSingleHtml` did
not, so the bundle wasn't actually self-contained despite the function name.
Fix: when no URL is configured, inline the runtime IIFE directly via
`getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before.
2. Bare-semicolon lines between joined JS chunks (cosmetic)
Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS,
composition scripts) which produced a lone `;` on its own line between
chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper
that ensures each chunk ends in `;` and joins on `\n`.
3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy)
The `_err` underscore prefix signals "intentionally swallowed" but bundle-time
linters often don't honor that convention. Replaced with `catch { /* ... */ }`
(no binding, explanatory comment) — same behavior, no rule fires.
Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus
existing tests updated to reflect the new inlined-runtime shape (the previous
"runtime block must not contain getElementById" assertion no longer holds
because the inlined body itself uses getElementById; replaced with a more
specific "author script not merged into runtime tag" check).
Issue #4 from the original report (Unterminated string at line 1111 col 18,
char 65497) was not directly reproducible after applying these fixes — esbuild
parses all 4 inline scripts in the rebundled output cleanly. The unterminated-
string symptom was likely a downstream artifact of the bare-semicolon joining
or the empty-src placeholder confusing the lint tool. If the original symptom
persists on a clean re-run against the fixed bundle, will open a follow-up PR
with a focused repro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both `composition_file_too_large` and `timeline_track_too_dense` previously
said "Agents produce better results when large scenes are split into smaller
sub-compositions." The audience-flavored framing ("Agents produce better
results") doesn't tell a reader (agent or human) WHY smaller is better.
Reframe to concrete properties of smaller compositions: easier to read,
iterate on, and diff. The fixHint already covers the inspect/revise/validate
detail; the message now leads with a tight reason.
Per Abhay in #C0ACCNHLG3U:
> "an agent reading 'Agents produce better results' sounds weird. We should
> give the agent an actual reason why smaller is better for them."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core.types.ts: export COMPOSITION_VARIABLE_TYPES, a runtime tuple of
every CompositionVariableType variant guarded by `as const satisfies
readonly CompositionVariableType[]`. Adding a new variant to the union
without also adding it to the tuple becomes a compile error rather
than silent drift in callers that maintain their own list.
- composition.ts (lint rule): the local
`new Set(["string","number","color","boolean","enum"])` now derives
from COMPOSITION_VARIABLE_TYPES instead of duplicating the list.
- index.ts: export COMPOSITION_VARIABLE_TYPES alongside the rest of the
variable type guards.
Reuse + efficiency reviews otherwise clean. The other reuse finding
(loadProjectHtml helper to dedupe readFileSync + ensureDOMParser across
3 callers) is real but reaches files outside this PR's scope; it's a
better fit as a follow-up cleanup once the variable-feature stack lands.
All 48 composition lint tests + 49 core suite tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two lint rules + render-time validation built on top of the existing
data-composition-variables schema.
Lint rules (packages/core/src/lint/rules/composition.ts):
- invalid_variable_values_json — host's data-variable-values must parse as
a JSON object. Today the runtime swallows parse failures silently and
falls back to declared defaults, masking typos.
- invalid_composition_variables_declaration — root <html>'s
data-composition-variables must parse as an array of objects with
`id` (string), `type` (one of string/number/color/boolean/enum), `label`
(string), and `default`. Per-entry findings report which fields are
missing or invalid.
Both rules read attributes via a new `readJsonAttr` helper in lint/utils.ts.
The existing `readAttr` regex `["']([^"']+)["']` truncates JSON-in-attribute
values at the first internal quote (e.g. `data-variable-values='{"x":"y"}'`
captures only `{`); `readJsonAttr` alternates double-vs-single-quoted
branches with quote-specific char classes so JSON values round-trip cleanly.
A second helper `findHtmlTag` returns the actual <html> open tag (where
data-composition-variables lives) — distinct from `findRootTag` which
returns the first in-body composition element.
Render-time validation (packages/core/src/runtime/validateVariables.ts):
- validateVariables(values, declarations) returns a structured array of
issues: undeclared keys, type mismatches, enum-out-of-range values.
Pure / sync; works in any environment.
- formatVariableValidationIssue(issue) renders a one-line user-facing
string for CLI output.
- Both exported from @hyperframes/core for studio/tooling reuse.
CLI integration (packages/cli/src/commands/render.ts):
- New --strict-variables flag. Default behavior: print warnings and
continue. With --strict-variables: print warnings then exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper:
reads the project's index.html, runs extractCompositionMetadata to
pull the declared schema, validates the CLI's --variables payload
against it. ensureDOMParser polyfill for Node-side parsing (same
pattern as compositions.ts).
Tests:
- 11 new validateVariables unit tests covering happy path, undeclared
keys, type mismatches (string/number/boolean/color/enum), enum range,
multiple-issue aggregation, and formatter output.
- 11 new composition.test.ts cases for both lint rules: parse errors,
shape errors, per-entry validation, unknown types, missing fields,
positive cases.
- 5 new render.test.ts cases for validateVariablesAgainstProject:
no-declarations, happy path, undeclared, type-mismatch, missing-file.
- All 646 core tests + 213 cli tests still green.
Docs:
- docs/packages/cli.mdx — added --strict-variables flag row.
This is PR 3 of a 4-PR stack. PR 4 ships skill/scaffold distribution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- compositionLoader.ts: drop the redundant inline `Window` cast; the
ambient `__hfVariablesByComp?` declaration in runtime/window.d.ts
already covers it within the same package.
- compositionScoping.test.ts: drop the `__captured: undefined as unknown`
initializers — `Record<string, unknown>` already permits the key, the
init was noise.
Reuse + efficiency reviews returned clean. The scoped getVariables's
per-call Object.assign({}, scoped) is consistent with the file's
existing scoped-utility conventions (gsap proxy returns fresh bound
functions per access) and acceptable since the idiomatic usage
destructures once at script init.
All 44 touched core tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>