diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index a494aa7825..0754ebea36 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -305,12 +305,6 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ # Adjust speech speed npx hyperframes tts "Slow and clear" --speed 0.8 - # Generate Spanish speech (lang auto-detected from the `e` voice prefix) - npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav - - # Override the phonemizer (read English text with a French voice) - npx hyperframes tts "Bonjour le monde" --voice af_heart --lang fr-fr - # Read text from a file npx hyperframes tts script.txt @@ -323,14 +317,9 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--output, -o` | Output file path (default: `speech.wav` in current directory) | | `--voice, -v` | Voice ID (run `--list` to see options) | | `--speed, -s` | Speech speed multiplier (default: 1.0) | - | `--lang, -l` | Phonemizer locale (`en-us`, `en-gb`, `es`, `fr-fr`, `hi`, `it`, `pt-br`, `ja`, `zh`). When omitted, inferred from the voice ID prefix. | | `--list` | List available voices and exit | | `--json` | Output result as JSON | - - Voice IDs encode the phonemizer language in their first letter (`a`=American, `b`=British, `e`=Spanish, `f`=French, `h`=Hindi, `i`=Italian, `j`=Japanese, `p`=Brazilian Portuguese, `z`=Mandarin). `--lang` is only needed when you want to override that — for example, giving English text a French phonemizer for a stylized accent. - - Combine `tts` with `transcribe` to generate narration and word-level timestamps for captions in a single workflow: generate the audio with `tts`, then transcribe the output with `transcribe` to get word-level timing. @@ -384,16 +373,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ Opens your composition in the Hyperframes Studio with live preview. Edits to `index.html` and any referenced sub-compositions are reflected automatically. The preview uses the same Hyperframes runtime as production rendering, so what you see is what you get. - - Visual output matches render exactly. Playback *performance* does not: preview plays in real time in your browser, so paint-heavy compositions (large images, stacked `backdrop-filter` layers, many shadowed elements) may stutter depending on your hardware. The rendered mp4 is always accurate regardless — render captures frames one at a time, so per-frame cost shows up as longer render duration, not dropped frames. See [Performance](/guides/performance) for details. - - The preview server runs in three modes, auto-detected: 1. **Embedded mode** (default for `npx`) — runs a standalone server with the studio bundled in the CLI. Zero extra dependencies. 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: @@ -476,15 +480,14 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--output` | path | `renders/.mp4` | Output file path | | `--format` | mp4, webm, mov | mp4 | Output format (WebM/MOV render with transparency) | | `--fps` | 24, 30, 60 | 30 | Frames per second | - | `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) | - | `--hdr` | — | off | Detect HDR sources and output HDR10 (H.265 10-bit, BT.2020 PQ/HLG). MP4 only. SDR-only compositions are unaffected. See [HDR Rendering](/guides/hdr) | + | `--quality` | draft, standard, high | standard | Encoding quality preset | + | `--crf` | 0–51 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` | + | `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` | | `--workers` | 1-8 | 4 | Parallel render workers | | `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) | | `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) | | `--quiet` | — | off | Suppress verbose output | - CRF and target bitrate are now driven by `--quality`. For programmatic renders, `RenderConfig.crf` and `RenderConfig.videoBitrate` still accept overrides. - #### WebM with Transparency Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video. @@ -646,26 +649,6 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--cursor` | Install to Cursor (`.cursor/skills/` in current project) | Skills are fetched from GitHub and include composition authoring, GSAP animation patterns, registry block/component wiring, and other domain-specific knowledge. The `init` command also offers to install skills automatically after scaffolding a project. - - #### Troubleshooting: `fatal: active post-checkout hook found during git clone` - - If you installed Git LFS globally (`git lfs install`), Git 2.45+ refuses to run the LFS post-checkout hook during any `git clone` — including the clone the upstream `skills` CLI performs under the hood. The error looks like: - - ``` - ■ Failed to clone repository - fatal: active `post-checkout` hook found during `git clone` - └ Installation failed - ``` - - **Using `hyperframes skills` is already fine** — as of v0.4.5 the CLI sets `GIT_CLONE_PROTECTION_ACTIVE=0` on the child environment, which is the opt-in knob Git provides for exactly this case. You don't need to do anything. - - **If you ran `npx skills add heygen-com/hyperframes` directly** (bypassing the HyperFrames CLI), set the env var yourself: - - ```bash - GIT_CLONE_PROTECTION_ACTIVE=0 npx skills add heygen-com/hyperframes - ``` - - This is tracked in [GH #316](https://github.com/heygen-com/hyperframes/issues/316). An upstream fix in the `skills` CLI itself is the right long-term answer; until that lands, the env var is the correct workaround. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7d6a83289b..63e8768fbe 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), @@ -82,18 +83,9 @@ if (!isHelp && command !== "telemetry" && command !== "unknown") { } if (!isHelp && !hasJsonFlag && command !== "upgrade") { - // Report any completed auto-install from the previous run first, before - // kicking off the next check — so the user sees "updated to vX" once and - // we don't over-print. - import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {}); - - import("./utils/updateCheck.js").then(async (mod) => { + import("./utils/updateCheck.js").then((mod) => { _printUpdateNotice = mod.printUpdateNotice; - const result = await mod.checkForUpdate().catch(() => null); - if (result?.updateAvailable) { - const auto = await import("./utils/autoUpdate.js").catch(() => null); - auto?.scheduleBackgroundInstall(result.latest, result.current); - } + mod.checkForUpdate().catch(() => {}); }); } diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 0000000000..132cc8edbe --- /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 fa77935fd3..7c89ceb13d 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/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts new file mode 100644 index 0000000000..a34cbaf3f8 --- /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 0000000000..8f7de17138 --- /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), + }; +}