Skip to content

Commit 72a4dbf

Browse files
miguel-heygenclaude
andcommitted
feat(cli): add hyperframes play command for lightweight player
New command that serves a composition in the <hyperframes-player> web component — clean, minimal, view-only. Injects the HyperFrames runtime into the composition HTML automatically. Usage: hyperframes play # play current project hyperframes play ./my-video # play specific project hyperframes play --port 8080 # custom port Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8310adb commit 72a4dbf

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
2525

2626
const subCommands = {
2727
init: () => import("./commands/init.js").then((m) => m.default),
28+
play: () => import("./commands/play.js").then((m) => m.default),
2829
preview: () => import("./commands/preview.js").then((m) => m.default),
2930
render: () => import("./commands/render.js").then((m) => m.default),
3031
lint: () => import("./commands/lint.js").then((m) => m.default),

packages/cli/src/commands/play.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { defineCommand } from "citty";
2+
import type { Example } from "./_examples.js";
3+
import { existsSync, readFileSync } from "node:fs";
4+
5+
export const examples: Example[] = [
6+
["Play the current project", "hyperframes play"],
7+
["Play a specific project directory", "hyperframes play ./my-video"],
8+
["Use a custom port", "hyperframes play --port 8080"],
9+
];
10+
import { resolve, dirname } from "node:path";
11+
import * as clack from "@clack/prompts";
12+
import { c } from "../ui/colors.js";
13+
import { resolveProject } from "../utils/project.js";
14+
15+
export default defineCommand({
16+
meta: { name: "play", description: "Play a composition in a lightweight browser player" },
17+
args: {
18+
dir: { type: "positional", description: "Project directory", required: false },
19+
port: { type: "string", description: "Port to run the player server on", default: "3003" },
20+
},
21+
async run({ args }) {
22+
const project = resolveProject(args.dir);
23+
const startPort = parseInt(args.port ?? "3003", 10);
24+
25+
// Resolve runtime path — same logic as studioServer.ts
26+
const runtimePath = resolveRuntimePath();
27+
if (!runtimePath) {
28+
clack.log.error("HyperFrames runtime not found. Run `pnpm build` first.");
29+
process.exitCode = 1;
30+
return;
31+
}
32+
33+
// Resolve player path
34+
const playerPath = resolvePlayerPath();
35+
if (!playerPath) {
36+
clack.log.error("@hyperframes/player not found. Run `pnpm build` in packages/player first.");
37+
process.exitCode = 1;
38+
return;
39+
}
40+
41+
const { Hono } = await import("hono");
42+
const { createAdaptorServer } = await import("@hono/node-server");
43+
44+
const app = new Hono();
45+
46+
// Serve the player JS
47+
app.get("/player.js", (ctx) => {
48+
return ctx.body(readFileSync(playerPath, "utf-8"), 200, {
49+
"Content-Type": "application/javascript",
50+
"Cache-Control": "no-cache",
51+
});
52+
});
53+
54+
// Serve the runtime JS
55+
app.get("/runtime.js", (ctx) => {
56+
return ctx.body(readFileSync(runtimePath, "utf-8"), 200, {
57+
"Content-Type": "application/javascript",
58+
"Cache-Control": "no-cache",
59+
});
60+
});
61+
62+
// Serve composition files (HTML + assets)
63+
app.get("/composition/*", async (ctx) => {
64+
const reqPath = ctx.req.path.replace("/composition/", "");
65+
const filePath = resolve(project.dir, reqPath);
66+
67+
// Security: don't allow path traversal outside project dir
68+
if (!filePath.startsWith(project.dir)) return ctx.text("Forbidden", 403);
69+
if (!existsSync(filePath)) return ctx.text("Not found", 404);
70+
71+
const content = readFileSync(filePath, "utf-8");
72+
73+
// For the main HTML, inject the runtime script before </body>
74+
if (filePath.endsWith(".html")) {
75+
const injected = injectRuntime(content);
76+
return ctx.html(injected);
77+
}
78+
79+
// Guess content type for other files
80+
const ext = filePath.split(".").pop() ?? "";
81+
const types: Record<string, string> = {
82+
js: "application/javascript",
83+
css: "text/css",
84+
json: "application/json",
85+
png: "image/png",
86+
jpg: "image/jpeg",
87+
jpeg: "image/jpeg",
88+
svg: "image/svg+xml",
89+
mp4: "video/mp4",
90+
webm: "video/webm",
91+
mp3: "audio/mpeg",
92+
wav: "audio/wav",
93+
};
94+
return ctx.body(readFileSync(filePath), 200, {
95+
"Content-Type": types[ext] ?? "application/octet-stream",
96+
});
97+
});
98+
99+
// Main page — the player wrapper
100+
app.get("/", (ctx) => {
101+
return ctx.html(buildPlayerPage(project.name));
102+
});
103+
104+
clack.intro(c.bold("hyperframes play"));
105+
const s = clack.spinner();
106+
s.start("Starting player...");
107+
108+
const server = createAdaptorServer({ fetch: app.fetch });
109+
let actualPort = startPort;
110+
111+
for (let attempt = 0; attempt < 10; attempt++) {
112+
const port = startPort + attempt;
113+
try {
114+
await new Promise<void>((res, rej) => {
115+
const onErr = (err: NodeJS.ErrnoException) => {
116+
server.removeListener("listening", onOk);
117+
rej(err);
118+
};
119+
const onOk = () => {
120+
server.removeListener("error", onErr);
121+
res();
122+
};
123+
server.once("error", onErr);
124+
server.once("listening", onOk);
125+
server.listen(port);
126+
});
127+
actualPort = port;
128+
break;
129+
} catch (err: unknown) {
130+
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") continue;
131+
throw err;
132+
}
133+
}
134+
135+
const url = `http://localhost:${actualPort}`;
136+
s.stop(c.success("Player running"));
137+
console.log();
138+
if (actualPort !== startPort) {
139+
console.log(` ${c.warn(`Port ${startPort} is in use, using ${actualPort} instead`)}`);
140+
}
141+
console.log(` ${c.dim("Project")} ${c.accent(project.name)}`);
142+
console.log(` ${c.dim("Player")} ${c.accent(url)}`);
143+
console.log();
144+
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
145+
console.log();
146+
import("open").then((mod) => mod.default(url)).catch(() => {});
147+
148+
return new Promise<void>(() => {});
149+
},
150+
});
151+
152+
function commandDir(): string {
153+
return dirname(new URL(import.meta.url).pathname);
154+
}
155+
156+
function resolveRuntimePath(): string | null {
157+
const d = commandDir();
158+
const candidates = [
159+
// Bundled with CLI dist
160+
resolve(d, "hyperframe-runtime.js"),
161+
resolve(d, "..", "hyperframe-runtime.js"),
162+
// Monorepo dev: commands/ → src/ → cli/ → packages/ then into core/dist/
163+
resolve(d, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js"),
164+
];
165+
for (const p of candidates) {
166+
if (existsSync(p)) return p;
167+
}
168+
return null;
169+
}
170+
171+
function resolvePlayerPath(): string | null {
172+
const d = commandDir();
173+
const candidates = [
174+
// Monorepo dev: commands/ → src/ → cli/ → packages/ then into player/dist/
175+
resolve(d, "..", "..", "..", "player", "dist", "hyperframes-player.global.js"),
176+
// Bundled with CLI dist
177+
resolve(d, "hyperframes-player.global.js"),
178+
resolve(d, "..", "hyperframes-player.global.js"),
179+
];
180+
for (const p of candidates) {
181+
if (existsSync(p)) return p;
182+
}
183+
return null;
184+
}
185+
186+
function injectRuntime(html: string): string {
187+
// Inject runtime script before closing </body> or at the end
188+
const runtimeTag = `<script src="/runtime.js"></script>`;
189+
if (html.includes("</body>")) {
190+
return html.replace("</body>", `${runtimeTag}\n</body>`);
191+
}
192+
return html + `\n${runtimeTag}`;
193+
}
194+
195+
function buildPlayerPage(projectName: string): string {
196+
return `<!doctype html>
197+
<html lang="en">
198+
<head>
199+
<meta charset="UTF-8" />
200+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
201+
<title>${projectName} — HyperFrames Player</title>
202+
<style>
203+
* { margin: 0; padding: 0; box-sizing: border-box; }
204+
body {
205+
background: #0a0a0a; color: #fff;
206+
font-family: system-ui, -apple-system, sans-serif;
207+
height: 100vh; display: flex; flex-direction: column;
208+
align-items: center; justify-content: center;
209+
padding: 24px;
210+
}
211+
.player-wrap {
212+
width: 100%; max-width: 1280px; aspect-ratio: 16/9;
213+
border-radius: 8px; overflow: hidden;
214+
}
215+
hyperframes-player { width: 100%; height: 100%; }
216+
.info {
217+
margin-top: 16px; font-size: 12px; color: #444;
218+
font-family: monospace;
219+
}
220+
</style>
221+
</head>
222+
<body>
223+
<div class="player-wrap">
224+
<hyperframes-player src="/composition/index.html" controls muted></hyperframes-player>
225+
</div>
226+
<div class="info">${projectName} — hyperframes play</div>
227+
<script src="/player.js"></script>
228+
</body>
229+
</html>`;
230+
}

0 commit comments

Comments
 (0)