mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
## 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`
231 lines
7.4 KiB
TypeScript
231 lines
7.4 KiB
TypeScript
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>`;
|
|
}
|