diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..c6c8b3621
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b3346785d..eb546bcac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,23 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
+ lint-and-format:
+ name: Lint & Format
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm lint
+ - run: pnpm format:check
+
typecheck:
name: Typecheck
runs-on: ubuntu-latest
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 000000000..3b62c5bee
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,8 @@
+{
+ "singleQuote": false,
+ "semi": true,
+ "useTabs": false,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 000000000..0fc2d5f8c
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
+ "categories": {
+ "correctness": "error"
+ },
+ "plugins": ["react", "typescript"],
+ "ignorePatterns": ["dist/", "coverage/", "node_modules/"]
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 81a300afc..a314bd29a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,6 +16,8 @@ pnpm install # Install all dependencies
pnpm dev # Run the studio (composition editor)
pnpm build # Build all packages
pnpm -r typecheck # Type-check all packages
+pnpm lint # Lint all packages
+pnpm format:check # Check formatting
```
### Running Tests
@@ -26,21 +28,32 @@ pnpm --filter @hyperframes/engine test # Engine unit tests (vitest)
pnpm --filter @hyperframes/core test:hyperframe-runtime-ci # Runtime contract tests
```
+### Linting & Formatting
+
+```bash
+pnpm lint # Run oxlint
+pnpm lint:fix # Run oxlint with auto-fix
+pnpm format # Format all files with oxfmt
+pnpm format:check # Check formatting without writing
+```
+
+Git hooks (via [lefthook](https://github.com/evilmartians/lefthook)) run automatically after `pnpm install` and enforce linting + formatting on staged files before each commit.
+
## Pull Requests
-- Use [conventional commit](https://www.conventionalcommits.org/) format for PR titles (e.g., `feat: add timeline export`, `fix: resolve seek overflow`)
+- Use [conventional commit](https://www.conventionalcommits.org/) format for **all commits** (e.g., `feat: add timeline export`, `fix: resolve seek overflow`). Enforced by a git hook.
- CI must pass before merge (build, typecheck, tests, semantic PR title)
- PRs require at least 1 approval
## Packages
-| Package | Description |
-|---|---|
-| `@hyperframes/core` | Types, HTML generation, runtime, linter |
-| `@hyperframes/engine` | Seekable page-to-video capture engine |
-| `@hyperframes/producer` | Full rendering pipeline (capture + encode) |
-| `@hyperframes/studio` | Composition editor UI |
-| `hyperframes` | CLI for creating, previewing, and rendering |
+| Package | Description |
+| ----------------------- | ------------------------------------------- |
+| `@hyperframes/core` | Types, HTML generation, runtime, linter |
+| `@hyperframes/engine` | Seekable page-to-video capture engine |
+| `@hyperframes/producer` | Full rendering pipeline (capture + encode) |
+| `@hyperframes/studio` | Composition editor UI |
+| `hyperframes` | CLI for creating, previewing, and rendering |
## Releasing (Maintainers)
diff --git a/README.md b/README.md
index 8e49c3630..80f3e52a5 100644
--- a/README.md
+++ b/README.md
@@ -25,14 +25,25 @@ npx hyperframes render # render to MP4
Define your video as HTML with data attributes:
```html
-
-
-
-
+
+
+
+
```
diff --git a/SECURITY.md b/SECURITY.md
index 5a3d44678..6b9b8fba7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -18,7 +18,7 @@ We will acknowledge receipt within 48 hours and aim to provide a fix or mitigati
## Supported Versions
| Version | Supported |
-|---------|-----------|
+| ------- | --------- |
| 0.x | Yes |
## Scope
diff --git a/commitlint.config.js b/commitlint.config.js
new file mode 100644
index 000000000..b29b5ae80
--- /dev/null
+++ b/commitlint.config.js
@@ -0,0 +1,3 @@
+export default {
+ extends: ["@commitlint/config-conventional"],
+};
diff --git a/knip.config.ts b/knip.config.ts
new file mode 100644
index 000000000..31dda6cbe
--- /dev/null
+++ b/knip.config.ts
@@ -0,0 +1,23 @@
+import type { KnipConfig } from "knip";
+
+const config: KnipConfig = {
+ workspaces: {
+ "packages/cli": {
+ entry: ["src/cli.ts"],
+ },
+ "packages/core": {
+ entry: ["src/index.ts", "src/lint/index.ts", "src/compiler/index.ts"],
+ },
+ "packages/engine": {
+ entry: ["src/index.ts"],
+ },
+ "packages/producer": {
+ entry: ["src/index.ts", "src/server.ts"],
+ },
+ "packages/studio": {
+ entry: ["src/index.ts", "src/styles/tailwind-preset.ts"],
+ },
+ },
+};
+
+export default config;
diff --git a/lefthook.yml b/lefthook.yml
new file mode 100644
index 000000000..807f83be7
--- /dev/null
+++ b/lefthook.yml
@@ -0,0 +1,14 @@
+pre-commit:
+ parallel: true
+ commands:
+ lint:
+ glob: "*.{js,jsx,ts,tsx}"
+ run: npx oxlint {staged_files}
+ format:
+ glob: "*.{js,jsx,ts,tsx,json,css,md,yaml,yml}"
+ run: npx oxfmt --check {staged_files}
+
+commit-msg:
+ commands:
+ commitlint:
+ run: npx commitlint --edit "{1}"
diff --git a/package.json b/package.json
index 451e76418..d0b59a70e 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,29 @@
"studio": "pnpm --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "pnpm --filter @hyperframes/core build:hyperframes-runtime",
"build:hyperframes-runtime:modular": "pnpm --filter @hyperframes/core build:hyperframes-runtime:modular",
- "set-version": "tsx scripts/set-version.ts"
+ "set-version": "tsx scripts/set-version.ts",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt .",
+ "format:check": "oxfmt --check .",
+ "knip": "knip",
+ "prepare": "lefthook install"
},
"devDependencies": {
+ "@commitlint/cli": "^20.5.0",
+ "@commitlint/config-conventional": "^20.5.0",
"@types/node": "^25.0.10",
"concurrently": "^8.2.0",
+ "knip": "^6.0.3",
+ "lefthook": "^2.1.4",
+ "oxfmt": "^0.41.0",
+ "oxlint": "^1.56.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "lefthook"
+ ]
}
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3a3a84309..95be34c34 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -2,13 +2,13 @@
"name": "hyperframes",
"version": "0.1.1",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
- "type": "module",
"bin": {
"hyperframes": "./dist/cli.js"
},
"files": [
"dist"
],
+ "type": "module",
"scripts": {
"dev": "tsx src/cli.ts",
"build": "pnpm build:studio && tsup && pnpm build:runtime && pnpm build:copy",
@@ -30,9 +30,9 @@
"puppeteer-core": "^24.39.1"
},
"devDependencies": {
- "@hyperframes/core": "workspace:*",
"@clack/prompts": "^1.1.0",
"@hono/node-server": "^1.0.0",
+ "@hyperframes/core": "workspace:*",
"@hyperframes/engine": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@types/adm-zip": "^0.5.7",
diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts
index 9a0c7bee4..4ca5a4ab4 100644
--- a/packages/cli/src/browser/manager.ts
+++ b/packages/cli/src/browser/manager.ts
@@ -2,12 +2,7 @@ import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
-import {
- Browser,
- detectBrowserPlatform,
- getInstalledBrowsers,
- install,
-} from "@puppeteer/browsers";
+import { Browser, detectBrowserPlatform, getInstalledBrowsers, install } from "@puppeteer/browsers";
const CHROME_VERSION = "131.0.6778.85";
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
@@ -18,11 +13,7 @@ export function setBrowserPath(path: string): void {
_browserPathOverride = path;
}
-export type BrowserSource =
- | "env"
- | "cache"
- | "system"
- | "download";
+export type BrowserSource = "env" | "cache" | "system" | "download";
export interface BrowserResult {
executablePath: string;
@@ -76,9 +67,7 @@ async function findFromCache(): Promise
{
}
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
- const match = installed.find(
- (b) => b.browser === Browser.CHROMEHEADLESSSHELL,
- );
+ const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match) {
return { executablePath: match.executablePath, source: "cache" };
}
@@ -93,8 +82,7 @@ function findFromSystem(): BrowserResult | undefined {
}
}
- const fromWhich =
- whichBinary("google-chrome") ?? whichBinary("chromium");
+ const fromWhich = whichBinary("google-chrome") ?? whichBinary("chromium");
if (fromWhich) {
return { executablePath: fromWhich, source: "system" };
}
@@ -122,17 +110,13 @@ export async function findBrowser(): Promise {
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
*/
-export async function ensureBrowser(
- options?: EnsureBrowserOptions,
-): Promise {
+export async function ensureBrowser(options?: EnsureBrowserOptions): Promise {
const existing = await findBrowser();
if (existing) return existing;
const platform = detectBrowserPlatform();
if (!platform) {
- throw new Error(
- `Unsupported platform: ${process.platform} ${process.arch}`,
- );
+ throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
const installed = await install({
diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts
index e2052614a..beb198fc2 100644
--- a/packages/cli/src/commands/benchmark.ts
+++ b/packages/cli/src/commands/benchmark.ts
@@ -36,7 +36,10 @@ const DEFAULT_CONFIGS: BenchmarkConfig[] = [
];
export default defineCommand({
- meta: { name: "benchmark", description: "Run multiple render configurations and compare results" },
+ meta: {
+ name: "benchmark",
+ description: "Run multiple render configurations and compare results",
+ },
args: {
dir: { type: "positional", description: "Project directory", required: false },
runs: { type: "string", description: "Number of runs per config", default: "3" },
@@ -64,7 +67,9 @@ export default defineCommand({
producer = await loadProducer();
} catch {
if (jsonOutput) {
- console.log(JSON.stringify({ error: "Producer module not available. Is the project built?" }));
+ console.log(
+ JSON.stringify({ error: "Producer module not available. Is the project built?" }),
+ );
} else {
errorBox(
"Producer module not available",
@@ -99,7 +104,10 @@ export default defineCommand({
for (let i = 0; i < runsPerConfig; i++) {
s?.message(`${config.label} — run ${i + 1}/${runsPerConfig}`);
- const outputPath = join(benchDir, `${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`);
+ const outputPath = join(
+ benchDir,
+ `${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`,
+ );
try {
const startTime = Date.now();
@@ -176,12 +184,9 @@ export default defineCommand({
console.log(separator);
for (const result of results) {
- const timeStr =
- result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed");
- const sizeStr =
- result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a");
- const failStr =
- result.failures > 0 ? c.warn(` (${result.failures} failed)`) : "";
+ const timeStr = result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed");
+ const sizeStr = result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a");
+ const failStr = result.failures > 0 ? c.warn(` (${result.failures} failed)`) : "";
console.log(
" " +
@@ -215,8 +220,7 @@ export default defineCommand({
} else {
console.log("");
console.log(
- c.error("\u2717") +
- " All configurations failed. Ensure the rendering pipeline is set up.",
+ c.error("\u2717") + " All configurations failed. Ensure the rendering pipeline is set up.",
);
}
diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts
index 24d075793..7eaade68a 100644
--- a/packages/cli/src/commands/browser.ts
+++ b/packages/cli/src/commands/browser.ts
@@ -30,9 +30,7 @@ async function runEnsure(): Promise {
s.stop("No browser found — downloading");
const downloadSpinner = clack.spinner();
- downloadSpinner.start(
- `Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`,
- );
+ downloadSpinner.start(`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`);
let lastPct = -1;
const result = await ensureBrowser({
@@ -66,9 +64,7 @@ async function runPath(): Promise {
const ensured = await ensureBrowser();
process.stdout.write(ensured.executablePath + "\n");
} catch (err: unknown) {
- console.error(
- err instanceof Error ? err.message : "Failed to find browser",
- );
+ console.error(err instanceof Error ? err.message : "Failed to find browser");
process.exit(1);
}
return;
@@ -81,9 +77,7 @@ function runClear(): void {
const removed = clearBrowser();
if (removed) {
- clack.outro(
- c.success("Removed cached browser from ") + c.dim(CACHE_DIR),
- );
+ clack.outro(c.success("Removed cached browser from ") + c.dim(CACHE_DIR));
} else {
clack.outro(c.dim("No cached browser to remove."));
}
@@ -92,7 +86,11 @@ function runClear(): void {
export default defineCommand({
meta: { name: "browser", description: "Manage the Chrome browser used for rendering" },
args: {
- subcommand: { type: "positional", description: "Subcommand: ensure, path, clear", required: false },
+ subcommand: {
+ type: "positional",
+ description: "Subcommand: ensure, path, clear",
+ required: false,
+ },
},
async run({ args }) {
const subcommand = args.subcommand;
diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts
index 499cc7133..a5f32e5fe 100644
--- a/packages/cli/src/commands/dev.ts
+++ b/packages/cli/src/commands/dev.ts
@@ -1,13 +1,6 @@
import { defineCommand } from "citty";
import { spawn } from "node:child_process";
-import {
- existsSync,
- lstatSync,
- symlinkSync,
- unlinkSync,
- readlinkSync,
- mkdirSync,
-} from "node:fs";
+import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as clack from "@clack/prompts";
@@ -181,6 +174,10 @@ async function runDevMode(dir: string): Promise {
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
*/
async function runEmbeddedMode(_dir: string, _port: number): Promise {
- console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev "));
+ console.error(
+ c.error(
+ "Embedded mode not yet available. Run from the monorepo root with: hyperframes dev ",
+ ),
+ );
process.exit(1);
}
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index 45ec046a3..8a7da94ce 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -1,6 +1,5 @@
import { defineCommand } from "citty";
import { execSync } from "node:child_process";
-import { existsSync } from "node:fs";
import { c } from "../ui/colors.js";
import { findBrowser } from "../browser/manager.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
@@ -20,8 +19,8 @@ function checkFFmpeg(): CheckResult {
const path = findFFmpeg();
if (path) {
try {
- const version = execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 })
- .split("\n")[0] ?? "";
+ const version =
+ execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
return { ok: true, detail: version.trim() };
} catch {
return { ok: true, detail: path };
@@ -89,7 +88,6 @@ function checkNode(): CheckResult {
return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` };
}
-
export default defineCommand({
meta: { name: "doctor", description: "Check system dependencies and environment" },
args: {},
@@ -113,7 +111,9 @@ export default defineCommand({
const result = await check.run();
const icon = result.ok ? c.success("\u2713") : c.error("\u2717");
const name = check.name.padEnd(16);
- console.log(` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`);
+ console.log(
+ ` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`,
+ );
if (!result.ok && result.hint) {
console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`);
}
diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts
index 61d561b53..6ccdb48d2 100644
--- a/packages/cli/src/commands/info.ts
+++ b/packages/cli/src/commands/info.ts
@@ -38,8 +38,7 @@ export default defineCommand({
(max, el) => Math.max(max, el.startTime + el.duration),
0,
);
- const resolution =
- parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
+ const resolution = parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
const size = totalSize(project.dir);
const typeCounts: Record = {};
@@ -51,17 +50,23 @@ export default defineCommand({
.join(", ");
if (args.json) {
- console.log(JSON.stringify({
- name: project.name,
- resolution: parsed.resolution,
- width: parsed.resolution === "portrait" ? 1080 : 1920,
- height: parsed.resolution === "portrait" ? 1920 : 1080,
- duration: maxEnd,
- elements: parsed.elements.length,
- tracks: tracks.size,
- types: typeCounts,
- size,
- }, null, 2));
+ console.log(
+ JSON.stringify(
+ {
+ name: project.name,
+ resolution: parsed.resolution,
+ width: parsed.resolution === "portrait" ? 1080 : 1920,
+ height: parsed.resolution === "portrait" ? 1920 : 1080,
+ duration: maxEnd,
+ elements: parsed.elements.length,
+ tracks: tracks.size,
+ types: typeCounts,
+ size,
+ },
+ null,
+ 2,
+ ),
+ );
return;
}
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 1537f4b2d..e28d6604b 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -13,10 +13,7 @@ import { fileURLToPath } from "node:url";
import { execSync, execFileSync, spawn } from "node:child_process";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
-import {
- TEMPLATES,
- type TemplateId,
-} from "../templates/generators.js";
+import { TEMPLATES, type TemplateId } from "../templates/generators.js";
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
@@ -53,7 +50,14 @@ function probeVideo(filePath: string): VideoMeta | undefined {
);
const parsed: {
- streams?: { codec_type?: string; codec_name?: string; width?: number; height?: number; r_frame_rate?: string; avg_frame_rate?: string }[];
+ streams?: {
+ codec_type?: string;
+ codec_name?: string;
+ width?: number;
+ height?: number;
+ r_frame_rate?: string;
+ avg_frame_rate?: string;
+ }[];
format?: { duration?: string };
} = JSON.parse(raw);
@@ -75,8 +79,7 @@ function probeVideo(filePath: string): VideoMeta | undefined {
}
const durationStr = parsed.format?.duration;
- const durationSeconds =
- durationStr !== undefined ? parseFloat(durationStr) : 5;
+ const durationSeconds = durationStr !== undefined ? parseFloat(durationStr) : 5;
return {
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
@@ -106,12 +109,26 @@ function hasFFmpeg(): boolean {
function transcodeToMp4(inputPath: string, outputPath: string): Promise {
return new Promise((resolvePromise) => {
- const child = spawn("ffmpeg", [
- "-i", inputPath,
- "-c:v", "libx264", "-preset", "fast", "-crf", "18",
- "-c:a", "aac", "-b:a", "192k",
- "-y", outputPath,
- ], { stdio: "pipe" });
+ const child = spawn(
+ "ffmpeg",
+ [
+ "-i",
+ inputPath,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "fast",
+ "-crf",
+ "18",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-y",
+ outputPath,
+ ],
+ { stdio: "pipe" },
+ );
child.on("close", (code) => resolvePromise(code === 0));
child.on("error", () => resolvePromise(false));
@@ -133,8 +150,8 @@ function getStaticTemplateDir(templateId: string): string {
function patchVideoSrc(dir: string, videoFilename: string | undefined): void {
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
- .filter(e => e.isFile() && e.name.endsWith(".html"))
- .map(e => join(e.parentPath ?? e.path, e.name));
+ .filter((e) => e.isFile() && e.name.endsWith(".html"))
+ .map((e) => join(e.parentPath ?? e.path, e.name));
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
@@ -319,7 +336,11 @@ export default defineCommand({
meta: { name: "init", description: "Scaffold a new composition project" },
args: {
name: { type: "positional", description: "Project name", required: false },
- template: { type: "string", description: `Template: ${ALL_TEMPLATE_IDS.join(", ")}`, alias: "t" },
+ template: {
+ type: "string",
+ description: `Template: ${ALL_TEMPLATE_IDS.join(", ")}`,
+ alias: "t",
+ },
video: { type: "string", description: "Path to a source video file", alias: "V" },
},
async run({ args }) {
@@ -340,9 +361,7 @@ export default defineCommand({
const destDir = resolve(name);
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
- console.error(
- c.error(`Directory already exists and is not empty: ${name}`),
- );
+ console.error(c.error(`Directory already exists and is not empty: ${name}`));
process.exit(1);
}
@@ -482,10 +501,7 @@ export default defineCommand({
scaffoldProject(destDir, name, templateId, localVideoName);
const files = readdirSync(destDir);
- clack.note(
- files.map((f) => c.accent(f)).join("\n"),
- c.success(`Created ${name}/`),
- );
+ clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
await nextStepLoop(destDir);
},
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index daaa3e222..59f5a1110 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -38,7 +38,9 @@ export default defineCommand({
}
const summaryIcon = result.errorCount > 0 ? c.error("◇") : c.success("◇");
- console.log(`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`);
+ console.log(
+ `\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`,
+ );
process.exit(result.errorCount > 0 ? 1 : 0);
},
});
diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts
index a07b82f80..46aa3364c 100644
--- a/packages/cli/src/commands/render.ts
+++ b/packages/cli/src/commands/render.ts
@@ -55,9 +55,7 @@ export default defineCommand({
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
- const outputPath = args.output
- ? resolve(args.output)
- : join(rendersDir, `${project.name}.mp4`);
+ const outputPath = args.output ? resolve(args.output) : join(rendersDir, `${project.name}.mp4`);
// Ensure output directory exists
const outputDir = dirname(outputPath);
@@ -73,8 +71,15 @@ export default defineCommand({
const workerCount = workers ?? 4;
if (!quiet) {
console.log("");
- console.log(c.accent("\u25C6") + " Rendering " + c.accent(project.name) + c.dim(" \u2192 " + outputPath));
- console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"));
+ console.log(
+ c.accent("\u25C6") +
+ " Rendering " +
+ c.accent(project.name) +
+ c.dim(" \u2192 " + outputPath),
+ );
+ console.log(
+ c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"),
+ );
console.log("");
}
@@ -102,7 +107,9 @@ export default defineCommand({
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
- s.message(`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`);
+ s.message(
+ `Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
+ );
},
});
s.stop(c.dim(`Browser: ${info.source}`));
diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md
index 98a103f53..d9c809a33 100644
--- a/packages/cli/src/docs/compositions.md
+++ b/packages/cli/src/docs/compositions.md
@@ -3,7 +3,9 @@
A composition is an HTML document that defines a video timeline.
## Structure
+
Every composition needs a root element with `data-composition-id`:
+
```html
@@ -11,16 +13,21 @@ Every composition needs a root element with `data-composition-id`:
```
## Nested Compositions
+
Embed one composition inside another:
+
```html
```
## Listing Compositions
+
Use `npx hyperframes compositions` to see all compositions in a project.
## Variables
+
Compositions can expose variables for dynamic content:
+
```html
-
+
```
diff --git a/packages/cli/src/docs/data-attributes.md b/packages/cli/src/docs/data-attributes.md
index 9eaf5d8ce..c682060ef 100644
--- a/packages/cli/src/docs/data-attributes.md
+++ b/packages/cli/src/docs/data-attributes.md
@@ -3,20 +3,24 @@
Core attributes for controlling element timing and behavior.
## Timing
+
- `data-start="0"` — Start time in seconds
- `data-duration="5"` — Duration in seconds
- `data-track-index="0"` — Timeline track number (controls z-ordering)
## Media
+
- `data-media-start="2"` — Media playback offset / trim point (seconds)
- `data-volume="0.8"` — Audio/video volume, 0 to 1
- `data-has-audio="true"` — Indicates video has an audio track
## Composition
+
- `data-composition-id="root"` — Unique ID for composition wrapper (required)
- `data-width="1920"` — Composition width in pixels
- `data-height="1080"` — Composition height in pixels
- `data-composition-src="./intro.html"` — Nested composition source
## Element Visibility
+
Add `class="clip"` to timed elements so the runtime can manage their visibility lifecycle.
diff --git a/packages/cli/src/docs/gsap.md b/packages/cli/src/docs/gsap.md
index 4058cb4b5..37dfbe431 100644
--- a/packages/cli/src/docs/gsap.md
+++ b/packages/cli/src/docs/gsap.md
@@ -3,6 +3,7 @@
HyperFrames uses GSAP for animation. Timelines are paused and controlled by the runtime.
## Setup
+
```html
-
+ window.__timelines["intro"] = tl;
+ })();
+
+
diff --git a/packages/cli/src/templates/play-mode/compositions/stats.html b/packages/cli/src/templates/play-mode/compositions/stats.html
index d96b6acd3..2496c74ef 100644
--- a/packages/cli/src/templates/play-mode/compositions/stats.html
+++ b/packages/cli/src/templates/play-mode/compositions/stats.html
@@ -2,7 +2,7 @@
-
+
-
+
-
@@ -49,7 +79,7 @@
width: 1920px;
height: 1080px;
position: relative;
- font-family: 'Nunito', sans-serif;
+ font-family: "Nunito", sans-serif;
overflow: hidden;
}
@@ -120,19 +150,41 @@
}
/* Colors */
- [data-composition-id="stats"] .blue-bg { background-color: #0057FF; }
- [data-composition-id="stats"] .pink-bg { background-color: #FF2D8A; }
- [data-composition-id="stats"] .lime-bg { background-color: #7FFF00; }
- [data-composition-id="stats"] .yellow-bg { background-color: #FFE500; }
- [data-composition-id="stats"] .white-bg { background-color: #FFFFFF; }
+ [data-composition-id="stats"] .blue-bg {
+ background-color: #0057ff;
+ }
+ [data-composition-id="stats"] .pink-bg {
+ background-color: #ff2d8a;
+ }
+ [data-composition-id="stats"] .lime-bg {
+ background-color: #7fff00;
+ }
+ [data-composition-id="stats"] .yellow-bg {
+ background-color: #ffe500;
+ }
+ [data-composition-id="stats"] .white-bg {
+ background-color: #ffffff;
+ }
- [data-composition-id="stats"] .blue-text { color: #0057FF; }
- [data-composition-id="stats"] .white-text { color: #FFFFFF; }
- [data-composition-id="stats"] .pink-text { color: #FF2D8A; }
+ [data-composition-id="stats"] .blue-text {
+ color: #0057ff;
+ }
+ [data-composition-id="stats"] .white-text {
+ color: #ffffff;
+ }
+ [data-composition-id="stats"] .pink-text {
+ color: #ff2d8a;
+ }
- [data-composition-id="stats"] .blue-border { border-color: #0057FF; }
- [data-composition-id="stats"] .white-border { border-color: #FFFFFF; }
- [data-composition-id="stats"] .pink-border { border-color: #FF2D8A; }
+ [data-composition-id="stats"] .blue-border {
+ border-color: #0057ff;
+ }
+ [data-composition-id="stats"] .white-border {
+ border-color: #ffffff;
+ }
+ [data-composition-id="stats"] .pink-border {
+ border-color: #ff2d8a;
+ }
/* Typography */
[data-composition-id="stats"] .stat-number {
@@ -161,14 +213,64 @@
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+ background-color: #ffffff;
+ }
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-