mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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));
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"pages": [
|
||||
"packages/core",
|
||||
"packages/engine",
|
||||
"packages/player",
|
||||
"packages/producer",
|
||||
"packages/studio",
|
||||
"packages/cli"
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>`;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user