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
+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>