Skip to content

fix(studio): run the composition hover preview in an opaque origin - #7522

Merged
jrusso1020 merged 2 commits into
mainfrom
fix/hover-preview-opaque-origin
Nov 11, 2026
Merged

jrusso1020 merged 2 commits into
mainfrom
fix/hover-preview-opaque-origin

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

The Compositions panel hover preview now runs each composition in an opaque origin (sandbox="allow-scripts", no allow-same-origin), so a composition's own scripts no longer share the Studio's origin. The card drives the preview only over the runtime's existing postMessage bridge: it plays muted on the runtime's ready message and sizes the stage from its timeline message.

What

  • CompositionsTab.tsx: the hover-preview iframe drops allow-same-origin.
  • Playback: on the frame's ready message the card posts set-muted then play. This replaces the old loop that polled contentWindow.__player up to ten times.
  • Stage size: taken from the timeline message's compositionWidth / compositionHeight. The old code read data-width / data-height from contentDocument. The runtime fills those fields from the same attributes, with the same 1920x1080 default.
  • Removed what only the same-origin path needed: syncIframePlayback, resolveIframeDuration, the __player window type and the sync timer. The pause-and-seek branch was never called, because the iframe unmounts when the pointer leaves.

Why

Hardening. The hover preview executes the composition's scripts (from the project, a registry block or a script the composition loads), and nothing in it needs the Studio's origin. An opaque origin keeps those scripts away from the Studio page, its storage and its same-origin requests.

Related work

Refs the same pattern in <hyperframes-player sandbox-origin="opaque">. The main Studio preview still frames same-origin. It needs a larger change, because the editing hooks read the frame's document directly.

How

Messages are accepted only when event.source is this card's iframe window and source is "hf-preview". The listener is installed when the hover starts, before the frame loads, so the runtime's one ready message is not missed. ready is posted again on every reload. Fade-in and display-scale timing are unchanged: both still key off the iframe's load.

Known difference. A document with an opaque origin makes CORS-mode requests with Origin: null, and the local preview server sends no CORS headers. So every fetch and CORS-mode load the runtime makes from the hover preview fails. Measured: project-hosted web fonts fall back to a system font (below). The same mechanism, from the code and not separately run, means:

  • module scripts and nested compositions the runtime fetches don't load;
  • a video the browser can't decode, such as ProRes or some HEVC clips, never plays in the hover preview. The runtime waits for its converted copy with a fetch (proxyWait.ts), and that fetch fails;
  • color-grading .cube LUT files (colorGrading.ts) and caption-overrides.json (captionOverrides.ts) don't load.

Classic scripts, images, and video and audio the browser can play directly load as before. The resting card thumbnail is rendered server-side and is unaffected. A follow-up will restore these loads for opaque frames, either by allowing null-origin CORS for asset requests or by having the parent fetch on the frame's behalf. One fix covers all of them.

Before

Before: the title-card hover preview playing in a same-origin frame, about 2.3s in

After

After: the same hover preview playing in an opaque-origin frame, about 2.3s in

Both are captured in headless Chrome while hovering the title-card card. The runtime reports isPlaying: true, muted: true at about 2.3s in each. The card looks the same: only the frame's origin changed.

Test plan

  • Unit tests added/updated. New CompositionsTab.hoverPreview.test.tsx:

    • the frame's sandbox is exactly allow-scripts, with no allow-same-origin
    • ready from the frame posts set-muted (muted: true) then play
    • timeline sizes the stage, while the frame's contentDocument getter throws, so any read fails the test
    • messages from other windows are ignored

    Mutation check: with the component reverted to main and the new tests kept, 3 of 4 fail. Putting allow-same-origin back fails the sandbox test. The removed syncIframePlayback unit test is replaced by the ordering assertion above.

  • Manual testing performed, in headless Chrome 153 against the Studio dev server with a two-composition project, hovering the non-root card:

    • this branch: frame sandbox="allow-scripts", stage 640x360 from the timeline message. The runtime reports isPlaying: true, muted: true, and time advances 0 to 2.5s within 2.5s of hover. Inside the frame, self.origin is "null", and window.parent.document and localStorage throw SecurityError.
    • main, same project: same playback (0 to 2.4s, muted). The frame's script sees the Studio origin and can read window.parent.document and localStorage.
    • project font: an @font-face served from the project loads on main and fails with NetworkError on this branch (the known difference above).
  • vitest run on src/components/sidebar/, src/player/lib/, pointerTargetSize and useRenderClipContent: 39 files, 412 tests pass. tsc --noEmit, oxlint, oxfmt, fallow and the studio copy check are clean.

  • Documentation updated (not applicable)

  • Comments say why, not what

Not exercised: a headed browser or the packaged CLI hyperframes preview (the dev server uses the same studio-server routes), Safari and Firefox, and a composition that uses module scripts or runtime-fetched nested compositions (same CORS mechanism as the font, not separately run).

— Rames Jusso

🤖 Generated with Claude Code

jrusso1020 and others added 2 commits November 10, 2026 23:39
The Compositions panel hover preview framed the composition with
allow-scripts and allow-same-origin, so the composition's scripts ran in
the Studio's own origin. The frame now gets only allow-scripts. The card
no longer reads the frame: it plays the preview muted when the runtime
posts "ready", and sizes the stage from the runtime's "timeline" message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of commenting them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 114 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 blocker · 1 important · 0 nits · head 7a54638

The opaque-origin frame and the bridge wiring check out. The first timeline is posted before the bridge's ready, so stage size arrives before play starts and isn't held. One gap in the PR body's Known difference list ↓ inline.

Scope

Read the full diff, runtime bridge.ts, postTimeline ordering in init.ts, mediaProxy/proxyWait, colorGrading, captionOverrides, and the /preview/comp/* route (no CORS headers anywhere). CI Test (studio), Lint and Typecheck are green on this head. I did not run a browser.

— Genesis

ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
sandbox={OPAQUE_ORIGIN_SCRIPTS_ONLY_SANDBOX}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: the Known difference list misses unplayable-codec video. It says video loads as before, but a hostile-codec clip (ProRes, or HEVC where Chrome can't decode it) is swapped to a proxy by maybeProxyProactively → makeCopy, and that waits on waitForServedProxy (core/src/runtime/proxyWait.ts:49). That is a CORS-mode fetch from this null origin, and the preview server sends no Access-Control-Allow-Origin, so it fails like the font does. After 3 failed asks (about 2 s apart) the copy is marked failed and the clip never plays in the hover preview. The same applies to .cube LUT fetches (colorGrading.ts:2029) and caption-overrides.json (captionOverrides.ts:120).

Fix: add these to Known difference and to the font follow-up's scope. The follow-up's fix (a null-origin CORS allowance or a parent-side fetch bridge) covers all of them.

@jrusso1020
jrusso1020 enabled auto-merge November 11, 2026 00:02
@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 11, 2026
@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 0c229a2 Nov 11, 2026
126 of 135 checks passed
@jrusso1020
jrusso1020 deleted the fix/hover-preview-opaque-origin branch November 11, 2026 00:29
@miguel-heygen miguel-heygen mentioned this pull request Nov 11, 2026
3 of 4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant