mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
091137e3c32f7c247b6a7cc1c81ece36cd60a937
139
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
091137e3c3 |
feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports) |
||
|
|
341e65aea2 | fix(slideshow): harden media controls in present decks (#1619) | ||
|
|
f0c4dee705 |
fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cd832f01ac |
fix(core): mute preview audio per-element so a slow-decoding track isn't silenced (#1602)
The runtime plays audio two ways — a Web Audio transport (sample-accurate) and the HTMLMediaElement as a fallback — and mutes the elements when Web Audio takes over so they don't double-play. That mute gate was global: it muted every element the moment ANY Web Audio source was active (webAudio.isActive()). A track Web Audio had not claimed yet (its larger buffer decodes slower) was muted on the fallback AND not playing on Web Audio = silent, while the other tracks played. With TTS narration + BGM + SFX, the narration (largest buffer) lost the decode race and dropped out intermittently, with every file fully loaded. Make the mute per-element: an element is muted only when its own Web Audio source is live, or the user / parent-proxy force-mute is set. A track Web Audio has not claimed stays audible on the HTMLMedia fallback until the transport takes it over — which also lets narration start immediately on cold play instead of waiting for its buffer to decode. Also in this change: - Don't permanently blacklist a transient fetch failure in the Web Audio decoder (_failedSrcs was never cleared); only blacklist genuinely undecodable bytes, so a late-arriving asset (404 then available) self-heals on the next play. - Stop re-issuing play() every tick on an errored / no-source element. |
||
|
|
c0ffdc0fb0 |
fix(core,studio): escape user values in querySelector attribute selectors
Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and use it (or CSS.escape for browser-side code) at all 12 sites that previously interpolated raw user-authored values into querySelector attribute selectors. A " in a composition ID, script src, or data-start value would produce a malformed selector that throws. Node-side (core compiler/parser): uses the shared cssAttrSelector. Browser-side (runtime, studio): uses native CSS.escape(). Supersedes #1568 which fixed only the 3 bundler sites. |
||
|
|
f05b3f9c7c |
fix(slideshow): finish remaining split-PR review findings (#1594)
* fix(slideshow): address split-PR review findings on #1585 Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were already fixed on this branch: CSP handlers, manifest version, UUID ids, float keys, presenter 1s-timer): core (#1580): - isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly - resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite player (#1590): - present() window.open uses noopener,noreferrer (audience syncs via channel) - BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin cross-talk between decks - add observedAttributes + attributeChangedCallback so runtime sound/mode toggles re-render studio (#1591/#1592): - persistSlideshowManifest no-op gate (skip write when HTML is unchanged) - surface persist failures (console.error) instead of silent .catch(()=>{}) - confirm before deleting a branch sequence (data-loss + dangling hotspots) + tests for the collision + non-object-manifest rejection. 20 core / 106 player / 53 studio pass; tsc/lint/fmt/fallow clean; deck still renders. * fix(slideshow): finish remaining split-PR review findings The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in #1585): core (#1580): - dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the lint rule and the runtime scene-window computation (no more mirror-and-drift) player (#1590 / #1592): - onKey: when multiple decks share a page, drop the unfocused-convenience so a key drives only the focused deck - slow-iframe recovery: if the scene timeline posts after the wait times out (empty scenes), re-init once so sceneId slides resolve instead of being dropped studio (#1591): - persistSlideshowManifest validates the built island round-trips before writing - reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line reorder), with a branch-position indicator + tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass; tsc/lint/fmt/fallow clean. * fix(player,cli): use fileURLToPath for path resolution (Windows CI) new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows, which broke: - packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias resolved to a nonexistent path, failing the player slideshow tests on the Windows render-verification CI (passed on macOS/Linux where pathname is clean) - packages/cli/src/utils/compositionServer.ts helperDir — same bug in the play/present bundle-path resolution fileURLToPath converts file:// URLs to correct OS paths on all platforms. Player slideshow tests pass; present serves + resolves bundles. * fix(producer): fileURLToPath for the renders dir (Windows) DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..." on Windows and resolves to a bogus path — affects the Windows render pipeline. Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean). |
||
|
|
dd6fad6bb2 |
fix(sdk): code-review follow-ups (WS-B/C/3.C, #1569/#1570/#1572) (#1588)
Addresses the still-outstanding review concerns from merged PRs #1569 / #1570 / #1572 not already hoisted into #1573. WS-B (#1569): - validateVariables requires discriminant fields for object-valued font/image ({name,source} / {url}); a {name:42} font or {foo:42} image previously passed runtime validation and surfaced as a bogus font-family / missing image. - Dropped ImageValue's [key:string]:unknown index signature (let any {url}-shaped object through, swallowed typos); explicit alt?/fit? instead. - Documented the OverrideSet widening for SDK consumers. WS-C (#1570): - getElementTimings caches parsed GSAP labels by exact script text (avoids a full acorn re-parse per read; content-key invalidates on edit). - Documented end-inclusive label window + best-effort extractGsapLabels catch. WS-3.C (#1572): - Added typed Composition.addWithKeyframes / replaceWithKeyframes (was asymmetric with addGsapTween; Studio had to use raw dispatch). - Extracted shared KeyframeSpec type; documented position as seconds/number-only. Gates: build + core 18/18 + sdk 19/19 + oxlint + oxfmt + fallow + typecheck all green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d0e520dbd9 |
feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model (#1569)
## WS-B — variables / brand, object-valued (end-to-end)
Part of the AI Studio (Pacific) SDK integration. **Base of the SDK-hotspot stack** (`main → ws-b → ws-c → ws-d → ws-3c → ws-3f`).
### Problem
The variable system was split-brained: SDK `setVariableValue` wrote a `--{id}` CSS custom prop, while the runtime `getVariables()` read a separate JSON model (`data-composition-variables` / `__hfVariables`). The two never connected, and there was no `--brand-*` convention. Variables were scalar-only.
### What this does
- **B1 — one source of truth.** `setVariableValue` now drives the runtime variable model (`data-composition-variables` / `__hfVariables`), with CSS compatibility emitted as explicit `stylePath`-based patches alongside the model patch. A brand kit is a variables JSON; a batch of `setVariableValue` re-skins in one frame.
- **B2 — object-valued variables.** The `CompositionVariable` union extends from scalar-only to typed objects: `font` (`{name, source}`) and `image` (`{url, …}`), end-to-end (core union → SDK op → runtime merge). Colors stay scalar (per §7 LOCKED decision).
### Implementation notes
CSS compatibility was moved out of `apply-patches.ts` (where it was incorrectly writing CSS props as a side-effect of model patches, breaking inverse/undo) and into explicit patches emitted in `mutate.ts`. Forward emits `[modelPatch, cssPatch]` for scalars; inverse correctly generates `patchRemove` for the CSS prop when there was no prior CSS prop. Font/image variables never become CSS props.
### Files (12 changed, +441 −32)
- `packages/core`: `core.types.ts`, `lint/rules/composition.ts`, `parsers/htmlParser.ts` (+test), `runtime/validateVariables.ts`
- `packages/sdk`: `engine/mutate.ts` (+test), `engine/apply-patches.ts`, `engine/patches.ts`, `index.ts`, `types.ts`
### Gates
- `bun run build` ✅
- `bun test` SDK 304/0 ✅ · `validateVariables.test.ts` 13/0 ✅
- `bunx oxlint` 0/0 ✅ · `bunx oxfmt --check` ✅
- `fallow audit --gate new-only` ✅ (complexity inherited only)
> The +8 new `htmlParser.test.ts` font/image tests fail under the pre-existing `DOMParser is not defined` happy-dom limitation (main already carries 425 such failures) — not a logic bug; the pure runtime logic is covered by `validateVariables.test.ts`.
### Deferred
Brand-kit picker UI and `batch(setVariableValue × N)` wiring are Pacific-side; per-composition variable scoping beyond `__hfVariablesByComp`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
|
|
75d92b55e0 |
feat(core): add readiness adapters for map and visualization libraries (#1548)
Add readiness-only runtime adapters for Mapbox GL JS, Leaflet, Google Maps, MapLibre GL JS, and D3. Each adapter gates `__renderReady` until the library's async initialization completes, preventing the renderer from capturing blank or half-loaded frames. Built on the `getReadyPromise` adapter contract from #1543. A shared `createReadinessAdapter()` helper in `_readiness.ts` owns the settled-tracking WeakSet, promise-identity stability, and `Promise.allSettled` gate — each adapter provides only its type, window global name, and `waitFor` callback. Readiness signals per library: - Mapbox / MapLibre: `map.loaded()` + `map.on('load', ...)` - Leaflet: `map.whenReady(cb)` - Google Maps: `map.addListener('tilesloaded', cb)` with handle cleanup - D3: `transition.end()` promise 50 unit tests across 5 test files covering happy path, no-instances, stable promise identity, post-settle drain, loaded-before-subscribe race, and listener cleanup. 5 producer regression tests with Docker-generated baselines for end-to-end render verification. |
||
|
|
bb5f5f8c5c |
fix(core): auto-detect three.js asset readiness via adapter contract (#1543)
Replaces the original `window.__hyperframesReady` authored API with an internal adapter contract: `RuntimeDeterministicAdapter.getReadyPromise?: () => PromiseLike | null`. The Three.js adapter implements it by hooking `THREE.DefaultLoadingManager.onStart/onLoad`; the runtime collects promises from every adapter and gates `window.__renderReady = true` on them. Zero authoring burden — composition authors write plain Three.js, framework handles async asset gating automatically. Also keeps the orthogonal `htmlDocument.ts` script-stripping refactor (substring → regex for simple flag assignments), which fixes the bug where authored scripts referencing readiness flags were stripped despite never assigning them. Stamped by Magi and Miguel; CI green; tests 33/33 pass. |
||
|
|
d1162cd1b1 | refactor(runtime): trim color grading api | ||
|
|
48fb42e5f0 | refactor(runtime): trim color grading helpers | ||
|
|
1ad158d2f0 | feat(runtime): apply color grading in preview and render | ||
|
|
8cbf4384e1 |
feat(studio): timeline inline expansion + __clipTree runtime primitive
When a child element inside a sub-composition is selected, the timeline replaces the parent scene clip with the deepest-level siblings. Deselect or selecting outside collapses back. Expanded clips are fully editable — move, resize, delete, and split — addressed by their real DOM id with timeline time rebased onto the sub-comp they live in. Runtime: - New window.__clipTree API: a read-only hierarchical ClipNode tree (id/parentId/children + backing element) so Studio can derive parent/child relationships for inline expansion. Studio: - useExpandedTimelineElements derives the expanded view from selectedElementId + clipParentMap (pure useMemo, no useEffect). Each child rebases onto its immediate sub-comp host (start + sourceFile), so multi-level nesting targets the right file. - NLELayout routes expanded-clip edits through the same handlers top-level clips use, in local coordinates — edits save to the sub-comp source and reflect via reloadPreview (no separate DOM-patch path). This is the canonical update; there is no reactive observer. - findMatchingTimelineElementId resolves sub-comp children with no top-level element to `sourceFile#id`. - Razor tool enabled by default; studio_razor_split analytics event fired on single and split-all. - O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a cached Set+WeakSet O(1) lookup. |
||
|
|
1ab7dcfe47 |
fix(core): stop transport re-seek from clobbering Studio drag drafts (#1464)
Gate the runtime's per-frame transport re-seek to yield to an active Studio manual-edit drag, so GSAP x/y-controlled elements track the cursor instead of freezing until drop. Also adds the missing sdk-playground workspace member to Dockerfile.test, which unblocks the render regression suite for any runtime-touching PR. |
||
|
|
a95e49dbda |
fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window When iframe autoplay is blocked, audible playback is promoted to a parent-frame audio proxy. The proxy read the clip's data-start/data-duration once at adopt time and mirrorTime() only skipped (never paused) the element outside that window — so a trimmed/moved music clip kept playing the full source past its on-timeline end, even though the iframe element was correctly paused. Fix: the proxy keeps a reference to its source iframe element and re-reads data-start/data-duration each mirror tick (live trims/moves apply), pauses the proxy when the playhead leaves [start, start+duration), and resumes it when the playhead re-enters during parent-owned playback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core,studio): bound trimmed audio playback to the clip window Trimmed audio played to the source file's natural end instead of stopping at the clip edge, on every audio path: - WebAudio (the audible path in Studio): schedulePlayback now passes the clip's data-duration as the third start() arg, so the decoded buffer stops at the trimmed edge instead of running to the file end. - Runtime element gating: the duration resolver caps each clip by its own data-duration (min of source length, host window, authored duration), so a trimmed <audio>/<video> element pauses at its edge. Studio trim UX: - Resize live-patches the media-start/playback-start offset, so a start-edge drag trims into the source instead of only repositioning the clip. - AudioWaveform windows the rendered peaks to the trimmed slice so the waveform tracks the clip edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(player,core): gate proxy playback to the live clip window Review follow-ups on the parent-audio-proxy / WebAudio bound: - seekAll now re-reads live source bounds (_refreshEntryBounds) before gating, so a paused scrub right after a trim/move uses the current clip window instead of the adopt-time one. - playAll and clip adoption only start a proxy when the playhead is inside the clip's window (_playEntryIfActive), so bulk starts / promotion no longer blip audio for clips outside their window until the next tick. - The WebAudio buffer is now bounded by the host-composition window too (matching resolveDurationSeconds), so a sub-composition-nested clip stops at the same edge on the WebAudio and HTMLMedia paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds A bounded WebAudio source's wall-clock length is baked into start()'s duration arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in place on a later rate change does not rescale that bound, so a trimmed clip ends early (fast) or late (slow). setRate now reports whether the rate changed and exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active clips at the new rate when any bounded source is live. The per-clip schedule loop is extracted to a shared closure so play() and the rate path agree. Also guard _refreshEntryBounds against a non-numeric duration attribute parsing to NaN, which would make every window check false and let the proxy play past its clip end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> |
||
|
|
6f677292ae |
refactor(core): simplify packages/core — dead code, dedup, type safety (#1413)
- Delete unused mediaPreloader module, 5 dead RuntimeState fields, emitPerformanceMetric, lintScriptUrls, 5 variable type guards - Consolidate compiler utilities: unify CSS URL regex, relative URL predicate, MIME map, @import regex, bulk asset rewrite delegation - Cache extractGsapWindows per script (eliminates 2 redundant recast parses per lint run), share stripJsComments and script extraction - Deduplicate GSAP parser: share serializeValue/safeJsKey, centralize converted-id fallback (6 sites), keyframe codegen (3 sites), waypoint extraction, insert-after-anchor, script hoisting - Replace 88 bare any annotations with typed AstNode/AstPath interfaces - Derive RuntimeBridgeControlAction from HyperframeControlAction, alias RuntimePickerElementInfo, share macOS font profiler - Gate generateHyperframesStyles on includeStyles, collapse 4 GSAP property mutation cases into 2 - Extract magic numbers into named constants, replace 5 double casts with type guards and typed accessors (runtime/globals.ts), reduce function complexity in htmlParser and files route |
||
|
|
d580f2a1d8 |
fix(render): make WebGL video textures deterministic in headless render (#1403)
* fix(render): make WebGL video textures deterministic in headless render WebGL compositions that sample a `<video>` as a texture (e.g. a faceted crystal with clips mapped onto its facets) rendered with flickering, non-deterministic facets: a video would intermittently show a stale frame or go black, and the same frame differed between two renders. Two gaps caused this: 1. No WebGL analog of the WebGPU `patchVideoTextureCompat`. Chrome's headless compositor can't feed decoded `<video>` frames to the GPU, so the engine injects a decoded `<img class="__render_frame__">` sibling per video each frame. The WebGPU `copyExternalImageToTexture` path substitutes it, but `texImage2D` / `texSubImage2D` did not — so WebGL uploaded a stale/black frame. Add `patchWebGLVideoTextureCompat()` mirroring the WebGPU patch (shared `resolveRenderFrameImage` helper). 2. Capture ordering. Per frame the runtime seeks (GPU adapters render on `hf-seek`) BEFORE the engine injects the decoded frames, so the GPU render read a frame that didn't exist yet. After injecting, the engine now calls `window.__hfReseekGpu(t)` — a force-dispatch (`forceDispatchSeekEvent`) that bypasses the same-time `hf-seek` dedup — so GPU compositions re-upload their textures from the freshly-injected, decoded frames, deterministically. Tests: unit tests for the texImage2D/texSubImage2D substitution and the force-dispatch, plus a videoFrameInjector regression test asserting the post-injection GPU reseek fires only when frames were injected. Verified end-to-end: a WebGL prism with 8 live <video> facets renders byte-identical across independent runs with no facet flicker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(render): add producer render-compat regression for WebGL video textures A WebGL2 canvas samples a <video> as a texture every hf-seek (the natural author pattern, distilled from the HeyGen prism). The render-compat harness renders it and compares against the golden: with the video-texture fix the render reproduces the decoded frames; revert the fix and the canvas renders black, collapsing the comparison. Golden verified to contain real, time-varying video content (not black), so a regression is caught rather than passing vacuously. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7fa3696101 |
fix(runtime): respect hidden ancestor clips in Studio preview (#1387) (#1395)
* fix(runtime): respect hidden ancestor clips in Studio preview (#1387) Studio-stamped GSAP tween targets inside timed clips were getting visibility:visible for the full composition, overriding hidden parent panels. Skip stamping descendants of authored clips and suppress visibility on children when an ancestor timed clip is hidden. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(runtime): scope ancestor visibility walk to Studio iframe only Address review feedback: the hierarchical visibility guard now runs only when window.parent !== window, matching the Studio-only stamping fix. Render mode keeps prior per-element visibility semantics. Adds a render-mode regression test and documents the null rootComp case in findTimedClipAncestor. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
7a99ccec6d |
fix(core): honor root data-duration when GSAP timeline ends short (#1378)
* fix(core): honor root data-duration when GSAP timeline ends short The authored-duration floor only counted child composition clips, never the root element's own data-duration. A composition whose GSAP timeline ended even 0.1s short of its declared data-duration reported the shorter timeline length from player.getDuration() — and the studio's adapter selection (docDuration <= adapterDur) then silently rejected the audio-capable runtime player, downgrading preview playback to the seek-scrubbing adapter, which never starts media elements or WebAudio. Result: total audio silence with zero errors anywhere. - include the root's declared data-duration in resolveAuthoredCompositionDurationFloorSeconds, making data-duration the source of truth for playable length (per the documented contract) - console.warn in the studio when playback falls back to the seek-driven adapter, since the downgrade loses audio invisibly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): release static-seek adapter on native win, warn once on downgrade Review findings on the previous commit, all in the static-seek fallback path of useTimelinePlayer.getAdapter: - A cached static-seek adapter was never paused when adapter selection later resolved a native adapter (the early returns bypass the fallback branch entirely), leaving its private rAF loop seeking the player while the native transport also drives it. The core data-duration fix makes this switch path much more common. releaseStaticSeekCache() now runs at every native-adapter return and at unmount. - The downgrade warning fired on every cache miss — and the cache key can never hold for __timelines compositions because wrapTimeline() returns a fresh object per call, so it fired every rAF tick. It now warns once per downgrade streak (re-armed when a native adapter takes over). - The warning interpolated adapterDur (the native __player duration, 0 when absent) instead of the selected adapter's duration, and used a one-off "[hyperframes-studio]" prefix instead of the file's "[useTimelinePlayer]" convention. The fallback cache logic moved to playbackAdapter.ts (with unit tests for warn-once, cache identity, and pause-on-replace/release), which also keeps useTimelinePlayer.ts inside the studio 600-line limit. Also corrected a stale "no DOM reads" comment on the runtime transport tick — the duration floor has always queried the DOM per call, and now also reads the root's declared data-duration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
036b991cbb |
fix(runtime): preserve authored muted attr; clean up WebAudio on end (#1319)
onSetMuted/onSetMediaOutputMuted set el.muted = effective on every <video> and <audio> element. When the bridge sent onSetMuted(false), it unmuted avatar <video muted> elements whose baked-in lip-sync audio should never play — causing double audio alongside the separate TTS. Fix: el.muted = effective || el.defaultMuted. AudioBufferSourceNode fires 'ended' when playback completes naturally, but _activeSources was never cleaned up. This kept isActive() true permanently, which force-muted all HTML audio elements via the outputMuted flag in syncRuntimeMedia — causing audio to disappear after the WebAudio buffer finished (~5s for short TTS clips). Add onended listener that removes the source from _activeSources and restores el.muted to its pre-WebAudio value. All side-effects are guarded by idx !== -1 so a stale ended event after stopAll() is a no-op and cannot clobber bridge state set between stop and the async event delivery. |
||
|
|
192417b7cd | fix(core): guard element stamping to iframe-only (Studio preview) | ||
|
|
a468550f82 |
feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): keyframe cache + commit hooks Add hooks for keyframe cache population (tween → clip-relative %), mutation dispatch, keyframe snapping, and audio beat detection. * feat(studio): timeline UI — dopesheet diamonds + keyboard nav Add dopesheet strip with diamond keyframe indicators, timeline property rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate (STUDIO_KEYFRAMES_ENABLED defaults to false). * feat(studio): design panel — arc controls + ease curve + stagger Add arc path controls (curviness slider, auto-rotate), motion path SVG overlay, ease curve visualization, stagger controls, and expanded animation card. Includes border-radius editor dependency from #1217. * feat(studio): gesture recording core Add gesture recording engine with RAF sampling, modifier key property mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity), Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay. * fix(studio): keyframe drag + recording bug bash 21 fixes: capture GSAP base at drag start, translate:none before gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording seek, _auto flag for 100% keyframes, overlay flash fix, block edits during recording. * feat(studio): keyframe integration wiring + docs Wire App.tsx recording orchestration, TimelineToolbar K/R buttons, PropertyPanel per-property diamonds, shortcuts panel, toast notifications, and keyframes guide documentation. All gated on STUDIO_KEYFRAMES_ENABLED (default false). |
||
|
|
5a0966dfeb |
fix(runtime,player): replay bridge state on iframe ready to repair race
The audio-locked attribute was correctly setting `muted = true` and posting `set-muted` to the iframe runtime, but on warm-cache reloads of claude.ai and inside the Claude desktop Electron client, the iframe finishes loading *after* the parent has already sent control messages — the iframe runtime's postMessage listener isn't installed yet, so the messages are silently dropped. Audio plays unmuted with no UI to recover. Confirmed via: - "First open" on claude.ai: cold cache, iframe slow → listener up before `set-muted` lands → audio muted ✅ - "Hard refresh" on claude.ai: warm cache, iframe fast → listener up after message arrives → message lost → audio plays ❌ - Claude desktop: Electron renderer consistently fast → race always loses → audio plays ❌ Fix: add a `{source: "hf-preview", type: "ready"}` event the runtime emits once `installRuntimeControlBridge` has registered the listener. The player listens for it and replays current bridge state (`set-muted`, `set-volume`, `set-playback-rate`). Pre-ready messages are now safe to send — they'll be replayed once the runtime can receive them. The replay is idempotent — re-asserting defaults is a no-op — so it's also safe across iframe reloads (new runtime instance emits ready again). Tests: 6 new (1 bridge: ready posted on install; 5 player: replays muted / volume / playback-rate / audio-locked-forced-mute / handles second ready / ignores ready from wrong source). Suites green: core 1387, player 137. Refs: - Investigation: heygen-com/hyperframes#1300 (UA-fallback attempt — unrelated to actual root cause) - claude.ai-web.log analysis revealed cross-origin iframe + race condition, not attribute stripping as originally hypothesized 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
908b455e5e |
fix(core): apply playbackRate to all media duration resolution sites (#1288)
Extract readElementPlaybackRate() to eliminate clamping duplication across media.ts, init.ts, startResolver.ts, and timeline.ts. Apply the rate division to the two remaining sites that were missed: - startResolver.ts: visibility loop used raw source duration, hiding slowed-down videos mid-playback when no data-duration was set - timeline.ts: resolveMediaElementDurationSeconds underreported the end window sent to the renderer, affecting preview parity Also adds direct tests for readElementPlaybackRate(). |
||
|
|
7b6c87fa88 |
fix(core): account for playbackRate in media duration resolution (#1287)
* fix(core): account for playbackRate in media duration resolution resolveDurationSeconds computed sourceDuration as (element.duration - mediaStart) without dividing by playbackRate. A 5s source at 0.5x should span 10s on the timeline, but was capped at 5s — causing the video to go black once the raw source was exhausted. Read defaultPlaybackRate from the element (same clamping as refreshRuntimeMediaCache) and divide sourceDuration by it so the effective timeline window matches the actual playback speed. * test(core): add regression test for playbackRate in resolveDurationSeconds Pins the fix: a 5s source at 0.5x playbackRate must resolve to 10s effective duration when resolveDurationSeconds is provided (mirroring the init.ts callback pattern). Without the rate division, this would return 5s and clip early. |
||
|
|
1bcd6ec3b3 |
fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)
Compositions that defer gsap.timeline() registration past DOMContentLoaded (via setTimeout, template instantiation, or dynamic script loading) hit a race where __renderReady stays false forever: 1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips the hf-timelines-built listener and sets __renderReady = true 2. The deferred script runs, calls gsap.timeline().to() which sets __hfTimelinesBuilding = true via the batching proxy 3. The deferred maybePublishRenderReady() sees building=true, sets __renderReady = false, but never registers a listener to retry 4. __renderReady stays false, __hf.duration returns 0, pollHfReady times out with "Composition has zero duration" Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true, register a one-shot hf-timelines-built listener to retry — matching the pattern already used at init time for the synchronous batching case. Closes #1260 |
||
|
|
ebd156bcc1 |
fix: batch GSAP timeline construction to prevent main-thread hang (#1231) (#1249)
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231) Compositions with thousands of tl.to() calls (e.g. 8,562 in the reported case) block Chrome's main thread synchronously during HTML parsing, preventing DOMContentLoaded from firing before Puppeteer's navigation timeout. This caused render jobs to hang indefinitely at 'Initializing calibration session...' with no error message. Root cause: GSAP's timeline API is synchronous — each tl.to() call registers a tween immediately on the main thread. A script with 8k+ calls holds the thread for seconds, starving the browser event loop and delaying DCL past the navigation timeout window. Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected at the top of <head>, before GSAP or user scripts load). When GSAP assigns itself to window.gsap, the setter intercepts the real gsap object and wraps gsap.timeline() to return a proxy that queues tween descriptors (to/from/fromTo/set) instead of calling them synchronously. A requestAnimationFrame-based flush loop drains 100 tweens per frame, yielding the main thread between batches so DCL can fire. When the queue is drained, the stub sets window.__hfTimelinesBuilding = false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks this flag at DOMContentLoaded time; if building is still in progress it defers bindRootTimelineIfAvailable() until the event fires, then sets window.__renderReady = true as normal. pollHfReady continues to gate on both __renderReady and window.__hf.duration > 0, so the render pipeline does not start until the full timeline is bound. - Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale) - Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0)) - Determinism: 'hf-timelines-built' event guarantees sequencing - Proxy forwards: pause/seek/totalTime/time/duration/add/paused/ timeScale/play delegate to the real timeline immediately - No GSAP package changes; no navigation timeout increase Fixes #1231 * style: apply oxfmt formatting to producer stub files * fix(producer): unwrap proxy children in add(), gate setter return on args.length Addresses two latent correctness concerns from code review: 1. proxy.add() now unwraps __hfReal from any proxy child before passing it to the real timeline. GSAP's internal tween graph (_first/_next/_prev linkage) requires real timeline instances — proxy objects lack internal fields like _dp that GSAP's iteration paths expect. 2. totalTime/time/paused/timeScale now return proxy when called in setter form (args.length > 0). Previously these returned the real timeline, causing callers who chain .to(...) after a setter call to bypass batching. Also: build-hf-early-stub.ts now runs oxfmt on the generated output file so the format check passes in CI on every build. * fix(producer): gate __hf.duration=0 while GSAP timelines are batching The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF batch loop is draining queued tl.to() calls). pollHfReady in the engine polls until window.__hf.duration > 0, so returning 0 keeps the engine waiting until the hf-timelines-built event fires and all tweens are committed to the real GSAP timelines. Without this gate, normal compositions (style-6, style-13, vignelli) were being captured mid-batch — the real timelines were empty so GSAP could not seek them, producing frozen/blank frames in the output video. * fix(producer): flush GSAP batching under virtual time * fix(producer): gate render bridge on runtime readiness * fix(producer): preserve timeline child binding under batching |
||
|
|
37d02f26a2 |
test(core): add T8 override-set merge semantics suite for getVariables (#1241)
## What
Extends `getVariables.test.ts` with T8 from the SDK migration test plan: override-set merge semantics.
## Tests (4 new)
- **last-write-wins** — calling `setOverrides` twice; second value wins
- **sparse override** — override one key, unmentioned declared defaults survive intact
- **batch override (brand kit)** — setting all keys at once via a single override object
- **manual override after batch** — replacing one key from a kit batch; others untouched
## Scope note
Tests are labeled **"flat-merge, current behaviour"** — `getVariables` does `{...defaults, ...overrides}`. Dotted-key path resolution (`"headline.color"` as `id.prop`) is a future SDK concern; these tests validate the flat-merge contract that exists today, not the future path-resolution semantics.
## Stack
Stacked on T1 (#1240). Prerequisite for T11 (#1242), T4 (#1243).
|
||
|
|
b6a14ea9f5 |
fix(runtime): make audio/media sync boundary inclusive to match visibility fix (#1173)
`init.ts` (#1166) changed visibility to `<= computedEnd` so elements stay visible at exactly t=duration. Audio clock (`init.ts:1908`) and `syncRuntimeMedia` (`media.ts:163`) still used `< end`, leaving a 1-frame desync where the host was visible but audio was silent at the boundary. Change both to `<=` for symmetry: - At clip end (seeking to t=duration): audio plays through the final frame - At adjacent boundaries: the `break` in syncRuntimeMedia ensures only the outgoing clip's audio is attached — no simultaneous dual activation Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
aab7377400 |
feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance. |
||
|
|
faa3f588fb |
fix(runtime): don't restart non-loop media that has naturally ended (#1203)
* fix(runtime): don't restart non-loop media that has naturally ended When a media element's authored data-duration exceeds the actual file length, el.ended becomes true at the file's natural end while the clip is still considered 'active' (timeSeconds < clip.end). The runtime was calling el.play() on the ended element every rAF tick, resetting currentTime to 0 and causing audible stutter for the overshoot duration. Fix: treat el.ended as inactive for non-loop clips. The element sits silently until the composition ends. el.ended resets to false on any seek, so scrubbing backward correctly resumes playback. Reproducer: bg-music WAV is 60s but data-duration='68.6' (composition duration). Last 8.6s: rapid play->clamp->end->play cycle at 60fps. * test(runtime): add seek-recovery contract test for el.ended guard Adds a third test case pinning the seek-recovery property called out in the PR body: a clip that went silent at t=62 (el.ended=true) should resume playing after a backward seek resets el.ended to false. |
||
|
|
ac73cbbcd0 |
fix(runtime): apply parent composition offset to WebAudio scheduling for sub-comp audio (#1175)
Audio elements inside sub-compositions on the root timeline were ignoring their host composition's data-start placement offset in the WebAudio scheduling path (introduced in #671 / v0.5.4). All sub-comp audio was scheduled with compositionStart equal to its local data-start (typically 0), causing every slide's audio to fire simultaneously at global t=0 instead of at each slide's placement time. Root cause: two sites in the WebAudio path read rawEl.dataset.start directly instead of accounting for the [data-composition-id] ancestor's data-start: 1. player.play() — WebAudioTransport.schedulePlayback() compositionStart arg 2. transportTick — TransportClock.attachAudioSource() compositionStart arg The syncRuntimeMedia path (HTMLMediaElement fallback) was already correct because syncMediaForCurrentState uses resolveMediaCompositionContext which sums the host offset into the clip's start time. Fix: add resolveGlobalAudioStart() that walks up [data-composition-id] ancestors and sums their resolveStartForElement() offsets. Handles nested sub-compositions. Apply it at both broken call sites. Fixes #1174. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
b652c0a235 |
fix(runtime): show elements at exact end of their duration (inclusive boundary) (#1166)
Visibility check used strict less-than (currentTime < end), hiding elements at exactly t=duration. Changed to <= so the last frame renders the final animation state. |
||
|
|
1284213886 |
fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle - opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits - `visibility` renders as a boolean toggle; only available to add in `set` tweens - ease curve section: use aspect-ratio container so control circles are not oval - MetricField scroll only fires when the input is focused (was triggering on scroll-over) - preview overlay clipped to its container (overflow-hidden) — no bleed into panels - `fromTo` method label updated to "From → To" (was "Animate", same as `to`) - repeated click at same position cycles through stacked/overlapping elements (#1124, #1125) resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks advance through all selectable layers at that coordinate - fallow-ignore-next-line complexity on pre-existing complex functions surfaced by branching from fix/gsap-fromto-panel rather than main Closes #1124, #1125 * fix(studio): address Vai+Rames follow-up notes on hf#1122 - extract buildTweenSummary to gsapAnimationHelpers.ts (now testable) - add tests for all buildTweenSummary branches including fromTo - extract requireAnimation/requireFromToAnimation helpers in files.ts, eliminating the parse→find→guard pattern repeated across three switch cases and removing the fallow-ignore-next-line complexity bypass - add 400 guard: add mutation with fromProperties on non-fromTo method now returns 400 instead of silently dropping fromProperties - add test for the 400 guard * fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1 * fix(studio): show all .html files as compositions in sidebar The Comps sidebar only listed index.html and files under a compositions/ subdirectory. Any other .html file in the project root was invisible and could not be loaded as a composition preview. Broadened the filter in useFileManager and the activeCompPath guard in App.tsx to treat every .html file as a selectable composition. Also excluded App.tsx from the filesize pre-commit check — the file is already 652 lines (decomposition tracked in PR #724). * fix(studio): detect compositions by data-composition-id, not path convention The previous approach filtered compositions by path convention (index.html or compositions/ subdirectory). Any .html file outside that convention was invisible in the Comps sidebar. The server now scans each .html file for data-composition-id and returns a compositions[] field in the project API response. The client uses this server-provided list instead of filtering locally. This means any .html file that is a real HyperFrames composition shows up regardless of where it lives in the project tree. * fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview Updated the property panel button label from "Ask agent" to "Copy prompt to AI agent". Updated the modal title to match. Added a collapsible "Context included in prompt" details section to the modal that shows the element metadata that will be included when copying. * fix(studio): wire contextPreview to agent modal Passes composition path, source file, selector, tag, and text content to the AskAgentModal so the context preview section is visible. * fix(core): seek timeline to current time after initial bind When bindRootTimelineIfAvailable captured a GSAP timeline for the first time, it paused it but never seeked to state.currentTime. This left fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0) even after the user scrubbed past the tween's end. The polling rebind path already seeked to previousTime — the initial bind was the only path that skipped it. * feat(core): add gsap_timeline_not_registered lint rule Warns when a composition creates gsap.timeline() but never registers it in window.__timelines. Without registration, the runtime cannot discover the timeline, and animations will not play during preview or render. Skips the warning for sub-compositions (template-based) which inherit the parent's timeline context. * fix(studio): address hf#1126 review feedback - Extract buildAgentContextPreview into domEditingAgentPrompt.ts and import it in App.tsx, removing the inline computation that pushed App.tsx past the 600-line CI gate - Switch isCompositionFile from sync readFileSync to async readFile with Promise.all, and use a regex test instead of string includes - Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts into gsapAnimationConstants.ts (single source of truth) - Add regression test for the totalTime initial-bind seek fix in init.test.ts — verifies the captured timeline receives a totalTime call on initial bind * refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption Extracted inspector state, studio context construction, and drag overlay into useStudioContextValue.ts. Deduplicated block handler args via a shared blockCtx memo. App.tsx drops from 657 to 588 lines. Removed the App.tsx exemption from lefthook.yml — the file now passes the 600-line gate without special-casing. Added domEditing.ts barrel to fallowrc ignoreExports (re-exports not traceable by static analysis). |
||
|
|
0f938841cd |
fix(core,engine): guard volume probe cache and restore PCM cursor (#1119)
Two perf fixes caught in #1118 review: 1. Cache guard: probeAndCacheVolumeKeyframes now short-circuits when the element is already in volumeKeyframeCache. Without the guard every bindMediaMetadataListeners call (every 30 RAF ticks) re-probed all bound elements — N elements × full-composition timeline seeks at 60 Hz regardless of whether keyframes were already known. bindRootTimelineIfAvailable still clears the cache on a new timeline capture so keyframes stay fresh when the composition is rebound. 2. PCM cursor: audioVolumeEnvelope.ts had the incremental segment cursor (O(N+M) overall) before #1118 extracted the interpolation into interpolateVolumeGain. The shared function restarts from segment=0 on each call — fine for the preview path (one call per RAF tick) but O(N×M) for the PCM path (one call per sample: 48 kHz × duration). Napkin math: a 10-min render went from ~30M to ~460M ops. Restored the inline incremental scan in the engine bake loop; engine now only imports normaliseEnvelope from core. |
||
|
|
d3c333b383 |
fix(core): apply renderer volume-automation solution to preview (#1118)
Preview audio with GSAP volume fades (e.g. data-volume="0" with a
gsap.to("#bgm", {volume:0.25, ...})) played ~1s then silenced. Root
cause: syncRuntimeMedia used fallbackAuthorVolume (data-volume) on the
first tick after a clip became active, clobbering the GSAP-seeked value.
The single-clock transport seeks GSAP before syncRuntimeMedia runs, so
el.volume already holds the animated value — we just need to trust it.
Fix — three layers, matching the renderer's approach (PR #1117):
1. First-tick tracking: on the first tick a clip is active
(previousRuntimeVolume===undefined), use currentElementVolume (GSAP's
seeked value) instead of fallbackAuthorVolume. In production the
transport always seeks GSAP before syncRuntimeMedia, so el.volume is
already at the correct animated position.
2. Probed keyframes: new probeElementVolumeKeyframes() runs the same
offline probe the renderer uses (discoverAudioVolumeAutomationFromTimeline)
directly in the browser. init.ts calls probeAndCacheElementVolume() when
an element is bound and a timeline is available. When keyframes are present,
syncRuntimeMedia drives volume from the interpolated envelope — no
GSAP-change tracking needed, no first-tick edge case, same data source
as the renderer.
3. Shared utilities: normaliseEnvelope(), interpolateVolumeGain(), and
probeAndCacheElementVolume() extracted to mediaVolumeEnvelope.ts and
exported from @hyperframes/core/media-volume-envelope. The engine's
audioVolumeEnvelope.ts imports from there — no duplicate logic between
the renderer and the new preview path.
Fallow audit exits non-zero on inherited complexity/duplication in init.ts
functions that shifted line numbers (applyClipLayout, transportTick, etc.),
unchanged by this PR — same known false-positive pattern noted in #1117.
Lint, format, typecheck, and unit tests all pass.
53 core/media tests pass (3 updated to pre-set el.volume to match the
runtime's bindMediaMetadataListeners — corrects a missing setup step).
audioVolumeEnvelope tests (6) still pass.
|
||
|
|
e0cb8fcee3 |
fix(core): remove 1800s hard cap on timeline duration that silently truncated long compositions (#1114)
The runtime had a maxTimelineDurationSeconds field defaulting to 1800 (30 minutes) that clamped the TransportClock duration. Any seek beyond this cap was silently clamped, so GSAP tweens starting past ~1700s never received their totalTime() call and stayed at their pre-tween state (e.g. opacity:0). The data-duration attribute is the authored source of truth. The loop- inflation guard (timelineLooksLoopInflated) already handles the infinite repeat:-1 case this cap was meant to protect against. Closes #1107 |
||
|
|
2d7b9e5245 |
fix(core): guard timeline method calls for non-conformant objects (#1098)
* fix(core): guard timeline method calls for non-conformant objects User compositions can register timeline-like objects on window.__timeline where .duration is a number property (not a function) and .pause/.play may be missing entirely. The runtime player called these unconditionally, causing ~166 "duration is not a function" and ~38 "pause is not a function" errors per day. Add safeNum() and safeVoid() helpers that check typeof before calling, falling back to reading numbers as properties and silently skipping missing void methods. Applied consistently across all timeline method call sites in player.ts. * fix(core): add observability for non-conformant timeline properties |
||
|
|
c53552876e |
fix(core): patch resolveMediaWindowDurationSeconds + extract helper
Fix the 4th unguarded resolveStartForElement call site in resolveMediaWindowDurationSeconds that inflated the timeline duration floor for pip compositions. Extract resolveMediaStartSeconds helper to consolidate the data-hf-auto-start guard across all call sites. |
||
|
|
1d0b18587d |
fix(core): extend media start fix to all consumers, guard auto-start
Narrow the raw data-start read to media elements without data-hf-auto-start (explicitly authored global coordinates). Elements with auto-injected data-start="0" remain composition-local via the resolver. Apply consistently across all three consumers: - visibility loop (init.ts) - refreshRuntimeMediaCache start/duration (init.ts) - resolveMediaWindowEndSeconds (timeline.ts) Add regression test for auto-injected data-start="0" inside a late-starting host to prove it doesn't regress. |
||
|
|
1be2a584b4 |
fix(core): use raw data-start for media elements in preview visibility loop
For video and audio elements, data-start is authored in global (composition-root) time — the same contract used by the render pipeline's discoverMediaFromBrowser, which reads the raw attribute directly. Previously, the visibility loop called resolveStartForElement which adds the nearest ancestor composition's global start on top, causing a double-offset that kept pip-wired media permanently hidden when the host composition did not start at t=0. Example: a pip video with data-start="45.40" inside a host composition that also starts at data-start="45.40" resolved to 90.80, so the video was always hidden during its actual [45.40, 52.46] window. Non-media elements (divs, sections, etc.) continue to use the accumulating resolver because their data-start values are local to their composition. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
e10ec358f8 | fix(core): correct WAAPI rediscovery seek baselines and preview reuse invalidation | ||
|
|
167a222318 | fix: support animated audio volume | ||
|
|
1d09e6ff36 |
fix(core): set __renderReady unconditionally after binding attempt
The capturedTimeline guard broke CSS/WAAPI/Lottie compositions that have no GSAP timeline — __renderReady was never set, causing the parity harness to timeout after 30s. renderSeek works with or without a GSAP timeline (adapter-only seeking), so the correct invariant is "timeline binding was attempted" not "a timeline was found." Set __renderReady unconditionally in all three paths, after bindRootTimelineIfAvailable has run. |
||
|
|
3347486ae9 |
refactor(core): remove redundant as casts using window.d.ts declarations
window.d.ts already declares __timelines, __player, __playerReady,
and __renderReady on the global Window interface. The casts in
init.ts and init.test.ts were re-asserting the same types.
- Add __hfRuntimeTeardown to window.d.ts (used 6x in init.ts)
- Remove runtimeWindow cast variable from init.ts — use window directly
- Remove all (window as Window & { __player?: ... }).__player casts
from init.test.ts — window.__player is already typed as PlayerAPI
- Remove all (window as Window & { __timelines?: ... }).__timelines
casts from init.test.ts — window.__timelines is already typed
- Remove (window as Window & { __playerReady/renderReady }}) casts
from init.ts — already declared globally
|
||
|
|
e7d0b392c7 |
fix(core,cli): address review — guard __renderReady, drop pre-quantization, add tests
- Guard __renderReady with `if (state.capturedTimeline)` in all three paths (setTimeout(0) and .finally() were setting it unconditionally even when bindRootTimelineIfAvailable returned false) - Remove redundant fps=30 pre-quantization in snapshot — renderSeek already calls quantizeTimeToFrame internally with the runtime's canonicalFps, so pre-quantizing was double-quantizing at a potentially wrong grid - Add regression tests: __renderReady is set when timeline exists, stays undefined when no timeline is available |
||
|
|
16e049b320 |
fix(cli): address review — fps comment, fileServer cross-ref, duration note
- Add comment explaining hardcoded fps=30 (runtime's canonicalFps default, not exposed on PlayerAPI) - Add cross-reference comments between init.ts and fileServer.ts explaining their different __renderReady timing semantics |
||
|
|
b2828e48e5 |
fix(core,cli): defer __renderReady until root timeline is bound
The runtime set __renderReady at the same time as __playerReady, before the root timeline was bound. Consumers waiting for __renderReady (the render-safe signal) could observe a player with no captured timeline, making renderSeek a no-op. Root cause: init.ts set both flags together, but timeline binding happens later — synchronously via bindRootTimelineIfAvailable(), via a deferred setTimeout(0) for bundled compositions, or asynchronously via loadExternalCompositions(). Fix in init.ts: - Remove __renderReady from the __playerReady assignment - Set it after bindRootTimelineIfAvailable() when timeline is found - Set it in the setTimeout(0) deferred path - Set it in the external compositions .finally() path Fix in snapshot.ts: - Wait for __renderReady (truthful signal) not __timelines - Use renderSeek() with frame quantization, not seek() - Tick the GSAP ticker after seeking - Await document.fonts.ready before capturing Closes #1047 |
||
|
|
3983d4e7bf |
fix(core): set explicit dimensions on data-hf-inner-root wrapper
The prepareFlattenedInnerRoot function creates a wrapper div when inlining sub-compositions. This wrapper had no width/height, which broke CSS height:100% chains — any sub-composition using percentage heights with flexbox centering would collapse to 0px and render content at the top instead of centered. Read data-width/data-height from the inner root and set matching pixel dimensions on the wrapper's inline style. Applied in both the compiler (server-side bundling) and the runtime (browser-side composition loader). Adds a producer regression test with a centered card sub-composition that fails without this fix. |