* 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>
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>
The Claude desktop Electron client appears to strip the `audio-locked`
custom-element attribute before it reaches the DOM, so chat-host audio
still plays even though Claude web (which preserves the attribute)
correctly mutes. Verified via DevTools: web renders `<hyperframes-player
audio-locked>` and is silent; desktop omits the attribute and plays sound.
Self-impose the same restriction when `navigator.userAgent` matches the
Claude desktop UA (Claude/<ver> + Electron). Internally route everything
through a new `_isAudioLocked()` helper — attribute OR host fallback —
and apply the lock from `connectedCallback` since `attributeChangedCallback`
never fires when the attribute is missing.
The public `audioLocked` property still reflects only the attribute, so
external consumers (e.g. pacific widget mirroring state) are unaffected
by the safety net.
Tests: 6 new (forces mute on Claude desktop UA, re-asserts on unmute,
hides controls, no-op for regular browsers, no-op for non-Claude Electron
apps, public property remains attribute-only). Player suite green:
132 tests.
Refs: pacific #28773, experiment-framework #38809.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>