Skip to content
Open
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
24 changes: 14 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,33 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_

The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule.

### `beats`

Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline:

```bash
npx hyperframes beats [dir]
npx hyperframes beats [dir] --json # machine-readable JSON output
```

The command finds the music track (an `<audio>` element with `data-timeline-role="music"`, or an id like `music`/`bgm`/`soundtrack`), runs the **same** detection the Studio uses inside a headless Chrome (identical decode + BPM analysis), and writes `beats/<audio-path>.json`:

```json
{
"version": 1,
"audio": "music.wav",
"beats": [{ "time": 2.027, "strength": 0.924 }]
}
```

Run it when authoring a composition so the beat file exists **before** the Studio is opened — the Studio loads this file as-is (it only auto-generates one when none exists). `time` is in seconds into the audio file; `strength` (0–1) is the beat's relative loudness. Beats edited in the Studio (add/move/delete) persist back to the same file.

| Flag | Description |
|------|-------------|
| `--json` | Output `{ ok, file, count, bpm }` as JSON |

Requires a local Chrome (the same one used by `render`; run `npx hyperframes browser ensure` if missing). Detection runs the **same** algorithm the Studio uses; results are near-identical (a different headless-Chrome audio sample rate can shift beat times by a frame or two).

### `inspect`

Inspect rendered visual layout across the composition timeline:
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
"scripts": {
"test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:copy",
"build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:beat-analyzer && bun run build:copy",
"build:fonts": "node scripts/build-fonts.mjs",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:beat-analyzer": "node scripts/build-beat-analyzer.mjs",
"build:copy": "node scripts/build-copy.mjs",
"typecheck": "tsc --noEmit"
},
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/scripts/build-beat-analyzer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Prebuild the beat-detection browser bundle into dist so `hyperframes beats`
// works in the published CLI (which ships only dist, not source). Mirrors how
// the runtime IIFE is shipped. headlessAnalyzer.ts loads this at runtime and
// injects it into a headless page.
import { build } from "esbuild";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";

const require = createRequire(import.meta.url);
const coreRoot = dirname(require.resolve("@hyperframes/core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");

await build({
stdin: {
contents:
`import { analyzeMusicFromBuffer } from ${JSON.stringify(entry)};\n` +
`globalThis.__hfAnalyze = analyzeMusicFromBuffer;`,
resolveDir: coreRoot,
loader: "ts",
},
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
outfile: "dist/beat-analyzer.global.js",
});

console.log("built dist/beat-analyzer.global.js");
141 changes: 141 additions & 0 deletions packages/cli/src/beats/headlessAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Run the shared beat detection (@hyperframes/core/beats) in a headless Chrome
// so results match the Studio exactly — same Web Audio decode + same
// bpm-detective. Used by the `beats` CLI command to write the beat file before
// the Studio is ever opened.

import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Browser, Page } from "puppeteer-core";

const require = createRequire(import.meta.url);

// The detection is browser code. We need it as an IIFE that exposes
// analyzeMusicFromBuffer on the page. Prefer the artifact prebuilt at CLI build
// time (shipped in dist); fall back to bundling from core source at runtime
// (dev/monorepo, where core's src is on disk).
let bundlePromise: Promise<string> | null = null;

function findPrebuiltBundle(): string | null {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "beat-analyzer.global.js"), // dist root (tsup-bundled cli)
join(here, "../beat-analyzer.global.js"), // dist/beats → dist
join(here, "../dist/beat-analyzer.global.js"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}

async function buildFromCoreSource(): Promise<string> {
const esbuild = await import("esbuild");
const coreRoot = dirname(require.resolve("@hyperframes/core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");
const result = await esbuild.build({
stdin: {
contents:
`import { analyzeMusicFromBuffer } from ${JSON.stringify(entry)};\n` +
`globalThis.__hfAnalyze = analyzeMusicFromBuffer;`,
resolveDir: coreRoot,
loader: "ts",
},
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
write: false,
});
const out = result.outputFiles?.[0];
if (!out) throw new Error("Failed to bundle beat analyzer");
return out.text;
}

function buildAnalyzerBundle(): Promise<string> {
if (bundlePromise) return bundlePromise;
bundlePromise = (async () => {
const prebuilt = findPrebuiltBundle();
if (prebuilt) return readFileSync(prebuilt, "utf8");
return buildFromCoreSource();
})().catch((err) => {
bundlePromise = null; // don't poison the process with a cached rejection
throw err;
});
return bundlePromise;
}

export interface HeadlessBeatResult {
beatTimes: number[];
beatStrengths: number[];
bpm: number | null;
bpmConfidence: string;
}

// Guard against pathological inputs that would blow CDP message limits when
// transferred to the page as base64 (≈ +33% over the raw bytes).
const MAX_AUDIO_BYTES = 80 * 1024 * 1024;

// Runs inside the headless page: decode the base64 audio and analyze it.
function inPageAnalyze(data: string) {
const bin = atob(data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const win = window as unknown as {
AudioContext: typeof AudioContext;
webkitAudioContext?: typeof AudioContext;
__hfAnalyze?: (buffer: AudioBuffer) => Promise<HeadlessBeatResult>;
};
if (typeof win.__hfAnalyze !== "function") throw new Error("beat analyzer not loaded");
const ctx = new (win.AudioContext || win.webkitAudioContext!)();
return ctx
.decodeAudioData(bytes.buffer)
.then((buf) => win.__hfAnalyze!(buf))
.finally(() => ctx.close());
}

// Load the analyzer bundle into the page, run analysis, and surface in-page
// errors (decode/codec failures, missing global) instead of an opaque rejection.
async function detectOnPage(page: Page, bundle: string, b64: string): Promise<HeadlessBeatResult> {
const pageErrors: string[] = [];
page.on("pageerror", (e) => {
pageErrors.push((e as Error).message);
});
page.on("console", (m) => {
if (m.type() === "error") pageErrors.push(m.text());
});
await page.setContent("<!doctype html><html><body></body></html>");
await page.addScriptTag({ content: bundle });
try {
return (await page.evaluate(inPageAnalyze, b64)) as HeadlessBeatResult;
} catch (err) {
const detail = pageErrors.length ? ` (${pageErrors.join("; ")})` : "";
throw new Error(`${err instanceof Error ? err.message : String(err)}${detail}`);
}
}

/** Decode + analyze the given audio bytes in headless Chrome. */
export async function analyzeBeatsHeadless(audioBytes: Buffer): Promise<HeadlessBeatResult> {
if (audioBytes.length > MAX_AUDIO_BYTES) {
const mb = Math.round(audioBytes.length / 1e6);
throw new Error(
`Audio file too large for headless analysis (${mb}MB > ${MAX_AUDIO_BYTES / 1e6}MB).`,
);
}
const bundle = await buildAnalyzerBundle();
const { ensureBrowser } = await import("../browser/manager.js");
const puppeteer = await import("puppeteer-core");
const browser = await ensureBrowser();
const chrome: Browser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--autoplay-policy=no-user-gesture-required"],
});
try {
const page = await chrome.newPage();
return await detectOnPage(page, bundle, audioBytes.toString("base64"));
} finally {
await chrome.close();
}
}
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const subCommands = {
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),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
Expand Down
Loading
Loading