diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 4af280e57..0754ebea3 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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: diff --git a/package.json b/package.json index 2497445e2..6a7c897c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 21155a4a0..988e2326e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -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": { @@ -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", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 76b5dae54..63e8768fb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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), diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 000000000..132cc8edb --- /dev/null +++ b/packages/cli/src/commands/publish.ts @@ -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; + } + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index fa77935fd..7c89ceb13 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -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"], ], }, @@ -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"], diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts new file mode 100644 index 000000000..edad2f1e9 --- /dev/null +++ b/packages/cli/src/server/studioServer.test.ts @@ -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()); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 9ea8a4670..bb9a05d0d 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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, @@ -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; @@ -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) => { diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts new file mode 100644 index 000000000..a34cbaf3f --- /dev/null +++ b/packages/cli/src/utils/publishProject.test.ts @@ -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"), "", "utf-8"); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets/logo.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"), "", "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 }); + } + }); +}); diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts new file mode 100644 index 000000000..8f7de1713 --- /dev/null +++ b/packages/cli/src/utils/publishProject.ts @@ -0,0 +1,97 @@ +import { basename, join, relative } from "node:path"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import AdmZip from "adm-zip"; + +const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]); +const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]); + +export interface PublishArchiveResult { + buffer: Buffer; + fileCount: number; +} + +export interface PublishedProjectResponse { + projectId: string; + title: string; + fileCount: number; + url: string; + claimToken: string; +} + +function shouldIgnoreSegment(segment: string): boolean { + return segment.startsWith(".") || IGNORED_DIRS.has(segment) || IGNORED_FILES.has(segment); +} + +function collectProjectFiles(rootDir: string, currentDir: string, paths: string[]): void { + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + if (shouldIgnoreSegment(entry.name)) continue; + const absolutePath = join(currentDir, entry.name); + const relativePath = relative(rootDir, absolutePath).replaceAll("\\", "/"); + if (!relativePath) continue; + + if (entry.isDirectory()) { + collectProjectFiles(rootDir, absolutePath, paths); + continue; + } + + if (!statSync(absolutePath).isFile()) continue; + paths.push(relativePath); + } +} + +export function createPublishArchive(projectDir: string): PublishArchiveResult { + const filePaths: string[] = []; + collectProjectFiles(projectDir, projectDir, filePaths); + if (!filePaths.includes("index.html")) { + throw new Error("Project must include an index.html file at the root before publish."); + } + + const archive = new AdmZip(); + for (const filePath of filePaths) { + archive.addFile(filePath, readFileSync(join(projectDir, filePath))); + } + + return { + buffer: archive.toBuffer(), + fileCount: filePaths.length, + }; +} + +export function getPublishApiBaseUrl(): string { + return ( + process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] || + process.env["HEYGEN_API_URL"] || + "https://api2.heygen.com" + ).replace(/\/$/, ""); +} + +export async function publishProjectArchive(projectDir: string): Promise { + const title = basename(projectDir); + const archive = createPublishArchive(projectDir); + const archiveBytes = new Uint8Array(archive.buffer.byteLength); + archiveBytes.set(archive.buffer); + const body = new FormData(); + body.set("title", title); + body.set("file", new File([archiveBytes], `${title}.zip`, { type: "application/zip" })); + + const response = await fetch(`${getPublishApiBaseUrl()}/v1/hyperframes/projects/publish`, { + method: "POST", + body, + signal: AbortSignal.timeout(30_000), + }); + + const payload = await response.json().catch(() => null); + const message = + typeof payload?.message === "string" ? payload.message : "Failed to publish project"; + if (!response.ok || !payload?.data) { + throw new Error(message); + } + + return { + projectId: String(payload.data.project_id), + title: String(payload.data.title), + fileCount: Number(payload.data.file_count), + url: String(payload.data.url), + claimToken: String(payload.data.claim_token), + }; +}