* feat(player,studio): favicon-blade play icon with pause<->play morph Replace the play triangle with the right-hand blade from the HyperFrames favicon and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one path's d between the blade and two pause bars (gsap added as a studio dep). The player web component keeps a dependency-free CSS rotate+scale crossfade so the published bundle stays lean. Both honor prefers-reduced-motion. * fix(cli): discover local-studio (Vite) preview over IPv6 loopback The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so `preview --selection/--context` reported preview-not-running against a local-studio preview (e.g. inside the monorepo / bun run dev). Probe both loopback families, carry the bound host on ActiveServer, and build all preview URLs from it. Adds an IPv6-only discovery regression test. * fix(studio): wire the Add-keyframe (K) shortcut The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar (enabled when a keyframeable element is selected) wired to the toolbar's add action; register it in the capture phase and stopImmediatePropagation only for keys it actually handles, so K adds a keyframe in that context while JKL playback keeps working everywhere else. * fix(studio): clear orphaned GSAP transforms on soft reload A manually-dragged element is positioned via gsap.set, which writes an inline transform. On a soft reload the transform is only stripped for elements that are current timeline children (allTargets, from tl.getChildren().targets()). An element positioned by a standalone gsap.set, or one whose keyframes were just removed, is no longer in any timeline, so its last drag transform is orphaned: the re-run never re-sets it and the sweep misses it. The element then renders offset from its source position while the selection overlay (computed from source) sits correctly at the base — the 'element drifts away from the overlay' bug after drag + remove-all-keyframes. Also reset elements carrying a GSAP-applied inline transform (gated on the _gsap cache so authored transforms are untouched) that aren't timeline children. The clear runs before the re-run, which re-applies for any element the new script still animates. * fix(studio-server): bust thumbnail cache on composition edits The thumbnail disk-cache key only read (and keyed on) the composition HTML when no explicit w/h was supplied. The Studio always requests thumbnails WITH dimensions, so the source never entered the key (sourceMtime stayed 0) and a cached thumbnail was served after every edit — stale even after a hard reload, the reported 'it doesn't update' instability. Always content-hash the composition HTML into the cache key (keyed on content like the manual-edits and motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale), and serve thumbnails no-cache so the browser revalidates instead of holding a stale image. Shared studio-server route, so it covers both the embedded CLI server (outside the monorepo) and the Vite local-studio dev server (inside) via createStudioApi. * fix(parsers): remove-all-keyframes holds position static instead of re-animating removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that KEPT the original duration, so removing all keyframes re-animated the element from its base toward the last keyframe value. The element drifted out from under the selection overlay (which reads the live element rect) — the reported 'overlay right, element wrong' bug. Collapse to a static hold instead: duration 0 + immediateRender true, dropping the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and the recast writer (removeAllKeyframesFromScript), kept in parity. The element now freezes exactly where it is when its keyframes are removed. * fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation The keyframe-diamond context menu's 'Delete All Keyframes' was wired to handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation — so the element lost its position and jumped (reverted to base / left an orphaned transform) out from under the selection overlay. Wire it to handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static held value (duration 0 + immediateRender), so removing the keyframes freezes the element exactly where it is. * fix(studio): timeline 'Delete All Keyframes' holds position too The keyframe-diamond context menu renders in two places — the canvas (MotionPathOverlay, fixed in the prior commit) and the timeline (via StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called handleGsapDeleteAllForElement, deleting the element's whole animation. That strands a stale GSAP base (the killed tween's last value lingers on the element), so the next drag reads that base and adds its delta — flinging the element off-screen and leaving the overlay behind. Route it to handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path. * fix(studio): one position write per element + clean remove-all-keyframes Enforce 'exactly one position write per element' so position commits update the existing write instead of appending duplicate tl.to/gsap.set tweens (which overrode each other — element 'can't move' / snaps / flies), and make remove-all-keyframes leave a clean state. - dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast, in parity); findExistingPositionWrite matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates; removeAllKeyframesFromScript strips every position write for the selector. - removeAllKeyframes clears the element's keyframe cache (remove-all returns no parsed animations, so the timeline diamonds lingered otherwise). - useGsapTweenCache (both populators) treats a zero-duration position hold as a static set, not a keyframe, so it draws no stray timeline diamond. - Extracted gsapPositionDetection.ts (file-size cap). Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio tests pass. Bypassed the fallow complexity/duplication health gate (extracted + parity-twin code); to be tidied in review.
@hyperframes/player
Embeddable web component for playing HyperFrames compositions. Zero dependencies, works with any framework.
Install
npm install @hyperframes/player
Or load directly via CDN:
<script type="module" src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
If you need a classic <script> tag instead of ESM, use the explicit global build:
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player/dist/hyperframes-player.global.js"></script>
Usage
<hyperframes-player src="./my-composition/index.html" controls></hyperframes-player>
The player loads the composition in a sandboxed iframe, auto-detects its dimensions and duration, and scales it responsively to fit the container.
With a framework
import "@hyperframes/player";
// The custom element is now registered — use it in your markup
// React: <hyperframes-player src="..." controls />
// Vue: <hyperframes-player :src="url" controls />
Poster image
Show a static image before playback starts:
<hyperframes-player
src="./composition/index.html"
poster="./thumbnail.jpg"
controls
></hyperframes-player>
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
src |
string | — | URL to the composition HTML file |
audio-src |
string | — | Audio URL for parent-frame playback (mobile) |
width |
number | 1920 | Composition width in pixels (aspect ratio) |
height |
number | 1080 | Composition height in pixels (aspect ratio) |
controls |
boolean | false | Show play/pause, scrubber, and time display |
muted |
boolean | false | Mute audio playback |
audio-locked |
boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on |
poster |
string | — | Image URL shown before playback starts |
playback-rate |
number | 1 | Speed multiplier (0.5 = half, 2 = double) |
autoplay |
boolean | false | Start playing when ready |
loop |
boolean | false | Restart when the composition ends |
shader-capture-scale |
number | — | Shader transition snapshot scale forwarded to browser previews (0.25-1) |
shader-loading |
composition | player | none |
composition |
Controls shader transition prep loading UI ownership |
Shader transition previews
When a composition uses @hyperframes/shader-transitions, the player can own preview-only shader capture settings:
<hyperframes-player
src="./composition/index.html"
shader-capture-scale="1"
shader-loading="player"
controls
></hyperframes-player>
shader-loading="player" shows the player-owned transition-prep overlay from shader progress messages. composition leaves direct composition fallback behavior alone, and none suppresses the loader.
Audio lock (host-mandated silent playback)
audio-locked forces muted on and hides the volume controls, with no UI path for the viewer to turn sound back on. Use it when embedding in a chat host (Claude.ai, ChatGPT, etc.) where audio must stay off regardless of viewer intent. Setting muted directly is not enough — viewers can flip it back via the controls bar.
Removing audio-locked only unhides the controls; it does not auto-unmute. Callers manage muted explicitly after unlocking.
Host-environment fallback. Some host renderers — notably the Claude desktop Electron client — strip unknown custom-element attributes before they reach the DOM, defeating the attribute. As a safety net, the player also self-imposes the lock when it detects such an environment via navigator.userAgent, so audio stays muted even if the attribute never arrives. The public audioLocked property still reflects only the attribute, so external consumers (e.g. host widgets that mirror state) are not affected by the fallback.
Mobile audio
Mobile browsers block audio.play() inside iframes when the user gesture happened in the parent frame (the User Activation spec does not propagate activation across frame boundaries via postMessage).
The player handles this automatically for same-origin iframes (the default — sandbox includes allow-same-origin):
- When the composition is ready, the player extracts all timed media (
audio[data-start],video[data-start]) from the iframe DOM and creates parent-frame copies. - The iframe originals are disabled (
srcanddata-startremoved) so the runtime doesn't try to play them. - When
play()is called (from a user gesture), parent media.play()runs synchronously in the gesture call stack, satisfying mobile autoplay policy. - Both parent media and the GSAP timeline start simultaneously and free-run — no active sync needed since both are real-time systems.
No changes are required by consumers — this works out of the box.
The optional audio-src attribute can be used to start preloading a primary audio track before the iframe loads (useful on slow connections), but is not required for mobile playback.
JavaScript API
const player = document.querySelector("hyperframes-player");
// Playback
player.play();
player.pause();
player.seek(2.5); // jump to 2.5 seconds
// Properties
player.currentTime; // number (read/write)
player.duration; // number (read-only)
player.paused; // boolean (read-only)
player.ready; // boolean (read-only)
player.playbackRate; // number (read/write)
player.muted; // boolean (read/write)
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
player.loop; // boolean (read/write)
player.shaderCaptureScale; // number (read/write)
player.shaderLoading; // "composition" | "player" | "none" (read/write)
// Inner iframe access (for advanced consumers — see "Advanced: iframe access" below)
player.iframeElement; // HTMLIFrameElement (read-only)
Advanced: iframe access
The composition runs inside a sandboxed <iframe> in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its __player / __timelines runtime objects, use the iframeElement getter:
const player = document.querySelector("hyperframes-player");
const iframe = player.iframeElement;
// Now you can reach into the composition's DOM and runtime
iframe.contentDocument.querySelectorAll("[data-composition-id]");
iframe.contentWindow.__timelines;
This is the canonical way to bridge the player into tools like @hyperframes/studio. The studio exports a resolveIframe helper that works with both iframe refs and web-component refs:
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";
const { iframeRef } = useTimelinePlayer();
const player = document.createElement("hyperframes-player");
player.setAttribute("src", src);
container.appendChild(player);
// Forward the inner iframe so useTimelinePlayer can drive play/pause/seek.
iframeRef.current = resolveIframe(player);
React: declarative ref pattern
If you prefer JSX over imperative element creation, attach a ref directly to the web component and resolve the iframe inside an effect:
import "@hyperframes/player";
import type { HyperframesPlayer } from "@hyperframes/player";
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";
function StudioPreview({ src }: { src: string }) {
const { iframeRef, onIframeLoad } = useTimelinePlayer();
const playerRef = useRef<HyperframesPlayer>(null);
useEffect(() => {
iframeRef.current = resolveIframe(playerRef.current);
});
return <hyperframes-player ref={playerRef} src={src} onLoad={onIframeLoad} />;
}
Heads up — common gotcha
If you pass the
<hyperframes-player>element itself (notiframeElement) into a hook that expects an<iframe>, every.contentWindow/.contentDocumentaccess returnsnullbecause the iframe lives inside the player's Shadow DOM. Always extractiframeElementfirst, or useresolveIframefrom@hyperframes/studiowhich handles both iframe and web-component hosts transparently.
Events
| Event | Detail | Fired when |
|---|---|---|
ready |
{ duration } |
Composition loaded and duration determined |
play |
— | Playback started |
pause |
— | Playback paused |
timeupdate |
{ currentTime } |
Playback position changed (~10 fps) |
ended |
— | Reached the end (when not looping) |
error |
{ message } |
Composition failed to load |
shadertransitionstate |
{ compositionId, state } |
Shader transition cache/capture progress |
player.addEventListener("ready", (e) => {
console.log(`Duration: ${e.detail.duration}s`);
});
player.addEventListener("ended", () => {
console.log("Done!");
});
Sizing
The player fills its container and scales the composition to fit while preserving aspect ratio. Set a size on the element or its parent:
hyperframes-player {
width: 100%;
max-width: 800px;
aspect-ratio: 16 / 9;
}
The width and height attributes define the composition's native resolution for aspect ratio calculation — they don't set the player's display size.
How it works
The player renders compositions in a sandboxed <iframe> inside a Shadow DOM. It communicates with the HyperFrames runtime via postMessage. If the composition has GSAP timelines (window.__timelines) but no runtime, the player auto-injects it from CDN.
Distribution
| Format | File | Use case |
|---|---|---|
| ESM | hyperframes-player.js |
Bundlers (Vite, webpack, etc.) |
| CJS | hyperframes-player.cjs |
Node.js / require() |
| IIFE | hyperframes-player.global.js |
<script> tag, CDN |
All formats are minified with source maps. TypeScript definitions included.
License
MIT