mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user