feat: allow clip animation + ship <hyperframes-player> web component (#209)

## Summary

Two independent initiatives that improve agent DX and expand HyperFrames' reach.

### Initiative 1: Fix the Clip Animation Footgun

- `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element
- All other properties (opacity, transform, x, y, scale, etc.) are allowed silently
- This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1)

### Initiative 2: `<hyperframes-player>` Web Component

- New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped
- Iframe-based web component with Shadow DOM for perfect isolation
- Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events
- Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide
- Full docs page at `docs/packages/player.mdx`

## Before / After

### Clip animation lint

**Before (10/10 agents hit this):**

```
✗ gsap_animates_clip_element: GSAP animation targets a clip element.
  Selector "#title" resolves to element <div id="title" class="clip">.
  The framework manages clip visibility — animate an inner wrapper instead.
  Fix: Wrap content in a child <div> and target that with GSAP.
```

**After (only errors on actual conflicts):**

```
# This passes lint — no error:
tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0);

# This still errors — actual conflict with runtime:
tl.to("#title", { visibility: "hidden" }, 3);
✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element.
  Fix: Remove the visibility/display tween. Use opacity for fade effects.
```

### Embeddable player

**Before:** No way to embed a composition in a web page.
**After:**

```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<hyperframes-player src="./composition/index.html" controls></hyperframes-player>
```

```js
const player = document.querySelector('hyperframes-player');
player.play();
player.pause();
player.seek(2.5);
player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration));
```

## Test plan

- [x] 427 core tests pass (20 GSAP lint tests with smart detection)
- [x] 7 player tests pass (formatTime + element registration)
- [x] TypeScript compiles cleanly (core + player)
- [x] Lint: GSAP animating clip with safe props → 0 errors
- [x] Lint: GSAP animating clip with `visibility` → 1 error (correct)
- [x] Player builds to 3.3KB gzipped ESM
- [x] Lockfile updated for CI
- [x] Docs page added at `docs/packages/player.mdx`
This commit is contained in:
Miguel Ángel
2026-04-06 19:59:39 +02:00
committed by GitHub
parent baa3d813be
commit 5655dabff6
18 changed files with 1332 additions and 25 deletions
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@hyperframes/player",
"version": "0.2.2",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/player"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/hyperframes-player.js",
"types": "./dist/hyperframes-player.d.ts",
"exports": {
".": {
"import": "./dist/hyperframes-player.js",
"require": "./dist/hyperframes-player.cjs",
"script": "./dist/hyperframes-player.global.js"
}
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
+150
View File
@@ -0,0 +1,150 @@
import { PLAY_ICON, PAUSE_ICON } from "./styles.js";
export interface ControlsCallbacks {
onPlay: () => void;
onPause: () => void;
onSeek: (fraction: number) => void;
}
export function formatTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
export function createControls(
parent: ShadowRoot | HTMLElement,
callbacks: ControlsCallbacks,
): {
updateTime: (current: number, duration: number) => void;
updatePlaying: (playing: boolean) => void;
show: () => void;
hide: () => void;
destroy: () => void;
} {
const controls = document.createElement("div");
controls.className = "hfp-controls";
// Keep overlay interactions from falling through to the host-level click toggle.
controls.addEventListener("click", (e) => {
e.stopPropagation();
});
const playBtn = document.createElement("button");
playBtn.className = "hfp-play-btn";
playBtn.type = "button";
playBtn.innerHTML = PLAY_ICON;
playBtn.setAttribute("aria-label", "Play");
const scrubber = document.createElement("div");
scrubber.className = "hfp-scrubber";
const progress = document.createElement("div");
progress.className = "hfp-progress";
progress.style.width = "0%";
scrubber.appendChild(progress);
const time = document.createElement("span");
time.className = "hfp-time";
time.textContent = "0:00 / 0:00";
controls.appendChild(playBtn);
controls.appendChild(scrubber);
controls.appendChild(time);
parent.appendChild(controls);
let isPlaying = false;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
playBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (isPlaying) callbacks.onPause();
else callbacks.onPlay();
});
const handleScrubAt = (clientX: number) => {
const rect = scrubber.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
callbacks.onSeek(fraction);
};
let scrubbing = false;
scrubber.addEventListener("mousedown", (e) => {
e.stopPropagation();
scrubbing = true;
handleScrubAt(e.clientX);
});
const onMouseMove = (e: MouseEvent) => {
if (scrubbing) handleScrubAt(e.clientX);
};
const onMouseUp = () => {
scrubbing = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
scrubber.addEventListener(
"touchstart",
(e) => {
scrubbing = true;
const touch = e.touches[0];
if (touch) handleScrubAt(touch.clientX);
},
{ passive: true },
);
const onTouchMove = (e: TouchEvent) => {
if (scrubbing) {
const touch = e.touches[0];
if (touch) handleScrubAt(touch.clientX);
}
};
const onTouchEnd = () => {
scrubbing = false;
};
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
const startHideTimer = () => {
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
if (isPlaying) controls.classList.add("hfp-hidden");
}, 3000);
};
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
host.addEventListener("mousemove", () => {
controls.classList.remove("hfp-hidden");
startHideTimer();
});
host.addEventListener("mouseleave", () => {
if (isPlaying) controls.classList.add("hfp-hidden");
});
return {
updateTime(current: number, duration: number) {
const pct = duration > 0 ? (current / duration) * 100 : 0;
progress.style.width = `${pct}%`;
time.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
},
updatePlaying(playing: boolean) {
isPlaying = playing;
playBtn.innerHTML = playing ? PAUSE_ICON : PLAY_ICON;
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
if (playing) startHideTimer();
else controls.classList.remove("hfp-hidden");
},
show() {
controls.style.display = "";
},
hide() {
controls.style.display = "none";
},
destroy() {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
if (hideTimeout) clearTimeout(hideTimeout);
},
};
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { formatTime } from "./controls.js";
describe("formatTime", () => {
it("formats 0 seconds", () => {
expect(formatTime(0)).toBe("0:00");
});
it("formats seconds under a minute", () => {
expect(formatTime(45)).toBe("0:45");
});
it("formats exact minutes", () => {
expect(formatTime(120)).toBe("2:00");
});
it("formats minutes and seconds", () => {
expect(formatTime(95)).toBe("1:35");
});
it("pads seconds with leading zero", () => {
expect(formatTime(61)).toBe("1:01");
});
it("floors fractional seconds", () => {
expect(formatTime(3.7)).toBe("0:03");
});
it("handles negative input", () => {
expect(formatTime(-5)).toBe("0:00");
});
});
+395
View File
@@ -0,0 +1,395 @@
import { createControls, type ControlsCallbacks } from "./controls.js";
import { PLAYER_STYLES } from "./styles.js";
const DEFAULT_FPS = 30;
const RUNTIME_CDN_URL =
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
class HyperframesPlayer extends HTMLElement {
static get observedAttributes() {
return ["src", "width", "height", "controls", "muted", "poster", "playback-rate"];
}
private shadow: ShadowRoot;
private container: HTMLDivElement;
private iframe: HTMLIFrameElement;
private posterEl: HTMLImageElement | null = null;
private controlsApi: ReturnType<typeof createControls> | null = null;
private resizeObserver: ResizeObserver;
private _ready = false;
private _duration = 0;
private _currentTime = 0;
private _paused = true;
private _compositionWidth = 1920;
private _compositionHeight = 1080;
private _probeInterval: ReturnType<typeof setInterval> | null = null;
private _lastUpdateMs = 0;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = PLAYER_STYLES;
this.shadow.appendChild(style);
this.container = document.createElement("div");
this.container.className = "hfp-container";
this.iframe = document.createElement("iframe");
this.iframe.className = "hfp-iframe";
this.iframe.sandbox.add("allow-scripts", "allow-same-origin");
this.iframe.allow = "autoplay; fullscreen";
this.iframe.referrerPolicy = "no-referrer";
this.iframe.title = "HyperFrames Composition";
this.container.appendChild(this.iframe);
this.shadow.appendChild(this.container);
// Clicking the bare player surface toggles play/pause.
// Ignore shadow-DOM control interactions so overlay clicks don't double-handle.
this.addEventListener("click", (event) => {
if (this._isControlsClick(event)) return;
if (this._paused) this.play();
else this.pause();
});
this.resizeObserver = new ResizeObserver(() => this._updateScale());
this._onMessage = this._onMessage.bind(this);
this._onIframeLoad = this._onIframeLoad.bind(this);
}
connectedCallback() {
this.resizeObserver.observe(this);
window.addEventListener("message", this._onMessage);
this.iframe.addEventListener("load", this._onIframeLoad);
if (this.hasAttribute("controls")) this._setupControls();
if (this.hasAttribute("poster")) this._setupPoster();
if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!;
}
disconnectedCallback() {
this.resizeObserver.disconnect();
window.removeEventListener("message", this._onMessage);
this.iframe.removeEventListener("load", this._onIframeLoad);
if (this._probeInterval) clearInterval(this._probeInterval);
this.controlsApi?.destroy();
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
switch (name) {
case "src":
if (val) {
this._ready = false;
this.iframe.src = val;
}
break;
case "width":
this._compositionWidth = parseInt(val || "1920", 10);
this._updateScale();
break;
case "height":
this._compositionHeight = parseInt(val || "1080", 10);
this._updateScale();
break;
case "controls":
if (val !== null) this._setupControls();
else {
this.controlsApi?.destroy();
this.controlsApi = null;
}
break;
case "poster":
this._setupPoster();
break;
case "playback-rate":
this._sendControl("set-playback-rate", { playbackRate: parseFloat(val || "1") });
break;
case "muted":
this._sendControl("set-muted", { muted: val !== null });
break;
}
}
// ── Public API ──
play() {
this._hidePoster();
this._sendControl("play");
this._paused = false;
this.controlsApi?.updatePlaying(true);
this.dispatchEvent(new Event("play"));
}
pause() {
this._sendControl("pause");
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("pause"));
}
seek(timeInSeconds: number) {
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
this._sendControl("seek", { frame });
this._currentTime = timeInSeconds;
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.controlsApi?.updateTime(this._currentTime, this._duration);
}
get currentTime() {
return this._currentTime;
}
set currentTime(t: number) {
this.seek(t);
}
get duration() {
return this._duration;
}
get paused() {
return this._paused;
}
get ready() {
return this._ready;
}
get playbackRate() {
return parseFloat(this.getAttribute("playback-rate") || "1");
}
set playbackRate(r: number) {
this.setAttribute("playback-rate", String(r));
}
get muted() {
return this.hasAttribute("muted");
}
set muted(m: boolean) {
if (m) this.setAttribute("muted", "");
else this.removeAttribute("muted");
}
get loop() {
return this.hasAttribute("loop");
}
set loop(l: boolean) {
if (l) this.setAttribute("loop", "");
else this.removeAttribute("loop");
}
// ── Private ──
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
try {
this.iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action, ...extra },
"*",
);
} catch {
/* cross-origin */
}
}
private _isControlsClick(event: Event) {
return event
.composedPath()
.some((target) => target instanceof HTMLElement && target.classList.contains("hfp-controls"));
}
private _onMessage(e: MessageEvent) {
if (e.source !== this.iframe.contentWindow) return;
const data = e.data;
if (!data || data.source !== "hf-preview") return;
if (data.type === "state") {
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
const wasPlaying = !this._paused;
this._paused = !data.isPlaying;
// Throttle UI updates and event dispatch to ~10fps to avoid excessive re-renders
const now = performance.now();
if (now - this._lastUpdateMs > 100 || this._paused !== wasPlaying) {
this._lastUpdateMs = now;
this.controlsApi?.updateTime(this._currentTime, this._duration);
this.controlsApi?.updatePlaying(!this._paused);
this.dispatchEvent(
new CustomEvent("timeupdate", { detail: { currentTime: this._currentTime } }),
);
}
if (this._currentTime >= this._duration && !this._paused) {
if (this.loop) {
this.seek(0);
this.play();
} else {
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("ended"));
}
}
}
if (data.type === "timeline" && data.durationInFrames > 0) {
this._duration = data.durationInFrames / DEFAULT_FPS;
this.controlsApi?.updateTime(this._currentTime, this._duration);
}
if (data.type === "stage-size" && data.width > 0 && data.height > 0) {
this._compositionWidth = data.width;
this._compositionHeight = data.height;
this._updateScale();
}
}
private _runtimeInjected = false;
private _onIframeLoad() {
let attempts = 0;
this._runtimeInjected = false;
if (this._probeInterval) clearInterval(this._probeInterval);
this._probeInterval = setInterval(() => {
attempts++;
try {
const win = this.iframe.contentWindow as Window & {
__player?: { getDuration: () => number };
__timelines?: Record<string, { duration: () => number }>;
__hf?: unknown;
};
if (!win) return;
// Check if the runtime bridge is active (__hf or __player from the runtime)
const hasRuntime = !!(win.__hf || win.__player);
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
// Auto-inject runtime if GSAP timelines exist but no runtime bridge
if (!hasRuntime && hasTimelines && !this._runtimeInjected && attempts >= 5) {
this._injectRuntime();
return; // Wait for runtime to load and initialize
}
const getAdapter = () => {
if (win.__player && typeof win.__player.getDuration === "function") return win.__player;
if (win.__timelines) {
const keys = Object.keys(win.__timelines);
if (keys.length > 0) {
const tl = win.__timelines[keys[keys.length - 1]];
return { getDuration: () => tl.duration() };
}
}
return null;
};
const adapter = getAdapter();
if (adapter && adapter.getDuration() > 0) {
clearInterval(this._probeInterval!);
this._duration = adapter.getDuration();
this._ready = true;
this.controlsApi?.updateTime(0, this._duration);
this.dispatchEvent(new CustomEvent("ready", { detail: { duration: this._duration } }));
// Auto-detect dimensions from composition
const doc = this.iframe.contentDocument;
const root = doc?.querySelector("[data-composition-id]");
if (root) {
const w = parseInt(root.getAttribute("data-width") || "0", 10);
const h = parseInt(root.getAttribute("data-height") || "0", 10);
if (w > 0 && h > 0) {
this._compositionWidth = w;
this._compositionHeight = h;
this._updateScale();
}
}
if (this.hasAttribute("autoplay")) {
this.play();
}
return;
}
} catch {
/* cross-origin */
}
if (attempts >= 40) {
clearInterval(this._probeInterval!);
this.dispatchEvent(
new CustomEvent("error", {
detail: { message: "Composition timeline not found after 8s" },
}),
);
}
}, 200);
}
/** Inject the HyperFrames runtime into the iframe if not already present. */
private _injectRuntime() {
this._runtimeInjected = true;
try {
const doc = this.iframe.contentDocument;
if (!doc) return;
const script = doc.createElement("script");
script.src = RUNTIME_CDN_URL;
script.onload = () => {
// Runtime loaded — the probe interval will pick up __hf on next tick
};
script.onerror = () => {
// CDN failed — the probe will continue and eventually timeout
};
(doc.head || doc.documentElement).appendChild(script);
} catch {
/* cross-origin — can't inject */
}
}
private _updateScale() {
const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const scale = Math.min(
rect.width / this._compositionWidth,
rect.height / this._compositionHeight,
);
this.iframe.style.width = `${this._compositionWidth}px`;
this.iframe.style.height = `${this._compositionHeight}px`;
this.iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
}
private _setupControls() {
if (this.controlsApi) return;
const callbacks: ControlsCallbacks = {
onPlay: () => this.play(),
onPause: () => this.pause(),
onSeek: (fraction) => this.seek(fraction * this._duration),
};
this.controlsApi = createControls(this.shadow, callbacks);
}
private _setupPoster() {
const url = this.getAttribute("poster");
if (!url) {
this.posterEl?.remove();
this.posterEl = null;
return;
}
if (!this.posterEl) {
this.posterEl = document.createElement("img");
this.posterEl.className = "hfp-poster";
this.shadow.appendChild(this.posterEl);
}
this.posterEl.src = url;
}
private _hidePoster() {
this.posterEl?.remove();
this.posterEl = null;
}
}
if (!customElements.get("hyperframes-player")) {
customElements.define("hyperframes-player", HyperframesPlayer);
}
export { HyperframesPlayer };
export { formatTime } from "./controls.js";
+114
View File
@@ -0,0 +1,114 @@
export const PLAYER_STYLES = /* css */ `
:host {
display: block;
position: relative;
overflow: hidden;
background: #000;
contain: layout style;
}
.hfp-container {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.hfp-iframe {
position: absolute;
top: 50%;
left: 50%;
border: none;
pointer-events: none;
}
.hfp-poster {
position: absolute;
inset: 0;
object-fit: contain;
z-index: 1;
pointer-events: none;
}
.hfp-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
z-index: 10;
pointer-events: auto;
opacity: 1;
transition: opacity 0.3s ease;
user-select: none;
}
.hfp-controls.hfp-hidden {
opacity: 0;
pointer-events: none;
}
.hfp-play-btn {
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
z-index: 10;
}
.hfp-play-btn:hover {
opacity: 0.8;
}
.hfp-play-btn svg,
.hfp-play-btn svg * {
pointer-events: none;
}
.hfp-scrubber {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.hfp-scrubber:hover {
height: 6px;
}
.hfp-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #fff;
border-radius: 2px;
pointer-events: none;
}
.hfp-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
opacity: 0.9;
}
`;
export const PLAY_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><polygon points="4,2 16,9 4,16"/></svg>`;
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/hyperframes-player.ts"],
format: ["esm", "cjs", "iife"],
globalName: "HyperframesPlayer",
dts: true,
clean: true,
minify: true,
sourcemap: true,
});