* fix(player): drive composition ticks from widget-frame rAF via postMessage
Chromium throttles requestAnimationFrame in deeply nested cross-origin
iframes. In Claude desktop (Electron), the composition iframe's own rAF
loop stalls, so GSAP is never seeked and animation freezes even when
TransportClock.isPlaying() is true.
The correct fix is to drive ticks from the widget-frame rAF, which lives
one level up and is not subject to the same throttling. When play() takes
the runtime bridge path (no direct timeline adapter), the player now starts
a parent-frame rAF loop that sends "tick" postMessages to the composition
iframe on every frame. The runtime's control bridge handles "tick" by calling
seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to
what transportTick does on each rAF, just driven from outside.
The composition iframe's own rAF loop is unchanged and keeps running
normally in standard browsers. Seeking GSAP twice per frame is idempotent,
so there is no regression on claude.ai or any other non-throttled environment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(player): address review issues in parent tick clock
- _paused must be false before _startParentTickClock runs; otherwise
the first RAF callback sees _paused=true and self-terminates immediately
- Guard _startParentTickClock behind this._ready && !this._directTimelineAdapter
so tick messages aren't sent into an uninitialized iframe when play() is
called before the composition probe has resolved
- Add clock.reachedEnd() check to onTick so end-of-composition handling
(pause, seek-to-end, postState) runs even when the composition iframe
RAF is fully throttled
- Stop the parent tick clock in seek() alongside _stopDirectTimelineClock
to avoid burning CPU frames while paused after a scrub
- Add onTick to bridge.test.ts createMockDeps() and add a dispatch test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: bump versions to 0.6.5-alpha.0 for testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0
because CSS opacity is not inherited — getComputedStyle on the child still
returns 1. Walk the ancestor chain in the picker, domEditing, and overlay
visibility checks to catch this.
Also:
- Containers with all-invisible children are no longer selectable
- Selection/hover overlay hides during playback and while loading
- Undo/redo no longer double-refreshes (echo suppression for all file writes)
- Undo/redo reloads iframe in-place instead of recreating the Player,
preserving shader transition cache
- Preview routes return ETag + Cache-Control headers; composition HTML uses
project signature for conditional 304, binary assets use mtime+size
- Loading overlay deferred 400ms so cached loads never flash it
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>
linkedom's document.querySelectorAll does not traverse <template>
content. Elements in template-based compositions (like .title-word,
.bullet-text) were invisible to the removal logic, so delete
returned changed: false and the element survived the reload.
Fall back to template.querySelectorAll when the document-level
query returns no matches. Uses template.querySelectorAll directly
(not template.content.querySelectorAll) because removing from
the content DocumentFragment doesn't update the serialized output.
Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).
Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.
Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.
Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:
1. seekRuntimeTimeline called timeline.pause() before every totalTime()
seek, 60x per second. GSAP cascades pause to media elements on every
frame. Fix: restore original inline seek for the captured timeline
(totalTime without pause). The timeline is already paused once in
player.play(). seekRuntimeTimeline with pause() remains only for
standalone child timelines.
2. player.play() removed the !tl guard, allowing play without a
captured timeline. But getSafeTimelineDurationSeconds(null) returns
0, so the clock has no duration → immediately reaches end → stops →
restarts. Fix: when no timeline provides duration, fall back to the
root composition element's data-duration attribute.
3. Audio source attachment added networkState guard that could cause
the clock to flicker between audio-source and monotonic timing
on transient media states. Fix: keep !rawEl.error guard (prevents
errored audio from freezing the clock) but drop the networkState
check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The seekRuntimeTimeline helper added timeline.pause() before every
totalTime() seek. During transport-driven playback, this runs 60 times
per second, causing GSAP to cascade pause events to media elements on
every frame. The result: audio plays/stops/plays/stops in a stutter
pattern.
The captured root timeline is already paused once in player.play() —
the TransportClock drives it via totalTime(t) which keeps it paused.
The extra per-tick pause() was redundant for the root timeline but
actively harmful for media sync.
Fix: restore the original inline seek for the captured timeline
(totalTime without pause), keep seekRuntimeTimeline with pause() only
for standalone child timelines where explicit pause control is needed.
Also fixes rebase artifact: missing PropertyPanel props in App.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.
When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.
Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs found via automated e2e testing of the v0.6.0-alpha preview:
1. core: add missing package.json export specifiers for
studio-api/manual-edits-render-script and
studio-api/studio-motion-render-script — the alpha.3 npm publish
failed because the studio build could not resolve these sub-paths.
2. cli: fix init --example creating empty projects — tsup leaves empty
template directories in dist/ during the build, causing
existsSync(templateDir) to return true and skip the remote fetch
fallback. Now checks for index.html inside the dir instead.
3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg
stdin/stdout had no error handlers, so a write after the ffmpeg
process exits throws an uncaught error that crashes the process.
Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404.
While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview.
- Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction.
- Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode.
- Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages.
- Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds.
- Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing.
Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched.
The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time.
The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`.
- `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts`
- `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts`
- `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/core build:hyperframes-runtime`
- `bun run --cwd packages/core typecheck`
- `git diff --check`
Pre-commit also reran lint, format, and typecheck successfully for the committed files.
Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened:
```text
http://127.0.0.1:5197/#project/Notion%20Showcase
```
Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`.
After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared.
Mean pixel diffs for preview vs capture were:
- `0s`: `0.0`
- `2s`: `0.8641`
- `10s`: `0.3496`
- `18s`: `0.2309`
The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions.
- Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed.
- The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed.
- Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused.
* fix: stabilize studio preview and runtime sync
* fix: pass selector through timeline thumbnails
* feat: add studio timeline editing
* fix: disambiguate timeline edit targets
* fix: stop timeline auto-scroll in fit mode
* feat: use percentage-based timeline zoom
* fix: sync timeline playhead on zoom changes
* fix: reset timeline scroll when returning to fit
* feat(studio): add manual DOM editing inspector
* docs: update studio manual dom editing guide
* feat(studio): add image asset picker for fills
* feat(studio): add inline image uploads for fills
* fix(studio): use real file input for image fill uploads
* fix(studio): restore toast plumbing after rebase
* fix(studio): explain in-app upload limitation
* fix(studio): reuse asset-tab upload pattern in fills
* feat(studio): refine manual design inspector
* fix(studio): polish manual design inspector
* fix(studio): keep color picker in viewport
* fix(studio): clarify color picker selection
* docs: update manual DOM editing guide
* fix(studio): keep gradient color picker open
* fix(studio): scope text color to text layers
* fix(studio): add agent fallback for immovable layers
* fix(studio): address manual editing review feedback
* fix(studio): make local font selection reliable
The property delegation on window.__player used Object.defineProperty
with only a getter, causing "Cannot set property renderSeek which has
only a getter" when Studio's motion-wrapping code tried to reassign
__player.renderSeek with a wrapped version. This cascaded into an
infinite error loop making the timeline unusable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clips whose compositionStart is ahead of the current timeline position
were starting immediately because sourceNode.start() always received
when=0. Use the AudioContext scheduling API to defer future clips:
sourceNode.start(ctx.currentTime + delay, mediaStart).
Closes#674
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.
Architecture:
TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
▲
AudioContext.currentTime (~21µs) ← WebAudio active
OR
audio.currentTime (~33ms) ← HTMLMediaElement fallback
OR
performance.now() (~1ms) ← no audio
Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)
Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).
Also fixes: CDN script HTML error responses in validate (pre-existing).
54 tests across clock, clock-drift, webAudioTransport, and media.
Closes#668
After hf#641 inlined the runtime IIFE into every bundle, lint tools
inspecting bundled output (including Abhay's c2v eval) started flagging
empty `catch {}` blocks across the runtime. The source had explanatory
comments inside, but esbuild's minifier strips them — the IIFE ships
~10 visible patterns of `}catch{}` and consumers' linters fire on each.
Each empty catch is intentional best-effort error swallowing —
postMessage to a parent frame that may not exist, `media.play()` /
`pause()` that throw under autoplay restrictions, timeline `seek()` on
a disposed timeline, anime.js / lottie feature detection on hosts that
don't load those libraries, etc. The right behaviour stays "tried,
didn't work, move on", but doing it visibly improves three things:
- lint clean: helper call is a real statement; no `no-empty` warnings
survive minification
- debuggable: flip `window.__hfDebug = true` in DevTools to see every
swallow site with `console.debug` (silent in prod by default)
- observable: studio / embeddings can install
`window.__hf.onSwallowed = handler` to collect runtime swallow
events without polluting the page console
Implementation: `packages/core/src/runtime/diagnostics.ts` exports
`swallow(label, err?)`. 41 catch sites across 12 runtime files
converted via mechanical pass (auto-generated `runtime.<module>.siteN`
labels — labels can be tightened site-by-site as a follow-up; the
shape of the change is what matters here).
Verification:
- core 674/674 (incl. 6 new diagnostics tests covering silent default,
__hfDebug logging, legacy __HYPERFRAMES_DEBUG flag, handler hook,
handler-throws-doesn't-recurse, both-active)
- typecheck clean
- format / lint clean
- runtime IIFE rebuilds successfully (`bun run build:hyperframes-runtime`)
Refs Abhay's c2v eval — bundler artefacts now lint-clean with the
runtime body inlined.