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
+28
View File
@@ -58,6 +58,7 @@ packages/
cli/ → hyperframes CLI (create, preview, lint, render)
core/ → Types, parsers, generators, linter, runtime, frame adapters
engine/ → Seekable page-to-video capture engine (Puppeteer + FFmpeg)
player/ → Embeddable <hyperframes-player> web component
producer/ → Full rendering pipeline (capture + encode + audio mix)
studio/ → Browser-based composition editor UI
```
@@ -94,6 +95,7 @@ When adding a new CLI command:
## Key Concepts
- **Compositions** are HTML files with `data-*` attributes defining timeline, tracks, and media
- **Clips** can be animated directly with GSAP. The only restriction: don't animate `visibility` or `display` on clip elements — the runtime manages those.
- **Frame Adapters** bridge animation runtimes (GSAP, Lottie, CSS) to the capture engine
- **Producer** orchestrates capture → encode → audio mix into final MP4
- **BeginFrame rendering** uses `HeadlessExperimental.beginFrame` for deterministic frame capture
@@ -185,3 +187,29 @@ Use `npx hyperframes tts --list` for the full set, or pass any valid Kokoro voic
- Python 3.8+ (auto-installs `kokoro-onnx` package on first run)
- Model downloads automatically on first use (~311 MB model + ~27 MB voices, cached in `~/.cache/hyperframes/tts/`)
## Embeddable Player
The `@hyperframes/player` package provides a `<hyperframes-player>` web component for embedding
compositions in any web page. Zero dependencies, works with any framework.
### Quick reference
```html
<!-- Load the player (CDN or npm) -->
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<!-- Embed a composition -->
<hyperframes-player src="./my-composition/index.html" controls></hyperframes-player>
```
### JavaScript API
```js
const player = document.querySelector("hyperframes-player");
player.play();
player.pause();
player.seek(2.5);
console.log(player.currentTime, player.duration, player.paused);
player.addEventListener("ready", (e) => console.log("Duration:", e.detail.duration));
```
+1
View File
@@ -68,6 +68,7 @@ ENV PATH="/root/.bun/bin:$PATH"
COPY package.json bun.lock ./
COPY packages/core/package.json packages/core/package.json
COPY packages/engine/package.json packages/engine/package.json
COPY packages/player/package.json packages/player/package.json
COPY packages/producer/package.json packages/producer/package.json
COPY packages/cli/package.json packages/cli/package.json
COPY packages/studio/package.json packages/studio/package.json
+16 -5
View File
@@ -19,7 +19,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.2.1",
"version": "0.2.2",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -59,7 +59,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@chenglou/pretext": "^0.0.3",
},
@@ -85,7 +85,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -100,9 +100,18 @@
"vitest": "^3.2.4",
},
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.2.2",
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4",
},
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -140,7 +149,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -412,6 +421,8 @@
"@hyperframes/engine": ["@hyperframes/engine@workspace:packages/engine"],
"@hyperframes/player": ["@hyperframes/player@workspace:packages/player"],
"@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"],
"@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"],
+1
View File
@@ -66,6 +66,7 @@
"pages": [
"packages/core",
"packages/engine",
"packages/player",
"packages/producer",
"packages/studio",
"packages/cli"
+175
View File
@@ -0,0 +1,175 @@
---
title: "@hyperframes/player"
description: "Embeddable web component for playing HyperFrames compositions in any web page."
---
The player package provides a `<hyperframes-player>` custom element that embeds a HyperFrames composition anywhere — in any framework or plain HTML. Zero dependencies, 3KB gzipped.
```bash
npm install @hyperframes/player
```
## When to Use
**Use `@hyperframes/player` when you need to:**
- Embed a rendered composition in a website, dashboard, or app
- Add a video-like player to a landing page or product demo
- Show compositions in documentation or blog posts
**Use a different package if you want to:**
- Edit compositions interactively — use the [studio](/packages/studio)
- Preview during development — use the [CLI](/packages/cli) (`npx hyperframes preview`)
- Render to MP4 — use the [CLI](/packages/cli) or [producer](/packages/producer)
## Quick Start
### Via CDN
```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<hyperframes-player
src="./my-composition/index.html"
controls
autoplay
muted
style="width: 100%; max-width: 800px; aspect-ratio: 16/9"
></hyperframes-player>
```
### Via npm
```js
import '@hyperframes/player';
```
```html
<hyperframes-player src="/compositions/intro.html" controls></hyperframes-player>
```
## HTML Attributes
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `src` | string | required | URL or relative path to composition HTML |
| `width` | number | 1920 | Composition width in pixels |
| `height` | number | 1080 | Composition height in pixels |
| `controls` | boolean | false | Show playback controls overlay |
| `autoplay` | boolean | false | Start playing on load |
| `loop` | boolean | false | Loop playback |
| `muted` | boolean | true | Mute audio (required for autoplay in most browsers) |
| `poster` | string | — | Image URL to show before first play |
| `playback-rate` | number | 1 | Playback speed multiplier |
## JavaScript API
The player mirrors the native `<video>` element API:
```js
const player = document.querySelector('hyperframes-player');
// Playback
player.play();
player.pause();
player.seek(2.5); // seek to 2.5 seconds
// Properties
player.currentTime; // number — current position in seconds
player.currentTime = 5; // seek to 5 seconds
player.duration; // number — total duration
player.paused; // boolean
player.ready; // boolean — true after composition loads
player.playbackRate; // number — get/set speed
player.muted; // boolean — get/set mute
player.loop; // boolean — get/set loop
```
## Events
```js
const player = document.querySelector('hyperframes-player');
player.addEventListener('ready', (e) => {
console.log('Duration:', e.detail.duration);
});
player.addEventListener('timeupdate', (e) => {
console.log('Time:', e.detail.currentTime);
});
player.addEventListener('play', () => console.log('Playing'));
player.addEventListener('pause', () => console.log('Paused'));
player.addEventListener('ended', () => console.log('Ended'));
player.addEventListener('error', (e) => console.error(e.detail.message));
```
| Event | Detail | Description |
|-------|--------|-------------|
| `ready` | `{ duration }` | Composition loaded and timeline discovered |
| `timeupdate` | `{ currentTime }` | Fires during playback (~30fps) |
| `play` | — | Playback started |
| `pause` | — | Playback paused |
| `ended` | — | Playback reached end |
| `error` | `{ message }` | Load or runtime error |
## Framework Examples
### React
```jsx
import '@hyperframes/player';
function VideoPreview({ src }) {
return (
<hyperframes-player
src={src}
controls
style={{ width: '100%', maxWidth: 800 }}
/>
);
}
```
### Vue
```vue
<template>
<hyperframes-player :src="compositionUrl" controls />
</template>
<script setup>
import '@hyperframes/player';
const compositionUrl = './compositions/intro.html';
</script>
```
### Programmatic
```js
import '@hyperframes/player';
const player = document.createElement('hyperframes-player');
player.src = './my-composition/index.html';
player.controls = true;
player.addEventListener('ready', () => player.play());
document.getElementById('player-container').appendChild(player);
```
## Architecture
The player uses an iframe inside a Shadow DOM container. This provides:
- **Isolation** — composition CSS/JS can't leak into or conflict with your page
- **Security** — iframe sandbox restricts composition capabilities
- **Scaling** — auto-scales the composition to fit the player's container via CSS transforms
The player communicates with the composition via the HyperFrames runtime bridge protocol (`postMessage`). Existing compositions work without modification.
## Controls
When the `controls` attribute is present, a minimal overlay appears at the bottom:
- **Play/Pause** button (left)
- **Scrub bar** with drag support (mouse + touch)
- **Time display** showing current / total duration (right)
- Auto-hides after 3 seconds of inactivity, reappears on hover
+1
View File
@@ -25,6 +25,7 @@ const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
const subCommands = {
init: () => import("./commands/init.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
+230
View File
@@ -0,0 +1,230 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { existsSync, readFileSync } from "node:fs";
export const examples: Example[] = [
["Play the current project", "hyperframes play"],
["Play a specific project directory", "hyperframes play ./my-video"],
["Use a custom port", "hyperframes play --port 8080"],
];
import { resolve, dirname } from "node:path";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
export default defineCommand({
meta: { name: "play", description: "Play a composition in a lightweight browser player" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
port: { type: "string", description: "Port to run the player server on", default: "3003" },
},
async run({ args }) {
const project = resolveProject(args.dir);
const startPort = parseInt(args.port ?? "3003", 10);
// Resolve runtime path — same logic as studioServer.ts
const runtimePath = resolveRuntimePath();
if (!runtimePath) {
clack.log.error("HyperFrames runtime not found. Run `pnpm build` first.");
process.exitCode = 1;
return;
}
// Resolve player path
const playerPath = resolvePlayerPath();
if (!playerPath) {
clack.log.error("@hyperframes/player not found. Run `pnpm build` in packages/player first.");
process.exitCode = 1;
return;
}
const { Hono } = await import("hono");
const { createAdaptorServer } = await import("@hono/node-server");
const app = new Hono();
// Serve the player JS
app.get("/player.js", (ctx) => {
return ctx.body(readFileSync(playerPath, "utf-8"), 200, {
"Content-Type": "application/javascript",
"Cache-Control": "no-cache",
});
});
// Serve the runtime JS
app.get("/runtime.js", (ctx) => {
return ctx.body(readFileSync(runtimePath, "utf-8"), 200, {
"Content-Type": "application/javascript",
"Cache-Control": "no-cache",
});
});
// Serve composition files (HTML + assets)
app.get("/composition/*", async (ctx) => {
const reqPath = ctx.req.path.replace("/composition/", "");
const filePath = resolve(project.dir, reqPath);
// Security: don't allow path traversal outside project dir
if (!filePath.startsWith(project.dir)) return ctx.text("Forbidden", 403);
if (!existsSync(filePath)) return ctx.text("Not found", 404);
const content = readFileSync(filePath, "utf-8");
// For the main HTML, inject the runtime script before </body>
if (filePath.endsWith(".html")) {
const injected = injectRuntime(content);
return ctx.html(injected);
}
// Guess content type for other files
const ext = filePath.split(".").pop() ?? "";
const types: Record<string, string> = {
js: "application/javascript",
css: "text/css",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
svg: "image/svg+xml",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
};
return ctx.body(readFileSync(filePath), 200, {
"Content-Type": types[ext] ?? "application/octet-stream",
});
});
// Main page — the player wrapper
app.get("/", (ctx) => {
return ctx.html(buildPlayerPage(project.name));
});
clack.intro(c.bold("hyperframes play"));
const s = clack.spinner();
s.start("Starting player...");
const server = createAdaptorServer({ fetch: app.fetch });
let actualPort = startPort;
for (let attempt = 0; attempt < 10; attempt++) {
const port = startPort + attempt;
try {
await new Promise<void>((res, rej) => {
const onErr = (err: NodeJS.ErrnoException) => {
server.removeListener("listening", onOk);
rej(err);
};
const onOk = () => {
server.removeListener("error", onErr);
res();
};
server.once("error", onErr);
server.once("listening", onOk);
server.listen(port);
});
actualPort = port;
break;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") continue;
throw err;
}
}
const url = `http://localhost:${actualPort}`;
s.stop(c.success("Player running"));
console.log();
if (actualPort !== startPort) {
console.log(` ${c.warn(`Port ${startPort} is in use, using ${actualPort} instead`)}`);
}
console.log(` ${c.dim("Project")} ${c.accent(project.name)}`);
console.log(` ${c.dim("Player")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
import("open").then((mod) => mod.default(url)).catch(() => {});
return new Promise<void>(() => {});
},
});
function commandDir(): string {
return dirname(new URL(import.meta.url).pathname);
}
function resolveRuntimePath(): string | null {
const d = commandDir();
const candidates = [
// Bundled with CLI dist
resolve(d, "hyperframe-runtime.js"),
resolve(d, "..", "hyperframe-runtime.js"),
// Monorepo dev: commands/ → src/ → cli/ → packages/ then into core/dist/
resolve(d, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
function resolvePlayerPath(): string | null {
const d = commandDir();
const candidates = [
// Monorepo dev: commands/ → src/ → cli/ → packages/ then into player/dist/
resolve(d, "..", "..", "..", "player", "dist", "hyperframes-player.global.js"),
// Bundled with CLI dist
resolve(d, "hyperframes-player.global.js"),
resolve(d, "..", "hyperframes-player.global.js"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
function injectRuntime(html: string): string {
// Inject runtime script before closing </body> or at the end
const runtimeTag = `<script src="/runtime.js"></script>`;
if (html.includes("</body>")) {
return html.replace("</body>", `${runtimeTag}\n</body>`);
}
return html + `\n${runtimeTag}`;
}
function buildPlayerPage(projectName: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${projectName} HyperFrames Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a; color: #fff;
font-family: system-ui, -apple-system, sans-serif;
height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 24px;
}
.player-wrap {
width: 100%; max-width: 1280px; aspect-ratio: 16/9;
border-radius: 8px; overflow: hidden;
}
hyperframes-player { width: 100%; height: 100%; }
.info {
margin-top: 16px; font-size: 12px; color: #444;
font-family: monospace;
}
</style>
</head>
<body>
<div class="player-wrap">
<hyperframes-player src="/composition/index.html" controls muted></hyperframes-player>
</div>
<div class="info">${projectName} hyperframes play</div>
<script src="/player.js"></script>
</body>
</html>`;
}
+5 -6
View File
@@ -49,11 +49,10 @@
></audio>
<!--
ANIMATION PATTERN: The clip div controls timing/visibility.
Always put your content in a CHILD element and animate THAT.
<div class="clip" ...> ← timing only, don't animate this
<div id="my-title">...</div> ← animate this with GSAP
Add your clips here. Example:
<div id="title" class="clip" data-start="0" data-duration="5" data-track-index="1"
style="font-size: 64px; color: #fff; padding: 40px">
Hello World
</div>
-->
</div>
@@ -61,7 +60,7 @@
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tl.from("#my-title", { opacity: 0, y: -50, duration: 1 }, 0);
// Example: tl.from("#title", { opacity: 0, y: -50, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body>
+113 -11
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("GSAP rules", () => {
it("reports error when GSAP targets a clip element by id", () => {
it("does NOT error when GSAP animates opacity on a clip element (by id)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -19,13 +19,10 @@ describe("GSAP rules", () => {
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("inner wrapper");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element by class", () => {
it("does NOT error when GSAP targets a clip element with safe properties (by class)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -42,8 +39,7 @@ describe("GSAP rules", () => {
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".my-card");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
@@ -86,7 +82,7 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
it("does NOT error when GSAP targets a clip element with safe properties (class-only, no id)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -100,12 +96,118 @@ describe("GSAP rules", () => {
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates opacity on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Title</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#title", { opacity: 0, y: -50, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates transform props on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box" class="clip" data-start="0" data-duration="5" data-track-index="0">
<div>Box</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { scale: 1.2, x: 100, rotation: 45, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("ERRORS when GSAP animates visibility on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<p>Overlay</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { visibility: "hidden", duration: 0.3 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".scene-card");
expect(finding?.elementId).toBeUndefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("visibility");
});
it("ERRORS when GSAP animates display on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" class="clip" data-start="0" data-duration="5" data-track-index="0">
<p>Card</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { display: "none", duration: 0.3 }, 3.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#card");
expect(finding?.message).toContain("display");
});
it("ERRORS when GSAP tween mixes safe properties with visibility on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { opacity: 0, visibility: "hidden", duration: 0.3 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("visibility");
});
it("warns when tl.to animates x on an element with CSS translateX", () => {
+8 -3
View File
@@ -284,19 +284,24 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
}
// gsap_animates_clip_element
// gsap_animates_clip_element — only error when GSAP animates visibility/display
for (const win of gsapWindows) {
const sel = win.targetSelector;
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
if (!clipInfo) continue;
const conflictingProps = win.properties.filter(
(p) => p === "visibility" || p === "display",
);
if (conflictingProps.length === 0) continue;
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
findings.push({
code: "gsap_animates_clip_element",
severity: "error",
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
selector: sel,
elementId: clipInfo.id || undefined,
fixHint: "Wrap content in a child <div> and target that with GSAP.",
fixHint:
"Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
snippet: truncateSnippet(win.raw),
});
}
+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,
});