Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,25 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
2. **Local studio mode** — if `@hyperframes/studio` is installed in your project's `node_modules`, spawns Vite with full HMR for faster iteration.
3. **Monorepo mode** — if running from the Hyperframes source repo, spawns the studio dev server directly.

### `publish`

Upload the project and get back a stable `hyperframes.dev` URL:

```bash
npx hyperframes publish [dir]
npx hyperframes publish --yes
```

| Flag | Description |
|------|-------------|
| `--yes` | Skip the confirmation prompt |

`publish` zips the current project, uploads it to the HyperFrames publish backend, and prints a stable `hyperframes.dev` URL for that stored project.

The printed URL already includes the claim token, so opening it on `hyperframes.dev` lets the intended user claim the uploaded project and continue editing in the web app.

This flow does not keep a local preview server alive and does not open a tunnel. The published URL resolves to the persisted project stored by HeyGen, so it keeps working after the CLI process exits.

### `lint`

Check a composition for common issues:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"type": "module",
"scripts": {
"dev": "bun run studio",
"build": "bun run --filter '*' build",
"build": "bun run --filter '!@hyperframes/cli' build && bun run --filter @hyperframes/cli build",
"build:producer": "bun run --filter @hyperframes/producer build",
"studio": "bun run --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.4.3",
"version": "0.4.13-alpha.2",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"repository": {
"type": "git",
Expand All @@ -17,11 +17,10 @@
"scripts": {
"test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy",
"build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:copy",
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
"build:studio": "cd ../studio && bun run build",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills dist/docker && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/blank src/templates/_shared dist/templates/ && cp -r ../../skills/hyperframes ../../skills/hyperframes-cli ../../skills/gsap dist/skills/ && cp src/docker/Dockerfile.render dist/docker/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"build:copy": "node scripts/build-copy.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand All @@ -44,6 +43,7 @@
"@hyperframes/core": "workspace:*",
"@hyperframes/engine": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@hyperframes/studio": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/mime-types": "^3.0.1",
"@types/node": "^22.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const subCommands = {
catalog: () => import("./commands/catalog.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { basename, resolve } from "node:path";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";

import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { publishProjectArchive } from "../utils/publishProject.js";

export const examples: Example[] = [
["Publish the current project with a public URL", "hyperframes publish"],
["Publish a specific directory", "hyperframes publish ./my-video"],
["Skip the consent prompt (scripts)", "hyperframes publish --yes"],
];

export default defineCommand({
meta: {
name: "publish",
description: "Upload the project and return a stable public URL",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
yes: {
type: "boolean",
alias: "y",
description: "Skip the publish confirmation prompt",
default: false,
},
},
async run({ args }) {
const rawArg = args.dir;
const dir = resolve(rawArg ?? ".");
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir);

const indexPath = join(dir, "index.html");
if (existsSync(indexPath)) {
const lintResult = lintProject({ dir, name: projectName, indexPath });
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
console.log();
for (const line of formatLintFindings(lintResult)) console.log(line);
console.log();
}
}

if (args.yes !== true) {
console.log();
console.log(
` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`,
);
console.log(
` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`,
);
console.log();
const approved = await clack.confirm({ message: "Publish this project?" });
if (clack.isCancel(approved) || approved !== true) {
console.log();
console.log(` ${c.dim("Aborted.")}`);
console.log();
return;
}
}

clack.intro(c.bold("hyperframes publish"));
const publishSpinner = clack.spinner();
publishSpinner.start("Uploading project...");

try {
const published = await publishProjectArchive(dir);
const claimUrl = new URL(published.url);
claimUrl.searchParams.set("claim_token", published.claimToken);
publishSpinner.stop(c.success("Project published"));

console.log();
console.log(` ${c.dim("Project")} ${c.accent(published.title)}`);
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
console.log();
console.log(
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
);
console.log();
return;
} catch (err: unknown) {
publishSpinner.stop(c.error("Publish failed"));
console.error();
console.error(` ${(err as Error).message}`);
console.error();
process.exitCode = 1;
return;
}
},
});
2 changes: 2 additions & 0 deletions packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const GROUPS: Group[] = [
["capture", "Capture a website for video production"],
["catalog", "Browse and install blocks and components"],
["preview", "Start the studio for previewing compositions"],
["publish", "Expose the preview via a public URL so collaborators can view it"],
["render", "Render a composition to MP4 or WebM"],
],
},
Expand Down Expand Up @@ -72,6 +73,7 @@ import type { Example } from "./commands/_examples.js";
const ROOT_EXAMPLES: Example[] = [
["Create a new project", "hyperframes init my-video"],
["Start the live preview studio", "hyperframes preview"],
["Share via public URL", "hyperframes publish"],
["Render to MP4", "hyperframes render -o out.mp4"],
["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"],
["Validate your composition", "hyperframes lint"],
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSourceFallback } from "./runtimeSource.js";

describe("loadRuntimeSourceFallback", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource());
});
});
43 changes: 37 additions & 6 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
import {
createStudioApi,
Expand Down Expand Up @@ -228,7 +229,31 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}, opts.seekTime);
// Let the seek render settle.
await new Promise((r) => setTimeout(r, 200));
const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer;
let clip: { x: number; y: number; width: number; height: number } | undefined;
if (opts.selector) {
clip = await page.evaluate((selector: string) => {
const el = document.querySelector(selector);
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
}, opts.selector);
}
const screenshot = (await page.screenshot({
type: "jpeg",
quality: 80,
...(clip ? { clip } : {}),
})) as Buffer;
return screenshot;
} catch {
return null;
Expand Down Expand Up @@ -256,11 +281,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {

// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
const serve = async () => {
const runtimeSource = existsSync(runtimePath)
? readFileSync(runtimePath, "utf-8")
: await loadRuntimeSourceFallback();
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
};
return serve();
});

app.get("/api/events", (c) => {
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/utils/publishProject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
createPublishArchive,
getPublishApiBaseUrl,
publishProjectArchive,
} from "./publishProject.js";

function makeProjectDir(): string {
return mkdtempSync(join(tmpdir(), "hf-publish-"));
}

describe("createPublishArchive", () => {
it("packages the project and skips hidden files and node_modules", () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
mkdirSync(join(dir, "assets"));
writeFileSync(join(dir, "assets/logo.svg"), "<svg />", "utf-8");
mkdirSync(join(dir, ".git"));
writeFileSync(join(dir, ".env"), "SECRET=1", "utf-8");
mkdirSync(join(dir, "node_modules"));
writeFileSync(join(dir, "node_modules/ignored.js"), "console.log('ignore')", "utf-8");

const archive = createPublishArchive(dir);

expect(archive.fileCount).toBe(2);
expect(archive.buffer.byteLength).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("publishProjectArchive", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 2,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("uploads the archive and returns the stable project URL", async () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");

const result = await publishProjectArchive(dir);

expect(getPublishApiBaseUrl()).toBe("https://api2.heygen.com");
expect(result).toMatchObject({
projectId: "hfp_123",
url: "https://hyperframes.dev/p/hfp_123",
});
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
Loading
Loading