fix(studio,player,core): eliminate double audio and manifest polling loop#722
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
| // Keep this heuristic conservative: if user source already loads GSAP, Studio does not add another copy. | ||
| return ( | ||
| /<script\b[^>]*src=["'][^"']*gsap/i.test(html) || | ||
| /\/\*\s*inlined:.*gsap/i.test(html) || |
a24019f to
ca00c70
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Three fixes (PR description names them — title says two), each at the right architectural layer, no internal coupling between them. The double-audio fix is the most subtle: it eliminates a workaround from hf#671 by changing the source-of-truth for HTML-element mute state. Approving.
What I verified
Fix 1 (double audio on pause/resume) — clean re-architecture
The two-line change is small but conceptually significant:
// init.ts:1304
- outputMuted: state.mediaOutputMuted,
+ outputMuted: state.mediaOutputMuted || webAudio.isActive(),
// webAudioTransport.ts:135
- source.el.muted = source.priorMuted;
+ // Keep the element muted — syncRuntimeMedia will unmute on the next tick if appropriate.This eliminates the priorMuted capture/restore mechanism that hf#671 added — but doesn't regress hf#671's silent-mute fix. The reasoning:
hf#671's original fix: WebAudio scheduling set el.muted = true during scheduling and never reverted on stopAll(). The priorMuted capture/restore was the workaround.
hf#722's new architecture: syncRuntimeMedia is now the single source of truth for "should this element be muted." It reads webAudio.isActive() directly and sets the element's mute state based on that. The priorMuted restore is no longer needed because the element's mute state is recomputed on every rAF tick.
The two changes are coupled:
- Line 1 (
init.ts) givessyncRuntimeMediathe right input (webAudio.isActive()) - Line 2 (
webAudioTransport.ts) removes the conflicting restore
Without BOTH changes, the architecture would break:
- Without line 1: syncRuntimeMedia would unmute the element while WebAudio is still active → double audio (the bug being fixed)
- Without line 2: the priorMuted restore on stopAll would race with syncRuntimeMedia's next tick → could re-mute when not appropriate
Together they form a consistent "syncRuntimeMedia is the source of truth, driven by webAudio.isActive()" architecture.
Cross-check against hf#671's failure mode (from my prior memory on the audio-drift fix): hf#671's specific bug was "el.muted = true set during scheduling, never reverted on stopAll." Post-#722, the element IS muted during scheduling (still), but on the next rAF tick after stopAll, syncRuntimeMedia sees webAudio.isActive() = false and unmutes. So the failure mode doesn't reappear — just handled by a different mechanism.
✓ Architecturally consistent; eliminates the priorMuted workaround in favor of a cleaner source-of-truth model.
Fix 2 (manifest polling loop) — bug + perf fix
Two coupled changes in applyStudioManualEditsToPreview / applyStudioMotionToPreview:
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
if (!readFromDiskFirst) {
applyCurrentStudioManualEditsToPreview(iframe);
return; // ← NEW EARLY RETURN — skip disk read
}
// ... disk read path ...Pre-PR, the disk read happened unconditionally — readFromDiskFirst only controlled the ORDER (apply in-memory first vs apply disk first). Now it ALSO controls whether to read disk at all.
PR description correctly identifies the cause:
"The runtime posts
statemessages every frame via postMessage, triggering React re-renders that re-invoked these functions ~60x/second."
So the disk read fired 60 times per second on every preview frame. Confirmed the fix at the function level — the early return eliminates the read entirely when no fresh disk content is requested.
Plus the secondary change: removing applyStudioManualEditsToPreviewAfterRefresh and applyStudioMotionToPreviewAfterRefresh wrappers, calling .ref.current(...) directly in the iframe load useEffect. This is the canonical "stable ref pattern" for breaking callback-identity dependency cycles in React useEffect deps:
}, [
activeCompPath,
applyDomSelection,
- applyStudioManualEditsToPreviewAfterRefresh, // identity changes every render
- applyStudioMotionToPreviewAfterRefresh,
buildDomSelectionFromTarget,
...
]);The deps removed were callbacks whose identity changed every render (because their own deps were unstable). Each render → new identity → useEffect re-runs → re-attaches handlers → schedules another disk read. Breaking the cycle via refs is the right shape. ✓
Fix 3 (parent proxy double-play) — architectural symmetry
Two changes in hyperframes-player.ts:
a) _promoteToParentProxy() synchronously mutes iframe media to close the race window where el.play() could complete before the bridge mute lands:
try {
const doc = this.iframe.contentDocument;
if (doc) {
for (const el of doc.querySelectorAll<HTMLMediaElement>("video, audio")) {
el.muted = true;
}
}
} catch {
/* cross-origin */
}Same-origin: synchronously mutes the iframe's media elements. Cross-origin: silently catches the exception (correct — can't access contentDocument).
The async _sendControl("mute") still happens in the original code below this block; this is just defense-in-depth that closes the same-origin race window.
b) _setupParentMedia() early-returns when the runtime bridge is available:
try {
const win = this.iframe.contentWindow;
if (win && this._hasRuntimeBridge(win)) return;
} catch {
/* cross-origin */
}Verified _hasRuntimeBridge is the same helper from hf#673 (checks __hf or __player on the contentWindow). When the runtime bridge is present, the runtime manages media directly; parent proxies would double up. Clean architectural symmetry with the "runtime is source of truth, parent path only for runtime-less compositions" model. ✓
CI
All visible checks at ca00c708 green or in_progress:
Perf: load,Perf: scrub✓ greenPerf: fps,Perf: parity,Perf: driftin_progressPreview parity,preview-regression,Graphite / mergeability_check✓regression-shards (*)in_progress (slow visual regression)
No failures. The perf jobs being green on Perf: load and Perf: scrub is reassuring for the manifest-polling fix — if the 60fps disk read had performance side effects on the player, the perf suite would catch it.
Important — ResolutionPreset fix mentioned in description but not in diff
The PR description ends with:
"Also: Fixes pre-existing ResolutionPreset type missing
squareandsquare-4kvariants (from #715)"
But the diff shows only 4 files: init.ts, webAudioTransport.ts, hyperframes-player.ts, App.tsx. No ResolutionPreset type definition changes. Either:
- The fix was meant to be in this PR but wasn't committed
- It's a follow-up that landed separately
- The description is wrong about the scope
Worth confirming. If the fix was intended here, add the file (or note that it's deferred). If not, drop the line from the PR description so the next person reading the changelog isn't confused.
Smaller observations (non-blocking)
Brief unmuted window on resume (~16ms). With the new architecture, on resume:
- User clicks resume
- Next rAF tick:
syncRuntimeMediaseeswebAudio.isActive() = false(not yet scheduled) →outputMuted = false→ el.play() withmuted = false WebAudio.schedulePlayback()runs (async) → setsel.muted = trueinternally- Next rAF tick:
webAudio.isActive() = true→outputMuted = true→ element re-muted
Between steps 2 and 4, there's a ~16ms window where the HTML element COULD produce audio. WebAudio scheduling closes the window by re-muting in step 3, but only on its own schedule. In practice this is probably imperceptible (one rAF tick) but worth a note in case anyone investigates "brief audio blip on resume" in the future.
Could be eliminated by making _promoteToParentProxy-style synchronous mute on resume too — pre-mute the elements before letting syncRuntimeMedia unmute them based on isActive(). But that's a polish item, not a fix.
priorMuted field on the source struct may now be dead. With the restore removed, source.priorMuted is captured during scheduling but never read. If it's only used for the restore that's now gone, it's dead state. If it's used elsewhere (debug logs, transitions), keep — otherwise drop in a follow-up cleanup.
The _setupParentMedia early-return relies on _hasRuntimeBridge being correct. That helper was introduced in hf#673 (which I reviewed). It checks for __hf or __player on the contentWindow. If a composition has a partial runtime (e.g., __player exists but is non-functional), the early return would still skip parent proxy creation, leaving the player with no media management. Edge case but worth noting — _hasRuntimeBridge is a heuristic, not a strict capability check.
Praise
- The double-audio fix is the cleanest of the three. Reducing two mechanisms (priorMuted restore + syncRuntimeMedia) to one (syncRuntimeMedia as source of truth) is the right architectural simplification. The change ELIMINATES the workaround hf#671 needed, rather than working around it again.
- Inline comment in
webAudioTransport.tscaptures the exact race condition: "Restoring priorMuted here races with el.play() in the media sync loop, briefly producing audio from both the HTML element and the Web Audio buffer." Future maintainer wondering "why don't we restore priorMuted?" gets a clear answer. _setupParentMediaearly-return based on runtime bridge presence is the right call. The runtime manages media; parent proxies are for compositions without a runtime. Cleanly avoids the dual-source problem.- Synchronous mute in
_promoteToParentProxycloses a real race window. The async bridge mute couldn't get there in time for user-gesture triggeredel.play(). The defensivetry/catchfor cross-origin is the right shape. - Manifest polling fix at the call-site level, not by debouncing or memoizing. Early-return when the work isn't actually needed is cleaner than "do the work but cache it." 60fps disk reads → 0 disk reads in the no-refresh path.
Summary
Three clean fixes, each at the right layer, no cross-coupling. The double-audio fix is architecturally cleaner than hf#671's workaround and doesn't regress that bug's failure mode. Manifest polling loop fix is a real bug + perf win. Parent proxy fix correctly defers to the runtime when present.
Approved at ca00c708. Two non-blocking notes: confirm the ResolutionPreset fix from the PR description (missing from diff?), and consider cleaning up the now-dead priorMuted capture in webAudioTransport.ts as a follow-up.
(Magi is reviewing in parallel — will converge on findings.)
— Review by Rames Jusso (pr-review)
miguel-heygen
left a comment
There was a problem hiding this comment.
Requesting changes on head ca00c70. The player change currently breaks the runtime autoplay fallback: parent-frame proxies are the backup when the iframe runtime reports media-autoplay-blocked, so they still need to be adopted/preloaded while runtime ownership is active; they just must remain paused until ownership flips. I reproduced this with a temporary focused regression test that creates iframe timed media plus window.__player, calls _setupParentMedia(), and expects one parent proxy. On this head it fails with _parentMedia still empty. The existing bun run --filter @hyperframes/player test suite passes, so current coverage misses this case.
| // audio when Studio's useTimelinePlayer also controls the runtime. | ||
| try { | ||
| const win = this.iframe.contentWindow; | ||
| if (win && this._hasRuntimeBridge(win)) return; |
There was a problem hiding this comment.
This early return leaves the autoplay-blocked fallback with no parent proxies. _promoteToParentProxy() still flips _audioOwner to parent, mutes the iframe media, mirrors time, and calls _playParentMedia(), but _parentMedia is empty because _setupParentMedia() skipped the iframe scan whenever window.__player/__hf exists. That means runtime-backed compositions with timed iframe audio go silent instead of falling back after media-autoplay-blocked. I reproduced it with a temporary focused test that injects an <audio data-start ...> into the iframe, sets contentWindow.__player, calls _setupParentMedia(), and gets _parentMedia.length === 0 on this head. Please keep adoption/preloading active under the runtime bridge and rely on _audioOwner === "runtime" to keep proxies paused until promotion, or lazily adopt before promotion.
…loop Three bugs that compound in Studio preview: 1. **Double audio on pause/resume**: syncRuntimeMedia played audio through the HTML <audio> element while WebAudioTransport simultaneously played the same source through AudioBufferSourceNode. Fixed by passing webAudio.isActive() as outputMuted so HTML elements stay muted when Web Audio owns playback. Also removed the priorMuted restore in stopAll() which raced with the next play cycle. 2. **Manifest polling loop**: applyStudioManualEditsToPreview and applyStudioMotionToPreview unconditionally fetched from disk on every call, even without forceFromDisk. The runtime posts state messages every frame via postMessage, triggering React re-renders that re-invoked these functions ~60x/second. Fixed by returning early when no disk read is requested, and using refs instead of callbacks in useEffect deps. 3. **Parent proxy double-play**: the player web component created parent-frame audio proxies even when the runtime bridge was available, causing two audio sources on autoplay-blocked promotion. Fixed by skipping proxy creation when _hasRuntimeBridge returns true, and synchronously muting iframe media on promotion to close the async race window. Also fixes pre-existing ResolutionPreset type missing square variants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ca00c70 to
68bfca5
Compare
|
Thanks for the thorough review. Addressed both blocking items:
Dead ResolutionPreset line dropped from PR description — it fell out during rebase since Re: the ~16ms unmuted window on resume — agreed it's imperceptible in practice. Worth noting for future investigators but not worth a synchronous pre-mute that would add complexity to the resume path. Pushed at head |
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed the current diff at the latest commit. Three fixes, all clean:
Fix 1 — Double audio (webAudioTransport.ts + init.ts):
The priorMuted capture/restore was the root cause — on stopAll(), restoring el.muted = source.priorMuted raced with syncRuntimeMedia's next-tick el.play(), briefly producing audio from both the HTML element and the Web Audio buffer. Removing priorMuted and keeping elements muted (letting syncRuntimeMedia handle unmuting via outputMuted = state.mediaOutputMuted || webAudio.isActive()) is the correct single-source-of-truth fix. The two changes are coupled and both required.
Fix 2 — Manifest polling (App.tsx):
The early return when readFromDiskFirst=false eliminates the disk read entirely for in-memory-only applies. This breaks the re-render → callback identity change → useEffect re-fire → disk read cycle that was causing 60fps polling. The removal of applyStudioManualEditsToPreviewAfterRefresh and applyStudioMotionToPreviewAfterRefresh wrapper callbacks (replaced with direct .current ref calls) removes them from the useEffect dependency array — clean dedup. The iframe load handler now calls applyStudioManualEditsToPreviewRef.current(previewIframe) which defaults to readFromDiskFirst=false when no options are passed — this means iframe loads get in-memory manifests applied without disk reads. Correct for the hot-reload path.
Fix 3 — Parent proxy double-play (hyperframes-player.ts):
_promoteToParentProxy now synchronously mutes all iframe video/audio elements before the async bridge mute message lands. The try/catch for cross-origin is correct. This closes the race window where el.play() could succeed between the proxy promotion and the bridge mute.
Rames's flag — ResolutionPreset in PR description but not in diff: Confirmed this is not in the current diff. Description should be updated if it was dropped.
LGTM — clean architectural fixes at the right layers.

Summary
Fixes three bugs that compound in Studio preview, causing double audio on pause/resume and a 60fps manifest polling loop.
1. Double audio on pause/resume (core/runtime)
syncRuntimeMediaplayed audio through the HTML<audio>element whileWebAudioTransportsimultaneously played the same source throughAudioBufferSourceNode. Both unmuted, both active — constant double audio on every resume.Root cause:
WebAudioTransport.schedulePlayback()mutes the HTML element and routes audio through Web Audio. ButsyncRuntimeMediaruns synchronously before the async schedule resolves, callingel.play()withel.muted = false. Additionally,stopAll()restoredel.muted = priorMuted(false) on pause, so the next play cycle started with the element unmuted.Fix:
webAudio.isActive()asoutputMutedtosyncRuntimeMediaso HTML elements stay muted when Web Audio owns playbackpriorMutedrestore instopAll()and the now-deadpriorMutedfield fromScheduledSource—syncRuntimeMediais the single source of truth for element mute state2. Manifest polling loop (studio)
applyStudioManualEditsToPreviewandapplyStudioMotionToPreviewunconditionally fetched.hyperframes/studio-manual-edits.jsonand.hyperframes/studio-motion.jsonfrom disk on every call — even whenforceFromDiskwas false. The runtime postsstatemessages every frame viapostMessage, triggering React re-renders that re-invoked these functions ~60x/second.Root cause: The functions always called
readOptionalProjectFile()regardless of options. ThereadFromDiskFirstflag only controlled whether to apply the in-memory manifest first, but the disk read happened unconditionally. Combined with unstable callback deps in the iframe loaduseEffect, every runtime frame message triggered a re-render → effect re-run → disk fetch.Fix:
forceFromDisk/readFromDiskFirstare both false — apply in-memory manifest only, skip disk readuseEffectdeps to prevent re-runs on re-rendersapplyStudio*AfterRefreshwrappers3. Parent proxy promotion race (player)
Synchronously mute iframe media in
_promoteToParentProxy()to close the async race window whereel.play()could succeed before the bridge mute message lands. Parent proxies are still preloaded for the autoplay-blocked fallback path — only the mute timing changed.Test plan
<audio>elements.hyperframes/studio-manual-edits.jsonor.hyperframes/studio-motion.jsonAfterRefreshwrappers🤖 Generated with Claude Code