initial code (#2)

* feat: initial code port from hyperframes-internal

Port all OSS-ready packages from the internal monorepo:
- @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime
- @hyperframes/cli — CLI for creating, previewing, and rendering compositions
- @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg)
- @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg)
- @hyperframes/ui-player — browser-based video player component
- @hyperframes/studio — composition editor (React frontend + Hono backend)

Includes regression test suite with Docker-based test harness.

All HeyGen-internal references, deployment infrastructure, and
proprietary assets have been removed. Package names migrated
from @app/* to @hyperframes/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: scrub internal codenames and stale references from OSS port

- Replace static.heygen.ai runtime URLs in test fixtures
- Remove internal CDN publish script (publish-hyperframe-runtime.ts)
- Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime
  with neutral names (studio, hyperframe-runtime, __hyperframeRuntime)
- Fix stale Vault API / localhost references in docs
- Remove broken deprecated_studio link

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: remove remaining internal codenames and stale references

- Delete stale producer README.md and PIPELINE.md (referenced nonexistent files)
- Replace "Cerberus" codename with "HyperFrames" in test design reviews
- Replace magic-edit postMessage identifiers with hf-preview/hf-parent
- Rename debug-magic-edit-timeline.ts to debug-timeline.ts
- Replace "Motion Cut" with "HyperFrames" in Timeline comments
- Fix studio/CLI references to nonexistent archive package
  (use local data/projects/ dir, stub render proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Vance Ingalls
2026-03-21 22:43:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 36972386f6
commit 69ad220e9f
401 changed files with 54545 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
# No environment variables required for basic usage.
# Run `pnpm dev` to start the studio, `npx hyperframes render` to render video.
# Optional integrations:
# ANTHROPIC_API_KEY= # For AI-assisted composition via MCP
+1
View File
@@ -1 +1,2 @@
# Golden baseline videos for regression tests
packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
+1 -1
View File
@@ -30,7 +30,7 @@ Examples of unacceptable behavior:
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainers at **oss@heygen.com**. All complaints will
reported to the project maintainers via [GitHub Issues](https://github.com/heygen-com/hyperframes/issues). All complaints will
be reviewed and investigated promptly and fairly.
## Attribution
+1 -1
View File
@@ -6,7 +6,7 @@ If you discover a security vulnerability in Hyperframes, please report it respon
**Do not open a public GitHub issue for security vulnerabilities.**
Instead, email **security@heygen.com** with:
Instead, open a [GitHub Security Advisory](https://github.com/heygen-com/hyperframes/security/advisories/new) with:
- A description of the vulnerability
- Steps to reproduce
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hyperframes",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm studio",
"build": "pnpm -r build",
"build:producer": "pnpm --filter @hyperframes/producer build",
"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"
},
"devDependencies": {
"@types/node": "^25.0.10",
"concurrently": "^8.2.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "hyperframes",
"version": "0.1.0",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"type": "module",
"bin": {
"hyperframes": "./dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"dev": "tsx src/cli.ts",
"build": "pnpm build:studio && tsup && pnpm build:runtime && pnpm build:copy",
"build:studio": "cd ../studio && pnpm build",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/warm-grain src/templates/play-mode src/templates/swiss-grid src/templates/vignelli dist/templates/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.8.0",
"@puppeteer/browsers": "^2.13.0",
"adm-zip": "^0.5.16",
"cheerio": "^1.2.0",
"citty": "^0.2.1",
"esbuild": "^0.25.0",
"hono": "^4.0.0",
"mime-types": "^3.0.2",
"open": "^10.0.0",
"puppeteer-core": "^24.39.1"
},
"devDependencies": {
"@hyperframes/core": "workspace:*",
"@clack/prompts": "^1.1.0",
"@hono/node-server": "^1.0.0",
"@hyperframes/engine": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/mime-types": "^3.0.1",
"@types/node": "^22.0.0",
"adm-zip": "^0.5.16",
"cheerio": "^1.2.0",
"hono": "^4.0.0",
"linkedom": "^0.18.12",
"mime-types": "^3.0.2",
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=22"
}
}
+4
View File
@@ -0,0 +1,4 @@
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { writeFileSync } from "node:fs";
writeFileSync("dist/hyperframe-runtime.js", loadHyperframeRuntimeSource());
+25
View File
@@ -0,0 +1,25 @@
import { execSync } from "node:child_process";
export function findFFmpeg(): string | undefined {
try {
const result = execSync("which ffmpeg", {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}).trim();
return result || undefined;
} catch {
return undefined;
}
}
export function getFFmpegInstallHint(): string {
switch (process.platform) {
case "darwin":
return "brew install ffmpeg";
case "linux":
return "sudo apt install ffmpeg";
default:
return "https://ffmpeg.org/download.html";
}
}
+161
View File
@@ -0,0 +1,161 @@
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";
const CHROME_VERSION = "131.0.6778.85";
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
/** Override browser path via --browser-path flag. Takes priority over env var. */
let _browserPathOverride: string | undefined;
export function setBrowserPath(path: string): void {
_browserPathOverride = path;
}
export type BrowserSource =
| "env"
| "cache"
| "system"
| "download";
export interface BrowserResult {
executablePath: string;
source: BrowserSource;
}
export interface EnsureBrowserOptions {
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
}
// --- Internal helpers -------------------------------------------------------
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
process.platform === "darwin"
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
function whichBinary(name: string): string | undefined {
try {
const result = execSync(`which ${name}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}).trim();
return result || undefined;
} catch {
return undefined;
}
}
function findFromEnv(): BrowserResult | undefined {
// --browser-path flag takes priority
if (_browserPathOverride && existsSync(_browserPathOverride)) {
return { executablePath: _browserPathOverride, source: "env" };
}
const envPath = process.env["HYPERFRAMES_BROWSER_PATH"];
if (envPath && existsSync(envPath)) {
return { executablePath: envPath, source: "env" };
}
return undefined;
}
async function findFromCache(): Promise<BrowserResult | undefined> {
if (!existsSync(CACHE_DIR)) {
return undefined;
}
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
const match = installed.find(
(b) => b.browser === Browser.CHROMEHEADLESSSHELL,
);
if (match) {
return { executablePath: match.executablePath, source: "cache" };
}
return undefined;
}
function findFromSystem(): BrowserResult | undefined {
for (const p of SYSTEM_CHROME_PATHS) {
if (existsSync(p)) {
return { executablePath: p, source: "system" };
}
}
const fromWhich =
whichBinary("google-chrome") ?? whichBinary("chromium");
if (fromWhich) {
return { executablePath: fromWhich, source: "system" };
}
return undefined;
}
// --- Public API -------------------------------------------------------------
/**
* Find an existing browser without downloading.
* Resolution: env var -> cached download -> system Chrome.
*/
export async function findBrowser(): Promise<BrowserResult | undefined> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
const fromCache = await findFromCache();
if (fromCache) return fromCache;
return findFromSystem();
}
/**
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
*/
export async function ensureBrowser(
options?: EnsureBrowserOptions,
): Promise<BrowserResult> {
const existing = await findBrowser();
if (existing) return existing;
const platform = detectBrowserPlatform();
if (!platform) {
throw new Error(
`Unsupported platform: ${process.platform} ${process.arch}`,
);
}
const installed = await install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,
buildId: CHROME_VERSION,
platform,
downloadProgressCallback: options?.onProgress,
});
return { executablePath: installed.executablePath, source: "download" };
}
/**
* Remove the cached Chrome download directory.
* Returns true if anything was removed.
*/
export function clearBrowser(): boolean {
if (!existsSync(CACHE_DIR)) {
return false;
}
rmSync(CACHE_DIR, { recursive: true, force: true });
return true;
}
export { CHROME_VERSION, CACHE_DIR };
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
import { VERSION } from "./version.js";
const main = defineCommand({
meta: {
name: "hyperframes",
version: VERSION,
description: "Create and render HTML video compositions",
},
subCommands: {
init: () => import("./commands/init.js").then((m) => m.default),
dev: () => import("./commands/dev.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),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
},
});
runMain(main);
+226
View File
@@ -0,0 +1,226 @@
import { defineCommand } from "citty";
import { existsSync, statSync } from "node:fs";
import { resolve, join } from "node:path";
import { resolveProject } from "../utils/project.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import * as clack from "@clack/prompts";
interface BenchmarkConfig {
label: string;
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
workers: number;
}
interface RunResult {
elapsedMs: number;
fileSize: number | null;
}
interface ConfigResult {
config: BenchmarkConfig;
runs: RunResult[];
failures: number;
avgTime: number | null;
avgSize: number | null;
}
const DEFAULT_CONFIGS: BenchmarkConfig[] = [
{ label: "30fps \u00B7 draft \u00B7 2w", fps: 30, quality: "draft", workers: 2 },
{ label: "30fps \u00B7 standard \u00B7 2w", fps: 30, quality: "standard", workers: 2 },
{ label: "30fps \u00B7 high \u00B7 2w", fps: 30, quality: "high", workers: 2 },
{ label: "30fps \u00B7 standard \u00B7 4w", fps: 30, quality: "standard", workers: 4 },
{ label: "60fps \u00B7 standard \u00B7 4w", fps: 60, quality: "standard", workers: 4 },
];
export default defineCommand({
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" },
json: { type: "boolean", description: "Output results as JSON", default: false },
},
async run({ args }) {
// ── Resolve project ──────────────────────────────────────────────────
const project = resolveProject(args.dir);
// ── Parse runs ───────────────────────────────────────────────────────
const runsPerConfig = parseInt(args.runs ?? "3", 10);
if (isNaN(runsPerConfig) || runsPerConfig < 1 || runsPerConfig > 20) {
errorBox("Invalid runs", `Got "${args.runs ?? "3"}". Must be between 1 and 20.`);
process.exit(1);
}
const jsonOutput = args.json ?? false;
// ── Temp output for benchmark renders ────────────────────────────────
const benchDir = resolve("renders", ".benchmark");
// ── Load producer ────────────────────────────────────────────────────
let producer: Awaited<ReturnType<typeof loadProducer>> | null = null;
try {
producer = await loadProducer();
} catch {
if (jsonOutput) {
console.log(JSON.stringify({ error: "Producer module not available. Is the project built?" }));
} else {
errorBox(
"Producer module not available",
"The rendering pipeline could not be loaded.",
"Ensure @hyperframes/producer is built and linked.",
);
}
process.exit(1);
}
// ── Print header ─────────────────────────────────────────────────────
if (!jsonOutput) {
console.log("");
console.log(
c.accent("\u25C6") +
" Benchmarking " +
c.accent(project.name) +
c.dim(` (${runsPerConfig} runs each)`),
);
console.log("");
}
// ── Run benchmarks ───────────────────────────────────────────────────
const results: ConfigResult[] = [];
for (const config of DEFAULT_CONFIGS) {
const runs: RunResult[] = [];
let failures = 0;
const s = !jsonOutput ? clack.spinner() : undefined;
s?.start(`Benchmarking ${config.label}...`);
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`);
try {
const startTime = Date.now();
await producer.renderComposition(project.dir, {
output: outputPath,
fps: config.fps,
quality: config.quality,
workers: config.workers,
quiet: true,
});
const elapsedMs = Date.now() - startTime;
let fileSize: number | null = null;
if (existsSync(outputPath)) {
const stat = statSync(outputPath);
fileSize = stat.size;
}
runs.push({ elapsedMs, fileSize });
} catch {
failures++;
}
}
s?.stop(`${config.label} — ${runs.length} runs${failures > 0 ? `, ${failures} failed` : ""}`);
const successfulRuns = runs.filter((r) => r.elapsedMs > 0);
const avgTime =
successfulRuns.length > 0
? successfulRuns.reduce((sum, r) => sum + r.elapsedMs, 0) / successfulRuns.length
: null;
const sizesWithValues = runs.map((r) => r.fileSize).filter((s): s is number => s != null);
const avgSize =
sizesWithValues.length > 0
? sizesWithValues.reduce((sum, s) => sum + s, 0) / sizesWithValues.length
: null;
results.push({ config, runs, failures, avgTime, avgSize });
}
// ── Output results ───────────────────────────────────────────────────
if (jsonOutput) {
console.log(
JSON.stringify(
results.map((r) => ({
config: r.config.label,
fps: r.config.fps,
quality: r.config.quality,
workers: r.config.workers,
avgTimeMs: r.avgTime,
avgSizeBytes: r.avgSize,
failures: r.failures,
runs: r.runs,
})),
null,
2,
),
);
return;
}
// ── Table output ─────────────────────────────────────────────────────
const configColWidth = 26;
const timeColWidth = 10;
const sizeColWidth = 10;
const header =
" " +
c.bold("Config".padEnd(configColWidth)) +
c.bold("Time".padEnd(timeColWidth)) +
c.bold("Size".padEnd(sizeColWidth));
const separator = " " + c.dim("\u2500".repeat(configColWidth + timeColWidth + sizeColWidth));
console.log(header);
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)`) : "";
console.log(
" " +
result.config.label.padEnd(configColWidth) +
timeStr.padEnd(timeColWidth) +
sizeStr.padEnd(sizeColWidth) +
failStr,
);
}
// ── Summary ──────────────────────────────────────────────────────────
const successfulResults = results.filter((r) => r.avgTime != null);
if (successfulResults.length > 0) {
let fastest = successfulResults[0];
for (const r of successfulResults) {
if (fastest == null || r.avgTime == null) continue;
if (fastest.avgTime == null || r.avgTime < fastest.avgTime) {
fastest = r;
}
}
if (fastest?.avgTime != null) {
console.log("");
console.log(
c.success("\u25C7") +
" Fastest: " +
c.accent(fastest.config.label) +
c.dim(` (${formatDuration(fastest.avgTime)})`),
);
}
} else {
console.log("");
console.log(
c.error("\u2717") +
" All configurations failed. Ensure the rendering pipeline is set up.",
);
}
console.log("");
},
});
+133
View File
@@ -0,0 +1,133 @@
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { formatBytes } from "../ui/format.js";
import {
ensureBrowser,
findBrowser,
clearBrowser,
CHROME_VERSION,
CACHE_DIR,
} from "../browser/manager.js";
async function runEnsure(): Promise<void> {
clack.intro(c.bold("hyperframes browser ensure"));
const s = clack.spinner();
s.start("Looking for an existing browser...");
const existing = await findBrowser();
if (existing) {
s.stop(c.success("Browser found"));
console.log();
console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`);
console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`);
console.log();
clack.outro(c.success("Ready to render."));
return;
}
s.stop("No browser found — downloading");
const downloadSpinner = clack.spinner();
downloadSpinner.start(
`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`,
);
let lastPct = -1;
const result = await ensureBrowser({
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
if (pct > lastPct) {
lastPct = pct;
downloadSpinner.message(
`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)} — ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
);
}
},
});
downloadSpinner.stop(c.success("Download complete"));
console.log();
console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`);
console.log(` ${c.dim("Path:")} ${c.bold(result.executablePath)}`);
console.log();
clack.outro(c.success("Ready to render."));
}
async function runPath(): Promise<void> {
const result = await findBrowser();
if (!result) {
// Try a full ensure (which includes download) but write only the path
try {
const ensured = await ensureBrowser();
process.stdout.write(ensured.executablePath + "\n");
} catch (err: unknown) {
console.error(
err instanceof Error ? err.message : "Failed to find browser",
);
process.exit(1);
}
return;
}
process.stdout.write(result.executablePath + "\n");
}
function runClear(): void {
clack.intro(c.bold("hyperframes browser clear"));
const removed = clearBrowser();
if (removed) {
clack.outro(
c.success("Removed cached browser from ") + c.dim(CACHE_DIR),
);
} else {
clack.outro(c.dim("No cached browser to remove."));
}
}
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 },
},
async run({ args }) {
const subcommand = args.subcommand;
if (!subcommand || subcommand === "") {
console.log(`
${c.bold("hyperframes browser")} ${c.dim("<subcommand>")}
Manage the Chrome browser used for rendering.
${c.bold("SUBCOMMANDS:")}
${c.accent("ensure")} ${c.dim("Find or download Chrome for rendering")}
${c.accent("path")} ${c.dim("Print browser executable path (for scripting)")}
${c.accent("clear")} ${c.dim("Remove cached Chrome download")}
${c.bold("EXAMPLES:")}
${c.accent("npx hyperframes browser ensure")} ${c.dim("Download Chrome if needed")}
${c.accent("npx hyperframes browser path")} ${c.dim("Print path for scripts")}
${c.accent("npx hyperframes browser clear")} ${c.dim("Remove cached browser")}
`);
return;
}
switch (subcommand) {
case "ensure":
return runEnsure();
case "path":
return runPath();
case "clear":
return runClear();
default:
console.error(
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`,
);
process.exit(1);
}
},
});
+107
View File
@@ -0,0 +1,107 @@
import { defineCommand } from "citty";
import { readFileSync } from "node:fs";
import { c } from "../ui/colors.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
interface CompositionInfo {
id: string;
duration: number;
width: number;
height: number;
elementCount: number;
}
function parseCompositions(html: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const compositionDivs = doc.querySelectorAll("[data-composition-id]");
const compositions: CompositionInfo[] = [];
compositionDivs.forEach((div) => {
const id = div.getAttribute("data-composition-id") ?? "unknown";
const width = parseInt(div.getAttribute("data-width") ?? "1920", 10);
const height = parseInt(div.getAttribute("data-height") ?? "1080", 10);
const timedChildren = div.querySelectorAll("[data-start]");
let maxEnd = 0;
let elementCount = 0;
timedChildren.forEach((el) => {
elementCount++;
const start = parseFloat(el.getAttribute("data-start") ?? "0");
const endAttr = el.getAttribute("data-end");
const durationAttr = el.getAttribute("data-duration");
let end: number;
if (endAttr) {
end = parseFloat(endAttr);
} else if (durationAttr) {
end = start + parseFloat(durationAttr);
} else {
end = start + 5;
}
if (end > maxEnd) {
maxEnd = end;
}
});
compositions.push({
id,
duration: maxEnd,
width,
height,
elementCount,
});
});
return compositions;
}
export default defineCommand({
meta: { name: "compositions", description: "List all compositions in a project" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const project = resolveProject(args.dir);
const html = readFileSync(project.indexPath, "utf-8");
ensureDOMParser();
const compositions = parseCompositions(html);
if (compositions.length === 0) {
console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`);
return;
}
if (args.json) {
console.log(JSON.stringify(compositions, null, 2));
return;
}
const compositionLabel =
compositions.length === 1 ? "1 composition" : `${compositions.length} compositions`;
console.log(
`${c.success("◇")} ${c.accent(project.name)} ${c.dim("—")} ${c.dim(compositionLabel)}`,
);
console.log();
// Calculate padding for alignment
const maxIdLen = compositions.reduce((max, comp) => Math.max(max, comp.id.length), 0);
for (const comp of compositions) {
const id = c.accent(comp.id.padEnd(maxIdLen));
const duration = c.bold(`${comp.duration.toFixed(1)}s`);
const resolution = c.dim(`${comp.width}×${comp.height}`);
const elements = c.dim(
`${comp.elementCount} ${comp.elementCount === 1 ? "element" : "elements"}`,
);
console.log(` ${id} ${duration} ${resolution} ${elements}`);
}
},
});
+186
View File
@@ -0,0 +1,186 @@
import { defineCommand } from "citty";
import { spawn } from "node:child_process";
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";
import { c } from "../ui/colors.js";
/**
* Check if a port is available by trying to listen on it briefly.
*/
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolvePromise) => {
const { createServer } = require("node:net") as typeof import("node:net");
const server = createServer();
server.once("error", () => resolvePromise(false));
server.once("listening", () => {
server.close(() => resolvePromise(true));
});
server.listen(port);
});
}
/**
* Find an available port starting from the given port.
*/
async function findAvailablePort(startPort: number): Promise<number> {
for (let port = startPort; port < startPort + 10; port++) {
if (await isPortAvailable(port)) return port;
}
return startPort; // fallback — let the server fail with a clear error
}
/**
* Detect whether we're running from source (monorepo dev) or from the built bundle.
* When running via tsx from source, the file is at cli/src/commands/dev.ts.
* When running from the built bundle, the file is at cli/dist/cli.js.
* We check the filename portion of the URL to avoid false positives from
* directory names (e.g., /Users/someone/src/...).
*/
function isDevMode(): boolean {
const url = new URL(import.meta.url);
// In dev mode the file is a .ts source file; in production it's a bundled .js
return url.pathname.endsWith(".ts");
}
export default defineCommand({
meta: { name: "dev", description: "Start the studio for local development" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
},
async run({ args }) {
const dir = resolve(args.dir ?? ".");
if (isDevMode()) {
return runDevMode(dir);
}
const port = await findAvailablePort(3002);
return runEmbeddedMode(dir, port);
},
});
/**
* Dev mode: spawn pnpm studio from the monorepo (existing behavior).
*/
async function runDevMode(dir: string): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const projectName = basename(dir);
const symlinkPath = join(projectsDir, projectName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
if (dir !== symlinkPath) {
if (existsSync(symlinkPath)) {
try {
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
const target = readlinkSync(symlinkPath);
if (resolve(target) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
}
// If it's a real directory, leave it alone
} catch {
// Not a symlink — don't touch it
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
clack.intro(c.bold("hyperframes dev"));
const s = clack.spinner();
s.start("Starting studio...");
// Run the new consolidated studio (single Vite dev server with API plugin)
const studioPkgDir = join(repoRoot, "packages", "studio");
const child = spawn("pnpm", ["exec", "vite"], {
cwd: studioPkgDir,
stdio: ["ignore", "pipe", "pipe"],
});
let frontendUrl = "";
function handleOutput(data: Buffer): void {
const text = data.toString();
// Detect Vite URL
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !frontendUrl) {
frontendUrl = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
const urlToOpen = `${frontendUrl}#/project/${projectName}`;
import("open").then((mod) => mod.default(urlToOpen)).catch(() => {});
child.stdout?.removeListener("data", handleOutput);
child.stderr?.removeListener("data", handleOutput);
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
// If child exits before we detect readiness, show what we have
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
function cleanup(): void {
if (createdSymlink && existsSync(symlinkPath)) {
try {
unlinkSync(symlinkPath);
} catch {
/* ignore */
}
}
}
return new Promise<void>((resolvePromise) => {
// Temporarily ignore SIGINT on the parent so Ctrl+C only kills the child.
// The child gets SIGINT from the terminal's process group signal.
// When the child exits, we clean up and resolve back to the caller.
const noop = (): void => {};
process.on("SIGINT", noop);
child.on("close", () => {
process.removeListener("SIGINT", noop);
cleanup();
resolvePromise();
});
});
}
/**
* Embedded mode — not yet available.
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
*/
async function runEmbeddedMode(_dir: string, _port: number): Promise<void> {
console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>"));
process.exit(1);
}
+129
View File
@@ -0,0 +1,129 @@
import { defineCommand } from "citty";
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { c } from "../ui/colors.js";
interface TopicEntry {
file: string;
description: string;
}
const TOPICS: Record<string, TopicEntry> = {
"data-attributes": {
file: "data-attributes.md",
description: "Timing, media, and composition attributes",
},
templates: {
file: "templates.md",
description: "Built-in project templates for init",
},
rendering: {
file: "rendering.md",
description: "Render compositions to MP4 (local & Docker)",
},
gsap: {
file: "gsap.md",
description: "GSAP animation setup and usage",
},
troubleshooting: {
file: "troubleshooting.md",
description: "Common issues and fixes",
},
compositions: {
file: "compositions.md",
description: "Composition structure, nesting, and variables",
},
};
function docsDir(): string {
const thisFile = fileURLToPath(import.meta.url);
const dir = dirname(thisFile);
// In dev: cli/src/commands/ → ../docs = cli/src/docs/
// In built: cli/dist/ → docs = cli/dist/docs/
const devPath = resolve(dir, "..", "docs");
const builtPath = resolve(dir, "docs");
return existsSync(devPath) ? devPath : builtPath;
}
function formatInlineCode(line: string): string {
// Replace inline backtick spans with accented text
return line.replace(/`([^`]+)`/g, (_match, code: string) => c.accent(code));
}
function renderMarkdown(content: string): void {
const lines = content.split("\n");
for (const line of lines) {
// Skip code fences
if (line.trim().startsWith("```")) {
continue;
}
// H1 heading
if (line.startsWith("# ")) {
console.log(c.bold(line.slice(2)));
continue;
}
// H2 subheading
if (line.startsWith("## ")) {
console.log(c.bold(c.dim(line.slice(3))));
continue;
}
// List items
if (line.startsWith("- ")) {
const rest = formatInlineCode(line.slice(2));
console.log(`${c.dim(" \u2022")} ${rest}`);
continue;
}
// Everything else
console.log(formatInlineCode(line));
}
}
export default defineCommand({
meta: { name: "docs", description: "View inline documentation in the terminal" },
args: {
topic: { type: "positional", description: "Topic to view", required: false },
},
async run({ args }) {
const topic = args.topic;
// No topic: list available topics
if (topic === undefined || topic === "") {
console.log(c.bold("Available topics:"));
console.log();
for (const [name, entry] of Object.entries(TOPICS)) {
console.log(` ${c.accent(name.padEnd(20))} ${c.dim(entry.description)}`);
}
console.log();
console.log(c.dim(`Run ${c.accent("hyperframes docs <topic>")} to view a topic.`));
return;
}
// Look up the topic
const entry = TOPICS[topic];
if (entry === undefined) {
console.error(c.error(`Unknown topic: ${topic}`));
console.error();
console.error("Available topics:");
for (const name of Object.keys(TOPICS)) {
console.error(` ${c.accent(name)}`);
}
process.exit(1);
}
const filePath = join(docsDir(), entry.file);
if (!existsSync(filePath)) {
console.error(c.error(`Doc file not found: ${filePath}`));
process.exit(1);
}
const content = readFileSync(filePath, "utf-8");
console.log();
renderMarkdown(content);
},
});
+131
View File
@@ -0,0 +1,131 @@
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";
interface Check {
name: string;
run: () => CheckResult | Promise<CheckResult>;
}
interface CheckResult {
ok: boolean;
detail: string;
hint?: string;
}
function checkFFmpeg(): CheckResult {
const path = findFFmpeg();
if (path) {
try {
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 };
}
}
return {
ok: false,
detail: "Not found",
hint: process.platform === "darwin" ? "brew install ffmpeg" : "sudo apt install ffmpeg",
};
}
function checkFFprobe(): CheckResult {
try {
const result = execSync("which ffprobe", { encoding: "utf-8", timeout: 5000 }).trim();
return { ok: true, detail: result };
} catch {
return {
ok: false,
detail: "Not found",
hint: "Installed with ffmpeg",
};
}
}
async function checkChrome(): Promise<CheckResult> {
const info = await findBrowser();
if (info) {
return { ok: true, detail: `${info.source}: ${info.executablePath}` };
}
return {
ok: false,
detail: "Not found",
hint: "Run: npx hyperframes browser ensure",
};
}
function checkDocker(): CheckResult {
try {
const version = execSync("docker --version", { encoding: "utf-8", timeout: 5000 }).trim();
return { ok: true, detail: version };
} catch {
return {
ok: false,
detail: "Not found",
hint: "https://docs.docker.com/get-docker/",
};
}
}
function checkDockerRunning(): CheckResult {
try {
execSync("docker info", { stdio: "pipe", timeout: 5000 });
return { ok: true, detail: "Running" };
} catch {
return {
ok: false,
detail: "Not running",
hint: "Start Docker Desktop or run: sudo systemctl start docker",
};
}
}
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: {},
async run() {
console.log();
console.log(c.bold("hyperframes doctor"));
console.log();
const checks: Check[] = [
{ name: "Node.js", run: checkNode },
{ name: "FFmpeg", run: checkFFmpeg },
{ name: "FFprobe", run: checkFFprobe },
{ name: "Chrome", run: checkChrome },
{ name: "Docker", run: checkDocker },
{ name: "Docker running", run: checkDockerRunning },
];
let allOk = true;
for (const check of checks) {
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)}`);
if (!result.ok && result.hint) {
console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`);
}
if (!result.ok) allOk = false;
}
console.log();
if (allOk) {
console.log(` ${c.success("\u25C7")} ${c.success("All checks passed")}`);
} else {
console.log(` ${c.warn("\u25C7")} ${c.warn("Some checks failed — see hints above")}`);
}
console.log();
},
});
+75
View File
@@ -0,0 +1,75 @@
import { defineCommand } from "citty";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { parseHtml } from "@hyperframes/core";
import { c } from "../ui/colors.js";
import { formatBytes, label } from "../ui/format.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
function totalSize(dir: string): number {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
total += totalSize(path);
} else {
total += statSync(path).size;
}
}
return total;
}
export default defineCommand({
meta: { name: "info", description: "Print project metadata" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const project = resolveProject(args.dir);
const html = readFileSync(project.indexPath, "utf-8");
ensureDOMParser();
const parsed = parseHtml(html);
const tracks = new Set(parsed.elements.map((el) => el.zIndex));
const maxEnd = parsed.elements.reduce(
(max, el) => Math.max(max, el.startTime + el.duration),
0,
);
const resolution =
parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
const size = totalSize(project.dir);
const typeCounts: Record<string, number> = {};
for (const el of parsed.elements) {
typeCounts[el.type] = (typeCounts[el.type] ?? 0) + 1;
}
const typeStr = Object.entries(typeCounts)
.map(([t, count]) => `${count} ${t}`)
.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));
return;
}
console.log(`${c.success("◇")} ${c.accent(project.name)}`);
console.log(label("Resolution", resolution));
console.log(label("Duration", `${maxEnd.toFixed(1)}s`));
console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`));
console.log(label("Tracks", `${tracks.size}`));
console.log(label("Size", formatBytes(size)));
},
});
+492
View File
@@ -0,0 +1,492 @@
import { defineCommand, runCommand } from "citty";
import {
existsSync,
mkdirSync,
copyFileSync,
cpSync,
writeFileSync,
readFileSync,
readdirSync,
} from "node:fs";
import { resolve, basename, join, dirname } from "node:path";
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";
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
interface VideoMeta {
durationSeconds: number;
width: number;
height: number;
fps: number;
hasAudio: boolean;
videoCodec: string;
}
const WEB_CODECS = new Set(["h264", "vp8", "vp9", "av1", "theora"]);
const DEFAULT_META: VideoMeta = {
durationSeconds: 5,
width: 1920,
height: 1080,
fps: 30,
hasAudio: false,
videoCodec: "h264",
};
// ---------------------------------------------------------------------------
// ffprobe helper — shells out to ffprobe to avoid engine dependency
// ---------------------------------------------------------------------------
function probeVideo(filePath: string): VideoMeta | undefined {
try {
const raw = execFileSync(
"ffprobe",
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
{ encoding: "utf-8", timeout: 15_000 },
);
const parsed: {
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);
const streams = parsed.streams ?? [];
const videoStream = streams.find((s) => s.codec_type === "video");
if (!videoStream) return undefined;
const hasAudio = streams.some((s) => s.codec_type === "audio");
let fps = 30;
const fpsStr = videoStream.avg_frame_rate ?? videoStream.r_frame_rate;
if (fpsStr) {
const parts = fpsStr.split("/");
const num = parseFloat(parts[0] ?? "");
const den = parseFloat(parts[1] ?? "1");
if (den !== 0 && !Number.isNaN(num) && !Number.isNaN(den)) {
fps = Math.round((num / den) * 100) / 100;
}
}
const durationStr = parsed.format?.duration;
const durationSeconds =
durationStr !== undefined ? parseFloat(durationStr) : 5;
return {
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
width: videoStream.width ?? 1920,
height: videoStream.height ?? 1080,
fps,
hasAudio,
videoCodec: videoStream.codec_name ?? "unknown",
};
} catch {
return undefined;
}
}
function isWebCompatible(codec: string): boolean {
return WEB_CODECS.has(codec.toLowerCase());
}
function hasFFmpeg(): boolean {
try {
execSync("ffmpeg -version", { stdio: "ignore", timeout: 5000 });
return true;
} catch {
return false;
}
}
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
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" });
child.on("close", (code) => resolvePromise(code === 0));
child.on("error", () => resolvePromise(false));
});
}
// ---------------------------------------------------------------------------
// Static template helpers
// ---------------------------------------------------------------------------
function getStaticTemplateDir(templateId: string): string {
const dir = dirname(fileURLToPath(import.meta.url));
// In dev: cli/src/commands/ → ../templates = cli/src/templates/
// In built: cli/dist/ → templates = cli/dist/templates/
const devPath = resolve(dir, "..", "templates", templateId);
const builtPath = resolve(dir, "templates", templateId);
return existsSync(devPath) ? devPath : builtPath;
}
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));
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
if (videoFilename) {
content = content.replaceAll("__VIDEO_SRC__", videoFilename);
} else {
// Remove video elements with placeholder src
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
}
writeFileSync(file, content, "utf-8");
}
}
// ---------------------------------------------------------------------------
// handleVideoFile — probe, check codec, optionally transcode, copy to destDir
// ---------------------------------------------------------------------------
async function handleVideoFile(
videoPath: string,
destDir: string,
interactive: boolean,
): Promise<{ meta: VideoMeta; localVideoName: string }> {
const probed = probeVideo(videoPath);
let meta: VideoMeta = { ...DEFAULT_META };
let localVideoName = basename(videoPath);
if (probed) {
meta = probed;
if (interactive) {
clack.log.info(
`Video: ${meta.width}x${meta.height}, ${meta.durationSeconds.toFixed(1)}s, ${meta.fps}fps${meta.hasAudio ? ", has audio" : ""}`,
);
}
} else {
const msg =
"ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: brew install ffmpeg";
if (interactive) {
clack.log.warn(msg);
} else {
console.log(c.warn(msg));
}
}
// Check codec compatibility
if (probed && !isWebCompatible(probed.videoCodec)) {
if (interactive) {
clack.log.warn(
c.warn(`Video codec "${probed.videoCodec}" is not supported by web browsers.`),
);
} else {
console.log(c.warn(`Video codec "${probed.videoCodec}" is not supported by browsers.`));
}
if (hasFFmpeg()) {
let shouldTranscode = !interactive; // non-interactive auto-transcodes
if (interactive) {
const transcode = await clack.select({
message: "Transcode to H.264 MP4 for browser playback?",
options: [
{ value: "yes", label: "Yes, transcode", hint: "converts to H.264 MP4" },
{ value: "no", label: "No, keep original", hint: "video won't play in browser" },
],
});
if (clack.isCancel(transcode)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
shouldTranscode = transcode === "yes";
}
if (shouldTranscode) {
const mp4Name = localVideoName.replace(/\.[^.]+$/, ".mp4");
const mp4Path = resolve(destDir, mp4Name);
const spin = clack.spinner();
spin.start("Transcoding to H.264 MP4...");
const ok = await transcodeToMp4(videoPath, mp4Path);
if (ok) {
spin.stop(c.success(`Transcoded to ${mp4Name}`));
localVideoName = mp4Name;
} else {
spin.stop(c.warn("Transcode failed — copying original file"));
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
if (interactive) {
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
clack.log.info(c.accent("Install: brew install ffmpeg"));
} else {
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
console.log(c.dim("Install: ") + c.accent("brew install ffmpeg"));
}
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
} else {
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
return { meta, localVideoName };
}
// ---------------------------------------------------------------------------
// scaffoldProject — copy template, patch video refs, write meta.json
// ---------------------------------------------------------------------------
function scaffoldProject(
destDir: string,
name: string,
templateId: TemplateId,
localVideoName: string | undefined,
): void {
mkdirSync(destDir, { recursive: true });
const templateDir = getStaticTemplateDir(templateId);
cpSync(templateDir, destDir, { recursive: true });
patchVideoSrc(destDir, localVideoName);
writeFileSync(
resolve(destDir, "meta.json"),
JSON.stringify(
{
id: name,
name,
createdAt: new Date().toISOString(),
},
null,
2,
),
"utf-8",
);
}
// ---------------------------------------------------------------------------
// nextStepLoop — "What do you want to do?" loop after scaffolding
// ---------------------------------------------------------------------------
async function nextStepLoop(destDir: string): Promise<void> {
while (true) {
const next = await clack.select({
message: "What do you want to do?",
options: [
{ value: "dev", label: "Open in studio", hint: "full editor with timeline" },
{ value: "render", label: "Render to MP4", hint: "export video now" },
{ value: "done", label: "Done for now" },
],
});
if (clack.isCancel(next) || next === "done") {
clack.outro(c.success("Happy editing!"));
return;
}
// Hand off to the selected command — use explicit imports so the
// bundler can resolve them (dynamic import with a variable fails in bundles)
try {
if (next === "dev") {
const devCmd = await import("./dev.js").then((m) => m.default);
await runCommand(devCmd, { rawArgs: [destDir] });
} else if (next === "render") {
const renderCmd = await import("./render.js").then((m) => m.default);
await runCommand(renderCmd, { rawArgs: [destDir] });
}
} catch {
// Command may throw on Ctrl+C — that's fine, loop back
}
// Wait a tick so any lingering SIGINT state clears before Clack prompts again
await new Promise((r) => setTimeout(r, 100));
console.log();
}
}
// ---------------------------------------------------------------------------
// Exported command
// ---------------------------------------------------------------------------
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" },
video: { type: "string", description: "Path to a source video file", alias: "V" },
},
async run({ args }) {
const templateFlag = args.template;
const videoFlag = args.video;
// -----------------------------------------------------------------------
// Non-interactive mode: flags provided
// -----------------------------------------------------------------------
if (templateFlag) {
if (!ALL_TEMPLATE_IDS.includes(templateFlag as TemplateId)) {
console.error(c.error(`Unknown template: ${templateFlag}`));
console.error(`Available: ${ALL_TEMPLATE_IDS.join(", ")}`);
process.exit(1);
}
const templateId = templateFlag as TemplateId;
const name = args.name ?? "my-video";
const destDir = resolve(name);
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
console.error(
c.error(`Directory already exists and is not empty: ${name}`),
);
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
let localVideoName: string | undefined;
if (videoFlag) {
const videoPath = resolve(videoFlag);
if (!existsSync(videoPath)) {
console.error(c.error(`Video file not found: ${videoFlag}`));
process.exit(1);
}
const result = await handleVideoFile(videoPath, destDir, false);
localVideoName = result.localVideoName;
}
scaffoldProject(destDir, basename(destDir), templateId, localVideoName);
console.log(c.success(`\nCreated ${c.accent(name + "/")}`));
for (const f of readdirSync(destDir)) {
console.log(` ${c.accent(f)}`);
}
return;
}
// -----------------------------------------------------------------------
// Interactive mode
// -----------------------------------------------------------------------
clack.intro("Create a new HyperFrames project");
// 1. Project name
let name: string;
const hasPositionalName = args.name !== undefined && args.name !== "";
if (hasPositionalName) {
name = args.name ?? "my-video";
} else {
const nameResult = await clack.text({
message: "Project name",
placeholder: "my-video",
defaultValue: "my-video",
});
if (clack.isCancel(nameResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
name = nameResult;
}
const destDir = resolve(name);
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
const overwrite = await clack.confirm({
message: `Directory ${c.accent(name)} already exists and is not empty. Overwrite?`,
initialValue: false,
});
if (clack.isCancel(overwrite) || !overwrite) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
}
// 2. Got a video?
let localVideoName: string | undefined;
if (videoFlag) {
// Video supplied via --video flag even in interactive mode
const videoPath = resolve(videoFlag);
if (!existsSync(videoPath)) {
clack.log.error(`Video file not found: ${videoFlag}`);
clack.cancel("Setup cancelled.");
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
const result = await handleVideoFile(videoPath, destDir, true);
localVideoName = result.localVideoName;
} else {
const videoChoice = await clack.select({
message: "Got a video file?",
options: [
{ value: "yes", label: "Yes", hint: "MP4 or WebM recommended" },
{
value: "no",
label: "No",
hint: "Start with motion graphics or text",
},
],
initialValue: "no" as "yes" | "no",
});
if (clack.isCancel(videoChoice)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
if (videoChoice === "yes") {
const pathResult = await clack.text({
message: "Path to your video file (drag and drop or paste)",
placeholder: "/path/to/video.mp4",
validate(val) {
const trimmed = val?.trim();
if (!trimmed) return "Please enter a file path";
if (!existsSync(resolve(trimmed))) return "File not found";
return undefined;
},
});
if (clack.isCancel(pathResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
const videoPath = resolve(String(pathResult).trim());
mkdirSync(destDir, { recursive: true });
const result = await handleVideoFile(videoPath, destDir, true);
localVideoName = result.localVideoName;
}
}
// 3. Pick template — single list for all templates
const templateResult = await clack.select({
message: "Pick a template",
options: TEMPLATES.map((t) => ({
value: t.id,
label: t.label,
hint: t.hint,
})),
initialValue: TEMPLATES[0]?.id,
});
if (clack.isCancel(templateResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
const templateId: TemplateId = templateResult;
// 4. Copy template and patch
scaffoldProject(destDir, name, templateId, localVideoName);
const files = readdirSync(destDir);
clack.note(
files.map((f) => c.accent(f)).join("\n"),
c.success(`Created ${name}/`),
);
await nextStepLoop(destDir);
},
});
+44
View File
@@ -0,0 +1,44 @@
import { defineCommand } from "citty";
import { readFileSync } from "node:fs";
import { lintHyperframeHtml } from "@hyperframes/core/lint";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
export default defineCommand({
meta: { name: "lint", description: "Validate a composition for common mistakes" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output findings as JSON", default: false },
},
async run({ args }) {
const project = resolveProject(args.dir);
const html = readFileSync(project.indexPath, "utf-8");
const result = lintHyperframeHtml(html, { filePath: project.indexPath });
if (args.json) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
}
console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/index.html")}`);
console.log();
if (result.ok) {
console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`);
return;
}
for (const finding of result.findings) {
const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
console.log(`${prefix} ${c.bold(finding.code)}${loc}: ${finding.message}`);
if (finding.fixHint) {
console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
}
}
const summaryIcon = result.errorCount > 0 ? c.error("◇") : c.success("◇");
console.log(`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`);
process.exit(result.errorCount > 0 ? 1 : 0);
},
});
+210
View File
@@ -0,0 +1,210 @@
import { defineCommand } from "citty";
import { existsSync, mkdirSync, statSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { resolveProject } from "../utils/project.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import { renderProgress } from "../ui/progress.js";
const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
export default defineCommand({
meta: { name: "render", description: "Render a composition to MP4" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
output: { type: "string", description: "Output path (default: renders/<name>.mp4)" },
fps: { type: "string", description: "Frame rate: 24, 30, 60", default: "30" },
quality: { type: "string", description: "Quality: draft, standard, high", default: "standard" },
workers: { type: "string", description: "Parallel workers 1-8" },
docker: { type: "boolean", description: "Use Docker for deterministic render", default: false },
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
quiet: { type: "boolean", description: "Suppress verbose output", default: false },
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
// ── Validate fps ───────────────────────────────────────────────────────
const fpsRaw = parseInt(args.fps ?? "30", 10);
if (!VALID_FPS.has(fpsRaw)) {
errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`);
process.exit(1);
}
const fps = fpsRaw as 24 | 30 | 60;
// ── Validate quality ───────────────────────────────────────────────────
const qualityRaw = args.quality ?? "standard";
if (!VALID_QUALITY.has(qualityRaw)) {
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
process.exit(1);
}
const quality = qualityRaw as "draft" | "standard" | "high";
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null) {
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 8) {
errorBox("Invalid workers", `Got "${args.workers}". Must be between 1 and 8.`);
process.exit(1);
}
workers = parsed;
}
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}.mp4`);
// Ensure output directory exists
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const quiet = args.quiet ?? false;
// ── Print render plan ─────────────────────────────────────────────────
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("");
}
// ── Check FFmpeg for local renders ───────────────────────────────────
if (!useDocker) {
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
if (!findFFmpeg()) {
errorBox(
"FFmpeg not found",
"Rendering requires FFmpeg for video encoding.",
`Install: ${getFFmpegInstallHint()}`,
);
process.exit(1);
}
}
// ── Ensure browser for local renders ────────────────────────────────
if (!useDocker) {
const { ensureBrowser } = await import("../browser/manager.js");
const clack = await import("@clack/prompts");
const s = clack.spinner();
s.start("Checking browser...");
try {
const info = await ensureBrowser({
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.stop(c.dim(`Browser: ${info.source}`));
} catch (err: unknown) {
s.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
err instanceof Error ? err.message : String(err),
"Run: npx hyperframes browser ensure",
);
process.exit(1);
}
}
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, { fps, quality, workers, gpu: useGpu, quiet });
} else {
await renderLocal(project.dir, outputPath, { fps, quality, workers, gpu: useGpu, quiet });
}
},
});
interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
workers?: number;
gpu: boolean;
quiet: boolean;
}
async function renderDocker(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<void> {
const producer = await loadProducer();
const startTime = Date.now();
try {
await producer.renderComposition(projectDir, {
output: outputPath,
fps: options.fps,
quality: options.quality,
workers: options.workers ?? null,
gpu: options.gpu,
quiet: options.quiet,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Try --docker for containerized rendering");
process.exit(1);
}
const elapsed = Date.now() - startTime;
printRenderComplete(outputPath, elapsed, options.quiet);
}
async function renderLocal(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<void> {
const producer = await loadProducer();
const startTime = Date.now();
const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
workers: options.workers,
useGpu: options.gpu,
});
const onProgress = options.quiet
? undefined
: (progressJob: { progress: number }, message: string) => {
renderProgress(progressJob.progress, message);
};
try {
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Try --docker for containerized rendering");
process.exit(1);
}
const elapsed = Date.now() - startTime;
printRenderComplete(outputPath, elapsed, options.quiet);
}
function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void {
if (quiet) return;
let fileSize = "unknown";
if (existsSync(outputPath)) {
const stat = statSync(outputPath);
fileSize = formatBytes(stat.size);
}
const duration = formatDuration(elapsedMs);
console.log("");
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed"));
}
+61
View File
@@ -0,0 +1,61 @@
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { VERSION } from "../version.js";
export default defineCommand({
meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" },
args: {},
async run() {
clack.intro(c.bold("hyperframes upgrade"));
const s = clack.spinner();
s.start("Checking for updates...");
let latest: string;
try {
const res = await fetch("https://registry.npmjs.org/hyperframes/latest");
if (!res.ok) {
s.stop("Could not check for updates");
clack.outro(c.dim("Package not yet published to npm."));
return;
}
const data = (await res.json()) as { version?: string };
latest = data.version ?? VERSION;
} catch {
s.stop("Could not check for updates");
clack.outro(c.dim("Network error. Check your connection."));
return;
}
if (latest === VERSION) {
s.stop(c.success("Already up to date"));
clack.outro(`${c.success("◇")} ${c.bold("v" + VERSION)}`);
return;
}
s.stop("Update available");
console.log();
console.log(` ${c.dim("Current:")} ${c.bold("v" + VERSION)}`);
console.log(` ${c.dim("Latest:")} ${c.bold(c.accent("v" + latest))}`);
console.log();
const shouldUpgrade = await clack.confirm({
message: "Upgrade now?",
});
if (clack.isCancel(shouldUpgrade) || !shouldUpgrade) {
clack.outro(c.dim("Skipped."));
return;
}
console.log();
console.log(` ${c.accent("npm install -g hyperframes@" + latest)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
},
});
+26
View File
@@ -0,0 +1,26 @@
# Compositions
A composition is an HTML document that defines a video timeline.
## Structure
Every composition needs a root element with `data-composition-id`:
```html
<div id="root" data-composition-id="root" data-width="1920" data-height="1080">
<!-- Elements go here -->
</div>
```
## Nested Compositions
Embed one composition inside another:
```html
<div data-composition-src="./intro.html" data-start="0" data-duration="5"></div>
```
## Listing Compositions
Use `npx hyperframes compositions` to see all compositions in a project.
## Variables
Compositions can expose variables for dynamic content:
```html
<div data-composition-id="card" data-var-title="string" data-var-color="color">
```
+22
View File
@@ -0,0 +1,22 @@
# Data Attributes
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.
+23
View File
@@ -0,0 +1,23 @@
# GSAP Animation
HyperFrames uses GSAP for animation. Timelines are paused and controlled by the runtime.
## Setup
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#title", { opacity: 1, duration: 0.5 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["root"] = tl;
</script>
```
## Key Rules
- Always create timelines with `{ paused: true }`
- Register timelines on `window.__timelines` with the composition ID as key
- Position parameter (3rd arg) sets absolute time: `tl.to(el, vars, 1.5)`
- Supported methods: `set`, `to`, `from`, `fromTo`
## Supported Properties
opacity, x, y, scale, scaleX, scaleY, rotation, width, height, visibility
+23
View File
@@ -0,0 +1,23 @@
# Rendering
Render compositions to MP4 with `npx hyperframes render`.
## Local Mode (default)
Uses Puppeteer (bundled Chromium) + system FFmpeg. Fast for iteration.
Requires: FFmpeg installed (`brew install ffmpeg` or `apt install ffmpeg`).
## Docker Mode (--docker)
Deterministic output with exact Chrome version and fonts. For production.
Requires: Docker installed and running.
## Options
- `-f, --fps` — 24, 30, or 60 (default: 30)
- `-q, --quality` — draft, standard, high (default: standard)
- `-w, --workers` — Parallel workers 1-8 (default: auto)
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI)
- `-o, --output` — Custom output path
## Tips
- Use `draft` quality for fast previews during development
- Use `npx hyperframes benchmark` to find optimal settings
- 4 workers is usually the sweet spot for most compositions
+15
View File
@@ -0,0 +1,15 @@
# Templates
Built-in templates available via `npx hyperframes init --template <name>`.
## blank
Empty 1920x1080 composition with GSAP timeline wired up. Start from scratch.
## title-card
Animated title and subtitle with GSAP fade-in/out. Good for intro cards.
## video-edit
Video element with trimming, audio, and track controls. Starting point for video editing.
## Custom Templates
Any directory with an `index.html` can serve as a template. Copy it manually or build your own init workflow.
+22
View File
@@ -0,0 +1,22 @@
# Troubleshooting
## "No composition found"
Your directory needs an `index.html`. Run `npx hyperframes init` to create one.
## "FFmpeg not found"
Local rendering requires FFmpeg. Install it:
- macOS: `brew install ffmpeg`
- Ubuntu: `sudo apt install ffmpeg`
- Windows: Download from https://ffmpeg.org/download.html
## Lint errors
Run `npx hyperframes lint` to check for common issues:
- Missing `data-composition-id` on root element
- Missing `class="clip"` on timed elements
- Overlapping timelines or invalid data attributes
## Preview not updating
Make sure you're editing the `index.html` in the project directory. The preview server watches for file changes and auto-reloads.
## Render looks different from preview
Use `--docker` mode for deterministic output. Local renders may differ due to font availability and Chrome version.
+14
View File
@@ -0,0 +1,14 @@
export type TemplateId = "warm-grain" | "play-mode" | "swiss-grid" | "vignelli";
export interface TemplateOption {
id: TemplateId;
label: string;
hint: string;
}
export const TEMPLATES: TemplateOption[] = [
{ id: "warm-grain", label: "Warm Grain", hint: "Cream aesthetic with grain texture" },
{ id: "play-mode", label: "Play Mode", hint: "Playful elastic animations" },
{ id: "swiss-grid", label: "Swiss Grid", hint: "Structured grid layout" },
{ id: "vignelli", label: "Vignelli", hint: "Bold typography with red accents" },
];
@@ -0,0 +1,97 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="16.04">
<div id="captions-container"></div>
<style>
[data-composition-id="captions"] {
width: 1920px;
height: 1080px;
pointer-events: none;
}
[data-composition-id="captions"] #captions-container {
position: absolute;
bottom: 100px; /* Position in bottom 1/3 */
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 150px; /* Fixed height for the caption box */
}
.caption-box {
position: absolute; /* Stacked in the same place */
background-color: #FF2D8A;
color: white;
padding: 20px 40px;
border-radius: 16px;
font-family: 'Nunito', sans-serif;
font-weight: 900; /* Nunito Black */
font-size: 64px;
text-align: center;
display: inline-block;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transform: scale(0);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.839, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics.', 'start': 3.579, 'end': 4.639}, {'text': 'Sixty-two', 'start': 4.659, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.099, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.479}, {'text': 'look', 'start': 10.52, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
const container = document.getElementById('captions-container');
const tl = gsap.timeline({ paused: true });
// Group transcript into lines (max 5 words)
const lines = [];
for (let i = 0; i < TRANSCRIPT.length; i += 5) {
lines.push(TRANSCRIPT.slice(i, i + 5));
}
lines.forEach((lineWords, index) => {
const lineText = lineWords.map(w => w.text).join(' ');
const startTime = lineWords[0].start;
const endTime = lineWords[lineWords.length - 1].end;
// Create element
const el = document.createElement('div');
el.className = 'caption-box';
el.textContent = lineText;
el.id = `caption-line-${index}`;
container.appendChild(el);
// Animation: Pop in with scale-up bounce (0% to 110% to 100%)
tl.to(el, {
autoAlpha: 1,
scale: 1.1,
duration: 0.2,
ease: "power2.out"
}, startTime);
tl.to(el, {
scale: 1,
duration: 0.1,
ease: "power2.inOut"
}, startTime + 0.2);
// Stay visible until next line or end of its duration
// We hide it when the next line starts or at its own end time
const hideTime = (index < lines.length - 1) ? Math.min(endTime, lines[index+1][0].start) : endTime;
tl.to(el, {
autoAlpha: 0,
scale: 0,
duration: 0.15,
ease: "power2.in"
}, hideTime);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,88 @@
<template id="intro-template">
<div id="intro-comp" data-composition-id="intro" data-start="0" data-duration="16.04" data-width="1920" data-height="1080">
<div class="intro-container">
<h1 class="title">HYPERFRAMES</h1>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap');
[data-composition-id="intro"] .intro-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
[data-composition-id="intro"] .title {
font-family: 'Nunito', sans-serif;
font-weight: 900;
font-size: 180px;
color: #FF2D8A; /* Hot Pink */
text-transform: uppercase;
transform: rotate(-5deg); /* Tilted 5 degrees */
margin: 0;
padding: 20px;
/* Layered offset shadow for depth without suffocating letterforms */
filter: drop-shadow(8px 8px 0 #FFFFFF) drop-shadow(15px 15px 0 rgba(0,0,0,0.1));
opacity: 0;
scale: 0;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// --- INTRO SEQUENCE ---
// Animation: Scale-up bounce (0% to 110% to 100%) with elastic overshoot
// Starts at 0.5s, lasts until 3s
tl.to('[data-composition-id="intro"] .title', {
opacity: 1,
scale: 1.1,
duration: 0.6,
ease: "back.out(1.7)",
delay: 0.5
})
.to('[data-composition-id="intro"] .title', {
scale: 1,
duration: 0.4,
ease: "elastic.out(1, 0.3)"
})
// Ambient motion
.to('[data-composition-id="intro"] .title', {
rotation: "-3deg",
duration: 1,
repeat: 1,
yoyo: true,
ease: "sine.inOut"
}, "-=0.5")
// Exit intro
.to('[data-composition-id="intro"] .title', {
opacity: 0,
scale: 0.5,
duration: 0.3,
ease: "power2.in"
}, 1.4);
// --- END CARD SEQUENCE ---
// Appears at 14.619s (after Moment 3 finishes exiting at 14.239 + 0.3)
tl.to('[data-composition-id="intro"] .title', {
opacity: 1,
scale: 1.1,
duration: 0.6,
ease: "back.out(1.7)"
}, 14.619)
.to('[data-composition-id="intro"] .title', {
scale: 1,
duration: 0.4,
ease: "elastic.out(1, 0.3)"
});
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,252 @@
<template id="stats-template">
<div data-composition-id="stats" data-width="1920" data-height="1080" data-duration="16.04">
<div id="stats-container">
<!-- Moment 1: 47% NEED MOTION GRAPHICS -->
<div id="moment-1" class="moment" style="opacity: 0; transform: scale(0) rotate(8deg);">
<div class="sticker-stack">
<div class="sticker-bg blue-bg"></div>
<div class="sticker-content white-bg blue-border">
<div class="stat-number blue-text">47%</div>
<div class="stat-label blue-text">NEED MOTION GRAPHICS</div>
</div>
</div>
<!-- Decorative shapes -->
<div class="shape circle pink-bg" style="top: -40px; left: -40px; width: 80px; height: 80px;"></div>
<div class="shape pill lime-bg" style="bottom: -30px; right: -50px; width: 120px; height: 40px; transform: rotate(-15deg);"></div>
</div>
<!-- Moment 2: 62% STRUGGLE WITH STATIC CONTENT -->
<div id="moment-2" class="moment" style="opacity: 0; transform: scale(0) rotate(-5deg);">
<div class="sticker-stack">
<div class="sticker-bg white-bg"></div>
<div class="sticker-content blue-bg white-border">
<div class="stat-number white-text">62%</div>
<div class="stat-label white-text">STRUGGLE WITH STATIC CONTENT</div>
</div>
</div>
<!-- Decorative shapes -->
<div class="shape blob yellow-bg" style="top: -50px; right: -30px; width: 100px; height: 100px; border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%;"></div>
<div class="shape circle pink-bg" style="bottom: -20px; left: -40px; width: 60px; height: 60px;"></div>
</div>
<!-- Moment 3: 75% LACK EDITING SKILLS -->
<div id="moment-3" class="moment" style="opacity: 0; transform: scale(0) rotate(3deg);">
<div class="sticker-stack">
<div class="sticker-bg pink-bg"></div>
<div class="sticker-content white-bg pink-border">
<div class="stat-number pink-text">75%</div>
<div class="stat-label pink-text">LACK EDITING SKILLS</div>
</div>
</div>
<!-- Decorative shapes -->
<div class="shape pill blue-bg" style="top: -30px; left: 50%; transform: translateX(-50%) rotate(5deg); width: 150px; height: 45px;"></div>
<div class="shape circle yellow-bg" style="bottom: -40px; right: -20px; width: 90px; height: 90px;"></div>
</div>
</div>
<style>
[data-composition-id="stats"] {
width: 1920px;
height: 1080px;
position: relative;
font-family: 'Nunito', sans-serif;
overflow: hidden;
}
[data-composition-id="stats"] #stats-container {
width: 100%;
height: 100%;
position: relative;
}
[data-composition-id="stats"] .moment {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
z-index: 100;
}
/* Layout Positions */
[data-composition-id="stats"] #moment-1 {
top: 200px;
right: 150px; /* Right side when A-roll is Left */
width: 600px;
}
[data-composition-id="stats"] #moment-2 {
top: 250px;
left: 100px; /* Left side when A-roll is Right */
width: 700px;
}
[data-composition-id="stats"] #moment-3 {
bottom: 100px; /* Below A-roll when centered */
left: 50%;
transform: translateX(-50%);
width: 800px;
}
/* Sticker Stack Effect */
[data-composition-id="stats"] .sticker-stack {
position: relative;
width: 100%;
padding: 40px;
filter: drop-shadow(0 15px 0 rgba(0, 87, 255, 0.2));
}
[data-composition-id="stats"] .sticker-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 40px;
z-index: -1;
transform: rotate(-2deg);
}
[data-composition-id="stats"] .sticker-content {
position: relative;
padding: 40px;
border-radius: 35px;
border: 8px solid currentColor;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
/* 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-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; }
/* Typography */
[data-composition-id="stats"] .stat-number {
font-size: 180px;
font-weight: 900;
line-height: 1;
margin-bottom: 10px;
}
[data-composition-id="stats"] .stat-label {
font-size: 48px;
font-weight: 900;
line-height: 1.1;
max-width: 500px;
}
/* Shapes */
[data-composition-id="stats"] .shape {
position: absolute;
z-index: -2;
border-radius: 50%;
}
[data-composition-id="stats"] .pill {
border-radius: 100px;
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.839, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics.', 'start': 3.579, 'end': 4.639}, {'text': 'Sixty-two', 'start': 4.659, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.099, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.479}, {'text': 'look', 'start': 10.52, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
const tl = gsap.timeline({ paused: true });
// Helper to find word timing
function getWordTime(wordText) {
const word = TRANSCRIPT.find(w =>
w.text.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g,"").startsWith(wordText.toLowerCase())
);
return word ? word.start : null;
}
// Timings from transcript
const t1 = getWordTime("Forty-seven") || 1.839;
const t2 = getWordTime("Sixty-two") || 4.659;
const t3 = getWordTime("three") || 8.88;
// Moment 1 Animation (approx 5s)
tl.to('#moment-1', {
opacity: 1,
scale: 1,
duration: 0.8,
ease: "elastic.out(1, 0.6)",
onStart: () => gsap.set('#moment-1', { transformOrigin: "center center" })
}, t1);
// Moment 1 Exit (before Moment 2)
tl.to('#moment-1', {
opacity: 0,
scale: 0.5,
duration: 0.3,
ease: "back.in(1.7)"
}, t2 - 0.4);
// Moment 2 Animation (approx 9s)
tl.to('#moment-2', {
opacity: 1,
scale: 1,
duration: 0.8,
ease: "elastic.out(1, 0.6)",
onStart: () => gsap.set('#moment-2', { transformOrigin: "center center" })
}, t2);
// Moment 2 Exit (before Moment 3)
tl.to('#moment-2', {
opacity: 0,
scale: 0.5,
duration: 0.3,
ease: "back.in(1.7)"
}, t3 - 0.4);
// Moment 3 Animation (approx 13s)
tl.to('#moment-3', {
opacity: 1,
scale: 1,
duration: 0.8,
ease: "elastic.out(1, 0.6)",
onStart: () => gsap.set('#moment-3', { transformOrigin: "center center" })
}, t3);
// Moment 3 Exit: 3 words before 'Hyperframes'
// 'Editor' is at 15.079. 3 words before is 'So' at 14.239.
tl.to('#moment-3', {
opacity: 0,
scale: 0.5,
duration: 0.3,
ease: "back.in(1.7)"
}, 14.239);
// Ambient Motion (Finite)
const totalDuration = 16.04;
gsap.utils.toArray('.shape').forEach((shape, i) => {
const stepDuration = 2 + (i * 0.2);
const steps = Math.ceil(totalDuration / stepDuration);
for(let s = 0; s < steps; s++) {
tl.to(shape, {
y: (s % 2 === 0 ? 15 : -15),
rotation: (s % 2 === 0 ? 5 : -5),
duration: stepDuration,
ease: "sine.inOut"
}, s * stepDuration);
}
});
window.__timelines["stats"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hyperframes - Play Mode</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background-color: #FFFFFF;
font-family: 'Nunito', sans-serif;
}
#main-composition {
width: 1920px;
height: 1080px;
position: relative;
}
#bg-comp,
#aroll-comp,
#intro-comp,
#stats-comp,
#captions-comp {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#bg-comp {
z-index: 1;
}
#aroll-comp {
z-index: 10;
}
#intro-comp {
z-index: 100;
}
#stats-comp {
z-index: 150;
}
#captions-comp {
z-index: 200;
}
#aroll-container {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 0;
}
#short_mag_cut {
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 8px solid #FFFFFF;
}
#background-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #FFFFFF;
z-index: 0;
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap" rel="stylesheet">
</head>
<body>
<div id="main-composition" data-composition-id="main-video" data-start="0" data-duration="16.04" data-width="1920" data-height="1080">
<!-- Background Layer Composition -->
<div id="bg-comp" data-composition-id="background" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="1">
<div id="background-layer"></div>
<style>
#background-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #FFFFFF;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
const bg = document.getElementById('background-layer');
// Sync with A-roll framing 3 (4.659s)
tl.to(bg, { backgroundColor: "#0057FF", duration: 0.5 }, 4.659);
// Sync with A-roll framing 4 (8.88s)
tl.to(bg, { backgroundColor: "#FFFFFF", duration: 0.5 }, 8.88);
window.__timelines["background"] = tl;
})();
</script>
</div>
<!-- A-roll Layer Composition -->
<div id="aroll-comp" data-composition-id="aroll-layer" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="10">
<div id="aroll-container">
<video id="short_mag_cut"
src="__VIDEO_SRC__"
data-start="0"
data-duration="16.04"
data-track-index="1"
style="width: 100%; height: auto;"></video>
</div>
<style>
#aroll-container {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#short_mag_cut {
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 8px solid #FFFFFF;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
const container = document.getElementById('aroll-container');
gsap.set(container, { y: -1100, scale: 0.8 });
tl.to(container, { y: 0, duration: 1.2, ease: "elastic.out(1, 0.6)" }, 0);
tl.to(container, { scale: 1.4, x: -300, duration: 1, ease: "back.out(1.7)" }, 1.839);
tl.to(container, { scale: 0.7, x: 400, duration: 1, ease: "back.out(1.7)" }, 4.659);
tl.to(container, { scale: 0.9, x: 0, duration: 1, ease: "back.out(1.7)" }, 8.88);
window.__timelines["aroll-layer"] = tl;
})();
</script>
</div>
<!-- Compositions -->
<div id="intro-comp" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="100"></div>
<div id="captions-comp" data-composition-id="captions" data-composition-src="compositions/captions.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="200"></div>
<div id="stats-comp" data-composition-id="stats" data-composition-src="compositions/stats.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="150"></div>
<!-- Audio Clips -->
<audio id="sfx-bounce-0" src="" data-start="0" data-track-index="5"></audio>
<audio id="sfx-whoosh-1" src="" data-start="1.839" data-track-index="6"></audio>
<audio id="sfx-pop-1" src="" data-start="2.039" data-track-index="7"></audio>
<audio id="sfx-whoosh-2" src="" data-start="4.659" data-track-index="6"></audio>
<audio id="sfx-pop-2" src="" data-start="4.859" data-track-index="7"></audio>
<audio id="sfx-whoosh-3" src="" data-start="8.88" data-track-index="6"></audio>
<audio id="sfx-pop-3" src="" data-start="9.08" data-track-index="7"></audio>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main-video"] = tl;
</script>
</div>
</body>
</html>
@@ -0,0 +1,116 @@
<svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#F2F2F2" />
<g id="thin-grid-lines" stroke="#0A1E3D" stroke-width="1" opacity="0.1">
<line id="v-thin-1" x1="60" y1="0" x2="60" y2="1080" />
<line id="v-thin-2" x1="120" y1="0" x2="120" y2="1080" />
<line id="v-thin-3" x1="180" y1="0" x2="180" y2="1080" />
<line id="v-thin-5" x1="300" y1="0" x2="300" y2="1080" />
<line id="v-thin-6" x1="360" y1="0" x2="360" y2="1080" />
<line id="v-thin-7" x1="420" y1="0" x2="420" y2="1080" />
<line id="v-thin-9" x1="540" y1="0" x2="540" y2="1080" />
<line id="v-thin-10" x1="600" y1="0" x2="600" y2="1080" />
<line id="v-thin-11" x1="660" y1="0" x2="660" y2="1080" />
<line id="v-thin-13" x1="780" y1="0" x2="780" y2="1080" />
<line id="v-thin-14" x1="840" y1="0" x2="840" y2="1080" />
<line id="v-thin-15" x1="900" y1="0" x2="900" y2="1080" />
<line id="v-thin-17" x1="1020" y1="0" x2="1020" y2="1080" />
<line id="v-thin-18" x1="1080" y1="0" x2="1080" y2="1080" />
<line id="v-thin-19" x1="1140" y1="0" x2="1140" y2="1080" />
<line id="v-thin-21" x1="1260" y1="0" x2="1260" y2="1080" />
<line id="v-thin-22" x1="1320" y1="0" x2="1320" y2="1080" />
<line id="v-thin-23" x1="1380" y1="0" x2="1380" y2="1080" />
<line id="v-thin-25" x1="1500" y1="0" x2="1500" y2="1080" />
<line id="v-thin-26" x1="1560" y1="0" x2="1560" y2="1080" />
<line id="v-thin-27" x1="1620" y1="0" x2="1620" y2="1080" />
<line id="v-thin-29" x1="1740" y1="0" x2="1740" y2="1080" />
<line id="v-thin-30" x1="1800" y1="0" x2="1800" y2="1080" />
<line id="v-thin-31" x1="1860" y1="0" x2="1860" y2="1080" />
<line id="h-thin-1" x1="0" y1="60" x2="1920" y2="60" />
<line id="h-thin-2" x1="0" y1="120" x2="1920" y2="120" />
<line id="h-thin-3" x1="0" y1="180" x2="1920" y2="180" />
<line id="h-thin-5" x1="0" y1="300" x2="1920" y2="300" />
<line id="h-thin-6" x1="0" y1="360" x2="1920" y2="360" />
<line id="h-thin-7" x1="0" y1="420" x2="1920" y2="420" />
<line id="h-thin-9" x1="0" y1="540" x2="1920" y2="540" />
<line id="h-thin-10" x1="0" y1="600" x2="1920" y2="600" />
<line id="h-thin-11" x1="0" y1="660" x2="1920" y2="660" />
<line id="h-thin-13" x1="0" y1="780" x2="1920" y2="780" />
<line id="h-thin-14" x1="0" y1="840" x2="1920" y2="840" />
<line id="h-thin-15" x1="0" y1="900" x2="1920" y2="900" />
<line id="h-thin-17" x1="0" y1="1020" x2="1920" y2="1020" />
</g>
<g id="thick-grid-lines" stroke="#0A1E3D" stroke-width="3" opacity="0.2">
<line id="v-thick-1" x1="240" y1="0" x2="240" y2="1080" />
<line id="v-thick-2" x1="480" y1="0" x2="480" y2="1080" />
<line id="v-thick-3" x1="720" y1="0" x2="720" y2="1080" />
<line id="v-thick-4" x1="960" y1="0" x2="960" y2="1080" />
<line id="v-thick-5" x1="1200" y1="0" x2="1200" y2="1080" />
<line id="v-thick-6" x1="1440" y1="0" x2="1440" y2="1080" />
<line id="v-thick-7" x1="1680" y1="0" x2="1680" y2="1080" />
<line id="h-thick-1" x1="0" y1="240" x2="1920" y2="240" />
<line id="h-thick-2" x1="0" y1="480" x2="1920" y2="480" />
<line id="h-thick-3" x1="0" y1="720" x2="1920" y2="720" />
<line id="h-thick-4" x1="0" y1="960" x2="1920" y2="960" />
</g>
<g id="intersections" stroke="#0A1E3D" stroke-width="2" opacity="0.3">
<line x1="230" y1="240" x2="250" y2="240" />
<line x1="240" y1="230" x2="240" y2="250" />
<line x1="230" y1="480" x2="250" y2="480" />
<line x1="240" y1="470" x2="240" y2="490" />
<line x1="230" y1="720" x2="250" y2="720" />
<line x1="240" y1="710" x2="240" y2="730" />
<line x1="230" y1="960" x2="250" y2="960" />
<line x1="240" y1="950" x2="240" y2="970" />
<line x1="470" y1="240" x2="490" y2="240" />
<line x1="480" y1="230" x2="480" y2="250" />
<line x1="470" y1="480" x2="490" y2="480" />
<line x1="480" y1="470" x2="480" y2="490" />
<line x1="470" y1="720" x2="490" y2="720" />
<line x1="480" y1="710" x2="480" y2="730" />
<line x1="470" y1="960" x2="490" y2="960" />
<line x1="480" y1="950" x2="480" y2="970" />
<line x1="710" y1="240" x2="730" y2="240" />
<line x1="720" y1="230" x2="720" y2="250" />
<line x1="710" y1="480" x2="730" y2="480" />
<line x1="720" y1="470" x2="720" y2="490" />
<line x1="710" y1="720" x2="730" y2="720" />
<line x1="720" y1="710" x2="720" y2="730" />
<line x1="710" y1="960" x2="730" y2="960" />
<line x1="720" y1="950" x2="720" y2="970" />
<line x1="950" y1="240" x2="970" y2="240" />
<line x1="960" y1="230" x2="960" y2="250" />
<line x1="950" y1="480" x2="970" y2="480" />
<line x1="960" y1="470" x2="960" y2="490" />
<line x1="950" y1="720" x2="970" y2="720" />
<line x1="960" y1="710" x2="960" y2="730" />
<line x1="950" y1="960" x2="970" y2="960" />
<line x1="960" y1="950" x2="960" y2="970" />
<line x1="1190" y1="240" x2="1210" y2="240" />
<line x1="1200" y1="230" x2="1200" y2="250" />
<line x1="1190" y1="480" x2="1210" y2="480" />
<line x1="1200" y1="470" x2="1200" y2="490" />
<line x1="1190" y1="720" x2="1210" y2="720" />
<line x1="1200" y1="710" x2="1200" y2="730" />
<line x1="1190" y1="960" x2="1210" y2="960" />
<line x1="1200" y1="950" x2="1200" y2="970" />
<line x1="1430" y1="240" x2="1450" y2="240" />
<line x1="1440" y1="230" x2="1440" y2="250" />
<line x1="1430" y1="480" x2="1450" y2="480" />
<line x1="1440" y1="470" x2="1440" y2="490" />
<line x1="1430" y1="720" x2="1450" y2="720" />
<line x1="1440" y1="710" x2="1440" y2="730" />
<line x1="1430" y1="960" x2="1450" y2="960" />
<line x1="1440" y1="950" x2="1440" y2="970" />
<line x1="1670" y1="240" x2="1690" y2="240" />
<line x1="1680" y1="230" x2="1680" y2="250" />
<line x1="1670" y1="480" x2="1690" y2="480" />
<line x1="1680" y1="470" x2="1680" y2="490" />
<line x1="1670" y1="720" x2="1690" y2="720" />
<line x1="1680" y1="710" x2="1680" y2="730" />
<line x1="1670" y1="960" x2="1690" y2="960" />
<line x1="1680" y1="950" x2="1680" y2="970" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,95 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="16.04">
<div id="caption-container"></div>
<style>
[data-composition-id="captions"] {
width: 1920px;
height: 1080px;
position: relative;
font-family: 'Helvetica', 'Arial', sans-serif;
font-weight: bold;
overflow: hidden;
}
[data-composition-id="captions"] #caption-container {
position: absolute;
bottom: 120px; /* Consistent bottom positioning */
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
[data-composition-id="captions"] .caption-box {
background-color: #0A1E3D; /* Solid navy */
padding: 20px 40px;
display: none; /* Hidden by default, shown via GSAP */
justify-content: center;
align-items: center;
/* Swiss Grid: sharp corners, solid block */
}
[data-composition-id="captions"] .caption-text {
color: #F2F2F2; /* Off-white */
font-size: 72px;
text-transform: uppercase; /* Swiss style often uses uppercase for impact */
letter-spacing: -2px;
line-height: 1;
white-space: nowrap;
text-align: center;
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.86, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics,', 'start': 3.579, 'end': 4.599}, {'text': 'sixty-two', 'start': 4.679, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.079, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.42}, {'text': 'look', 'start': 10.519, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
const container = document.getElementById('caption-container');
const tl = gsap.timeline({ paused: true });
if (!TRANSCRIPT || TRANSCRIPT.length === 0) {
window.__timelines["captions"] = tl;
return;
}
// Group transcript into max 5 words per group
const groups = [];
let currentGroup = [];
TRANSCRIPT.forEach((word, index) => {
currentGroup.push(word);
// Group by 5 words OR if it's the last word
if (currentGroup.length === 5 || index === TRANSCRIPT.length - 1) {
groups.push([...currentGroup]);
currentGroup = [];
}
});
// Create DOM elements and timeline for each group
groups.forEach((group, i) => {
const box = document.createElement('div');
box.className = 'caption-box';
box.id = `group-${i}`;
const text = document.createElement('div');
text.className = 'caption-text';
text.textContent = group.map(w => w.text).join(' ');
box.appendChild(text);
container.appendChild(box);
const startTime = group[0].start;
const endTime = group[group.length - 1].end;
// Hard cut animation (no fades) as requested
tl.set(box, { display: 'flex' }, startTime);
tl.set(box, { display: 'none' }, endTime);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,198 @@
<template id="graphics-template">
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="15">
<div class="grid-container">
<!-- Stat 1: 47% -->
<div id="stat1" class="stat-group">
<div class="stat-value">47%</div>
<div class="stat-label">NEED MOTION GRAPHICS</div>
</div>
<!-- Stat 2: 62% -->
<div id="stat2" class="stat-group">
<div class="stat-value">62%</div>
<div class="stat-label">LOSING ATTENTION</div>
<div class="progress-container">
<div class="progress-bar"></div>
</div>
</div>
<!-- Stat 3: 3 OF 4 -->
<div id="stat3" class="stat-group">
<div class="stat-value">3 OF 4</div>
<div class="stat-label">LACK EDITING SKILLS</div>
<div class="grid-blocks">
<div class="block gold"></div>
<div class="block gold"></div>
<div class="block gold"></div>
<div class="block navy"></div>
</div>
</div>
</div>
<style>
[data-composition-id="graphics"] {
font-family: 'Helvetica', Arial, sans-serif;
color: #0A1E3D;
background: transparent;
width: 1920px;
height: 1080px;
position: relative;
overflow: hidden;
}
[data-composition-id="graphics"] .grid-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: repeat(12, 1fr);
width: 100%;
height: 100%;
padding: 80px;
gap: 20px;
}
[data-composition-id="graphics"] .stat-group {
opacity: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
background: #F2F2F2;
padding: 40px;
border-left: 10px solid #D4A017;
box-shadow: 20px 20px 0px rgba(10, 30, 61, 0.1);
}
/* Positioning Stats on Grid - Adjusted for A-roll structure */
[data-composition-id="graphics"] #stat1 {
grid-column: 2 / 6;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] #stat2 {
grid-column: 2 / 6;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] #stat3 {
grid-column: 7 / 12;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] .stat-value {
font-size: 140px;
font-weight: 900;
color: #D4A017;
line-height: 0.8;
margin-bottom: 15px;
}
[data-composition-id="graphics"] .stat-label {
font-size: 24px;
font-weight: 300; /* Lighter weight */
color: #0A1E3D;
letter-spacing: 2px;
text-transform: uppercase;
}
/* Progress Bar for Stat 2 */
[data-composition-id="graphics"] .progress-container {
width: 100%;
height: 24px;
background: #E0E0E0;
margin-top: 30px;
border: 2px solid #0A1E3D;
position: relative;
}
[data-composition-id="graphics"] .progress-bar {
width: 0%;
height: 100%;
background: #D4A017;
}
/* Grid Blocks for Stat 3 */
[data-composition-id="graphics"] .grid-blocks {
display: flex;
gap: 20px;
margin-top: 30px;
}
[data-composition-id="graphics"] .block {
width: 80px;
height: 80px;
border: 3px solid #0A1E3D;
}
[data-composition-id="graphics"] .block.gold {
background: #D4A017;
}
[data-composition-id="graphics"] .block.navy {
background: #0A1E3D;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Timing Constants
const S1_START = 1.86;
const S1_END = 4.5;
const S2_START = 4.68;
const S2_END = 8.6;
const S3_START = 8.88;
const S3_END = 14.2;
const SLIDE_DUR = 0.25; // 250ms
const SHARP_EASE = "power2.out";
// Stat 1 Animation
tl.fromTo('#stat1',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S1_START
);
tl.to('#stat1',
{ x: 200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S1_END - SLIDE_DUR
);
// Stat 2 Animation
tl.fromTo('#stat2',
{ y: 200, opacity: 0 },
{ y: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S2_START
);
// Progress bar fill
tl.to('#stat2 .progress-bar',
{ width: '62%', duration: S2_END - S2_START - (SLIDE_DUR * 2), ease: "none" },
S2_START + SLIDE_DUR
);
tl.to('#stat2',
{ y: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S2_END - SLIDE_DUR
);
// Stat 3 Animation
tl.fromTo('#stat3',
{ x: 200, opacity: 0 },
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S3_START
);
// Stagger blocks
tl.from('#stat3 .block',
{ scale: 0, duration: 0.3, stagger: 0.1, ease: "back.out(1.7)" },
S3_START + SLIDE_DUR
);
tl.to('#stat3',
{ x: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S3_END - SLIDE_DUR
);
window.__timelines["graphics"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,114 @@
<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="1.86">
<div class="container">
<div class="grid-line"></div>
<div class="text-wrapper">
<h1 class="title">HYPERFRAMES</h1>
<h2 class="subtitle">THE SURVEY FINDINGS</h2>
</div>
</div>
<style>
[data-composition-id="intro"] {
background-color: #F5F5F5; /* Off-white */
width: 1920px;
height: 1080px;
display: flex;
align-items: center;
justify-content: center;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow: hidden;
}
[data-composition-id="intro"] .container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 160px; /* Swiss grid alignment */
}
[data-composition-id="intro"] .grid-line {
position: absolute;
left: 160px;
top: 50%;
width: 0px;
height: 12px;
background-color: #0A1E3D; /* Navy */
transform: translateY(-180px); /* Positioned above the text */
}
[data-composition-id="intro"] .text-wrapper {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
[data-composition-id="intro"] .title {
color: #0A1E3D;
font-size: 180px;
font-weight: 900; /* Black weight */
line-height: 0.9;
margin: 0;
letter-spacing: -0.04em;
text-transform: uppercase;
transform: translateX(-100%); /* Start off-screen left */
}
[data-composition-id="intro"] .subtitle {
color: #0A1E3D;
font-size: 64px;
font-weight: 300; /* Lighter weight for contrast */
line-height: 1.2;
margin: 0;
letter-spacing: 0.1em;
text-transform: uppercase;
transform: translateX(-100%); /* Start off-screen left */
margin-top: 20px;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Mechanical timing: 200ms (0.2s)
const MECHANICAL_DURATION = 0.2;
const STAGGER = 0.1;
// 1. Animate the grid line first
tl.to('.grid-line', {
width: '1200px',
duration: 0.4,
ease: 'power4.out'
}, 0.2);
// 2. Slide in the title
tl.to('.title', {
x: 0,
duration: MECHANICAL_DURATION,
ease: 'power2.inOut'
}, 0.5);
// 3. Slide in the subtitle with a slight stagger
tl.to('.subtitle', {
x: 0,
duration: MECHANICAL_DURATION,
ease: 'power2.inOut'
}, 0.5 + STAGGER);
// 4. Subtle ambient motion (slow drift) to keep it alive
tl.to('.text-wrapper', {
x: 30,
duration: 1.16, // Adjusted to fit 1.86s total
ease: 'none'
}, 0.7);
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swiss Grid - Hyperframes</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
background-color: #F2F2F2;
overflow: hidden;
font-family: 'Helvetica', Arial, sans-serif;
}
#master-root {
width: 1920px;
height: 1080px;
position: relative;
}
.background-grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* A-roll styling */
#short_mag_cut_frame {
position: absolute;
border-radius: 8px; /* Required */
z-index: 1;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
overflow: hidden;
background: #000;
}
#short_mag_cut,
#short_mag_cut_frame > img.__render_frame__,
#short_mag_cut_frame > img.__preview_render_frame__ {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* Overlay layers */
.overlay-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#intro-comp { z-index: 2; }
#graphics-comp { z-index: 3; }
#captions-comp { z-index: 4; }
</style>
</head>
<body>
<div id="master-root" data-composition-id="master" data-width="1920" data-height="1080" data-duration="16.04">
<!-- Background Grid -->
<img id="bg-grid"
class="background-grid"
src="assets/swiss-grid.svg"
alt="Grid"
data-start="0"
data-duration="16.04"
data-track-index="0">
<!-- A-roll Video -->
<div id="short_mag_cut_frame">
<video id="short_mag_cut"
src="__VIDEO_SRC__"
data-start="0"
data-duration="16.04"
data-track-index="1">
</video>
</div>
<!-- Intro Sub-composition -->
<div id="intro-comp"
class="overlay-layer"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="1.86"
data-track-index="2">
</div>
<!-- Graphics Sub-composition -->
<div id="graphics-comp"
class="overlay-layer"
data-composition-id="graphics"
data-composition-src="compositions/graphics.html"
data-start="0"
data-duration="16.04"
data-track-index="3">
</div>
<!-- Captions Sub-composition -->
<div id="captions-comp"
class="overlay-layer"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="16.04"
data-track-index="4">
</div>
<!-- Sound Effects -->
<audio id="sfx-intro" data-start="0" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat1" data-start="1.86" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat2" data-start="4.68" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat3" data-start="8.88" data-track-index="5" data-duration="1" src=""></audio>
<script>
window.__timelines = window.__timelines || {};
const masterTL = gsap.timeline({ paused: true });
const v = document.getElementById('short_mag_cut_frame');
// --- A-ROLL MOVEMENT (Structured Swiss Layout) ---
// 1. Entrance (0-1.86s): Slide in from left, settle centered for Intro
masterTL.fromTo(v,
{ x: -1000, y: 120, width: 960, height: 840 },
{ x: 480, y: 120, width: 960, height: 840, duration: 0.8, ease: "power2.out" },
0
);
// 2. Stat 1 (1.86s - 4.68s): Move to right side (Graphic is on left)
masterTL.to(v, {
x: 1080, y: 120, width: 720, height: 720,
duration: 0.25, ease: "expo.out"
}, 1.86);
// 3. Stat 2 (4.68s - 8.88s): Stay right, zoom in slightly
masterTL.to(v, {
x: 1080, y: 60, width: 780, height: 780,
duration: 0.25, ease: "expo.out"
}, 4.68);
// 4. Stat 3 (8.88s - 14.2s): Move to left side (Graphic is on right)
masterTL.to(v, {
x: 120, y: 120, width: 720, height: 720,
duration: 0.25, ease: "expo.out"
}, 8.88);
// 5. Conclusion (14.2s - 16.04s): Center large
masterTL.to(v, {
x: 240, y: 60, width: 1440, height: 810,
duration: 0.5, ease: "expo.out"
}, 14.2);
window.__timelines["master"] = masterTL;
</script>
</div>
</body>
</html>
@@ -0,0 +1,122 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1080" data-height="1920" data-duration="13.88">
<div id="captions-container"></div>
<style>
[data-composition-id="captions"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
font-family: "Helvetica", "Helvetica Neue", Arial, sans-serif;
font-weight: 900; /* Helvetica Black/Bold */
text-transform: uppercase;
pointer-events: none;
}
[data-composition-id="captions"] #captions-container {
position: absolute;
inset: 0;
}
[data-composition-id="captions"] .caption-group {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: auto;
max-width: 920px; /* 1080 - 2*80 */
bottom: 672px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
opacity: 0;
z-index: 10;
}
[data-composition-id="captions"] .caption-text {
font-size: 64px;
line-height: 1.1;
color: #000000;
background-color: #FFFFFF;
padding: 15px 30px;
border-top: 8px solid #CC0000; /* Vignelli Red top border accent */
display: inline-block;
letter-spacing: -0.02em; /* Tight Helvetica spacing */
white-space: nowrap; /* Prevent wrapping at the element level */
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
const tl = gsap.timeline({ paused: true });
const container = document.getElementById('captions-container');
// Group words into single lines (max words or based on pauses/sentence ends)
function groupTranscript(transcript, maxWords = 3) {
const groups = [];
let currentGroup = [];
transcript.forEach((word, index) => {
currentGroup.push(word);
const nextWord = transcript[index + 1];
const isPause = nextWord && (nextWord.start - word.end > 0.15);
const isSentenceEnd = word.text.includes('.') || word.text.includes('?') || word.text.includes('!');
if (currentGroup.length >= maxWords || isPause || isSentenceEnd || !nextWord) {
groups.push({
words: currentGroup,
start: currentGroup[0].start,
end: currentGroup[currentGroup.length - 1].end,
text: currentGroup.map(w => w.text).join(' ')
});
currentGroup = [];
}
});
return groups;
}
const captionGroups = groupTranscript(TRANSCRIPT);
captionGroups.forEach((group, index) => {
const div = document.createElement('div');
div.className = 'caption-group';
div.id = `group-${index}`;
const textSpan = document.createElement('span');
textSpan.className = 'caption-text';
textSpan.innerText = group.text;
div.appendChild(textSpan);
container.appendChild(div);
// Animation: Modern "expo.out" transitions
// Entrance
tl.fromTo(div,
{ opacity: 0, y: 20 },
{
opacity: 1,
y: 0,
duration: 0.4,
ease: "expo.out"
},
group.start
);
// Exit
tl.to(div, {
opacity: 0,
y: -10,
duration: 0.3,
ease: "expo.out"
}, group.end - 0.3);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,271 @@
<template id="overlays-template">
<div data-composition-id="overlays" data-width="1080" data-height="1920" data-duration="16.88">
<!-- 6-Column Grid Overlay (for visual reference during development, hidden by default) -->
<div class="vignelli-grid">
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
</div>
<!-- 1. 47% Motion Graphics -->
<div class="overlay-item motion-graphics-stat" id="stat-47">
<div class="red-bar"></div>
<div class="stat-content">
<div class="number">47%</div>
<div class="label">Motion Graphics</div>
</div>
</div>
<!-- 2. 62% Static Content (Full Canvas) -->
<div class="overlay-item static-content-stat" id="stat-62">
<div class="bg-charcoal"></div>
<div class="full-content">
<div class="large-number">62%</div>
<div class="large-label">Static Content</div>
<div class="red-accent"></div>
</div>
</div>
<!-- 3. 3 out of 4 lack editing skills -->
<div class="overlay-item editing-skills-stat" id="stat-3-4">
<div class="text-block">
<span class="highlight">3 out of 4</span>
<br>lack editing skills
</div>
<div class="side-bar"></div>
</div>
<!-- 4. Hyperframes Branding -->
<div class="overlay-item branding-reveal" id="branding">
<div class="logo-container">
<div class="logo-text"><span class="heavy">HYPERFRAMES</span></div>
<div class="logo-underline"></div>
</div>
</div>
<style>
[data-composition-id="overlays"] {
position: relative;
width: 1080px;
height: 1920px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000000;
overflow: hidden;
pointer-events: none;
}
/* Grid System */
[data-composition-id="overlays"] .vignelli-grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(6, 1fr);
pointer-events: none;
z-index: 100;
opacity: 0; /* Hidden */
}
[data-composition-id="overlays"] .vignelli-grid .col {
border-right: 1px solid rgba(204, 0, 0, 0.1);
}
[data-composition-id="overlays"] .overlay-item {
position: absolute;
opacity: 0;
display: flex;
z-index: 10;
}
/* 1. 47% Stat Styles */
[data-composition-id="overlays"] #stat-47 {
bottom: 200px;
left: 0;
width: 540px; /* 3 columns */
height: 240px;
background: #FFFFFF;
flex-direction: row;
align-items: stretch;
}
[data-composition-id="overlays"] #stat-47 .red-bar {
width: 20px;
background: #CC0000;
}
[data-composition-id="overlays"] #stat-47 .stat-content {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
[data-composition-id="overlays"] #stat-47 .number {
font-size: 120px;
font-weight: 900;
line-height: 1;
letter-spacing: -2px;
}
[data-composition-id="overlays"] #stat-47 .label {
font-size: 32px;
font-weight: 700;
text-transform: uppercase;
margin-top: 10px;
}
/* 2. 62% Stat Styles (Full Canvas) */
[data-composition-id="overlays"] #stat-62 {
top: 0;
left: 0;
width: 1080px;
height: 1920px;
z-index: 50;
}
[data-composition-id="overlays"] #stat-62 .bg-charcoal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #1A1A1A;
}
[data-composition-id="overlays"] #stat-62 .full-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 90px; /* Align with grid */
}
[data-composition-id="overlays"] #stat-62 .large-number {
font-size: 400px;
font-weight: 900;
color: #FFFFFF;
line-height: 0.8;
letter-spacing: -10px;
}
[data-composition-id="overlays"] #stat-62 .large-label {
font-size: 80px;
font-weight: 700;
color: #FFFFFF;
text-transform: uppercase;
margin-top: 20px;
max-width: 600px;
}
[data-composition-id="overlays"] #stat-62 .red-accent {
position: absolute;
top: 0;
right: 0;
width: 180px; /* 1 column */
height: 100%;
background: #CC0000;
}
/* 3. 3 out of 4 Stat Styles */
[data-composition-id="overlays"] #stat-3-4 {
top: 400px;
right: 0;
width: 360px; /* 2 columns */
background: #FFFFFF;
padding: 40px;
flex-direction: row-reverse;
}
[data-composition-id="overlays"] #stat-3-4 .text-block {
font-size: 48px;
font-weight: 700;
line-height: 1.1;
text-align: right;
}
[data-composition-id="overlays"] #stat-3-4 .highlight {
color: #CC0000;
font-size: 64px;
font-weight: 900;
}
[data-composition-id="overlays"] #stat-3-4 .side-bar {
width: 10px;
background: #000000;
margin-left: 20px;
}
/* 4. Branding Styles */
[data-composition-id="overlays"] #branding {
bottom: 100px;
left: 90px;
width: 900px;
justify-content: center;
z-index: 60;
}
[data-composition-id="overlays"] #branding .logo-container {
display: flex;
flex-direction: column;
align-items: center;
}
[data-composition-id="overlays"] #branding .logo-text {
font-size: 64px;
letter-spacing: 10px;
color: #000000;
}
[data-composition-id="overlays"] #branding .logo-text .heavy {
font-weight: 900;
}
[data-composition-id="overlays"] #branding .logo-text .light {
font-weight: 300;
}
[data-composition-id="overlays"] #branding .logo-underline {
width: 100%;
height: 8px;
background: #CC0000;
margin-top: 10px;
transform-origin: left;
}
</style>
<script>
const tl = gsap.timeline({ paused: true });
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
// Requirement 1: "47% Motion Graphics" (Start: 1.199, End: 3.759)
// Positioned in bottom half, columns 1-3.
tl.fromTo("#stat-47",
{ x: -540, opacity: 1 },
{ x: 0, duration: 0.8, ease: "expo.out" },
1.199
);
tl.to("#stat-47",
{ x: -540, duration: 0.6, ease: "expo.in" },
3.759 - 0.6
);
// Requirement 2: "62% Static Content" (Start: 3.799, End: 7.299)
// Full canvas moment. Large bold typography across columns.
tl.set("#stat-62", { opacity: 1 }, 3.799);
tl.from("#stat-62 .bg-charcoal", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.799);
tl.from("#stat-62 .red-accent", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.899);
tl.from("#stat-62 .large-number", { y: 100, opacity: 0, duration: 1, ease: "expo.out" }, 4.099);
tl.from("#stat-62 .large-label", { y: 50, opacity: 0, duration: 1, ease: "expo.out" }, 4.299);
tl.to("#stat-62", { x: "-100%", duration: 0.8, ease: "expo.in" }, 7.299 - 0.8);
// Requirement 3: "3 out of 4 lack editing skills" (Start: 7.319, End: 12.239)
// Positioned in columns 5-6 (right side) to avoid face (x: 300-776)
tl.fromTo("#stat-3-4",
{ x: 360, opacity: 1 },
{ x: 0, duration: 0.8, ease: "expo.out" },
7.319
);
tl.to("#stat-3-4",
{ x: 360, duration: 0.6, ease: "expo.in" },
12.239 - 0.6
);
// Requirement 4: "Hyperframes" (Start: 12.84, End: 13.84)
tl.set("#branding", { opacity: 1 }, 12.84);
tl.from("#branding .logo-text", { y: 20, opacity: 0, duration: 0.6, ease: "expo.out" }, 12.84);
tl.from("#branding .logo-underline", { scaleX: 0, duration: 0.8, ease: "expo.out" }, 13.04);
window.__timelines["overlays"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Massimo Vignelli Video</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
background: #FFFFFF;
overflow: hidden;
width: 1080px;
height: 1920px;
font-family: 'Helvetica', Arial, sans-serif;
}
/* 6-Column Grid System */
.grid-container {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
display: grid;
grid-template-columns: repeat(6, 1fr);
pointer-events: none;
z-index: 1000;
opacity: 0; /* Keep grid invisible but present for layout references */
}
.grid-column {
border-right: 1px solid rgba(204, 0, 0, 0.2); /* Vignelli Red accent */
}
#main-composition {
position: relative;
width: 1080px;
height: 1920px;
background: #FFFFFF;
overflow: hidden;
}
/* Layer Hosts */
#a-roll-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
overflow: hidden;
}
#a-roll,
#a-roll-wrapper img.__render_frame__,
#a-roll-wrapper img.__preview_render_frame__ {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#overlays-comp,
#captions-comp {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
pointer-events: none;
}
#overlays-comp {
z-index: 20;
}
#captions-comp {
z-index: 30;
}
/* Curtain Transition Layer */
.curtain {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: #1A1A1A;
z-index: 25;
pointer-events: none;
}
.curtain.red {
background: #CC0000;
z-index: 26;
}
</style>
</head>
<body>
<div id="main-composition" data-composition-id="main-comp" data-width="1080" data-height="1920" data-duration="13.88">
<!-- A-Roll -->
<div id="a-roll-wrapper">
<video id="a-roll"
src="__VIDEO_SRC__"
data-start="0"
data-duration="13.88"
data-track-index="0">
</video>
</div>
<!-- Transitions -->
<div id="curtain-black" class="curtain"></div>
<div id="curtain-red" class="curtain red"></div>
<!-- Overlays Composition -->
<div id="overlays-comp"
data-composition-id="overlays"
data-composition-src="compositions/overlays.html"
data-start="0"
data-duration="13.88"
data-track-index="2">
</div>
<!-- Captions Composition -->
<div id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="13.88"
data-track-index="3">
</div>
<!-- Grid Visualization -->
<div class="grid-container">
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
// Transition: Curtain Pull logic
// A-roll leaves the screen at least once.
// Timing: Let's do a curtain pull at 3.7s (transitioning to the 62% stat)
// and return at 7.3s (end of attention stat).
// Curtain Out (A-roll hidden)
tl.to("#curtain-black", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 3.5);
tl.to("#a-roll-wrapper", { opacity: 0, duration: 0 }, 3.7);
tl.to("#curtain-black", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 3.7);
// Curtain In (A-roll return)
tl.set("#curtain-red", { left: "-100%" }, 7.1);
tl.to("#curtain-red", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 7.1);
tl.to("#a-roll-wrapper", { opacity: 1, duration: 0 }, 7.3);
tl.to("#curtain-red", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 7.3);
window.__timelines["main-comp"] = tl;
</script>
</div>
</body>
</html>
@@ -0,0 +1,133 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="18">
<div class="captions-container">
<div id="caption-box" class="caption-box">
<span id="caption-text" class="caption-text"></span>
</div>
</div>
<style>
[data-composition-id="captions"] .captions-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 230px; /* Position at y: 850 (1080 - 230 = 850) */
pointer-events: none;
}
[data-composition-id="captions"] .caption-box {
background-color: #7A6248;
padding: 12px 32px;
border-radius: 24px;
display: flex;
justify-content: center;
align-items: center;
min-width: 100px;
max-width: 80%;
opacity: 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
[data-composition-id="captions"] .caption-text {
color: #F5F0E0;
font-family: 'Outfit', sans-serif;
font-size: 48px;
font-weight: 700;
text-align: center;
line-height: 1.2;
white-space: nowrap;
}
</style>
<script>
(function() {
const script = [
{"text": "We", "start": 0.119, "end": 0.259},
{"text": "asked", "start": 0.319, "end": 0.479},
{"text": "what", "start": 0.519, "end": 0.659},
{"text": "you", "start": 0.699, "end": 0.819},
{"text": "needed.", "start": 0.859, "end": 1.819},
{"text": "Forty-seven", "start": 1.86, "end": 2.299},
{"text": "percent", "start": 2.399, "end": 2.679},
{"text": "of", "start": 2.7, "end": 2.799},
{"text": "you", "start": 2.839, "end": 2.939},
{"text": "said", "start": 3.039, "end": 3.179},
{"text": "motion", "start": 3.24, "end": 3.559},
{"text": "graphics,", "start": 3.579, "end": 4.599},
{"text": "sixty-two", "start": 4.679, "end": 5.179},
{"text": "percent", "start": 5.299, "end": 5.759},
{"text": "said", "start": 5.859, "end": 5.98},
{"text": "static", "start": 6.079, "end": 6.399},
{"text": "content", "start": 6.46, "end": 6.879},
{"text": "was", "start": 6.92, "end": 7.079},
{"text": "costing", "start": 7.099, "end": 7.48},
{"text": "you", "start": 7.5, "end": 7.579},
{"text": "attention,", "start": 7.679, "end": 8.659},
{"text": "and", "start": 8.699, "end": 8.86},
{"text": "three", "start": 8.88, "end": 9.06},
{"text": "out", "start": 9.079, "end": 9.18},
{"text": "of", "start": 9.199, "end": 9.34},
{"text": "four", "start": 9.38, "end": 9.799},
{"text": "said", "start": 9.84, "end": 10.0},
{"text": "you", "start": 10.019, "end": 10.159},
{"text": "know", "start": 10.179, "end": 10.36},
{"text": "the", "start": 10.38, "end": 10.42},
{"text": "look", "start": 10.519, "end": 10.699},
{"text": "you", "start": 10.739, "end": 10.859},
{"text": "want", "start": 10.98, "end": 11.34},
{"text": "but", "start": 11.359, "end": 11.52},
{"text": "don't", "start": 11.56, "end": 11.779},
{"text": "have", "start": 11.819, "end": 11.94},
{"text": "the", "start": 11.96, "end": 12.06},
{"text": "editing", "start": 12.079, "end": 12.4},
{"text": "skills", "start": 12.52, "end": 12.86},
{"text": "to", "start": 12.88, "end": 13.0},
{"text": "get", "start": 13.019, "end": 13.18},
{"text": "there.", "start": 13.22, "end": 14.22},
{"text": "So", "start": 14.239, "end": 14.399},
{"text": "we", "start": 14.42, "end": 14.52},
{"text": "built", "start": 14.619, "end": 14.88},
{"text": "Hyperframes.", "start": 15.079, "end": 16.02}
];
// Group words into lines (max 5 words per line)
const lines = [];
for (let i = 0; i < script.length; i += 5) {
const lineWords = script.slice(i, i + 5);
lines.push({
text: lineWords.map(w => w.text).join(' '),
start: lineWords[0].start,
end: lineWords[lineWords.length - 1].end
});
}
const tl = gsap.timeline({ paused: true });
const box = document.querySelector('[data-composition-id="captions"] #caption-box');
const textEl = document.querySelector('[data-composition-id="captions"] #caption-text');
lines.forEach((line, index) => {
// Fade in and set text
tl.to(box, {
opacity: 1,
duration: 0.1,
ease: "power2.out",
onStart: () => {
textEl.textContent = line.text;
}
}, line.start);
// Fade out at the end of the line
tl.to(box, {
opacity: 0,
duration: 0.1,
ease: "power2.in"
}, line.end);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,157 @@
<template id="graphics-template">
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="14">
<!-- Moment 1: 47% Stat (1.8s - 4.5s) -->
<div id="moment-1" class="moment circle-stat">
<div class="stat-content">
<div class="stat-text">47%</div>
<div class="stat-desc">Need Motion Graphics</div>
</div>
</div>
<!-- Moment 2: 62% Stat (4.6s - 8.6s) -->
<div id="moment-2" class="moment pill-stat">
<div class="stat-text">62%</div>
</div>
<!-- Moment 3: Editing Skills (8.8s - 14s) -->
<div id="moment-3" class="moment rect-stat">
<div class="icon-shape">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M20,20 Q50,0 80,20 Q100,50 80,80 Q50,100 20,80 Q0,50 20,20" fill="currentColor" />
</svg>
</div>
<div class="stat-text">Editing Skills</div>
</div>
<style>
[data-composition-id="graphics"] {
position: relative;
width: 1920px;
height: 1080px;
font-family: 'Outfit', sans-serif;
color: white;
overflow: hidden;
}
[data-composition-id="graphics"] .moment {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
/* We'll use GSAP to handle the centering and drift */
}
/* Moment 1: Forest Green Circle */
[data-composition-id="graphics"] #moment-1 {
width: 500px;
height: 500px;
background-color: #3B5E3A;
border-radius: 50%;
left: 1400px;
top: 540px;
transform: translate(-50%, -50%);
}
[data-composition-id="graphics"] .stat-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
[data-composition-id="graphics"] .stat-desc {
font-size: 36px;
font-weight: 400;
margin-top: 10px;
max-width: 300px;
line-height: 1.2;
}
/* Moment 2: Ochre Pill */
[data-composition-id="graphics"] #moment-2 {
width: 500px;
height: 220px;
background-color: #CC8832;
border-radius: 110px;
left: 960px;
top: 200px;
transform: translate(-50%, -50%);
}
/* Moment 3: Terracotta Rounded Rect */
[data-composition-id="graphics"] #moment-3 {
width: 550px;
height: 180px;
background-color: #C45D3E;
border-radius: 40px;
left: 400px;
top: 540px;
transform: translate(-50%, -50%);
padding: 0 40px;
justify-content: center;
gap: 30px;
}
[data-composition-id="graphics"] .stat-text {
font-size: 80px;
font-weight: 700;
letter-spacing: -2px;
}
[data-composition-id="graphics"] #moment-3 .stat-text {
font-size: 54px;
letter-spacing: -1px;
}
[data-composition-id="graphics"] .icon-shape {
width: 80px;
height: 80px;
color: rgba(255, 255, 255, 0.9);
}
[data-composition-id="graphics"] .icon-shape svg {
width: 100%;
height: 100%;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Moment 1: 1.8s - 4.5s
// Gentle drift into place and settle
tl.fromTo('#moment-1',
{ opacity: 0, x: '+=40', y: '+=15', scale: 0.8 },
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
1.8
);
// Hold until 4.5s, then fade out
tl.to('#moment-1', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 4.2);
// Moment 2: 4.6s - 8.6s
// Gentle drift into place and settle
tl.fromTo('#moment-2',
{ opacity: 0, y: '-=40', x: '+=10', scale: 0.8 },
{ opacity: 1, y: 0, x: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
4.6
);
// Hold until 8.6s, then fade out
tl.to('#moment-2', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 8.3);
// Moment 3: 8.8s - 14s
// Gentle drift into place and settle
tl.fromTo('#moment-3',
{ opacity: 0, x: '-=40', y: '-=10', scale: 0.8 },
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
8.8
);
// Hold until end (14s)
tl.to('#moment-3', { opacity: 0, duration: 0.5, ease: 'power2.in' }, 13.5);
window.__timelines["graphics"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,77 @@
<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="3">
<div class="container">
<div class="title-card">
<h1 class="title">Hyperframes</h1>
<p class="subtitle">Design simplified.</p>
</div>
</div>
<style>
/* Import a rounded humanist sans-serif font */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap');
[data-composition-id="intro"] .container {
width: 100%;
height: 100%;
display: flex;
justify-content: flex-start; /* Align to left for speaker card */
align-items: center;
padding-left: 5%;
font-family: 'Outfit', sans-serif;
background: transparent;
}
[data-composition-id="intro"] .title-card {
background-color: #3B5E3A; /* Forest Green for contrast */
padding: 40px 60px;
border-radius: 30px;
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
text-align: left;
opacity: 0;
transform: translateX(-100%); /* Start off-screen left */
}
[data-composition-id="intro"] .title {
font-size: 100px;
font-weight: 600;
color: #F5F0E0; /* Cream text on green card */
margin: 0;
line-height: 1.1;
letter-spacing: -2px;
}
[data-composition-id="intro"] .subtitle {
font-size: 50px;
font-weight: 400;
color: #CC8832; /* Ochre accent */
margin: 10px 0 0 0;
line-height: 1.2;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Animation: Speaker card slides in from the left
tl.to('[data-composition-id="intro"] .title-card', {
opacity: 1,
x: 0,
duration: 0.8,
ease: 'power2.out'
}, 0.2);
// Exit: Slide off left at 1.8s (synced with 47% graphic)
tl.to('[data-composition-id="intro"] .title-card', {
x: '-120%',
opacity: 0,
duration: 0.6,
ease: 'power2.in'
}, 1.8);
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warm Grain - Hyperframes</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background-color: #F5F0E0; /* Cream background */
font-family: 'Outfit', 'Lexend', sans-serif;
}
#main-composition {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/* Paper texture and Grain overlay */
#grain-overlay-comp {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
}
/* Grain animation */
@keyframes grain-noise {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-5%, -5%); }
20% { transform: translate(-10%, 5%); }
30% { transform: translate(5%, -10%); }
40% { transform: translate(-5%, 15%); }
50% { transform: translate(-10%, 5%); }
60% { transform: translate(15%, 0); }
70% { transform: translate(0, 10%); }
80% { transform: translate(-15%, 0); }
90% { transform: translate(10%, 5%); }
}
.grain-texture {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: url('https://www.transparenttextures.com/patterns/natural-paper.png');
opacity: 0.15;
animation: grain-noise 0.5s steps(1) infinite;
}
#a-roll {
position: absolute;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.comp-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>
</head>
<body>
<div id="main-composition" data-composition-id="main-video" data-width="1920" data-height="1080" data-duration="17">
<!-- Background Layer -->
<div id="grain-overlay-comp" data-composition-id="grain-overlay" data-width="1920" data-height="1080" data-duration="17" data-track-index="100">
<div class="grain-texture"></div>
<script>
const grainTl = gsap.timeline({ paused: true });
window.__timelines["grain-overlay"] = grainTl;
</script>
</div>
<!-- A-Roll Video -->
<video id="a-roll"
src="__VIDEO_SRC__"
data-start="0"
data-duration="17"
data-track-index="0">
</video>
<!-- Compositions -->
<div id="intro-layer" class="comp-layer"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="2.5"
data-track-index="1">
</div>
<div id="graphics-layer" class="comp-layer"
data-composition-id="graphics"
data-composition-src="compositions/graphics.html"
data-start="0"
data-duration="17"
data-track-index="2">
</div>
<div id="captions-layer" class="comp-layer"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="17"
data-track-index="3">
</div>
<!-- SFX -->
<audio id="sfx-swipe-1" src="" data-start="1.8" data-duration="1" data-track-index="4"></audio>
<audio id="sfx-swipe-2" src="" data-start="4.6" data-duration="1" data-track-index="4"></audio>
<audio id="sfx-swipe-3" src="" data-start="8.8" data-duration="1" data-track-index="4"></audio>
<script>
const tl = gsap.timeline({ paused: true });
// A-Roll Framing Strategy
// 1. 0s-1.8s: Intro scale up from small (60%) to medium (80%) - Shifted right slightly to clear speaker card
// 2. 1.8s-4.6s: Grid view / Shift Left (x: 600, scale: 70%) for 47% graphic
// 3. 4.6s-8.8s: Shift Center-Bottom (y: 800, scale: 70%) for 62% graphic (top)
// 4. 8.8s-14s: Shift Right (x: 1320, scale: 75%) for Editing Skills graphic (left)
// 5. 14s-17s: Final Reveal (Center, scale: 100%)
// Initial state
gsap.set('#a-roll', { x: 1100, y: 540, xPercent: -50, yPercent: -50, width: 1920 * 0.6, height: 1080 * 0.6 });
// 1. Intro Scale Up (0s - 1.8s)
tl.to('#a-roll', {
x: 1200,
width: 1920 * 0.8,
height: 1080 * 0.8,
duration: 1.8,
ease: "power2.inOut"
}, 0);
// 2. Moment 1: 47% Graphic Trigger (1.8s) - Shift Left
tl.to('#a-roll', {
x: 600,
y: 540,
width: 1920 * 0.7,
height: 1080 * 0.7,
duration: 0.5,
ease: "power2.inOut"
}, 1.8);
// 3. Moment 2: 62% Graphic Trigger (4.6s) - Shift Center-Bottom
tl.to('#a-roll', {
x: 960,
y: 750,
width: 1920 * 0.7,
height: 1080 * 0.7,
duration: 0.5,
ease: "power2.inOut"
}, 4.6);
// 4. Moment 3: Editing Skills Trigger (8.8s) - Shift Right
tl.to('#a-roll', {
x: 1320,
y: 540,
width: 1920 * 0.75,
height: 1080 * 0.75,
duration: 0.5,
ease: "power2.inOut"
}, 8.8);
// 5. Final Reveal (14s - 17s)
tl.to('#a-roll', {
x: 960,
y: 540,
width: 1920 * 1.0,
height: 1080 * 1.0,
duration: 1.0,
ease: "power2.inOut"
}, 14);
window.__timelines["main-video"] = tl;
</script>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import pc from "picocolors";
const isColorSupported =
process.stdout.isTTY === true &&
!process.env["NO_COLOR"] &&
process.env["FORCE_COLOR"] !== "0";
function wrap(fn: (s: string) => string): (s: string) => string {
return isColorSupported ? fn : (s: string) => s;
}
export const c = {
success: wrap(pc.green),
error: wrap(pc.red),
warn: wrap(pc.yellow),
dim: wrap(pc.dim),
bold: wrap(pc.bold),
accent: wrap(pc.cyan),
progress: wrap(pc.magenta),
reset: isColorSupported ? pc.reset : (s: string) => s,
};
export { isColorSupported };
+27
View File
@@ -0,0 +1,27 @@
import { c } from "./colors.js";
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDuration(ms: number): string {
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const remaining = seconds - minutes * 60;
return `${minutes}m ${remaining.toFixed(1)}s`;
}
export function label(name: string, value: string): string {
const pad = 14 - name.length;
return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`;
}
export function errorBox(title: string, hint?: string, suggestion?: string): void {
console.error(`\n${c.error("\u2717")} ${c.bold(title)}`);
if (hint) console.error(`\n ${c.dim(hint)}`);
if (suggestion) console.error(` ${c.accent(suggestion)}`);
console.error();
}
+20
View File
@@ -0,0 +1,20 @@
import { c } from "./colors.js";
const { stdout } = process;
export function renderProgress(percent: number, stage: string, row?: number): void {
const width = 25;
const filled = Math.floor(percent / (100 / width));
const empty = width - filled;
const bar =
c.progress("\u2588".repeat(filled)) +
c.dim("\u2591".repeat(empty));
const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`;
if (row !== undefined && stdout.isTTY) {
stdout.write(`\x1b[${row};1H\x1b[2K${line}`);
} else {
stdout.write(`\r\x1b[2K${line}`);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { DOMParser } from "linkedom";
/**
* Polyfill DOMParser on globalThis so @hyperframes/core's parseHtml works in Node.js.
* Safe to call multiple times — only sets the global once.
*/
export function ensureDOMParser(): void {
if (typeof globalThis.DOMParser === "undefined") {
(globalThis as Record<string, unknown>).DOMParser = DOMParser;
}
}
+21
View File
@@ -0,0 +1,21 @@
export const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".ico": "image/x-icon",
};
+7
View File
@@ -0,0 +1,7 @@
/**
* Dynamically load the producer module. tsup inlines @hyperframes/producer
* via noExternal so this resolves in the published bundle.
*/
export async function loadProducer() {
return await import("@hyperframes/producer");
}
+30
View File
@@ -0,0 +1,30 @@
import { existsSync, statSync } from "node:fs";
import { resolve, basename } from "node:path";
import { errorBox } from "../ui/format.js";
export interface ProjectDir {
dir: string;
name: string;
indexPath: string;
}
export function resolveProject(dirArg: string | undefined): ProjectDir {
const dir = resolve(dirArg ?? ".");
const name = basename(dir);
const indexPath = resolve(dir, "index.html");
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
errorBox("Not a directory: " + dir);
process.exit(1);
}
if (!existsSync(indexPath)) {
errorBox(
"No composition found in " + dir,
"No index.html file found.",
"Run npx hyperframes init to create a new composition.",
);
process.exit(1);
}
return { dir, name, indexPath };
}
+1
View File
@@ -0,0 +1 @@
export const VERSION = "0.1.0";
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from "tsup";
import { resolve } from "node:path";
export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
outDir: "dist",
target: "node22",
platform: "node",
bundle: true,
splitting: false,
sourcemap: false,
clean: true,
banner: {
js: `import { createRequire as __hf_createRequire } from "node:module";
import { fileURLToPath as __hf_fileURLToPath } from "node:url";
import { dirname as __hf_dirname } from "node:path";
const require = __hf_createRequire(import.meta.url);
const __filename = __hf_fileURLToPath(import.meta.url);
const __dirname = __hf_dirname(__filename);`,
},
external: [
"puppeteer-core",
"puppeteer",
"@puppeteer/browsers",
"open",
"hono",
"hono/*",
"@hono/node-server",
"cheerio",
"mime-types",
"adm-zip",
"esbuild",
],
noExternal: [
"@hyperframes/core",
"@hyperframes/producer",
"@hyperframes/studio-backend",
"@hyperframes/engine",
"@clack/prompts",
"@clack/core",
"picocolors",
"linkedom",
"sisteransi",
"is-unicode-supported",
"citty",
],
esbuildOptions(options) {
options.alias = {
"@hyperframes/producer": resolve(__dirname, "../producer/src/index.ts"),
};
},
});
+125
View File
@@ -0,0 +1,125 @@
# HyperFrames Core
> Create videos by writing HTML. The HTML is the source of truth - timing, layout, content. Scripts define how clips animate.
HyperFrames Core is the foundational library for video composition. It defines the data attributes and HTML structure that represent video timelines, with GSAP animations to bring clips to life.
Frame adapter direction:
- [`../FRAME.md`](../FRAME.md) - repository-level frame model and deterministic renderer direction
- [`adapters/README.md`](adapters/README.md) - adapter contract and contribution guide
## Philosophy
```
HTML = Scene description (content, timing, layout)
CSS = Styling
JS = GSAP animations (motion)
```
Every element in the video timeline is represented as an HTML element with data attributes that define its position in time.
---
## Documentation
### Schema
The specification for valid HyperFrames HTML:
- **[schema.md](docs/schema.md)** - Element types, data attributes, HTML patterns
### Guides
For AI agents and developers working with HyperFrames:
| Document | Description |
| --------------------------------------------------------- | -------------------------------------- |
| [Cheat Sheet](docs/guides/cheat-sheet.md) | Quick reference for common actions |
| [Position Guide](docs/guides/position.md) | Natural language → position mappings |
| [Text Style Guide](docs/guides/text-style.md) | Natural language → text style mappings |
| [Motion Design Guide](docs/guides/motion-design/guide.md) | Creating and editing motion designs |
| [Caption Guide](docs/guides/motion-design/captions.md) | Adding captions to videos |
---
## Quick Start
### Using the Editor (Recommended)
```bash
pnpm install
pnpm dev
```
This starts the studio dev server.
### Writing Compositions Manually
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
#stage {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
</style>
</head>
<body>
<div id="stage">
<video id="el-1" data-start="0" data-end="10" src="video.mp4" muted playsinline></video>
<div id="el-2" data-start="2" data-end="6" data-type="text">
<div>Hello World</div>
</div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
// Add animations...
</script>
</body>
</html>
```
---
## Element Types
| Type | HTML Tag | Purpose |
| ----------- | ------------------------------- | -------------------- |
| video | `<video>` | Video clips |
| image | `<img>` | Static images |
| text | `<div data-type="text">` | Text overlays |
| audio | `<audio>` | Music, sound effects |
| composition | `<div data-type="composition">` | Motion designs |
## Data Attributes
| Attribute | Purpose | Required |
| ------------ | --------------------------------- | -------- |
| `data-start` | When element appears (seconds) | Yes |
| `data-end` | When element disappears (seconds) | No |
| `data-name` | Display name in UI | No |
| `data-layer` | Z-index for stacking | No |
See [schema.md](docs/schema.md) for the complete reference.
---
**Remember:** HTML is the storyboard. CSS is the art direction. JS is just for motion.
+73
View File
@@ -0,0 +1,73 @@
# Common Mistakes
Pitfalls that break HyperFrames compositions that can't be caught by the linter.
> **Linter:** Run `lintHyperframeHtml()` on your composition to catch most issues automatically. The linter detects: missing timeline registration, unmuted video, nested video in timed elements, missing `class="clip"`, deprecated attribute names (`data-layer`, `data-end`), and missing dimensions. See `core/src/lint/` for the full rule list.
---
## 1. Animating video element dimensions with GSAP
**Symptom:** Video frames stop updating, or browser performance drops.
**Cause:** GSAP animating `width`, `height`, `top`, `left` directly on a `<video>` element can cause the browser to stop rendering frames.
```js
// BROKEN — animating video element dimensions
tl.to("#el-video", { width: 500, height: 280, top: 700, left: 1400 }, 26);
// FIXED — animate a wrapper div, video fills it at 100%
tl.to("#pip-wrapper", { width: 500, height: 280, top: 700, left: 1400 }, 26);
```
Use a non-timed wrapper div for visual effects like picture-in-picture. Animate the wrapper; let the video fill it.
---
## 2. Controlling media playback in scripts
**Symptom:** Audio/video playback is out of sync, or plays when it shouldn't.
**Cause:** Calling `video.play()`, `video.pause()`, `audio.currentTime = ...` in your scripts. The framework owns all media playback.
```js
// BROKEN — conflicts with framework media sync
document.getElementById("el-video").play();
document.getElementById("el-audio").currentTime = 5;
// FIXED — don't do this. The framework handles it.
// Use GSAP for visual animations only:
tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
```
---
## 3. Composition duration shorter than video
**Symptom:** Video plays for a few seconds then stops. Timeline shows 8-10 seconds even though the video is minutes long.
**Cause:** The composition duration equals the GSAP timeline duration, not `data-duration` on the video. If your last GSAP animation ends at 8 seconds, the composition is 8 seconds long.
```js
// BROKEN — timeline is only 8s long, video cuts off
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
// Last tween ends at 7.8s → composition = 7.8s
// FIXED — extend timeline to match video length
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
tl.set({}, {}, 283); // ← extends timeline to 283 seconds
```
`tl.set({}, {}, TIME)` adds a zero-duration tween at the specified time, which extends the timeline without affecting any elements.
---
## Debugging checklist
When something doesn't work, check in this order:
1. Run the linter: `lintHyperframeHtml(html)` — catches most structural issues
2. Is `window.__timelines["<id>"]` registered? Does the key match `data-composition-id`?
3. Are GSAP animations only on visual properties (opacity, transform, color)?
4. Is the GSAP timeline long enough? (`tl.set({}, {}, DURATION)` at the end)
5. Open browser console — runtime errors show up as `[Browser:ERROR]` or `[Browser:PAGEERROR]`
+532
View File
@@ -0,0 +1,532 @@
# HyperFrames Schema
Reference for generating and editing HyperFrames HTML compositions. This is your source of truth for how to author compositions.
**New to HyperFrames?** Start with the [quickstart template](./quickstart-template.html) — a copy-paste composition with inline comments explaining every required piece. See [common mistakes](./common-mistakes.md) for pitfalls that break compositions.
For Frame adapters and deterministic frame rendering direction, see [`../../FRAME.md`](../../FRAME.md) and [`../adapters/README.md`](../adapters/README.md).
Producer-canonical parity note:
- Producer render behavior is the source of truth for deterministic parity.
- Preview should emulate producer seek semantics (`renderSeek`, frame quantization, readiness gates) in parity mode.
- Non-parity smooth playback can exist, but parity mode is the correctness baseline.
## Overview
HyperFrames uses HTML as the source of truth for describing a video:
- **HTML clips** = video, image, audio, composition
- **Data attributes** = timing, metadata, styling
- **CSS** = positioning and appearance
- **GSAP timeline** = animations and playback sync
### Framework-Managed Behavior
The framework reads data attributes and automatically manages:
- **Primitive clip timeline entries** — the framework reads `data-start`, `data-duration`, and `data-track-index` from primitive clips and adds them to the composition's GSAP timeline. You do not manually add primitive clips to the timeline in scripts.
- **Media playback** (play, pause, seek) for `<video>` and `<audio>`
- **Clip lifecycle** — clips are **mounted** (made visible on screen) and **unmounted** (removed from screen) based on `data-start` and `data-duration`
- **Timeline synchronization** (keeping media in sync with the GSAP master timeline)
- **Media loading** — the framework waits for all media elements to load before resolving timing and starting playback
Mounting and unmounting controls **presence**, not appearance. A clip that is mounted is on screen; a clip that is unmounted is not. Transitions (fade in, slide in, etc.) are separate — they are animated in scripts and happen _after_ a clip is mounted or _before_ it is unmounted.
The framework does **not** handle transitions, effects, or visual animation — those are driven by GSAP in JavaScript.
Do not manually call `video.play()`, `video.pause()`, set `audio.currentTime`, or mount/unmount clips in scripts. The framework owns media playback and clip lifecycle. Animating visual properties like `opacity` or `transform` for transitions is fine — that's what scripts are for.
## Viewport
Every composition must include `data-width` and `data-height` so scripts and CSS can reference concrete pixel dimensions for layout. Common sizes:
- **Landscape**: `data-width="1920" data-height="1080"`
- **Portrait**: `data-width="1080" data-height="1920"`
e.g.,
```html
<div id="main" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- clips -->
</div>
```
Every composition's container is full-screen within the viewport by default. The framework applies full-screen sizing to composition containers automatically.
To position or size individual clips (e.g., picture-in-picture, overlay placement), use standard CSS on the element.
## Compositions
A composition is the fundamental grouping unit in HyperFrames. Every clip — video, image, audio — must live inside a composition. The `index.html` file is itself a composition (the top-level one), and it can contain nested compositions within it. Any composition can be imported into another composition as a sub-composition — there is no special "root" type.
A composition carries the same core attributes as any other clip (`id`, `data-start`, `data-track-index`), so it can be placed and timed on a timeline just like a video or image. A composition's length is determined by its GSAP timeline — there is no `data-duration` on compositions. This means compositions can be nested: a composition clip inside another composition behaves like a self-contained video within the parent timeline.
### Composition File Structure
**Each composition should be defined in its own HTML file.** This keeps compositions modular, reusable, and maintainable. The file contains the complete composition: HTML structure, inline styles, and script.
```
project/
├── index.html # Root composition
├── compositions/
│ ├── intro-anim.html # Intro animation composition
│ ├── caption-overlay.html # Caption composition
│ └── outro-title.html # Outro composition
```
**Composition file format** (`compositions/intro-anim.html`):
```html
<!-- Define the composition as a template that can be loaded -->
<template id="intro-anim-template">
<div data-composition-id="intro-anim" data-width="1920" data-height="1080">
<div class="title">Welcome!</div>
<div class="subtitle">Let's get started</div>
<style>
[data-composition-id="intro-anim"] .title {
font-size: 72px;
color: white;
text-align: center;
}
[data-composition-id="intro-anim"] .subtitle {
font-size: 36px;
color: #ccc;
text-align: center;
}
</style>
<script>
const tl = gsap.timeline({ paused: true });
tl.from(".title", { opacity: 0, y: -50, duration: 1 });
tl.from(".subtitle", { opacity: 0, y: 50, duration: 1 }, 0.5);
window.__timelines["intro-anim"] = tl;
</script>
</div>
</template>
```
### Loading Compositions
Use the `data-composition-src` attribute to load a composition from an external HTML file. The framework will automatically fetch the template and instantiate it:
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Primitive clips -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Load composition from external file -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="3"
></div>
<!-- Another loaded composition -->
<div
id="el-6"
data-composition-id="captions"
data-composition-src="compositions/caption-overlay.html"
data-start="0"
data-track-index="4"
></div>
</div>
```
The framework will:
1. Fetch the HTML file specified in `data-composition-src`
2. Extract the `<template>` content
3. Clone and mount it into the composition element
4. Execute any `<script>` tags within the template
5. Register the timeline in `window.__timelines`
### Best Practices for Composition Files
**Use separate HTML files when:**
- The composition is reusable across multiple projects or scenes
- The composition has complex logic, styling, or structure (>20 lines)
- You want to keep the main `index.html` clean and focused on orchestration
- The composition represents a distinct functional unit (captions, titles, animations)
**Use inline compositions when:**
- The composition is truly one-off and project-specific
- The composition is very simple (<10 lines total)
- You're prototyping and iterating quickly
**File naming conventions:**
- Use kebab-case: `intro-anim.html`, `caption-overlay.html`, `emoji-burst.html`
- Name files descriptively based on their purpose or visual function
- Group related compositions in subdirectories if you have many: `compositions/titles/`, `compositions/overlays/`
## Clip Types
A clip is any discrete block on the timeline. We represent clips as HTML elements and apply data-attributes to describe them.
- `<video>` — Video clips, B-roll, A-roll
- `<img>` — Static images, overlays
- `<audio>` — Music, sound effects
- `<div data-composition-id="...">` — Nested compositions (animations, grouped sequences)
## HTML Attributes
### All Clips
- `id` — Unique identifier (e.g., "el-1")
- `data-start` — Start time in seconds, or a clip `id` reference. See [Relative Timing](#relative-timing).
- `data-duration` — Duration in seconds. Required for `<img>` clips. Optional for `<video>` and `<audio>` (defaults to the source media's full duration). Not used on compositions.
- `data-track-index` — Timeline track number. Tracks serve two purposes: they determine visual layering (higher tracks render in front) and they group clips into rows on the timeline. Clips on the same track **cannot overlap in time**.
### Media Clips (video, audio)
- `data-media-start` — (optional) Playback begins at this time in the source file, in seconds. Defaults to `0`.
### Composition Clips
- `data-composition-id` — Unique composition ID
- `data-composition-src` — (optional) Path to external HTML file containing the composition template. The framework will fetch, instantiate, and mount the template automatically.
> Compositions do **not** use `data-duration`. Their duration is determined by their GSAP timeline.
## Relative Timing
Instead of calculating absolute start times, a clip can reference another clip's `id` in its `data-start` attribute. This means "start when that clip ends." The referenced clip must be in the same composition and must have a known duration (either an explicit `data-duration` or an inferred duration from the source media).
### Basic Sequential Clips
```html
<video id="intro" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="main" data-start="intro" data-duration="20" data-track-index="0" src="..."></video>
<video id="outro" data-start="main" data-duration="5" data-track-index="0" src="..."></video>
```
`main` resolves to second 10, `outro` resolves to second 30. If `intro`'s duration changes to 15, `main` and `outro` shift automatically.
### Offsets (gaps and overlaps)
Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
```html
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
<video id="scene-a" data-start="intro + 2" data-duration="20" data-track-index="0" src="..."></video>
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
<!-- Different track because clips on the same track cannot overlap -->
<video id="scene-b" data-start="intro - 0.5" data-duration="20" data-track-index="1" src="..."></video>
```
### Rules
- **Same composition only** — references resolve within the clip's parent composition
- **No circular references** — A cannot start after B if B starts after A
- **Referenced clip must have a known duration** — the system needs a known end time to resolve the reference (either explicit `data-duration` or inferred from source media)
- **Parsing** — if the value is a valid number, it is absolute seconds; otherwise it is parsed as `<id>`, `<id> + <number>`, or `<id> - <number>`
## Video Clips
Full-screen or positioned video clips. Videos sync their playback to the timeline position.
```html
<video
id="el-1"
data-start="0"
data-duration="15"
data-track-index="0"
src="./assets/video.mp4"
></video>
```
- `data-media-start` — Playback begins at this time in the source video file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted showing the last frame). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Image Clips
Static images that appear for a duration.
```html
<img
id="el-2"
data-start="5"
data-duration="4"
data-track-index="1"
src="./assets/video.mp4"
/>
```
## Audio Clips
Background music or sound effects. Audio clips are invisible.
```html
<audio
id="el-4"
data-start="0"
data-duration="30"
data-track-index="2"
src="./assets/music.mp3"
></audio>
```
- `data-media-start` — Playback begins at this time in the source audio file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted but silent). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Two Layers: Primitives and Scripts
Every composition — master or sub — has the same two layers:
- **HTML** — primitive clips (`video`, `img`, `audio`, nested `div[data-composition-id]`). This is the declarative structure: what plays, when, and on which track.
- **Script** — effects, transitions, dynamic DOM, canvas, SVG — creative animation and visuals via GSAP. Scripts do **not** control media playback or clip visibility; the framework handles those via data attributes.
Both layers are available to every composition. The schema defines the primitives and the timeline contract; scripts handle visual creativity on top of that.
> **Warning:** Never use scripts to play/pause/seek media elements or to show/hide clips based on timing. The framework does this automatically from `data-start`, `data-duration`, and `data-media-start`. Scripts that duplicate this behavior will conflict with the framework.
### Script Isolation
Each composition's script is scoped to that composition. When a composition is loaded from an external HTML file via `data-composition-src`, its inline `<script>` and `<style>` tags are automatically included and scoped to that composition.
**Preferred approach** — Composition in separate HTML file:
```html
<!-- In index.html -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="2"
></div>
```
**Alternative approach** — External JS file:
```html
<!-- In index.html -->
<div
id="el-5"
data-composition-id="intro-anim"
data-start="0"
data-track-index="2"
data-width="1920"
data-height="1080"
>
<script src="intro-anim.js"></script>
</div>
```
The separate HTML file approach is preferred because it keeps all composition code (structure, style, script) in one self-contained, reusable file.
The only required file is `index.html`. Every composition must have at least a script to create and register its GSAP timeline.
### Top-Level Composition
The top-level composition is the `index.html` entry point. It acts as the conductor — sequencing clips and placing sub-composition timelines into an overall master timeline. It can technically do anything in its script, but its primary purpose is high-level orchestration. Any composition can serve as a top-level composition or be nested into another — there is no structural difference.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Primitive clips -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Load sub-compositions from external files -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="3"
></div>
<div
id="el-6"
data-composition-id="captions"
data-composition-src="compositions/caption-overlay.html"
data-start="0"
data-track-index="4"
></div>
<script>
// Just register the timeline - framework auto-nests sub-compositions
const tl = gsap.timeline({ paused: true });
window.__timelines["my-video"] = tl;
</script>
</div>
```
### Sub-Compositions
Sub-compositions are a spectrum. One might simply group a few primitive clips. Another might be a fully custom program with its own HTML, CSS, and JavaScript — creating, animating, and destroying DOM however it sees fit. There are no categories or constraints on what a sub-composition does internally. The only rule: it must be driven by a GSAP timeline and export it. If children use `position: absolute`, always set explicit `left`/`top`/`bottom`/`right` — the composition root is a positioned container, so omitting coordinates produces unpredictable placement.
## Wrapping Dynamic Content in Compositions
**Critical Rule: All visual content must live inside a composition with data attributes to appear in the timeline.**
When you have dynamic or script-animated content (captions, emojis, overlays, text animations), wrap them in a composition element with `data-start`, `data-duration` (or let the timeline determine duration), and `data-track-index`. The children inside can be freely created and animated via JavaScript—they don't need individual data attributes.
### Wrong: Dynamic content outside a composition
```html
<!-- BAD: captions-container is not a composition - won't appear in timeline -->
<div id="ui-layer">
<div id="captions-container">
<!-- Dynamically created caption groups via JS -->
</div>
<div class="emoji" id="emoji-1">🤩</div>
</div>
<script>
// These animations work visually but elements don't appear in timeline
tl.to(".caption-group", { opacity: 1 }, 0.5);
tl.to("#emoji-1", { scale: 1.2 }, 2);
</script>
```
### Correct: Dynamic content wrapped in compositions
**Preferred approach** — Load from external HTML files:
```html
<!-- GOOD: Each logical group is a composition loaded from its own file -->
<div
id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-track-index="5"
></div>
<div
id="emojis-comp"
data-composition-id="emojis"
data-composition-src="compositions/emojis.html"
data-start="0"
data-track-index="6"
></div>
```
**Alternative approach** — Inline composition (useful for one-off custom compositions):
```html
<div
id="captions-comp"
data-composition-id="captions"
data-start="0"
data-track-index="5"
data-width="1080"
data-height="1920"
>
<!-- Children created/animated by script - no data attributes needed -->
<div id="captions-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
// Dynamically create and animate caption groups...
window.__timelines["captions"] = captionTL;
</script>
</div>
<div
id="emojis-comp"
data-composition-id="emojis"
data-start="0"
data-track-index="6"
data-width="1080"
data-height="1920"
>
<div class="emoji" id="emoji-1">🤩</div>
<div class="emoji" id="emoji-2">🏔️</div>
<script>
const emojiTL = gsap.timeline({ paused: true });
emojiTL.to("#emoji-1", { opacity: 1, scale: 1.2 }, 2);
emojiTL.to("#emoji-2", { opacity: 1, scale: 1.2 }, 4);
window.__timelines["emojis"] = emojiTL;
</script>
</div>
```
### When to create separate compositions
- **Captions**: One composition for all captions, script manages word groups
- **Emojis/Stickers**: One composition for the emoji layer
- **Hooks/Titles**: One composition per distinct title sequence
- **Overlays**: Group related overlays into compositions by purpose
The composition appears in the timeline with its start time and duration (determined by its GSAP timeline). Everything inside is managed by the script but inherits the composition's timeline position.
### Caption discoverability contract
To make caption previews deterministic and easy to isolate in downstream UIs (timeline, mini-player, editor overlays), caption compositions should expose a stable root selector.
Use these attributes on the caption root node:
- `data-timeline-role="captions"` (required)
- `data-caption-root="true"` (recommended)
Example:
```html
<div
id="captions-comp"
data-composition-id="captions"
data-start="0"
data-track-index="5"
data-width="1080"
data-height="1920"
data-timeline-role="captions"
data-caption-root="true"
>
<div id="caption-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
window.__timelines["captions"] = captionTL;
</script>
</div>
```
## Timeline Contract
The framework initializes `window.__timelines = {}` before any scripts run. Every composition **must** have a script that creates a GSAP timeline and registers it:
```js
const tl = gsap.timeline({ paused: true });
// ... add tweens, nested timelines, etc.
window.__timelines["<data-composition-id>"] = tl;
```
### Rules
- **Every composition needs a script** — at minimum, to create and register its timeline. A composition without a script has no timeline and cannot participate in the hierarchy.
- **All timelines start paused** — create timelines with `{ paused: true }`. The top-level timeline is controlled externally by the frontend player or renderer.
- **Framework auto-nests sub-timelines** — you do **not** need to manually add sub-composition timelines to the master timeline. The framework automatically nests any timeline registered in `window.__timelines` into its parent based on the composition's `data-start` attribute.
- **Duration comes from the timeline** — a composition's duration is `tl.duration()`. The timeline is the sole source of truth; there is no `data-duration` on compositions.
- **Timelines must be finite** — every timeline must have a finite duration. Infinite or indefinite timelines are not supported.
### What NOT to do
```js
// UNNECESSARY - the framework does this automatically
if (window.__timelines["captions"]) {
masterTL.add(window.__timelines["captions"], 0);
}
```
Just register your timeline in `window.__timelines` and the framework handles the rest.
## Output Checklist
- [ ] Every composition has `data-width` and `data-height` attributes
- [ ] Each reusable composition is in its own HTML file (in `compositions/` directory)
- [ ] Compositions loaded via `data-composition-src` attribute
- [ ] Each composition file uses `<template>` tag to wrap its content
- [ ] `window.__timelines` given all compositions' timelines
- [ ] Complex/dynamic animations are handled by scripts in compositions
+61
View File
@@ -0,0 +1,61 @@
# Core Notes
Extended design rationale and context for decisions in `core.md`. This file is for humans reviewing the design — it is not consumed by the LLM agent.
## Interactive Compositions
### Why `data-start="interactive"`
We considered three alternatives:
1. **`data-interactive` boolean attribute** — Keeps `data-start` clean, but then `data-start` must either be omitted (breaking the convention that every clip has `data-start`) or present but meaningless. Two attributes to express what one value handles.
2. **Event-driven `data-start="on:click:#element"`** — More expressive and extensible to other events (`on:hover`, `on:end:#video`), but complex to parse, harder for an LLM to author correctly, and the trigger/target relationship is declared on the target which reads backwards.
3. **`data-start="interactive"` (chosen)** — Reuses the existing `data-start` attribute with a new keyword value. Reads naturally: "when does this start?" → "interactively." One attribute, no ambiguity, easy to parse (`=== "interactive"` check).
The tradeoff is that `data-start` is now overloaded (was purely numeric/reference, now has a keyword). We accepted this because the parsing is trivial and the readability gain is significant.
### Why `window.__navigate()` instead of `data-goto`
Navigation is behavior, not structure. The framework's philosophy separates these: HTML declares structure and timing, scripts handle behavior.
A `data-goto="composition-id"` attribute on trigger elements would be declarative and concise, but limits what authors can do. With a runtime API, scripts can:
- Add conditional logic (`if (score > 50) navigate('win') else navigate('lose')`)
- Animate a transition before navigating
- Add delays or timeouts
- Chain multiple actions on a single click
- Use any DOM event, not just clicks
The trigger element is just normal HTML with a normal `addEventListener`. The LLM only needs to know `window.__navigate(id)` — plain JS.
### Navigation Behavior: Replace
When `__navigate()` is called:
1. The currently active composition's timeline pauses
2. The current composition hides (visibility/display)
3. The target interactive composition shows
4. The target's timeline seeks to 0 and plays
We chose Replace (parent hides entirely) over Overlay (target plays on top) or Pause-and-branch (parent pauses, resumes when target ends) because it's the simplest mental model and matches how most interactive video works (YouTube branching, Netflix Bandersnatch).
### Ownership Model
Interactive compositions are children of the composition that branches to them in the DOM tree. The parent composition is the "root" of its branching experience — it owns its branches.
However, `window.__navigate()` resolves composition IDs globally across the full tree, not scoped to the current parent. This means:
- A deeply nested interactive composition can navigate to any other interactive composition by ID
- A shared "game over" or "credits" composition can be reached from anywhere
- Circular navigation is possible (A → B → A) — the framework does not prevent loops
### Future Extensions
These are not implemented but the design accommodates them without breaking changes:
- **Overlay mode**: `window.__navigate(id, { mode: 'overlay' })` — target plays on top, parent pauses or continues
- **History / back**: `window.__navigate('$back')` to return to the previous composition, `window.__navigateHistory` to read the stack
- **Auto-advance / timeout**: Scripts can implement this today with `setTimeout(() => window.__navigate('default'), 10000)`. A declarative shorthand could be added later.
- **Pause-and-branch**: `window.__navigate(id, { mode: 'branch' })` — parent pauses, resumes when target ends
+180
View File
@@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
REQUIRED: viewport must match your composition dimensions.
Landscape: width=1920, height=1080
Portrait: width=1080, height=1920
-->
<meta name="viewport" content="width=1920, height=1080" />
<!--
REQUIRED: data-composition-id identifies this composition.
Must match the key you register in window.__timelines below.
Optional: data-width/data-height help the producer determine resolution.
-->
<meta data-composition-id="my-video" data-width="1920" data-height="1080" />
<title>My Video</title>
<!-- REQUIRED: GSAP for timeline animations -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
#stage {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/*
REQUIRED: All timed clips need visibility: hidden.
The framework toggles visibility based on data-start/data-duration.
Never toggle visibility yourself in scripts.
*/
.clip {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
visibility: hidden;
object-fit: cover;
}
</style>
</head>
<body>
<div id="stage">
<!--
════════════════════════════════════════════════════
VIDEO CLIPS
════════════════════════════════════════════════════
REQUIRED attributes:
data-start — when the clip appears (seconds)
data-duration — how long the clip stays on screen (seconds)
(optional for video/audio — defaults to source duration)
data-track-index — z-stacking order + timeline track grouping
(higher = in front, clips on same track cannot overlap)
REQUIRED on <video>:
muted — framework manages audio via separate <audio> element
playsinline — prevents fullscreen on mobile
REQUIRED:
class="clip" — sets visibility: hidden so framework can manage lifecycle
DO NOT:
- Call video.play(), video.pause(), or set video.currentTime in scripts
- Toggle visibility in scripts
- Use the video element for audio (use a separate <audio> element)
-->
<video
id="el-video"
class="clip"
data-start="0"
data-duration="10"
data-track-index="0"
src="my-video.mp4"
muted
playsinline
></video>
<!--
════════════════════════════════════════════════════
IMAGE CLIPS
════════════════════════════════════════════════════
data-duration is REQUIRED for images (they have no intrinsic duration).
-->
<img id="el-image" class="clip" data-start="10" data-duration="5" data-track-index="0" src="my-image.png" />
<!--
════════════════════════════════════════════════════
AUDIO
════════════════════════════════════════════════════
Audio is invisible — no class="clip" needed (no visual to hide).
Use a separate <audio> element even if the source is the same .mp4 file.
The framework syncs audio playback to the timeline position.
Optional: data-volume="0.5" (0-1, default 1)
-->
<audio
id="el-audio"
data-start="0"
data-duration="15"
data-track-index="1"
data-volume="0.8"
src="my-video.mp4"
></audio>
<!--
════════════════════════════════════════════════════
TEXT / HTML OVERLAYS
════════════════════════════════════════════════════
Any div with data-start becomes a timed clip.
Content inside can be freely styled with CSS.
Animate visual properties (opacity, transform) with GSAP — fine.
Do NOT animate visibility — the framework owns that.
-->
<div
id="el-title"
class="clip"
data-start="1"
data-duration="4"
data-track-index="2"
style="display: flex; align-items: center; justify-content: center; z-index: 10"
>
<h1 style="font-family: sans-serif; font-size: 72px; color: white; opacity: 0">Hello World</h1>
</div>
</div>
<script>
/*
════════════════════════════════════════════════════
GSAP TIMELINE
════════════════════════════════════════════════════
REQUIRED: Create a paused GSAP timeline.
REQUIRED: Register it in window.__timelines with the SAME ID
as data-composition-id on the <meta> tag.
Without this registration, NOTHING works — no playback,
no seeking, no rendering. This is the #1 mistake.
*/
var tl = gsap.timeline({ paused: true });
// Animate visual properties only — opacity, transform, color, etc.
// The framework handles clip mounting/unmounting and media playback.
tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el-video", { opacity: 0, duration: 0.5 }, 9.5);
tl.to("#el-image", { opacity: 1, duration: 0.5 }, 10);
tl.to("#el-image", { opacity: 0, duration: 0.5 }, 14.5);
tl.to("#el-title h1", { opacity: 1, y: 0, duration: 0.8, ease: "power2.out" }, 1);
tl.to("#el-title h1", { opacity: 0, duration: 0.5 }, 4);
// REQUIRED: Register the timeline. The key MUST match data-composition-id.
window.__timelines = window.__timelines || {};
window.__timelines["my-video"] = tl;
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
# HyperFrames Core Changelog
## v0.1
Initial schema specification. Defines the foundational HTML-as-video format: compositions, clip types (video, image, audio, nested compositions), data attributes for timing and tracks, relative timing with offsets, the two-layer primitives + scripts model, and the GSAP timeline contract.
+326
View File
@@ -0,0 +1,326 @@
# HyperFrames Schema
Reference for generating and editing HyperFrames HTML compositions. This is your source of truth for how to author compositions.
## Overview
HyperFrames uses HTML as the source of truth for describing a video:
- **HTML clips** = video, image, audio, composition
- **Data attributes** = timing, metadata, styling
- **CSS** = positioning and appearance
- **GSAP timeline** = animations and playback sync
### Framework-Managed Behavior
The framework reads data attributes and automatically manages:
- **Primitive clip timeline entries** — the framework reads `data-start`, `data-duration`, and `data-track-index` from primitive clips and adds them to the composition's GSAP timeline. You do not manually add primitive clips to the timeline in scripts.
- **Media playback** (play, pause, seek) for `<video>` and `<audio>`
- **Clip lifecycle** — clips are **mounted** (made visible on screen) and **unmounted** (removed from screen) based on `data-start` and `data-duration`
- **Timeline synchronization** (keeping media in sync with the GSAP master timeline)
- **Media loading** — the framework waits for all media elements to load before resolving timing and starting playback
Mounting and unmounting controls **presence**, not appearance. A clip that is mounted is on screen; a clip that is unmounted is not. Transitions (fade in, slide in, etc.) are separate — they are animated in scripts and happen _after_ a clip is mounted or _before_ it is unmounted.
The framework does **not** handle transitions, effects, or visual animation — those are driven by GSAP in JavaScript.
Do not manually call `video.play()`, `video.pause()`, set `audio.currentTime`, or mount/unmount clips in scripts. The framework owns media playback and clip lifecycle. Animating visual properties like `opacity` or `transform` for transitions is fine — that's what scripts are for.
## Viewport
The root composition must declare its intended render dimensions using `data-width` and `data-height` attributes. Common sizes:
- **Landscape**: `data-width="1920" data-height="1080"`
- **Portrait**: `data-width="1080" data-height="1920"`
e.g.,
```html
<div id="main" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- clips -->
</div>
```
Every composition's container is full-screen within the viewport by default. The framework applies full-screen sizing to composition containers automatically.
To position or size individual clips (e.g., picture-in-picture, overlay placement), use standard CSS on the element.
## Compositions
A composition is the fundamental grouping unit in HyperFrames. Every clip — video, image, audio — must live inside a composition. The `index.html` file is itself a composition (the top-level one), and it can contain nested compositions within it. Any composition can be imported into another composition as a sub-composition — there is no special "root" type.
A composition carries the same core attributes as any other clip (`id`, `data-start`, `data-track-index`), so it can be placed and timed on a timeline just like a video or image. A composition's length is determined by its GSAP timeline — there is no `data-duration` on compositions. This means compositions can be nested: a composition clip inside another composition behaves like a self-contained video within the parent timeline.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Clips live inside the composition -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Nested composition (e.g. a motion graphic or title sequence) -->
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="3">
<!-- its own clips, timeline, and script -->
<script src="intro-anim.js"></script>
</div>
</div>
```
> Note: `data-width` and `data-height` are only required on the **root** composition. Nested compositions inherit the viewport dimensions.
## Clip Types
A clip is any discrete block on the timeline. We represent clips as HTML elements and apply data-attributes to describe them.
- `<video>` — Video clips, B-roll, A-roll
- `<img>` — Static images, overlays
- `<audio>` — Music, sound effects
- `<div data-composition-id="...">` — Nested compositions (animations, grouped sequences)
## HTML Attributes
### All Clips
- `id` — Unique identifier (e.g., "el-1")
- `data-start` — Start time in seconds, or a clip `id` reference. See [Relative Timing](#relative-timing).
- `data-duration` — Duration in seconds. Required for `<img>` clips. Optional for `<video>` and `<audio>` (defaults to the source media's full duration). Not used on compositions.
- `data-track-index` — Timeline track number. Tracks serve two purposes: they determine visual layering (higher tracks render in front) and they group clips into rows on the timeline. Clips on the same track **cannot overlap in time**.
### Media Clips (video, audio)
- `data-media-start` — (optional) Playback begins at this time in the source file, in seconds. Defaults to `0`.
### Composition Clips
- `data-composition-id` — Unique composition ID
> Compositions do **not** use `data-duration`. Their duration is determined by their GSAP timeline.
## Relative Timing
Instead of calculating absolute start times, a clip can reference another clip's `id` in its `data-start` attribute. This means "start when that clip ends." The referenced clip must be in the same composition and must have a known duration (either an explicit `data-duration` or an inferred duration from the source media).
### Basic Sequential Clips
```html
<video id="intro" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="main" data-start="intro" data-duration="20" data-track-index="0" src="..."></video>
<video id="outro" data-start="main" data-duration="5" data-track-index="0" src="..."></video>
```
`main` resolves to second 10, `outro` resolves to second 30. If `intro`'s duration changes to 15, `main` and `outro` shift automatically.
### Offsets (gaps and overlaps)
Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
```html
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
<video id="scene-a" data-start="intro + 2" data-duration="20" data-track-index="0" src="..."></video>
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
<!-- Different track because clips on the same track cannot overlap -->
<video id="scene-b" data-start="intro - 0.5" data-duration="20" data-track-index="1" src="..."></video>
```
### Rules
- **Same composition only** — references resolve within the clip's parent composition
- **No circular references** — A cannot start after B if B starts after A
- **Referenced clip must have a known duration** — the system needs a known end time to resolve the reference (either explicit `data-duration` or inferred from source media)
- **Parsing** — if the value is a valid number, it is absolute seconds; otherwise it is parsed as `<id>`, `<id> + <number>`, or `<id> - <number>`
## Video Clips
Full-screen or positioned video clips. Videos sync their playback to the timeline position.
```html
<video
id="el-1"
data-start="0"
data-duration="15"
data-track-index="0"
src="./assets/video.mp4"
></video>
```
- `data-media-start` — Playback begins at this time in the source video file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted showing the last frame). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Image Clips
Static images that appear for a duration.
```html
<img
id="el-2"
data-start="5"
data-duration="4"
data-track-index="1"
src="./assets/video.mp4"
/>
```
## Audio Clips
Background music or sound effects. Audio clips are invisible.
```html
<audio
id="el-4"
data-start="0"
data-duration="30"
data-track-index="2"
src="./assets/music.mp3"
></audio>
```
- `data-media-start` — Playback begins at this time in the source audio file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted but silent). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Two Layers: Primitives and Scripts
Every composition — master or sub — has the same two layers:
- **HTML** — primitive clips (`video`, `img`, `audio`, nested `div[data-composition-id]`). This is the declarative structure: what plays, when, and on which track.
- **Script** — effects, transitions, dynamic DOM, canvas, SVG — creative animation and visuals via GSAP. Scripts do **not** control media playback or clip visibility; the framework handles those via data attributes.
Both layers are available to every composition. The schema defines the primitives and the timeline contract; scripts handle visual creativity on top of that.
> **Warning:** Never use scripts to play/pause/seek media elements or to show/hide clips based on timing. The framework does this automatically from `data-start`, `data-duration`, and `data-media-start`. Scripts that duplicate this behavior will conflict with the framework.
### Script Isolation
Each composition's script is scoped to that composition. For sub-compositions, external JS files are the natural way to keep them self-contained and reusable:
```html
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="2">
<script src="intro-anim.js"></script>
</div>
```
Inline scripts are fine when the script belongs to that composition. The JS file itself is the documentation for what a composition does — the schema doesn't need to describe its internals.
The only required file is `index.html`. Every composition must have at least a script to create and register its GSAP timeline.
### Top-Level Composition
The top-level composition is the `index.html` entry point. It acts as the conductor — sequencing clips and placing sub-composition timelines into an overall master timeline. It can technically do anything in its script, but its primary purpose is high-level orchestration. Any composition can serve as a top-level composition or be nested into another — there is no structural difference.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="3">
<script src="intro-anim.js"></script>
</div>
<script>
// Just register the timeline - framework auto-nests sub-compositions
const tl = gsap.timeline({ paused: true });
window.__timelines["my-video"] = tl;
</script>
</div>
```
### Sub-Compositions
Sub-compositions are a spectrum. One might simply group a few primitive clips. Another might be a fully custom program with its own HTML, CSS, and JavaScript — creating, animating, and destroying DOM however it sees fit. There are no categories or constraints on what a sub-composition does internally. All compositions default to full-screen within the viewport. The only rule: it must be driven by a GSAP timeline and export it.
## Wrapping Dynamic Content in Compositions
**Critical Rule: All visual content must live inside a composition with data attributes to appear in the timeline.**
When you have dynamic or script-animated content (captions, emojis, overlays, text animations), wrap them in a composition element with `data-start`, `data-duration` (or let the timeline determine duration), and `data-track-index`. The children inside can be freely created and animated via JavaScript—they don't need individual data attributes.
### Wrong: Dynamic content outside a composition
```html
<!-- BAD: captions-container is not a composition - won't appear in timeline -->
<div id="ui-layer">
<div id="captions-container">
<!-- Dynamically created caption groups via JS -->
</div>
<div class="emoji" id="emoji-1">🤩</div>
</div>
<script>
// These animations work visually but elements don't appear in timeline
tl.to(".caption-group", { opacity: 1 }, 0.5);
tl.to("#emoji-1", { scale: 1.2 }, 2);
</script>
```
### Correct: Dynamic content wrapped in compositions
```html
<!-- GOOD: Each logical group is a composition that appears in timeline -->
<div id="captions-comp" data-composition-id="captions" data-start="0" data-track-index="5">
<!-- Children created/animated by script - no data attributes needed -->
<div id="captions-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
// Dynamically create and animate caption groups...
window.__timelines["captions"] = captionTL;
</script>
</div>
<div id="emojis-comp" data-composition-id="emojis" data-start="0" data-track-index="6">
<div class="emoji" id="emoji-1">🤩</div>
<div class="emoji" id="emoji-2">🏔️</div>
<script>
const emojiTL = gsap.timeline({ paused: true });
emojiTL.to("#emoji-1", { opacity: 1, scale: 1.2 }, 2);
emojiTL.to("#emoji-2", { opacity: 1, scale: 1.2 }, 4);
window.__timelines["emojis"] = emojiTL;
</script>
</div>
```
### When to create separate compositions
- **Captions**: One composition for all captions, script manages word groups
- **Emojis/Stickers**: One composition for the emoji layer
- **Hooks/Titles**: One composition per distinct title sequence
- **Overlays**: Group related overlays into compositions by purpose
The composition appears in the timeline with its start time and duration (determined by its GSAP timeline). Everything inside is managed by the script but inherits the composition's timeline position.
## Timeline Contract
The framework initializes `window.__timelines = {}` before any scripts run. Every composition **must** have a script that creates a GSAP timeline and registers it:
```js
const tl = gsap.timeline({ paused: true });
// ... add tweens, nested timelines, etc.
window.__timelines["<data-composition-id>"] = tl;
```
### Rules
- **Every composition needs a script** — at minimum, to create and register its timeline. A composition without a script has no timeline and cannot participate in the hierarchy.
- **All timelines start paused** — create timelines with `{ paused: true }`. The top-level timeline is controlled externally by the frontend player or renderer.
- **Framework auto-nests sub-timelines** — you do **not** need to manually add sub-composition timelines to the master timeline. The framework automatically nests any timeline registered in `window.__timelines` into its parent based on the composition's `data-start` attribute.
- **Duration comes from the timeline** — a composition's duration is `tl.duration()`. The timeline is the sole source of truth; there is no `data-duration` on compositions.
### What NOT to do
```js
// UNNECESSARY - the framework does this automatically
if (window.__timelines["captions"]) {
masterTL.add(window.__timelines["captions"], 0);
}
```
Just register your timeline in `window.__timelines` and the framework handles the rest.
## Output Checklist
- [ ] Root composition has `data-width` and `data-height` attributes
- [ ] `window.__timelines` given all compositions' timelines
- [ ] Complex/dynamic animations are handled by scripts in compositions
+83
View File
@@ -0,0 +1,83 @@
{
"name": "@hyperframes/core",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./lint": {
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
},
"./compiler": {
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
},
"files": [
"dist",
"docs",
"README.md"
],
"publishConfig": {
"access": "public",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./lint": {
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
},
"./compiler": {
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
}
},
"scripts": {
"build": "tsc && pnpm build:hyperframes-runtime",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts",
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts",
"test:hyperframe-runtime-behavior": "tsx scripts/test-hyperframe-runtime-behavior.ts",
"test:hyperframe-runtime-seek": "tsx scripts/test-hyperframe-runtime-seek.ts",
"test:hyperframe-runtime-duration-guards": "tsx scripts/test-hyperframe-runtime-duration-guards.ts",
"test:hyperframe-runtime-parity": "tsx scripts/test-hyperframe-runtime-parity.ts",
"test:hyperframe-runtime-security": "tsx scripts/test-hyperframe-runtime-security.ts",
"test:hyperframe-linter": "tsx scripts/test-hyperframe-linter.ts",
"test:hyperframe-runtime-ci": "pnpm build:hyperframes-runtime && pnpm test:hyperframe-runtime-contract && pnpm test:hyperframe-runtime-behavior && pnpm test:hyperframe-runtime-seek && pnpm test:hyperframe-runtime-duration-guards && pnpm test:hyperframe-runtime-parity && pnpm test:hyperframe-runtime-security",
"check:hyperframe-html": "tsx scripts/check-hyperframe-static.ts",
"debug:timeline": "tsx scripts/debug-timeline.ts",
"prepublishOnly": "pnpm build"
},
"optionalDependencies": {
"cheerio": "^1.2.0",
"esbuild": "^0.25.12"
},
"devDependencies": {
"@types/jsdom": "^28.0.0",
"@types/node": "^24.10.13",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^29.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import {
HYPERFRAME_RUNTIME_ARTIFACTS,
HYPERFRAME_RUNTIME_CONTRACT,
loadHyperframeRuntimeSource,
} from "../src/inline-scripts/hyperframe";
const thisDir = dirname(fileURLToPath(import.meta.url));
const distDir = resolve(thisDir, "../dist");
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm);
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
const runtimeSource = `${loadHyperframeRuntimeSource()}\n`;
const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex");
const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev";
const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts");
const esmBuild = buildSync({
entryPoints: [runtimeEntryPath],
bundle: true,
write: false,
platform: "browser",
format: "esm",
target: ["es2020"],
minify: true,
legalComments: "none",
});
const esmSource = `${esmBuild.outputFiles[0]?.text ?? ""}\n`;
const manifest = {
version: process.env.HYPERFRAME_RUNTIME_VERSION?.trim() || "0.1.0",
buildId,
sha256: runtimeSha256,
artifacts: {
iife: HYPERFRAME_RUNTIME_ARTIFACTS.iife,
esm: HYPERFRAME_RUNTIME_ARTIFACTS.esm,
},
contract: HYPERFRAME_RUNTIME_CONTRACT,
};
mkdirSync(distDir, { recursive: true });
writeFileSync(iifePath, runtimeSource, "utf8");
writeFileSync(esmPath, esmSource, "utf8");
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(
JSON.stringify({
event: "hyperframe_runtime_artifacts_generated",
buildId,
distDir,
iifePath,
esmPath,
manifestPath,
sourceBytes: Buffer.byteLength(runtimeSource, "utf8"),
esmBytes: Buffer.byteLength(esmSource, "utf8"),
sha256: runtimeSha256,
}),
);
@@ -0,0 +1,65 @@
import fs from "node:fs";
import path from "node:path";
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
import type { HyperframeLintResult } from "../src/lint/types";
function formatHumanOutput(result: HyperframeLintResult, resolvedPath: string): string {
const lines = [
result.ok
? `PASS ${resolvedPath} (${result.warningCount} warning${result.warningCount === 1 ? "" : "s"})`
: `FAIL ${resolvedPath} (${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${result.warningCount} warning${result.warningCount === 1 ? "" : "s"})`,
];
for (const finding of result.findings) {
lines.push(`- [${finding.severity.toUpperCase()}] ${finding.code}: ${finding.message}`);
if (finding.selector) {
lines.push(` selector: ${finding.selector}`);
}
if (finding.elementId) {
lines.push(` elementId: ${finding.elementId}`);
}
if (finding.fixHint) {
lines.push(` fix: ${finding.fixHint}`);
}
}
return lines.join("\n");
}
function main() {
const args = process.argv.slice(2);
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
const jsonOutput = normalizedArgs.includes("--json");
const positionalArgs = normalizedArgs.filter((arg) => arg !== "--json");
const inputPath = positionalArgs[0];
if (!inputPath) {
console.error(
"Usage: pnpm check:hyperframe-html [--json] <path-to-html>\nExample: pnpm check:hyperframe-html core/src/tests/broken-video.html",
);
process.exit(2);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`File not found: ${resolvedPath}`);
process.exit(2);
}
const html = fs.readFileSync(resolvedPath, "utf-8");
const result = lintHyperframeHtml(html, { filePath: resolvedPath });
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
}
if (result.ok) {
console.log(formatHumanOutput(result, resolvedPath));
process.exit(0);
}
console.error(formatHumanOutput(result, resolvedPath));
process.exit(1);
}
main();
+297
View File
@@ -0,0 +1,297 @@
import fs from "node:fs";
import path from "node:path";
type AttrMap = Record<string, string>;
type ParsedTag = {
tagName: string;
attrs: AttrMap;
raw: string;
};
type TimelineClip = {
id: string | null;
label: string;
start: number;
duration: number;
track: number;
kind: "video" | "audio" | "image" | "element";
tagName: string | null;
compositionId: string | null;
compositionSrc: string | null;
assetUrl: string | null;
durationSource: "deterministic" | "fallback";
};
type TimelinePayload = {
source: "hf-preview";
type: "timeline";
durationInFrames: number;
clips: TimelineClip[];
scenes: [];
compositionWidth: number;
compositionHeight: number;
};
function parseNum(value: string | null | undefined): number | null {
if (value == null) return null;
const parsed = Number.parseFloat(String(value));
return Number.isFinite(parsed) ? parsed : null;
}
function parseArgs() {
const args = process.argv.slice(2);
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
const inputPath = normalizedArgs[0];
let forcedFps: number | null = null;
let forcedMaxDuration: number | null = null;
for (let i = 1; i < normalizedArgs.length; i += 1) {
const arg = normalizedArgs[i];
if (arg === "--fps" && normalizedArgs[i + 1]) {
forcedFps = parseNum(normalizedArgs[i + 1]);
i += 1;
continue;
}
if (arg === "--max-duration" && normalizedArgs[i + 1]) {
forcedMaxDuration = parseNum(normalizedArgs[i + 1]);
i += 1;
}
}
return { inputPath, forcedFps, forcedMaxDuration };
}
function parseAttributes(rawAttrs: string): AttrMap {
const attrs: AttrMap = {};
const attrRegex = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
let match: RegExpExecArray | null = null;
while (true) {
match = attrRegex.exec(rawAttrs);
if (!match) break;
const key = match[1];
const value = match[3] ?? match[4] ?? match[5] ?? "";
attrs[key] = value;
}
return attrs;
}
function parseTags(html: string): ParsedTag[] {
const tags: ParsedTag[] = [];
const tagRegex = /<([a-zA-Z][\w:-]*)([^>]*)>/g;
let match: RegExpExecArray | null = null;
while (true) {
match = tagRegex.exec(html);
if (!match) break;
const raw = match[0];
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
const tagName = String(match[1] || "").toLowerCase();
if (!tagName) continue;
tags.push({
tagName,
raw,
attrs: parseAttributes(match[2] || ""),
});
}
return tags;
}
function extractWindowNumber(html: string, key: string): number | null {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`${escapedKey}\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)`, "i");
const match = html.match(regex);
if (!match) return null;
return parseNum(match[1]);
}
function normalizeDurationSeconds(
rawDuration: number | null,
fallbackDuration: number | null,
maxDuration: number,
): number {
const safeFallback = fallbackDuration != null && fallbackDuration > 0 ? fallbackDuration : 0;
const safeRaw = rawDuration != null && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
if (safeRaw > 0) return Math.min(safeRaw, maxDuration);
if (safeFallback > 0) return Math.min(safeFallback, maxDuration);
return 0;
}
function shouldIncludeTimelineNode(tag: ParsedTag, rootCompositionId: string | null): boolean {
const attrs = tag.attrs;
if (attrs["data-composition-id"] && rootCompositionId && attrs["data-composition-id"] === rootCompositionId) {
return false;
}
if (tag.tagName === "script" || tag.tagName === "style" || tag.tagName === "link" || tag.tagName === "meta") {
return false;
}
if ((attrs.class || "").split(/\s+/).includes("__preview_render_frame__")) return false;
if (attrs["data-start"] != null) return true;
if (attrs["data-track-index"] != null) return true;
if (attrs["data-composition-src"] != null) return true;
if (attrs["data-composition-id"] != null) return true;
return tag.tagName === "video" || tag.tagName === "audio" || tag.tagName === "img";
}
function inferClipDuration(tag: ParsedTag, start: number, maxDuration: number): number | null {
const attrs = tag.attrs;
const durationAttr = parseNum(attrs["data-duration"]);
if (durationAttr != null && durationAttr > 0) return normalizeDurationSeconds(durationAttr, null, maxDuration);
const endAttr = parseNum(attrs["data-end"]);
if (endAttr != null && endAttr > start) {
return normalizeDurationSeconds(endAttr - start, null, maxDuration);
}
if (tag.tagName === "video" || tag.tagName === "audio") {
const sourceDuration = parseNum(attrs["data-source-duration"]);
const playbackStart = parseNum(attrs["data-playback-start"]) ?? parseNum(attrs["data-playbackStart"]) ?? 0;
if (sourceDuration != null && sourceDuration > 0) {
return normalizeDurationSeconds(Math.max(0, sourceDuration - playbackStart), null, maxDuration);
}
}
if (attrs["data-composition-id"]) {
const sourceDuration = parseNum(attrs["data-source-duration"]);
if (sourceDuration != null && sourceDuration > 0) {
return normalizeDurationSeconds(sourceDuration, null, maxDuration);
}
}
return null;
}
function resolveNodeAssetUrl(tag: ParsedTag): string | null {
const attrs = tag.attrs;
return (
attrs.src ??
attrs["data-src"] ??
attrs["data-asset-src"] ??
attrs["data-video-src"] ??
attrs["data-image-src"] ??
null
);
}
function resolveRootTag(tags: ParsedTag[]): ParsedTag | null {
const explicitRoot = tags.find((tag) => tag.attrs["data-composition-id"] === "master");
if (explicitRoot) return explicitRoot;
return tags.find((tag) => tag.attrs["data-composition-id"] != null) ?? null;
}
function main() {
const { inputPath, forcedFps, forcedMaxDuration } = parseArgs();
if (!inputPath) {
console.error("Usage: pnpm debug:timeline <path-to-html> [--fps 30] [--max-duration 1800]");
process.exit(2);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`File not found: ${resolvedPath}`);
process.exit(2);
}
const html = fs.readFileSync(resolvedPath, "utf-8");
const tags = parseTags(html);
const root = resolveRootTag(tags);
const rootCompositionId = root?.attrs["data-composition-id"] ?? null;
const maxDuration =
(forcedMaxDuration != null && forcedMaxDuration > 0
? forcedMaxDuration
: extractWindowNumber(html, "window.__HF_MAX_DURATION_SEC")) ?? 1800;
const canonicalFps =
(forcedFps != null && forcedFps > 0
? forcedFps
: (parseNum(root?.attrs["data-fps"]) ?? extractWindowNumber(html, "window.__HF_FPS"))) ?? 30;
const rootDurationRaw =
parseNum(root?.attrs["data-composition-duration"]) ??
parseNum(root?.attrs["data-duration"]) ??
parseNum(tags.find((tag) => tag.tagName === "html")?.attrs["data-composition-duration"] ?? null);
const rootDuration = normalizeDurationSeconds(rootDurationRaw, null, maxDuration);
const nodes = tags.filter((tag) => shouldIncludeTimelineNode(tag, rootCompositionId));
const clips: TimelineClip[] = [];
let maxEnd = 0;
let unresolvedClipCount = 0;
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const attrs = node.attrs;
const start = Math.max(0, parseNum(attrs["data-start"]) ?? 0);
const inferredDuration = inferClipDuration(node, start, maxDuration);
const hasDeterministicDuration = inferredDuration != null && inferredDuration > 0;
let duration = hasDeterministicDuration ? normalizeDurationSeconds(inferredDuration, 0, maxDuration) : 0;
let durationSource: "deterministic" | "fallback" = "deterministic";
if (duration <= 0 && rootDuration > start) {
duration = normalizeDurationSeconds(rootDuration - start, 0, maxDuration);
durationSource = "fallback";
}
if (duration <= 0) {
unresolvedClipCount += 1;
continue;
}
if (hasDeterministicDuration) {
const end = start + duration;
if (end > maxEnd) maxEnd = end;
}
const kind =
node.tagName === "video"
? "video"
: node.tagName === "audio"
? "audio"
: node.tagName === "img"
? "image"
: "element";
const trackRaw = parseNum(attrs["data-track-index"]);
const track = trackRaw != null && Number.isFinite(trackRaw) ? Math.floor(trackRaw) : i;
clips.push({
id: attrs.id ?? null,
label: attrs["data-label"] ?? attrs["aria-label"] ?? attrs.id ?? kind,
start,
duration,
track,
kind,
tagName: node.tagName || null,
compositionId: attrs["data-composition-id"] ?? null,
compositionSrc: attrs["data-composition-src"] ?? null,
assetUrl: resolveNodeAssetUrl(node),
durationSource,
});
}
let effectiveDuration = 0;
if (maxEnd > 0) effectiveDuration = normalizeDurationSeconds(maxEnd, 0, maxDuration);
if (effectiveDuration <= 0) effectiveDuration = normalizeDurationSeconds(rootDuration, 1, maxDuration);
if (effectiveDuration <= 0) effectiveDuration = 1;
const compositionWidth = parseNum(root?.attrs["data-width"]) ?? 1920;
const compositionHeight = parseNum(root?.attrs["data-height"]) ?? 1080;
const payload: TimelinePayload = {
source: "hf-preview",
type: "timeline",
durationInFrames: Math.max(1, Math.round(effectiveDuration * canonicalFps)),
clips,
scenes: [],
compositionWidth,
compositionHeight,
};
const output = {
file: resolvedPath,
debug: {
canonicalFps,
maxDurationSeconds: maxDuration,
rootCompositionId,
rootDurationSeconds: rootDuration,
deterministicMaxEndSeconds: maxEnd,
effectiveDurationSeconds: effectiveDuration,
unresolvedClipCount,
clipCount: clips.length,
},
payload,
};
console.log(JSON.stringify(output, null, 2));
}
main();
@@ -0,0 +1,119 @@
import fs from "node:fs";
import path from "node:path";
type GuardSpec = {
id: string;
description: string;
filePath: string;
pattern: RegExp;
};
type GuardCheckResult = {
passed: GuardSpec[];
failed: GuardSpec[];
};
const GUARD_SPECS: GuardSpec[] = [
{
id: "external_compositions_gate",
description: "Do not bind timelines before external compositions are loaded",
filePath: "src/runtime/init.ts",
pattern: /if\s*\(\s*!externalCompositionsReady\s*\)\s*return\s+false;/,
},
{
id: "usable_timeline_gate",
description: "Skip rebinding when current timeline is already usable",
filePath: "src/runtime/init.ts",
pattern: /if\s*\(\s*currentTimeline\s*&&\s*currentTimelineUsable\s*\)\s*return\s+false;/,
},
{
id: "child_timeline_activation",
description: "Force root child timelines active before composition binding",
filePath: "src/runtime/init.ts",
pattern: /timelineWithPaused\.paused\(false\)/,
},
{
id: "root_unusable_fallback",
description: "Fallback to composite timeline when root duration is unusable",
filePath: "src/runtime/init.ts",
pattern:
/if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/,
},
{
id: "loop_guard_rebind",
description: "Enable loop guard based timeline rebinding",
filePath: "src/runtime/init.ts",
pattern: /if\s*\(\s*rebindTimelineFromResolution\(resolution,\s*"loop_guard"\)\s*\)/,
},
{
id: "early_play_rebind_hold",
description: "Hold rebinding during first playback seconds",
filePath: "src/runtime/init.ts",
pattern: /shouldHoldRebindDuringEarlyPlay/,
},
{
id: "external_script_ordering",
description: "Inject external composition scripts with deterministic ordering",
filePath: "src/runtime/compositionLoader.ts",
pattern: /injectedScript\.async\s*=\s*false;/,
},
{
id: "external_script_load_wait",
description: "Await external composition script load before continuing",
filePath: "src/runtime/compositionLoader.ts",
pattern: /await\s+waitForExternalScriptLoad\(injectedScript\);/,
},
];
function resolveFilePath(relativePath: string): string {
return path.resolve(process.cwd(), relativePath);
}
function checkGuards(guards: GuardSpec[]): GuardCheckResult {
const passed: GuardSpec[] = [];
const failed: GuardSpec[] = [];
for (const guard of guards) {
const absolutePath = resolveFilePath(guard.filePath);
if (!fs.existsSync(absolutePath)) {
failed.push(guard);
continue;
}
const content = fs.readFileSync(absolutePath, "utf8");
if (guard.pattern.test(content)) {
passed.push(guard);
} else {
failed.push(guard);
}
}
return { passed, failed };
}
function main(): void {
const result = checkGuards(GUARD_SPECS);
if (result.failed.length === 0) {
console.log(
JSON.stringify({
event: "runtime_preview_guards_lint_passed",
checkedGuards: GUARD_SPECS.length,
}),
);
return;
}
console.error(
JSON.stringify({
event: "runtime_preview_guards_lint_failed",
checkedGuards: GUARD_SPECS.length,
missingGuards: result.failed.map((guard) => ({
id: guard.id,
filePath: guard.filePath,
description: guard.description,
})),
}),
);
process.exit(1);
}
main();
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
function testCleanFixturePasses() {
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
const html = fs.readFileSync(fixturePath, "utf8");
const result = lintHyperframeHtml(html, { filePath: fixturePath });
assert.equal(result.ok, true, "chat-project-9 should pass without lint errors");
assert.equal(result.errorCount, 0, "chat-project-9 should have zero lint errors");
}
function testDetectsMissingCompositionHostId() {
const html = `
<html>
<body>
<div id="root" data-width="1080" data-height="1920">
<div data-composition-src="compositions/overlays.html"></div>
</div>
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
window.__timelines["root"] = tl;
</script>
</body>
</html>
`;
const result = lintHyperframeHtml(html);
const codes = result.findings.map((finding) => finding.code);
assert.equal(result.ok, false, "missing composition ids should fail lint");
assert.ok(codes.includes("root_missing_composition_id"));
assert.ok(codes.includes("host_missing_composition_id"));
}
function testDetectsOverlappingGsapTweens() {
const html = `
<html>
<body>
<div id="main" data-composition-id="main" data-width="1080" data-height="1920">
<div id="frame"></div>
</div>
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
tl.to("#frame", { y: 15, duration: 2.5, repeat: 1, yoyo: true }, 1.5);
tl.to("#frame", { y: 400, duration: 1.2 }, 4.5);
window.__timelines["main"] = tl;
</script>
</body>
</html>
`;
const result = lintHyperframeHtml(html);
const overlapFinding = result.findings.find((finding) => finding.code === "overlapping_gsap_tweens");
assert.ok(overlapFinding, "expected an overlapping GSAP tween warning");
assert.equal(overlapFinding?.severity, "warning");
}
function testCliJsonOutput() {
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
const tsxBin = path.join(ROOT, "node_modules/.bin/tsx");
const stdout = execFileSync(tsxBin, ["scripts/check-hyperframe-static.ts", "--json", fixturePath], {
cwd: ROOT,
encoding: "utf8",
});
const payload = JSON.parse(stdout);
assert.equal(payload.ok, true);
assert.equal(typeof payload.errorCount, "number");
assert.ok(Array.isArray(payload.findings));
}
function main() {
testCleanFixturePasses();
testDetectsMissingCompositionHostId();
testDetectsOverlappingGsapTweens();
testCliJsonOutput();
console.log("hyperframe linter tests passed");
}
main();
@@ -0,0 +1,32 @@
import { buildHyperframesRuntimeScript } from "../src/inline-scripts/hyperframesRuntime.engine";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const baseline = buildHyperframesRuntimeScript();
const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true });
const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false });
const withSourceUrl = buildHyperframesRuntimeScript({
sourceUrl: "hyperframe.runtime.iife.js",
});
assert(baseline.includes("window.__player"), "Baseline runtime should include player contract");
assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source");
assert(parityDisabled.length > 0, "Parity-disabled build should produce non-empty runtime source");
assert(
withSourceUrl.includes("//# sourceURL=hyperframe.runtime.iife.js"),
"Build with sourceUrl should append sourceURL comment",
);
console.log(
JSON.stringify({
event: "hyperframe_runtime_behavior_verified",
baselineBytes: baseline.length,
parityEnabledBytes: parityEnabled.length,
parityDisabledBytes: parityDisabled.length,
sourceUrlBytes: withSourceUrl.length,
}),
);
@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadHyperframeRuntimeSource } from "../src/inline-scripts/hyperframe";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const runtimeSource = loadHyperframeRuntimeSource();
const requiredSnippets = [
"window.__player",
"window.__playerReady",
"window.__renderReady",
"hf-preview",
"hf-parent",
"renderSeek",
];
for (const snippet of requiredSnippets) {
assert(runtimeSource.includes(snippet), `Runtime contract snippet missing: ${snippet}`);
}
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
const manifestPath = resolve(scriptDir, "../dist/hyperframe.manifest.json");
try {
const manifestRaw = readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(manifestRaw) as { artifacts?: { iife?: string; esm?: string } };
assert(Boolean(manifest.artifacts?.iife), "Manifest is missing iife artifact");
assert(Boolean(manifest.artifacts?.esm), "Manifest is missing esm artifact");
} catch {
// Build may not have run yet; contract-only checks above still provide signal.
}
console.log(
JSON.stringify({
event: "hyperframe_runtime_contract_verified",
requiredSnippetsChecked: requiredSnippets.length,
}),
);
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const initPath = resolve(scriptDir, "../src/runtime/init.ts");
const timelinePath = resolve(scriptDir, "../src/runtime/timeline.ts");
const initSource = readFileSync(initPath, "utf8");
const timelineSource = readFileSync(timelinePath, "utf8");
// Guard against regressions where preview duration gets capped by earliest video.
assert(!initSource.includes("resolveMainVideoDurationSeconds"), "init.ts should not use first-video duration helper");
assert(
!initSource.includes("Math.max(0, Math.min(safeDuration, mediaFloor))"),
"init.ts should not hard-clamp safe duration to media floor",
);
assert(
initSource.includes("resolveMediaWindowDurationSeconds"),
"init.ts should compute media window duration across timed media",
);
// Timeline payload windowing should also avoid first-video truncation.
assert(
!timelineSource.includes("resolveMainVideoWindowEndSeconds"),
"timeline.ts should not use first-video window end helper",
);
assert(
timelineSource.includes("resolveMediaWindowEndSeconds"),
"timeline.ts should compute media window end across timed media",
);
console.log(
JSON.stringify({
event: "hyperframe_runtime_duration_guards_verified",
initPath,
timelinePath,
}),
);
@@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { HYPERFRAME_RUNTIME_ARTIFACTS } from "../src/inline-scripts/hyperframe";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
const scriptsDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
const distDir = resolve(scriptsDir, "../dist");
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
const iifeSource = readFileSync(iifePath, "utf8");
const manifestRaw = readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(manifestRaw) as {
sha256?: string;
artifacts?: { iife?: string; esm?: string };
};
assert(iifeSource.length > 0, "IIFE runtime artifact is empty");
assert(
manifest.artifacts?.iife === HYPERFRAME_RUNTIME_ARTIFACTS.iife,
"Manifest iife artifact name is not strict expected value",
);
assert(
manifest.artifacts?.esm === HYPERFRAME_RUNTIME_ARTIFACTS.esm,
"Manifest esm artifact name is not strict expected value",
);
assert(Boolean(manifest.artifacts?.iife), "Manifest missing iife artifact entry");
assert(Boolean(manifest.artifacts?.esm), "Manifest missing esm artifact entry");
const runtimeSha = createHash("sha256").update(iifeSource, "utf8").digest("hex");
assert(runtimeSha === manifest.sha256, "Manifest sha256 does not match runtime artifact");
console.log(
JSON.stringify({
event: "hyperframe_runtime_parity_verified",
bytes: iifeSource.length,
sha256: runtimeSha,
}),
);
@@ -0,0 +1,96 @@
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const ALLOWED_HOSTS = new Set(["runtime.example.com"]);
const ALLOWED_PATH_PREFIX = "/static/hyperframes-runtime/";
function isAllowedRuntimeUrl(candidate: string): boolean {
try {
const parsed = new URL(candidate);
return (
parsed.protocol === "https:" &&
ALLOWED_HOSTS.has(parsed.hostname.toLowerCase()) &&
parsed.pathname.startsWith(ALLOWED_PATH_PREFIX) &&
parsed.pathname.endsWith(".js")
);
} catch {
return false;
}
}
function isGuardedPreviewMessage(data: unknown): boolean {
if (!data || typeof data !== "object") {
return false;
}
const record = data as { source?: unknown; type?: unknown };
const source = typeof record.source === "string" ? record.source : null;
const type = typeof record.type === "string" ? record.type : null;
const guardedTypes = new Set([
"state",
"timeline",
"element-picked",
"element-picked-many",
"element-pick-candidates",
"pick-mode-cancelled",
]);
if (!type || !guardedTypes.has(type)) {
return false;
}
return source === "hf-preview";
}
const allowedRuntimeFixtures = [
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/hyperframes-runtime/v2026.02.20/hyperframe.runtime.iife.js",
];
const blockedRuntimeFixtures = [
"http://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"javascript:alert(1)",
"data:text/javascript,alert(1)",
"https://evil.example/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/other/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.css",
];
for (const fixture of allowedRuntimeFixtures) {
assert(isAllowedRuntimeUrl(fixture), `Expected runtime URL to be allowed: ${fixture}`);
}
for (const fixture of blockedRuntimeFixtures) {
assert(!isAllowedRuntimeUrl(fixture), `Expected runtime URL to be blocked: ${fixture}`);
}
const allowedMessages = [
{ type: "state", source: "hf-preview" },
{ type: "timeline", source: "hf-preview" },
{ type: "element-picked", source: "hf-preview" },
];
const blockedMessages = [
{ type: "state", source: "hf-parent" },
{ type: "timeline", source: "evil-origin" },
{ type: "element-picked", source: "hf-parent" },
{ type: "pick-mode-cancelled", source: null },
{ type: "unknown-type", source: "hf-preview" },
];
for (const fixture of allowedMessages) {
assert(isGuardedPreviewMessage(fixture), `Expected message fixture to pass guard: ${JSON.stringify(fixture)}`);
}
for (const fixture of blockedMessages) {
assert(!isGuardedPreviewMessage(fixture), `Expected message fixture to fail guard: ${JSON.stringify(fixture)}`);
}
console.log(
JSON.stringify({
event: "hyperframe_runtime_security_fixtures_verified",
allowedRuntimeFixtures: allowedRuntimeFixtures.length,
blockedRuntimeFixtures: blockedRuntimeFixtures.length,
allowedMessages: allowedMessages.length,
blockedMessages: blockedMessages.length,
}),
);
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import { createGsapAdapter } from "../src/runtime/adapters/gsap";
import { createRuntimePlayer } from "../src/runtime/player";
import type { RuntimeTimelineLike } from "../src/runtime/types";
type Call = {
method: "pause" | "seek" | "totalTime";
time?: number;
suppressEvents?: boolean;
};
function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } {
const calls: Call[] = [];
const timeline: RuntimeTimelineLike = {
play: () => undefined,
pause: () => {
calls.push({ method: "pause" });
},
seek: (timeSeconds: number, suppressEvents?: boolean) => {
calls.push({ method: "seek", time: timeSeconds, suppressEvents });
},
time: () => 0,
duration: () => 12,
add: () => undefined,
paused: () => undefined,
set: () => undefined,
};
if (withTotalTime) {
timeline.totalTime = (timeSeconds: number, suppressEvents?: boolean) => {
calls.push({ method: "totalTime", time: timeSeconds, suppressEvents });
};
}
return { calls, timeline };
}
function createPlayer(timeline: RuntimeTimelineLike) {
const deterministicSeekCalls: number[] = [];
const syncMediaCalls: number[] = [];
const renderFrameSeekCalls: number[] = [];
const player = createRuntimePlayer({
getTimeline: () => timeline,
setTimeline: () => undefined,
getIsPlaying: () => false,
setIsPlaying: () => undefined,
getPlaybackRate: () => 1,
setPlaybackRate: () => undefined,
getCanonicalFps: () => 30,
onSyncMedia: (timeSeconds) => {
syncMediaCalls.push(timeSeconds);
},
onStatePost: () => undefined,
onDeterministicSeek: (timeSeconds) => {
deterministicSeekCalls.push(timeSeconds);
},
onDeterministicPause: () => undefined,
onDeterministicPlay: () => undefined,
onRenderFrameSeek: (timeSeconds) => {
renderFrameSeekCalls.push(timeSeconds);
},
onShowNativeVideos: () => undefined,
getSafeDuration: () => 12,
});
return { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls };
}
function testSeekUsesDeterministicGsapPath(): void {
const { calls, timeline } = createTimeline(true);
const { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls } = createPlayer(timeline);
const quantizedTime = 2;
player.seek(2.017);
assert.deepEqual(
calls,
[{ method: "pause" }, { method: "totalTime", time: quantizedTime, suppressEvents: false }],
"player.seek() should use quantized totalTime() when available",
);
assert.deepEqual(
deterministicSeekCalls,
[quantizedTime],
"player.seek() should notify adapters with the quantized time",
);
assert.deepEqual(syncMediaCalls, [quantizedTime], "media sync should use quantized time");
assert.deepEqual(renderFrameSeekCalls, [quantizedTime], "render frame seek should use quantized time");
}
function testGsapAdapterPreservesTotalTime(): void {
const { calls, timeline } = createTimeline(true);
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: 2.033333333333333 });
assert.deepEqual(
calls,
[{ method: "pause" }, { method: "totalTime", time: 2.033333333333333, suppressEvents: false }],
"GSAP adapter should not downgrade deterministic seeks back to seek()",
);
}
function testGsapAdapterFallsBackToSeek(): void {
const { calls, timeline } = createTimeline(false);
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: 1.5 });
assert.deepEqual(
calls,
[{ method: "pause" }, { method: "seek", time: 1.5, suppressEvents: false }],
"GSAP adapter should keep working with timelines that only expose seek()",
);
}
testSeekUsesDeterministicGsapPath();
testGsapAdapterPreservesTotalTime();
testGsapAdapterFallsBackToSeek();
console.log(
JSON.stringify({
event: "hyperframe_runtime_seek_verified",
assertions: 3,
}),
);
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import { createGSAPFrameAdapter } from "./gsap.js";
import type { FrameAdapter, FrameAdapterContext } from "./types.js";
function makeMockTimeline(duration = 10, totalDuration?: number) {
return {
duration: vi.fn().mockReturnValue(duration),
totalDuration: totalDuration !== undefined ? vi.fn().mockReturnValue(totalDuration) : undefined,
seek: vi.fn(),
pause: vi.fn(),
};
}
describe("createGSAPFrameAdapter", () => {
it("returns a FrameAdapter with required properties", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.id).toBe("gsap");
expect(typeof adapter.init).toBe("function");
expect(typeof adapter.getDurationFrames).toBe("function");
expect(typeof adapter.seekFrame).toBe("function");
});
it("uses custom id when provided", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ id: "custom-gsap", fps: 30, timeline });
expect(adapter.id).toBe("custom-gsap");
});
it("getDurationFrames returns correct frame count", () => {
const timeline = makeMockTimeline(10); // 10 seconds
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// 10 seconds * 30 fps = 300 frames
expect(adapter.getDurationFrames()).toBe(300);
});
it("getDurationFrames uses totalDuration when available", () => {
const timeline = makeMockTimeline(5, 15); // duration=5, totalDuration=15
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// Should use totalDuration (15) not duration (5)
// 15 seconds * 30 fps = 450 frames
expect(adapter.getDurationFrames()).toBe(450);
});
it("getDurationFrames returns 0 for zero-duration timeline", () => {
const timeline = makeMockTimeline(0);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.getDurationFrames()).toBe(0);
});
it("init pauses the timeline", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.init!({} as FrameAdapterContext);
expect(timeline.pause).toHaveBeenCalled();
});
it("seekFrame seeks to the correct time in seconds", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(90); // Frame 90 at 30fps = 3 seconds
expect(timeline.seek).toHaveBeenCalledWith(3, false);
expect(timeline.pause).toHaveBeenCalled();
});
it("seekFrame clamps negative frames to 0", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(-5);
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("seekFrame handles non-finite frame numbers", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(NaN);
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("seekFrame handles Infinity", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(Infinity);
// Infinity is not finite, so it gets clamped to 0
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("works without pause method on timeline", () => {
const timeline = {
duration: vi.fn().mockReturnValue(5),
seek: vi.fn(),
// No pause method
};
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// Should not throw
adapter.init!({} as FrameAdapterContext);
adapter.seekFrame(0);
expect(timeline.seek).toHaveBeenCalled();
});
it("getDurationFrames ceiling the frame count", () => {
const timeline = makeMockTimeline(1.05); // 1.05 seconds * 30 fps = 31.5 -> ceil = 32
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.getDurationFrames()).toBe(32);
});
});
describe("FrameAdapter type", () => {
it("adapter conforms to FrameAdapter interface", () => {
const timeline = makeMockTimeline(10);
const adapter: FrameAdapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.id).toBeDefined();
expect(adapter.getDurationFrames).toBeDefined();
expect(adapter.seekFrame).toBeDefined();
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { FrameAdapter } from "./types";
export interface GSAPTimelineLike {
// Base timeline span excluding repeats.
duration: () => number;
// Full span including repeats/yoyo when available.
totalDuration?: () => number;
seek: (timeInSeconds: number, suppressEvents?: boolean) => unknown;
pause?: () => unknown;
}
export interface CreateGSAPFrameAdapterOptions {
id?: string;
fps: number;
timeline: GSAPTimelineLike;
}
export function createGSAPFrameAdapter(options: CreateGSAPFrameAdapterOptions): FrameAdapter {
const { fps, timeline } = options;
const adapterId = options.id ?? "gsap";
const getDurationSeconds = (): number => {
const totalDuration = typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
return Number.isFinite(totalDuration) && totalDuration > 0 ? totalDuration : 0;
};
return {
id: adapterId,
init: () => {
timeline.pause?.();
},
getDurationFrames: () => {
const durationSeconds = getDurationSeconds();
return Math.max(0, Math.ceil(durationSeconds * fps));
},
seekFrame: (frame: number) => {
const clampedFrame = Number.isFinite(frame) ? Math.max(0, frame) : 0;
const targetSeconds = clampedFrame / fps;
timeline.pause?.();
timeline.seek(targetSeconds, false);
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export type { FrameAdapter, FrameAdapterContext } from "./types";
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap";
export { createGSAPFrameAdapter } from "./gsap";
+15
View File
@@ -0,0 +1,15 @@
export interface FrameAdapterContext {
compositionId: string;
fps: number;
width: number;
height: number;
rootElement?: HTMLElement;
}
export interface FrameAdapter {
id: string;
init?: (ctx: FrameAdapterContext) => Promise<void> | void;
getDurationFrames: () => number;
seekFrame: (frame: number) => Promise<void> | void;
destroy?: () => Promise<void> | void;
}
+402
View File
@@ -0,0 +1,402 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, isAbsolute, sep } from "path";
import * as cheerio from "cheerio";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import { validateHyperframeHtmlContract } from "./staticGuard";
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
function safePath(projectDir: string, relativePath: string): string | null {
const resolved = resolve(projectDir, relativePath);
const normalizedBase = resolve(projectDir) + sep;
if (!resolved.startsWith(normalizedBase) && resolved !== resolve(projectDir)) return null;
return resolved;
}
const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
const DEFAULT_RUNTIME_SCRIPT_URL = "";
function stripEmbeddedRuntimeScripts(html: string): string {
if (!html) return html;
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
const runtimeSrcMarkers = [
"hyperframe.runtime.iife.js",
"hyperframe-runtime.modular-runtime.inline.js",
RUNTIME_BOOTSTRAP_ATTR,
];
const runtimeInlineMarkers = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
"window.__player =",
"window.__playerReady",
"window.__renderReady",
];
const shouldStrip = (block: string): boolean => {
const lowered = block.toLowerCase();
for (const marker of runtimeSrcMarkers) {
if (lowered.includes(marker.toLowerCase())) return true;
}
for (const marker of runtimeInlineMarkers) {
if (block.includes(marker)) return true;
}
return false;
};
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
}
function getRuntimeScriptUrl(): string {
const configured = (process.env.HYPERFRAME_RUNTIME_URL || "").trim();
return configured || DEFAULT_RUNTIME_SCRIPT_URL;
}
function injectInterceptor(html: string): string {
const sanitized = stripEmbeddedRuntimeScripts(html);
if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized;
const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, "&quot;");
const tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeScriptUrl}"></script>`;
if (sanitized.includes("</head>")) {
return sanitized.replace("</head>", `${tag}\n</head>`);
}
const doctypeIdx = sanitized.toLowerCase().indexOf("<!doctype");
if (doctypeIdx >= 0) {
const insertPos = sanitized.indexOf(">", doctypeIdx) + 1;
return sanitized.slice(0, insertPos) + tag + sanitized.slice(insertPos);
}
return tag + sanitized;
}
function isRelativeUrl(url: string): boolean {
if (!url) return false;
return !url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//") && !url.startsWith("data:") && !isAbsolute(url);
}
function safeReadFile(filePath: string): string | null {
if (!existsSync(filePath)) return null;
try { return readFileSync(filePath, "utf-8"); } catch { return null; }
}
function safeReadFileBuffer(filePath: string): Buffer | null {
if (!existsSync(filePath)) return null;
try { return readFileSync(filePath); } catch { return null; }
}
function splitUrlSuffix(urlValue: string): { basePath: string; suffix: string } {
const queryIdx = urlValue.indexOf("?");
const hashIdx = urlValue.indexOf("#");
if (queryIdx < 0 && hashIdx < 0) return { basePath: urlValue, suffix: "" };
const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
return { basePath: urlValue.slice(0, cutIdx), suffix: urlValue.slice(cutIdx) };
}
function appendSuffixToUrl(baseUrl: string, suffix: string): string {
if (!suffix) return baseUrl;
if (suffix.startsWith("#")) return `${baseUrl}${suffix}`;
if (suffix.startsWith("?")) {
const queryWithOptionalHash = suffix.slice(1);
if (!queryWithOptionalHash) return baseUrl;
const hashIdx = queryWithOptionalHash.indexOf("#");
const queryPart = hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
const hashPart = hashIdx >= 0 ? queryWithOptionalHash.slice(hashIdx) : "";
if (!queryPart) return `${baseUrl}${hashPart}`;
const joiner = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${joiner}${queryPart}${hashPart}`;
}
return baseUrl;
}
function guessMimeType(filePath: string): string {
const l = filePath.toLowerCase();
if (l.endsWith(".svg")) return "image/svg+xml";
if (l.endsWith(".json")) return "application/json";
if (l.endsWith(".txt")) return "text/plain";
if (l.endsWith(".xml")) return "application/xml";
return "application/octet-stream";
}
function shouldInlineAsDataUrl(filePath: string): boolean {
const l = filePath.toLowerCase();
return l.endsWith(".svg") || l.endsWith(".json") || l.endsWith(".txt") || l.endsWith(".xml");
}
function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
if (!urlValue || !isRelativeUrl(urlValue)) return null;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return null;
const filePath = safePath(projectDir, basePath);
if (!filePath || !shouldInlineAsDataUrl(filePath)) return null;
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
const mimeType = guessMimeType(filePath);
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
return appendSuffixToUrl(dataUrl, suffix);
}
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue.split(",").map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
if (parts.length === 0) return candidate;
const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir);
if (maybeInlined) parts[0] = maybeInlined;
return parts.join(" ");
}).join(", ");
}
function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): string {
if (!cssText) return cssText;
return cssText.replace(
/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g,
(_full, quote: string, rawUrl: string) => {
const maybeInlined = maybeInlineRelativeAssetUrl((rawUrl || "").trim(), projectDir);
if (!maybeInlined) return _full;
return `url(${quote || ""}${maybeInlined}${quote || ""})`;
},
);
}
function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
const compositionEls = $("[data-composition-id][data-width][data-height]").toArray();
if (compositionEls.length === 0) return;
const sizeMap = new Map<string, { w: number; h: number }>();
for (const el of compositionEls) {
const compId = $(el).attr("data-composition-id");
const w = Number($(el).attr("data-width"));
const h = Number($(el).attr("data-height"));
if (compId && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
sizeMap.set(compId, { w, h });
}
}
if (sizeMap.size === 0) return;
$("style").each((_, styleEl) => {
let css = $(styleEl).html() || "";
let modified = false;
for (const [compId, { w, h }] of sizeMap) {
const escaped = compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockRe = new RegExp(`(\\[data-composition-id=["']${escaped}["']\\]\\s*\\{)([^}]*)(})`, "g");
css = css.replace(blockRe, (_, open, body, close) => {
const newBody = body.replace(/(\bwidth\s*:\s*)100%/g, `$1${w}px`).replace(/(\bheight\s*:\s*)100%/g, `$1${h}px`);
if (newBody !== body) modified = true;
return open + newBody + close;
});
}
if (modified) $(styleEl).text(css);
});
}
function autoHealMissingCompositionIds($: cheerio.CheerioAPI): void {
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
const referencedIds = new Set<string>();
$("style, script").each((_, el) => {
const text = ($(el).html() || "").trim();
if (!text) return;
let match: RegExpExecArray | null;
while ((match = compositionIdRe.exec(text)) !== null) {
const compId = (match[1] || "").trim();
if (compId) referencedIds.add(compId);
}
});
if (referencedIds.size === 0) return;
const existingIds = new Set<string>();
$("[data-composition-id]").each((_, el) => {
const id = ($(el).attr("data-composition-id") || "").trim();
if (id) existingIds.add(id);
});
for (const compId of referencedIds) {
if (compId === "root" || existingIds.has(compId)) continue;
const candidates = [`${compId}-layer`, `${compId}-comp`, compId];
for (const targetId of candidates) {
const match = $(`#${targetId}`).first();
if (match.length > 0 && !match.attr("data-composition-id")) {
match.attr("data-composition-id", compId);
break;
}
}
}
}
function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
const headStyleEls = $("head style").toArray();
if (headStyleEls.length > 1) {
const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set<string>();
for (const el of headStyleEls) {
const raw = ($(el).html() || "").trim();
if (!raw) continue;
const nonImportCss = raw.replace(importRe, (match) => {
const cleaned = match.trim();
if (!seenImports.has(cleaned)) { seenImports.add(cleaned); imports.push(cleaned); }
return "";
});
const trimmed = nonImportCss.trim();
if (trimmed) cssParts.push(trimmed);
}
const merged = [...imports, ...cssParts].join("\n\n").trim();
if (merged) {
$(headStyleEls[0]).text(merged);
for (let i = 1; i < headStyleEls.length; i++) $(headStyleEls[i]).remove();
}
}
const bodyInlineScripts = $("body script").toArray().filter((el) => {
const src = ($(el).attr("src") || "").trim();
if (src) return false;
const type = ($(el).attr("type") || "").trim().toLowerCase();
return !type || type === "text/javascript" || type === "application/javascript";
});
if (bodyInlineScripts.length > 0) {
const mergedJs = bodyInlineScripts.map((el) => ($(el).html() || "").trim()).filter(Boolean).join("\n;\n").trim();
for (const el of bodyInlineScripts) $(el).remove();
if (mergedJs) {
const stripped = stripJsCommentsParserSafe(mergedJs);
$("body").append(`<script>${stripped}</script>`);
}
}
}
function stripJsCommentsParserSafe(source: string): string {
if (!source) return source;
try {
const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" });
return result.code.trim();
} catch { return source; }
}
export interface BundleOptions {
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
probeMediaDuration?: MediaDurationProber;
}
/**
* Bundle a project's index.html into a single self-contained HTML file.
*
* - Compiles timing attributes and optionally resolves media durations
* - Injects the HyperFrames runtime script
* - Inlines local CSS and JS files
* - Inlines sub-composition HTML fragments (data-composition-src)
* - Inlines small textual assets as data URLs
*/
export async function bundleToSingleHtml(projectDir: string, options?: BundleOptions): Promise<string> {
const indexPath = join(projectDir, "index.html");
if (!existsSync(indexPath)) throw new Error("index.html not found in project directory");
const rawHtml = readFileSync(indexPath, "utf-8");
const compiled = await compileHtml(rawHtml, projectDir, options?.probeMediaDuration);
const staticGuard = validateHyperframeHtmlContract(compiled);
if (!staticGuard.isValid) {
console.warn(`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`);
}
const withInterceptor = injectInterceptor(compiled);
const $ = cheerio.load(withInterceptor);
// Inline local CSS
const localCssChunks: string[] = [];
let cssAnchorPlaced = false;
$('link[rel="stylesheet"]').each((_, el) => {
const href = $(el).attr("href");
if (!href || !isRelativeUrl(href)) return;
const cssPath = safePath(projectDir, href);
const css = cssPath ? safeReadFile(cssPath) : null;
if (css == null) return;
localCssChunks.push(css);
if (!cssAnchorPlaced) { $(el).replaceWith('<style data-hf-bundled-local-css="1"></style>'); cssAnchorPlaced = true; } else { $(el).remove(); }
});
if (localCssChunks.length > 0) {
const $anchor = $('style[data-hf-bundled-local-css="1"]').first();
if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
else $("head").append(`<style>${localCssChunks.join("\n\n")}</style>`);
}
// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
$("script[src]").each((_, el) => {
const src = $(el).attr("src");
if (!src || !isRelativeUrl(src)) return;
const jsPath = safePath(projectDir, src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) return;
localJsChunks.push(js);
if (!jsAnchorPlaced) { $(el).replaceWith('<script data-hf-bundled-local-js="1"></script>'); jsAnchorPlaced = true; } else { $(el).remove(); }
});
if (localJsChunks.length > 0) {
const $anchor = $('script[data-hf-bundled-local-js="1"]').first();
if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
else $("body").append(`<script>${localJsChunks.join("\n;\n")}</script>`);
}
// Inline sub-compositions
const compStyleChunks: string[] = [];
const compScriptChunks: string[] = [];
$("[data-composition-src]").each((_, hostEl) => {
const src = $(hostEl).attr("data-composition-src");
if (!src || !isRelativeUrl(src)) return;
const compPath = safePath(projectDir, src);
const compHtml = compPath ? safeReadFile(compPath) : null;
if (compHtml == null) { console.warn(`[Bundler] Composition file not found: ${src}`); return; }
const $comp = cheerio.load(compHtml);
const compId = $(hostEl).attr("data-composition-id");
const $contentRoot = $comp("template").first();
const contentHtml = $contentRoot.length ? $contentRoot.html() || "" : $comp("body").html() || "";
const $content = cheerio.load(contentHtml);
const $innerRoot = compId ? $content(`[data-composition-id="${compId}"]`).first() : $content("[data-composition-id]").first();
$content("style").each((_, s) => { compStyleChunks.push($content(s).html() || ""); $content(s).remove(); });
$content("script").each((_, s) => {
compScriptChunks.push(`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`);
$content(s).remove();
});
if ($innerRoot.length) {
const innerCompId = $innerRoot.attr("data-composition-id");
const innerW = $innerRoot.attr("data-width");
const innerH = $innerRoot.attr("data-height");
if (innerCompId && !$(hostEl).attr("data-composition-id")) $(hostEl).attr("data-composition-id", innerCompId);
if (innerW && !$(hostEl).attr("data-width")) $(hostEl).attr("data-width", innerW);
if (innerH && !$(hostEl).attr("data-height")) $(hostEl).attr("data-height", innerH);
$innerRoot.find("style, script").remove();
$(hostEl).html($innerRoot.html() || "");
} else {
$content("style, script").remove();
$(hostEl).html($content.html() || "");
}
$(hostEl).removeAttr("data-composition-src");
});
if (compStyleChunks.length) $("head").append(`<style>${compStyleChunks.join("\n\n")}</style>`);
if (compScriptChunks.length) $("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
enforceCompositionPixelSizing($);
autoHealMissingCompositionIds($);
coalesceHeadStylesAndBodyScripts($);
// Inline textual assets
$("[src], [href], [poster], [xlink\\:href]").each((_, el) => {
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
const value = $(el).attr(attr);
if (!value) continue;
const inlined = maybeInlineRelativeAssetUrl(value, projectDir);
if (inlined) $(el).attr(attr, inlined);
}
});
$("[srcset]").each((_, el) => {
const srcset = $(el).attr("srcset");
if (srcset) $(el).attr("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir));
});
$("style").each((_, el) => { $(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir)); });
$("[style]").each((_, el) => { $(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir)); });
return $.html();
}
@@ -0,0 +1,90 @@
import { resolve } from "path";
import {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
type ResolvedDuration,
} from "./timingCompiler";
/**
* Callback to probe media duration. If not provided, media duration resolution is skipped.
* Return duration in seconds, or 0 if unknown.
*/
export type MediaDurationProber = (src: string) => Promise<number>;
function resolveMediaSrc(src: string, projectDir: string): string {
return src.startsWith("http://") || src.startsWith("https://")
? src
: resolve(projectDir, src);
}
/**
* Compile HTML with full duration resolution.
*
* 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists
* 2. For unresolved video/audio (no data-duration): probe via probeMediaDuration, inject durations
* 3. For pre-resolved video/audio: validate data-duration against actual source, clamp if needed
*
* @param rawHtml - The raw HTML string
* @param projectDir - The project directory for resolving relative paths
* @param probeMediaDuration - Optional callback to probe media duration (e.g., via ffprobe)
*/
export async function compileHtml(
rawHtml: string,
projectDir: string,
probeMediaDuration?: MediaDurationProber,
): Promise<string> {
const { html: staticCompiled, unresolved } = compileTimingAttrs(rawHtml);
let html = staticCompiled;
if (!probeMediaDuration) return html;
// Phase 1: Resolve missing durations
const mediaUnresolved = unresolved.filter(
(el) => el.tagName === "video" || el.tagName === "audio",
);
if (mediaUnresolved.length > 0) {
const resolutions: ResolvedDuration[] = [];
for (const el of mediaUnresolved) {
if (!el.src) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
const effectiveDuration = fileDuration - el.mediaStart;
resolutions.push({
id: el.id,
duration: effectiveDuration > 0 ? effectiveDuration : fileDuration,
});
}
if (resolutions.length > 0) {
html = injectDurations(html, resolutions);
}
}
// Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration
const preResolved = extractResolvedMedia(html);
const clampList: ResolvedDuration[] = [];
for (const el of preResolved) {
if (!el.src) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
const maxDuration = fileDuration - el.mediaStart;
if (maxDuration > 0 && el.duration > maxDuration) {
clampList.push({ id: el.id, duration: maxDuration });
}
}
if (clampList.length > 0) {
html = clampDurations(html, clampList);
}
return html;
}
+24
View File
@@ -0,0 +1,24 @@
// Timing compiler (browser-safe)
export {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
type UnresolvedElement,
type ResolvedDuration,
type ResolvedMediaElement,
type CompilationResult,
} from "./timingCompiler";
// HTML compiler (Node.js — requires fs)
export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
// HTML bundler (Node.js — requires fs, cheerio, esbuild)
export { bundleToSingleHtml, type BundleOptions } from "./htmlBundler";
// Static guard
export {
validateHyperframeHtmlContract,
type HyperframeStaticFailureReason,
type HyperframeStaticGuardResult,
} from "./staticGuard";
+39
View File
@@ -0,0 +1,39 @@
import { lintHyperframeHtml } from "../lint/hyperframeLinter";
export type HyperframeStaticFailureReason =
| "missing_composition_id"
| "missing_composition_dimensions"
| "missing_timeline_registry"
| "invalid_script_syntax"
| "invalid_static_hyperframe_contract";
export type HyperframeStaticGuardResult = {
isValid: boolean;
missingKeys: string[];
failureReason: HyperframeStaticFailureReason | null;
};
export function validateHyperframeHtmlContract(html: string): HyperframeStaticGuardResult {
const result = lintHyperframeHtml(html);
const missingKeys = result.findings
.filter((finding) => finding.severity === "error")
.map((finding) => finding.message);
if (missingKeys.length === 0) {
return { isValid: true, missingKeys: [], failureReason: null };
}
const joined = missingKeys.join(" ").toLowerCase();
let failureReason: HyperframeStaticFailureReason = "invalid_static_hyperframe_contract";
if (joined.includes("data-composition-id")) {
failureReason = "missing_composition_id";
} else if (joined.includes("data-width") || joined.includes("data-height")) {
failureReason = "missing_composition_dimensions";
} else if (joined.includes("window.__timelines")) {
failureReason = "missing_timeline_registry";
} else if (joined.includes("script syntax")) {
failureReason = "invalid_script_syntax";
}
return { isValid: false, missingKeys, failureReason };
}
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./timingCompiler.js";
describe("compileTimingAttrs", () => {
it("adds data-end when data-start and data-duration are present on a video", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="7"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(0);
});
it("leaves data-end unchanged when already present", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-end="3">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="3"');
expect(compiled).not.toContain("data-duration");
expect(unresolved).toHaveLength(0);
});
it("marks video as unresolved when data-duration and data-end are missing", () => {
const html = '<video id="v1" src="a.mp4" data-start="1">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("v1");
expect(unresolved[0].tagName).toBe("video");
expect(unresolved[0].start).toBe(1);
});
it("compiles audio tags the same as video (minus data-has-audio)", () => {
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="10"');
expect(compiled).not.toContain("data-has-audio");
});
it("detects unresolved div/section elements with data-start but no data-end", () => {
const html = '<div id="comp1" data-start="0" data-composition-src="comp.html">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("comp1");
expect(unresolved[0].tagName).toBe("div");
expect(unresolved[0].compositionSrc).toBe("comp.html");
});
it("does not report div as unresolved when data-end is present", () => {
const html = '<div id="comp1" data-start="0" data-end="5">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(0);
});
});
describe("injectDurations", () => {
it("adds data-duration and data-end for resolved elements", () => {
const html = '<video id="v1" src="a.mp4" data-start="2">';
const result = injectDurations(html, [{ id: "v1", duration: 4 }]);
expect(result).toContain('data-duration="4"');
expect(result).toContain('data-end="6"');
});
it("does not overwrite existing data-duration", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3">';
const result = injectDurations(html, [{ id: "v1", duration: 10 }]);
// data-duration already present, should not be duplicated
expect(result).toContain('data-duration="3"');
});
});
describe("extractResolvedMedia", () => {
it("extracts video and audio elements with data-duration set", () => {
const html = [
'<video id="v1" src="vid.mp4" data-start="1" data-duration="5" data-media-start="0">',
'<audio id="a1" src="song.mp3" data-start="0" data-duration="10">',
'<video id="v2" src="other.mp4" data-start="0">', // no duration
].join("\n");
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(2);
expect(resolved[0].id).toBe("v1");
expect(resolved[0].tagName).toBe("video");
expect(resolved[0].duration).toBe(5);
expect(resolved[0].start).toBe(1);
expect(resolved[1].id).toBe("a1");
expect(resolved[1].tagName).toBe("audio");
expect(resolved[1].duration).toBe(10);
});
it("skips elements with invalid durations", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="NaN">';
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(0);
});
});
describe("clampDurations", () => {
it("replaces data-duration and recomputes data-end", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="10" data-end="12">';
const result = clampDurations(html, [{ id: "v1", duration: 5 }]);
expect(result).toContain('data-duration="5"');
expect(result).toContain('data-end="7"');
});
});
@@ -0,0 +1,250 @@
/**
* Timing Compiler
*
* Shared, pure HTML compilation that normalizes timing attributes.
* Works in both Node.js and browser (no dependencies, regex-based).
*
* Guarantees every timed element gets:
* - data-end (computed from data-start + data-duration when possible)
* - data-has-audio="true" on <video> elements
*
* For elements without data-duration (e.g. videos relying on source duration),
* this compiler identifies them as "unresolved" so the caller can provide
* durations via an environment-specific resolver (ffprobe, el.duration, etc.)
* and call injectDurations() to complete the compilation.
*/
// ── Types ────────────────────────────────────────────────────────────────
export interface UnresolvedElement {
id: string;
tagName: string;
src?: string;
start: number;
end?: number;
duration?: number;
mediaStart: number;
compositionSrc?: string;
}
export interface ResolvedDuration {
id: string;
duration: number;
}
export interface ResolvedMediaElement {
id: string;
tagName: string;
src?: string;
start: number;
duration: number;
mediaStart: number;
}
export interface CompilationResult {
html: string;
unresolved: UnresolvedElement[];
}
// ── Helpers ──────────────────────────────────────────────────────────────
function getAttr(tag: string, attr: string): string | null {
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
return match ? match[1] ?? null : null;
}
function hasAttr(tag: string, attr: string): boolean {
return new RegExp(`${attr}=["']`).test(tag);
}
function injectAttr(tag: string, attr: string, value: string): string {
return tag.replace(/>$/, ` ${attr}="${value}">`);
}
// ── Core compilation ─────────────────────────────────────────────────────
function compileTag(tag: string, isVideo: boolean): { tag: string; unresolved: UnresolvedElement | null } {
let result = tag;
let unresolved: UnresolvedElement | null = null;
const id = getAttr(result, "id");
const startStr = getAttr(result, "data-start");
const start = startStr !== null ? parseFloat(startStr) : 0;
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
// 1. Compute data-end from data-start + data-duration
if (!hasAttr(result, "data-end")) {
const durationStr = getAttr(result, "data-duration");
if (durationStr !== null) {
const end = start + parseFloat(durationStr);
result = injectAttr(result, "data-end", String(end));
} else if (id) {
// No data-duration: mark as unresolved so caller can provide it
unresolved = {
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(result, "src") ?? undefined,
start,
mediaStart,
};
}
}
// 2. Add data-has-audio="true" to <video> elements
if (isVideo && !hasAttr(result, "data-has-audio")) {
result = injectAttr(result, "data-has-audio", "true");
}
return { tag: result, unresolved };
}
/**
* Compile timing attributes in HTML.
*
* Phase 1 (static): Adds data-end where data-duration exists,
* adds data-has-audio on videos.
*
* Returns the compiled HTML and a list of elements that could not be
* resolved statically (missing data-duration). The caller should resolve
* these via ffprobe / el.duration and call injectDurations().
*/
export function compileTimingAttrs(html: string): CompilationResult {
const unresolved: UnresolvedElement[] = [];
// Process <video ...> tags
html = html.replace(/<video[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, true);
if (u) unresolved.push(u);
return tag;
});
// Process <audio ...> tags
html = html.replace(/<audio[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, false);
if (u) unresolved.push(u);
return tag;
});
// Identify unresolved timed elements (divs with data-start but no data-end/data-duration)
// These are typically compositions whose duration depends on GSAP timelines
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
if (!hasAttr(match, "data-start")) return match;
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) return match;
const id = getAttr(match, "id");
const compositionSrc = getAttr(match, "data-composition-src");
if (id) {
const startStr = getAttr(match, "data-start");
unresolved.push({
id,
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
mediaStart: 0,
compositionSrc: compositionSrc ?? undefined,
});
}
return match;
});
return { html, unresolved };
}
/**
* Inject resolved durations into compiled HTML.
*
* For each resolved element, adds data-duration and data-end attributes.
* Call this after resolving durations via ffprobe, el.duration, or
* GSAP timeline queries.
*/
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
for (const { id, duration } of resolutions) {
// Match the element's opening tag by id
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
let result = tag;
// Add data-duration if missing
if (!hasAttr(result, "data-duration")) {
result = injectAttr(result, "data-duration", String(duration));
}
// Add data-end if missing
if (!hasAttr(result, "data-end")) {
const startStr = getAttr(result, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
result = injectAttr(result, "data-end", String(start + duration));
}
return result;
});
}
return html;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Extract video/audio elements that already have data-duration set.
* Used by callers to validate declared durations against actual source durations.
*/
export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
const resolved: ResolvedMediaElement[] = [];
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = mediaRegex.exec(html)) !== null) {
const tag = match[0];
const id = getAttr(tag, "id");
const durationStr = getAttr(tag, "data-duration");
if (!id || durationStr === null) continue;
const duration = parseFloat(durationStr);
if (!Number.isFinite(duration) || duration <= 0) continue;
const isVideo = /^<video/i.test(tag);
const startStr = getAttr(tag, "data-start");
const mediaStartStr = getAttr(tag, "data-media-start");
resolved.push({
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(tag, "src") ?? undefined,
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
});
}
return resolved;
}
/**
* Clamp existing data-duration and data-end on media elements.
* For each resolution, replaces the declared duration with the clamped value
* and recomputes data-end accordingly.
*/
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
for (const { id, duration } of clamps) {
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
// Replace data-duration value
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
// Recompute data-end from data-start + clamped duration
const startStr = getAttr(tag, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
return tag;
});
}
return html;
}
+364
View File
@@ -0,0 +1,364 @@
// ── Shared cross-package types ──────────────────────────────────────────────
export type ExecutionMode = "planning" | "design" | "execution" | null;
/** Video orientation / aspect ratio. */
export type Orientation = "16:9" | "9:16";
export interface Asset {
id: string;
url: string;
type: string;
is_reference?: boolean;
/** Duration in seconds for video/audio assets */
duration?: number;
}
// ── Timeline types ──────────────────────────────────────────────────────────
export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition";
export type MediaElementType = "video" | "image" | "audio";
export type CanvasResolution = "landscape" | "portrait";
export const CANVAS_DIMENSIONS = {
landscape: { width: 1920, height: 1080 },
portrait: { width: 1080, height: 1920 },
} as const;
export interface TimelineElementBase {
id: string;
type: TimelineElementType;
name: string;
startTime: number;
duration: number;
zIndex: number;
x?: number;
y?: number;
scale?: number;
opacity?: number;
}
export interface TimelineMediaElement extends TimelineElementBase {
type: MediaElementType;
src: string;
mediaStartTime?: number;
sourceDuration?: number;
isAroll?: boolean;
sourceWidth?: number;
sourceHeight?: number;
volume?: number; // 0-1 (0% to 100%), default 1.0
hasAudio?: boolean; // For videos - indicates if video has audio track
}
export interface WaveformData {
peaks: number[];
duration: number;
sampleRate?: number;
}
export interface TimelineTextElement extends TimelineElementBase {
type: "text";
content: string;
color?: string;
fontSize?: number;
textShadow?: boolean;
fontFamily?: string;
fontWeight?: number;
textOutline?: boolean;
textOutlineColor?: string;
textOutlineWidth?: number;
textHighlight?: boolean;
textHighlightColor?: string;
textHighlightPadding?: number;
textHighlightRadius?: number;
}
export interface TimelineCompositionElement extends TimelineElementBase {
type: "composition";
src: string;
compositionId: string;
scale?: number;
sourceDuration?: number;
variableValues?: Record<string, string | number | boolean>;
sourceWidth?: number;
sourceHeight?: number;
}
// Composition Variable Types
export type CompositionVariableType = "string" | "number" | "color" | "boolean" | "enum";
export interface CompositionVariableBase {
id: string;
type: CompositionVariableType;
label: string;
description?: string;
}
export interface StringVariable extends CompositionVariableBase {
type: "string";
default: string;
placeholder?: string;
maxLength?: number;
}
export interface NumberVariable extends CompositionVariableBase {
type: "number";
default: number;
min?: number;
max?: number;
step?: number;
unit?: string;
}
export interface ColorVariable extends CompositionVariableBase {
type: "color";
default: string;
}
export interface BooleanVariable extends CompositionVariableBase {
type: "boolean";
default: boolean;
}
export interface EnumVariable extends CompositionVariableBase {
type: "enum";
default: string;
options: { value: string; label: string }[];
}
export type CompositionVariable = StringVariable | NumberVariable | ColorVariable | BooleanVariable | EnumVariable;
export interface CompositionSpec {
id: string;
duration: number;
variables: CompositionVariable[];
}
export function isStringVariable(v: CompositionVariable): v is StringVariable {
return v.type === "string";
}
export function isNumberVariable(v: CompositionVariable): v is NumberVariable {
return v.type === "number";
}
export function isColorVariable(v: CompositionVariable): v is ColorVariable {
return v.type === "color";
}
export function isBooleanVariable(v: CompositionVariable): v is BooleanVariable {
return v.type === "boolean";
}
export function isEnumVariable(v: CompositionVariable): v is EnumVariable {
return v.type === "enum";
}
export type TimelineElement = TimelineMediaElement | TimelineTextElement | TimelineCompositionElement;
export function isTextElement(el: TimelineElement): el is TimelineTextElement {
return el.type === "text";
}
export function isMediaElement(el: TimelineElement): el is TimelineMediaElement {
return el.type === "video" || el.type === "image" || el.type === "audio";
}
export function isCompositionElement(el: TimelineElement): el is TimelineCompositionElement {
return el.type === "composition";
}
export interface MediaFile {
id: string;
name: string;
type: TimelineElementType;
src: string;
file?: File;
duration?: number;
compositionId?: string;
sourceWidth?: number; // Intrinsic width for compositions
sourceHeight?: number; // Intrinsic height for compositions
}
export const TIMELINE_COLORS: Record<TimelineElementType, string> = {
video: "#ec4899",
image: "#3b82f6",
text: "#06b6d4",
audio: "#10b981",
composition: "#f97316",
};
export const DEFAULT_DURATIONS: Record<TimelineElementType, number> = {
video: 5,
image: 5,
text: 2,
audio: 5,
composition: 5,
};
export interface CompositionAPI {
id: string;
duration: number;
seek(time: number): void;
getTime(): number;
getDuration(): number;
}
// ── Player API types (used by runtime) ────────────────────────────────────
export interface PlayerAPI {
play(): void;
pause(): void;
seek(time: number): void;
getTime(): number;
getDuration(): number;
isPlaying(): boolean;
getMainTimeline(): unknown;
getElementBounds(elementId: string): void;
getElementsAtPoint(x: number, y: number): void;
setElementPosition(elementId: string, x: number, y: number): void;
previewElementPosition(elementId: string, x: number, y: number): void;
setElementKeyframes(
elementId: string,
keyframes: Array<{
id: string;
time: number;
properties: { x?: number; y?: number };
}> | null
): void;
setElementScale(elementId: string, scale: number): void;
setElementFontSize(elementId: string, fontSize: number): void;
setElementTextContent(elementId: string, content: string): void;
setElementTextColor(elementId: string, color: string): void;
setElementTextShadow(elementId: string, enabled: boolean): void;
setElementTextFontWeight(elementId: string, weight: number): void;
setElementTextFontFamily(elementId: string, fontFamily: string): void;
setElementTextOutline(elementId: string, enabled: boolean, color?: string, width?: number): void;
setElementTextHighlight(elementId: string, enabled: boolean, color?: string, padding?: number, radius?: number): void;
setElementVolume(elementId: string, volume: number): void;
setStageZoom(scale: number, focusX: number, focusY: number): void;
getStageZoom(): { scale: number; focusX: number; focusY: number };
setStageZoomKeyframes(
keyframes: Array<{
id: string;
time: number;
zoom: { scale: number; focusX: number; focusY: number };
ease?: string;
}> | null
): void;
getStageZoomKeyframes(): Array<{
id: string;
time: number;
zoom: { scale: number; focusX: number; focusY: number };
ease?: string;
}>;
addElement(data: AddElementData): boolean;
removeElement(elementId: string): boolean;
updateElementTiming(elementId: string, start?: number, end?: number): boolean;
setElementTiming(elementId: string, startTime: number, duration: number, mediaStartTime?: number): void;
updateElementSrc(elementId: string, src: string): boolean;
updateElementLayer(elementId: string, zIndex: number): boolean;
updateElementBasePosition(elementId: string, x?: number, y?: number, scale?: number): boolean;
markTimelineDirty(): void;
isTimelineDirty(): boolean;
rebuildTimeline(): void;
ensureTimeline(): void;
enableRenderMode(): void;
disableRenderMode(): void;
renderSeek(time: number): void;
getElementVisibility(elementId: string): { visible: boolean; opacity?: number };
getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>;
getRenderState(): { time: number; duration: number; isPlaying: boolean; renderMode: boolean; timelineDirty: boolean };
}
export interface AddElementData {
id: string;
type: "video" | "image" | "text" | "audio" | "composition";
name?: string;
src?: string;
content?: string;
start: number;
end: number;
zIndex?: number;
x?: number;
y?: number;
scale?: number;
fontSize?: number;
color?: string;
textShadow?: boolean;
fontWeight?: number;
textOutline?: boolean;
textOutlineColor?: string;
textOutlineWidth?: number;
textHighlight?: boolean;
textHighlightColor?: string;
textHighlightPadding?: number;
textHighlightRadius?: number;
compositionId?: string;
sourceWidth?: number;
sourceHeight?: number;
isAroll?: boolean;
}
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export interface CompositionAsset {
id: string;
name: string;
type: "composition";
src: string;
duration: number;
compositionId: string;
thumbnail?: string;
}
export interface Keyframe {
id: string;
time: number;
properties: Partial<KeyframeProperties>;
ease?: string;
}
export interface KeyframeProperties {
x: number;
y: number;
opacity: number;
scale: number;
scaleX: number;
scaleY: number;
rotation: number;
width: number;
height: number;
}
export interface ElementKeyframes {
elementId: string;
keyframes: Keyframe[];
}
export interface StageZoom {
scale: number;
focusX: number;
focusY: number;
}
export interface StageZoomKeyframe {
id: string;
time: number;
zoom: StageZoom;
ease?: string;
}
export function getDefaultStageZoom(resolution: CanvasResolution): StageZoom {
const { width, height } = CANVAS_DIMENSIONS[resolution];
return {
scale: 1,
focusX: width / 2,
focusY: height / 2,
};
}
@@ -0,0 +1,296 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import { generateHyperframesHtml, generateGsapTimelineScript, generateHyperframesStyles } from "./hyperframes.js";
import { GSAP_CDN } from "../templates/constants.js";
import type { TimelineTextElement, TimelineMediaElement, TimelineCompositionElement } from "../core.types";
function makeTextElement(overrides: Partial<TimelineTextElement> = {}): TimelineTextElement {
return {
id: "text-1",
type: "text",
name: "Title",
content: "Hello World",
startTime: 0,
duration: 5,
zIndex: 1,
...overrides,
};
}
function makeVideoElement(overrides: Partial<TimelineMediaElement> = {}): TimelineMediaElement {
return {
id: "vid-1",
type: "video",
name: "Background",
src: "video.mp4",
startTime: 0,
duration: 10,
zIndex: 0,
...overrides,
};
}
describe("generateHyperframesHtml", () => {
it("generates valid HTML with proper data attributes", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("data-composition-id=");
expect(html).toContain("data-composition-duration=");
expect(html).toContain('id="stage"');
expect(html).toContain('id="stage-zoom-container"');
});
it("includes element data attributes", () => {
const elements = [makeTextElement({ id: "my-text", startTime: 2, duration: 3 })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain('id="my-text"');
expect(html).toContain('data-start="2"');
expect(html).toContain('data-end="5"');
expect(html).toContain('data-layer="1"');
});
it("includes GSAP CDN script tag when includeScripts is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
expect(html).toContain(`<script src="${GSAP_CDN}"></script>`);
});
it("does NOT include GSAP CDN by default", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5);
expect(html).not.toContain(GSAP_CDN);
});
it("includes timeline script when includeScripts is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
expect(html).toContain("gsap.timeline({ paused: true })");
});
it("generates GSAP timeline with visibility animations when includeScripts is true", () => {
const elements = [makeTextElement({ id: "el-1", startTime: 1, duration: 3 })];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
// Default animations include visibility bookends
expect(html).toContain('tl.set("#el-1"');
expect(html).toContain('visibility: "hidden"');
expect(html).toContain('visibility: "visible"');
});
it("sets resolution data attribute", () => {
const elements = [makeTextElement()];
const landscapeHtml = generateHyperframesHtml(elements, 5, { resolution: "landscape" });
expect(landscapeHtml).toContain('data-resolution="landscape"');
const portraitHtml = generateHyperframesHtml(elements, 5, { resolution: "portrait" });
expect(portraitHtml).toContain('data-resolution="portrait"');
});
it("generates video elements with proper tags", () => {
const elements = [makeVideoElement()];
const html = generateHyperframesHtml(elements, 10);
expect(html).toContain("<video");
expect(html).toContain('src="video.mp4"');
expect(html).toContain("playsinline");
});
it("generates text elements with content wrapper div", () => {
const elements = [makeTextElement({ content: "My Content" })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain("<div>My Content</div>");
});
it("includes custom compositionId", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { compositionId: "test-comp-123" });
expect(html).toContain('data-composition-id="test-comp-123"');
});
it("calculates total duration from elements if they exceed provided duration", () => {
const elements = [makeTextElement({ startTime: 0, duration: 15 })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain('data-composition-duration="15"');
});
it("includes style tags when includeStyles is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeStyles: true });
expect(html).toContain('<style data-hf-core="true">');
});
it("includes custom styles when provided", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, {
styles: ".custom { color: red; }",
includeStyles: true,
});
expect(html).toContain('<style data-hf-custom="true">');
expect(html).toContain(".custom { color: red; }");
});
it("serializes keyframes as data attributes", () => {
const elements = [makeTextElement({ id: "text-kf" })];
const keyframes = {
"text-kf": [
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
],
};
const html = generateHyperframesHtml(elements, 5, { keyframes });
expect(html).toContain("data-keyframes=");
expect(html).toContain("kf1");
expect(html).toContain("kf2");
});
it("serializes zoom keyframes on zoom container", () => {
const elements = [makeTextElement()];
const stageZoomKeyframes = [
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z2", time: 5, zoom: { scale: 2, focusX: 400, focusY: 300 } },
];
const html = generateHyperframesHtml(elements, 10, { stageZoomKeyframes });
expect(html).toContain("data-zoom-keyframes=");
});
it("includes x, y, scale data attributes for non-default values", () => {
const elements = [makeVideoElement({ x: 100, y: 200, scale: 1.5, opacity: 0.8 })];
const html = generateHyperframesHtml(elements, 10);
expect(html).toContain('data-x="100"');
expect(html).toContain('data-y="200"');
expect(html).toContain('data-scale="1.5"');
expect(html).toContain('data-opacity="0.8"');
});
it("omits x, y, scale, opacity data attributes when at default values", () => {
const elements = [makeVideoElement({ x: 0, y: 0, scale: 1, opacity: 1 })];
const html = generateHyperframesHtml(elements, 10);
expect(html).not.toContain("data-x=");
expect(html).not.toContain("data-y=");
expect(html).not.toContain("data-scale=");
expect(html).not.toContain("data-opacity=");
});
});
describe("generateGsapTimelineScript", () => {
it("generates a timeline script with visibility animations", () => {
const elements = [makeTextElement({ id: "el1", startTime: 1, duration: 4 })];
const script = generateGsapTimelineScript(elements, 5);
expect(script).toContain("const tl = gsap.timeline({ paused: true });");
expect(script).toContain('tl.set("#el1"');
expect(script).toContain('visibility: "hidden"');
expect(script).toContain('visibility: "visible"');
});
it("generates empty timeline for no elements", () => {
const script = generateGsapTimelineScript([], 5);
expect(script).toContain("const tl = gsap.timeline({ paused: true });");
expect(script).toContain("duration: 5");
});
it("includes media sync for video elements", () => {
const elements = [makeVideoElement()];
const script = generateGsapTimelineScript(elements, 10);
expect(script).toContain("Sync media playback");
expect(script).toContain("media.currentTime");
});
it("generates initial position sets for elements with x/y offsets", () => {
const elements = [makeVideoElement({ id: "vid-pos", x: 100, y: 200 })];
const script = generateGsapTimelineScript(elements, 10);
expect(script).toContain('tl.set("#vid-pos", { x: 100, y: 200 }');
});
it("generates animations from keyframes", () => {
const elements = [makeTextElement({ id: "el-kf", startTime: 0, duration: 5 })];
const keyframes = {
"el-kf": [
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
],
};
const script = generateGsapTimelineScript(elements, 5, { keyframes });
// Should contain keyframe-based animations
expect(script).toContain("el-kf");
});
});
describe("generateHyperframesStyles", () => {
it("generates core CSS with stage dimensions for landscape", () => {
const elements = [makeTextElement()];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("width: 1920px");
expect(coreCss).toContain("height: 1080px");
expect(coreCss).toContain("#stage");
});
it("generates core CSS with stage dimensions for portrait", () => {
const elements = [makeTextElement()];
const { coreCss } = generateHyperframesStyles(elements, "portrait");
expect(coreCss).toContain("width: 1080px");
expect(coreCss).toContain("height: 1920px");
});
it("generates element-specific styles for text", () => {
const elements = [makeTextElement({ id: "styled-text", fontSize: 72, color: "red" })];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("#styled-text");
expect(coreCss).toContain("position: absolute");
});
it("generates element-specific styles for video", () => {
const elements = [makeVideoElement({ id: "vid-styled" })];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("#vid-styled");
expect(coreCss).toContain("object-fit: contain");
});
it("includes custom CSS when provided", () => {
const elements = [makeTextElement()];
const { customCss } = generateHyperframesStyles(elements, "landscape", ".custom { color: blue; }");
expect(customCss).toContain(".custom { color: blue; }");
});
it("generates Google Fonts link for Inter (always included)", () => {
const elements = [makeTextElement()];
const { googleFontsLink } = generateHyperframesStyles(elements, "landscape");
expect(googleFontsLink).toContain("fonts.googleapis.com");
expect(googleFontsLink).toContain("Inter");
});
it("includes additional font families from text elements", () => {
const elements = [makeTextElement({ fontFamily: "Montserrat" })];
const { googleFontsLink } = generateHyperframesStyles(elements, "landscape");
expect(googleFontsLink).toContain("Montserrat");
});
});
+737
View File
@@ -0,0 +1,737 @@
import type {
TimelineElement,
CanvasResolution,
Keyframe,
StageZoomKeyframe,
} from "../core.types";
import {
CANVAS_DIMENSIONS,
isTextElement,
isMediaElement,
isCompositionElement,
} from "../core.types";
import type { GsapAnimation } from "../parsers/gsapParser";
import { serializeGsapAnimations, keyframesToGsapAnimations } from "../parsers/gsapParser";
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
Inter: "400;500;600;700;800;900",
Roboto: "400;500;700;900",
Montserrat: "400;500;600;700;800;900",
Poppins: "400;500;600;700;800;900",
"Bebas Neue": "400",
Oswald: "400;500;600;700",
Anton: "400",
"Playfair Display": "400;500;600;700;800;900",
Lora: "400;500;600;700",
Pacifico: "400",
"Permanent Marker": "400",
"Fira Code": "400;500;600;700",
};
function generateGoogleFontsUrl(fontFamilies: string[]): string | null {
if (fontFamilies.length === 0) return null;
const families = fontFamilies
.filter((f) => f in FONT_WEIGHTS)
.map((f) => {
const weights = FONT_WEIGHTS[f];
const encodedName = f.replace(/ /g, "+");
return `family=${encodedName}:wght@${weights}`;
});
if (families.length === 0) return null;
return `${GOOGLE_FONTS_BASE}?${families.join("&")}&display=swap`;
}
export interface SerializeOptions {
animations?: GsapAnimation[];
styles?: string;
generateDefaultAnimations?: boolean;
resolution?: CanvasResolution;
compositionId?: string;
keyframes?: Record<string, Keyframe[]>;
stageZoomKeyframes?: StageZoomKeyframe[];
includeScripts?: boolean;
includeStyles?: boolean;
}
/**
* Stage Positioning Conventions:
*
* 1. All elements are absolutely positioned relative to the #stage container
* 2. The #stage has position: relative and fixed dimensions (1920x1080 or 1080x1920)
* 3. Elements start with opacity: 0 and are revealed via GSAP animations
*
* Media Elements (video, image):
* - position: absolute (relative to #stage)
* - width: 100%, height: 100% (fill the stage)
* - object-fit: contain (preserve aspect ratio, centered, no cropping)
* - This ensures media is always visible and centered within the stage
*
* Text Elements:
* - position: absolute, width/height: 100%
* - Inner div uses flexbox to center content (selected via > div)
*
* Audio Elements:
* - position: absolute (invisible, for timing only)
*/
function sortElements(elements: TimelineElement[]): TimelineElement[] {
return [...elements].sort((a, b) => {
if (a.zIndex !== b.zIndex) {
return (a.zIndex ?? 0) - (b.zIndex ?? 0);
}
return a.startTime - b.startTime;
});
}
export function generateHyperframesStyles(
elements: TimelineElement[],
resolution: CanvasResolution,
customStyles?: string,
): { coreCss: string; customCss: string; googleFontsLink: string } {
const { width, height } = CANVAS_DIMENSIONS[resolution];
const sortedElements = sortElements(elements);
const elementStyles = sortedElements.map((el) => generateElementStyles(el)).join("\n");
// Collect unique font families from text elements
const usedFonts = new Set<string>();
for (const el of sortedElements) {
if (isTextElement(el) && el.fontFamily) {
usedFonts.add(el.fontFamily);
}
}
// Always include Inter as the default
usedFonts.add("Inter");
const googleFontsUrl = generateGoogleFontsUrl([...usedFonts]);
const googleFontsLink = googleFontsUrl
? `<link data-hf-fonts="true" rel="preconnect" href="https://fonts.googleapis.com">
<link data-hf-fonts="true" rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link data-hf-fonts="true" href="${googleFontsUrl}" rel="stylesheet">`
: "";
const coreCss = `${BASE_STYLES}
#stage { position: relative; width: ${width}px; height: ${height}px; overflow: hidden; background: #fff; }
#stage-zoom-container { ${ZOOM_CONTAINER_STYLES} }
${elementStyles}`.trim();
const customCss = customStyles?.trim() ? customStyles.trim() : "";
return { coreCss, customCss, googleFontsLink };
}
function generateElementStyles(element: TimelineElement): string {
const baseStyles = "position: absolute;";
if (isTextElement(element)) {
const fontSize = element.fontSize ?? 48;
const fontWeight = element.fontWeight ?? 700;
const fontFamily = element.fontFamily ?? "Inter";
const color = element.color ?? "white";
const textShadow = element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
// Text outline using -webkit-text-stroke
const textOutline = element.textOutline
? `-webkit-text-stroke: ${element.textOutlineWidth ?? 2}px ${
element.textOutlineColor ?? "#000000"
}; paint-order: stroke fill;`
: "";
// Text highlight using background
const textHighlight = element.textHighlight
? `background-color: ${element.textHighlightColor ?? "yellow"}; padding: ${element.textHighlightPadding ?? 4}px ${
(element.textHighlightPadding ?? 4) * 1.5
}px; border-radius: ${
element.textHighlightRadius ?? 4
}px; box-decoration-break: clone; -webkit-box-decoration-break: clone;`
: "";
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
#${element.id} > div { font-family: '${fontFamily}', sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; color: ${color}; ${textShadow} ${textOutline} ${textHighlight} pointer-events: auto; cursor: grab; white-space: pre-wrap; text-align: center; }`;
}
switch (element.type) {
case "video":
// Videos fill the stage with standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; object-fit: contain; transform-origin: center center; }`;
case "image":
// Images use standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} max-width: 100%; max-height: 100%; transform-origin: center center; }`;
case "audio":
return ` #${element.id} { ${baseStyles} }`;
case "composition":
// Compositions use standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; position: absolute; }`;
}
}
export function generateGsapTimelineScript(
elements: TimelineElement[],
totalDuration: number,
options: SerializeOptions = {},
): string {
const {
animations,
generateDefaultAnimations = true,
resolution = "landscape",
keyframes,
stageZoomKeyframes,
} = options;
const { width, height } = CANVAS_DIMENSIONS[resolution];
const sortedElements = sortElements(elements);
const hasMedia = sortedElements.some((el) => el.type === "video" || el.type === "audio");
// Convert keyframes to GSAP animations
let keyframeAnimations: GsapAnimation[] = [];
if (keyframes) {
for (const element of sortedElements) {
const elementKeyframes = keyframes[element.id];
if (elementKeyframes && elementKeyframes.length > 0) {
const baseScale = isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
const converted = keyframesToGsapAnimations(element.id, elementKeyframes, element.startTime, {
x: element.x ?? 0,
y: element.y ?? 0,
scale: baseScale,
});
keyframeAnimations = keyframeAnimations.concat(converted);
}
}
}
// Generate zoom keyframes GSAP animations
const zoomAnimations = generateZoomGsapAnimations(stageZoomKeyframes || [], width, height);
// Generate initial position/scale set() calls for all elements
// This must be included regardless of keyframe animations
const initialPositionSets = generateInitialPositionSets(sortedElements, keyframes);
// Generate visibility animations for elements without keyframes
// When using keyframes path, elements without keyframes need explicit visibility
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(sortedElements, keyframes);
let gsapScript: string;
if (animations && animations.length > 0) {
// Merge provided animations with keyframe animations
const allAnimations = [...animations, ...keyframeAnimations];
gsapScript = serializeGsapAnimations(allAnimations, "tl", {
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
`const tl = gsap.timeline({ paused: true });\n${prependAnimations}`,
);
}
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
} else if (keyframeAnimations.length > 0) {
// Use only keyframe animations
gsapScript = serializeGsapAnimations(keyframeAnimations, "tl", {
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
`const tl = gsap.timeline({ paused: true });\n${prependAnimations}`,
);
}
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
} else if (generateDefaultAnimations) {
gsapScript = generateDefaultGsapAnimations(sortedElements, totalDuration, stageZoomKeyframes, width, height);
} else {
gsapScript = `
const tl = gsap.timeline({ paused: true });
${initialPositionSets ? initialPositionSets + "\n" : ""} tl.to({}, { duration: ${totalDuration || 1} });
`;
// Append zoom animations
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
}
return gsapScript;
}
export function generateHyperframesHtml(
elements: TimelineElement[],
totalDuration: number,
options: SerializeOptions = {},
): string {
const {
animations,
styles,
generateDefaultAnimations = true,
resolution = "landscape",
compositionId = `comp-${Date.now()}`,
keyframes,
stageZoomKeyframes,
includeScripts = false,
includeStyles = false,
} = options;
const { width, height } = CANVAS_DIMENSIONS[resolution];
// Include zoom keyframes in duration calculation
const maxZoomTime =
stageZoomKeyframes && stageZoomKeyframes.length > 0 ? Math.max(...stageZoomKeyframes.map((kf) => kf.time)) : 0;
const calculatedDuration =
elements.length > 0
? Math.max(...elements.map((el) => el.startTime + el.duration), totalDuration, maxZoomTime)
: Math.max(totalDuration, maxZoomTime);
const sortedElements = sortElements(elements);
const elementsHtml = sortedElements.map((el) => generateElementHtml(el, keyframes?.[el.id])).join("\n ");
const customStyles = styles || "";
// Serialize zoom keyframes to data attribute
const zoomKeyframesAttr =
stageZoomKeyframes && stageZoomKeyframes.length > 0
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "&#39;")}'`
: "";
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(sortedElements, resolution, customStyles);
const gsapScript = includeScripts
? generateGsapTimelineScript(sortedElements, totalDuration, {
animations,
generateDefaultAnimations,
resolution,
keyframes,
stageZoomKeyframes,
})
: "";
const gsapCdnTag = includeScripts ? ` <script src="${GSAP_CDN}"></script>` : "";
const gsapScriptTag = includeScripts
? ` <script>
${gsapScript}
</script>`
: "";
const styleTags = includeStyles
? [
coreCss
? ` <style data-hf-core="true">
${coreCss.split("\n").join("\n ")}
</style>`
: "",
customCss
? ` <style data-hf-custom="true">
${customCss.split("\n").join("\n ")}
</style>`
: "",
]
.filter(Boolean)
.join("\n")
: "";
const customStylesAttr = customStyles
? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "&#39;")}'`
: "";
const resolutionAttr = ` data-resolution="${resolution}"`;
return `<!DOCTYPE html>
<html data-composition-id="${compositionId}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${includeStyles ? googleFontsLink : ""}
${gsapCdnTag}
${styleTags ? ` ${styleTags}` : ""}
</head>
<body>
<div id="stage">
<div id="stage-zoom-container"${zoomKeyframesAttr}>
${elementsHtml}
</div>
</div>
${gsapScriptTag}
</body>
</html>`;
}
function calculateZoomTransform(
scale: number,
focusX: number,
focusY: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const x = centerX - focusX * scale;
const y = centerY - focusY * scale;
return { x, y };
}
function generateZoomGsapAnimations(
zoomKeyframes: StageZoomKeyframe[],
canvasWidth: number,
canvasHeight: number,
): string {
if (!zoomKeyframes || zoomKeyframes.length === 0) {
return "";
}
const sortedKeyframes = [...zoomKeyframes].sort((a, b) => a.time - b.time);
const animations: string[] = [];
animations.push(" // Stage zoom animations");
for (let i = 0; i < sortedKeyframes.length; i++) {
const kf = sortedKeyframes[i];
if (!kf) continue;
const { x, y } = calculateZoomTransform(
kf.zoom.scale,
kf.zoom.focusX,
kf.zoom.focusY,
canvasWidth,
canvasHeight,
);
if (i === 0) {
animations.push(
` tl.set("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y} }, ${kf.time});`,
);
} else {
const prevKf = sortedKeyframes[i - 1];
if (!prevKf) continue;
const duration = kf.time - prevKf.time;
const ease = kf.ease ? `, ease: "${kf.ease}"` : "";
animations.push(
` tl.to("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y}, duration: ${duration}${ease} }, ${prevKf.time});`,
);
}
}
return animations.join("\n");
}
function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): string {
const baseAttrs = [
`id="${element.id}"`,
`data-start="${element.startTime}"`,
`data-end="${element.startTime + element.duration}"`,
`data-layer="${element.zIndex}"`,
`data-name="${element.name}"`,
];
// Serialize transform properties (x, y, scale, opacity) if non-default
if (element.x !== undefined && element.x !== 0) {
baseAttrs.push(`data-x="${element.x}"`);
}
if (element.y !== undefined && element.y !== 0) {
baseAttrs.push(`data-y="${element.y}"`);
}
if (element.scale !== undefined && element.scale !== 1) {
baseAttrs.push(`data-scale="${element.scale}"`);
}
if (element.opacity !== undefined && element.opacity !== 1) {
baseAttrs.push(`data-opacity="${element.opacity}"`);
}
// Serialize keyframes to data attribute if present
if (keyframes && keyframes.length > 0) {
const kfJson = JSON.stringify(keyframes);
baseAttrs.push(`data-keyframes='${kfJson.replace(/'/g, "&#39;")}'`);
}
if (isTextElement(element)) {
const textAttrs = [...baseAttrs, `data-type="text"`];
if (element.color) {
textAttrs.push(`data-color="${element.color}"`);
}
if (element.fontSize) {
textAttrs.push(`data-font-size="${element.fontSize}"`);
}
if (element.fontWeight) {
textAttrs.push(`data-font-weight="${element.fontWeight}"`);
}
if (element.fontFamily) {
textAttrs.push(`data-font-family="${element.fontFamily}"`);
}
if (element.textShadow === false) {
textAttrs.push(`data-text-shadow="false"`);
}
if (element.textOutline) {
textAttrs.push(`data-text-outline="true"`);
if (element.textOutlineColor) {
textAttrs.push(`data-text-outline-color="${element.textOutlineColor}"`);
}
if (element.textOutlineWidth) {
textAttrs.push(`data-text-outline-width="${element.textOutlineWidth}"`);
}
}
if (element.textHighlight) {
textAttrs.push(`data-text-highlight="true"`);
if (element.textHighlightColor) {
textAttrs.push(`data-text-highlight-color="${element.textHighlightColor}"`);
}
if (element.textHighlightPadding) {
textAttrs.push(`data-text-highlight-padding="${element.textHighlightPadding}"`);
}
if (element.textHighlightRadius) {
textAttrs.push(`data-text-highlight-radius="${element.textHighlightRadius}"`);
}
}
const content = element.content || element.name;
return `<div ${textAttrs.join(" ")}><div>${content}</div></div>`;
}
if (isCompositionElement(element)) {
const compositionAttrs = [
...baseAttrs,
`data-type="composition"`,
`data-composition-id="${element.compositionId}"`,
];
if (element.sourceDuration) {
compositionAttrs.push(`data-source-duration="${element.sourceDuration}"`);
}
if (element.sourceWidth) {
compositionAttrs.push(`data-source-width="${element.sourceWidth}"`);
}
if (element.sourceHeight) {
compositionAttrs.push(`data-source-height="${element.sourceHeight}"`);
}
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const varJson = JSON.stringify(element.variableValues);
compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "&#39;")}'`);
}
const attrs = compositionAttrs.join(" ");
// Build iframe src with variable values as query params if present
// Strip any existing query params first to avoid duplication
let iframeSrc = element.src.split("?")[0];
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(element.variableValues)) {
params.set(key, String(value));
}
iframeSrc = `${iframeSrc}?${params.toString()}`;
}
// Motion designs are full-screen overlays - always use 100% sizing
// The motion design HTML handles its own internal positioning
// Wrap iframe in container with click overlay for selection
return `<div ${attrs} style="width: 100%; height: 100%;">
<iframe src="${iframeSrc}" sandbox="allow-scripts allow-same-origin" style="width: 100%; height: 100%; border: none; pointer-events: none;"></iframe>
<div class="composition-click-overlay" style="position: absolute; inset: 0; cursor: pointer;"></div>
</div>`;
}
if (isMediaElement(element)) {
if (element.mediaStartTime) {
baseAttrs.push(`data-media-start="${element.mediaStartTime}"`);
}
if (element.sourceDuration) {
baseAttrs.push(`data-source-duration="${element.sourceDuration}"`);
}
if (element.isAroll) {
baseAttrs.push(`data-aroll="true"`);
}
if (element.volume !== undefined && element.volume !== 1) {
baseAttrs.push(`data-volume="${element.volume}"`);
}
if (element.type === "video" && element.hasAudio) {
baseAttrs.push(`data-has-audio="true"`);
}
}
const attrs = baseAttrs.join(" ");
switch (element.type) {
case "video":
return `<video ${attrs} src="${element.src}" playsinline></video>`;
case "image":
return `<img ${attrs} src="${element.src}" alt="${element.name}" />`;
case "audio":
return `<audio ${attrs} src="${element.src}"></audio>`;
default:
return "";
}
}
/**
* Generate initial position sets for elements.
*
* Center-based coordinate system with standard CSS origin:
* - (0, 0) = top-left corner of the canvas
* - (960, 540) = center of canvas (landscape 1920x1080)
* - x/y specifies where the element's CENTER goes (not top-left corner)
*
* Note: xPercent: -50, yPercent: -50 is applied once at player init via
* _initializeElementCentering(), so we only set x, y, scale here.
* This keeps generated timeline code clean (no repeated xPercent/yPercent).
*/
function generateInitialPositionSets(elements: TimelineElement[], keyframes?: Record<string, Keyframe[]>): string {
const sets: string[] = [];
const timeEpsilon = 0.001;
for (const el of elements) {
const elementKeyframes = keyframes?.[el.id];
const hasBaseKeyframe = elementKeyframes?.some(
(kf) =>
Math.abs(kf.time) <= timeEpsilon &&
(kf.properties.x !== undefined || kf.properties.y !== undefined || kf.properties.scale !== undefined),
);
const xVal = el.x ?? 0;
const yVal = el.y ?? 0;
const scaleVal = isMediaElement(el) ? (el.scale ?? 1) : 1;
// Audio elements don't need positioning
if (el.type === "audio") continue;
// Composition elements (motion designs) are full-screen overlays
// They don't need x/y/scale positioning - the HTML handles internal layout
if (isCompositionElement(el)) continue;
// Skip if element has a base keyframe that will handle positioning
if (hasBaseKeyframe) {
continue;
}
// Set position and scale (xPercent/yPercent applied at player init)
if (scaleVal !== 1) {
sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal}, scale: ${scaleVal} }, 0);`);
} else if (xVal !== 0 || yVal !== 0) {
sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal} }, 0);`);
}
}
return sets.length > 0 ? sets.join("\n") : "";
}
/**
* Generates visibility bookends for ALL elements to ensure they appear/disappear
* at the correct times. Elements with keyframes still need visibility bookends
* because keyframesToGsapAnimations only handles property animations, not visibility.
*
* If opacity keyframes exist, the first keyframe defines the base.
*/
function generateVisibilityForElementsWithoutKeyframes(
elements: TimelineElement[],
keyframes?: Record<string, Keyframe[]>,
): string {
const animations: string[] = [];
for (const el of elements) {
const elementKeyframes = keyframes?.[el.id];
const opacityKeyframes = elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
const start = el.startTime;
const end = el.startTime + el.duration;
const safeName = el.name.replace(/[\r\n]+/g, " ");
animations.push(` // ${safeName} (visibility)`);
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
let elementOpacity = el.opacity ?? 1;
if (opacityKeyframes.length > 0) {
const firstOpacityKeyframe = [...opacityKeyframes].sort((a, b) => a.time - b.time)[0];
if (firstOpacityKeyframe?.properties.opacity !== undefined) {
elementOpacity = firstOpacityKeyframe.properties.opacity;
}
}
// Only include opacity in visibility bookend if non-default or has opacity keyframes
const needsOpacity = elementOpacity !== 1 || opacityKeyframes.length > 0;
if (needsOpacity) {
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`);
}
return animations.length > 0 ? animations.join("\n") : "";
}
function generateDefaultGsapAnimations(
elements: TimelineElement[],
totalDuration: number,
stageZoomKeyframes?: StageZoomKeyframe[],
canvasWidth?: number,
canvasHeight?: number,
): string {
if (elements.length === 0 && (!stageZoomKeyframes || stageZoomKeyframes.length === 0)) {
return `
const tl = gsap.timeline({ paused: true });
tl.to({}, { duration: ${totalDuration || 1} });
`;
}
const animations: string[] = [];
// First, set initial positions and scales for elements with x/y offsets or scale
const initialPositionSets = generateInitialPositionSets(elements);
if (initialPositionSets) {
animations.push(initialPositionSets);
}
for (const el of elements) {
const start = el.startTime;
const end = el.startTime + el.duration;
const safeName = el.name.replace(/[\r\n]+/g, " ");
const elementOpacity = el.opacity ?? 1;
animations.push(` // ${safeName}`);
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
// Only include opacity if non-default
if (elementOpacity !== 1) {
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`);
}
const mediaElements = elements.filter((el) => el.type === "video" || el.type === "audio");
const mediaSync =
mediaElements.length > 0
? `
// Sync media playback
tl.eventCallback("onUpdate", function() {
const time = tl.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !tl.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`
: "";
// Generate zoom animations if present
const zoomAnimations =
stageZoomKeyframes && stageZoomKeyframes.length > 0 && canvasWidth && canvasHeight
? "\n" + generateZoomGsapAnimations(stageZoomKeyframes, canvasWidth, canvasHeight)
: "";
return `
const tl = gsap.timeline({ paused: true });
${animations.join("\n")}
${mediaSync}${zoomAnimations}
`;
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import * as core from "./index.js";
describe("@hyperframes/core public API exports", () => {
describe("type-related constants and utilities", () => {
it("exports CANVAS_DIMENSIONS", () => {
expect(core.CANVAS_DIMENSIONS).toBeDefined();
expect(core.CANVAS_DIMENSIONS.landscape).toEqual({ width: 1920, height: 1080 });
expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 });
});
it("exports TIMELINE_COLORS", () => {
expect(core.TIMELINE_COLORS).toBeDefined();
expect(core.TIMELINE_COLORS.video).toBeDefined();
expect(core.TIMELINE_COLORS.image).toBeDefined();
expect(core.TIMELINE_COLORS.text).toBeDefined();
expect(core.TIMELINE_COLORS.audio).toBeDefined();
expect(core.TIMELINE_COLORS.composition).toBeDefined();
});
it("exports DEFAULT_DURATIONS", () => {
expect(core.DEFAULT_DURATIONS).toBeDefined();
expect(core.DEFAULT_DURATIONS.video).toBe(5);
expect(core.DEFAULT_DURATIONS.text).toBe(2);
});
it("exports type guard functions", () => {
expect(typeof core.isTextElement).toBe("function");
expect(typeof core.isMediaElement).toBe("function");
expect(typeof core.isCompositionElement).toBe("function");
});
it("exports getDefaultStageZoom", () => {
expect(typeof core.getDefaultStageZoom).toBe("function");
const zoom = core.getDefaultStageZoom("landscape");
expect(zoom.scale).toBe(1);
expect(zoom.focusX).toBe(960);
expect(zoom.focusY).toBe(540);
});
it("exports composition variable type guards", () => {
expect(typeof core.isStringVariable).toBe("function");
expect(typeof core.isNumberVariable).toBe("function");
expect(typeof core.isColorVariable).toBe("function");
expect(typeof core.isBooleanVariable).toBe("function");
expect(typeof core.isEnumVariable).toBe("function");
});
});
describe("template exports", () => {
it("exports generateBaseHtml", () => {
expect(typeof core.generateBaseHtml).toBe("function");
});
it("exports getStageStyles", () => {
expect(typeof core.getStageStyles).toBe("function");
});
it("exports template constants", () => {
expect(core.GSAP_CDN).toBeDefined();
expect(core.BASE_STYLES).toBeDefined();
expect(core.ELEMENT_BASE_STYLES).toBeDefined();
expect(core.MEDIA_STYLES).toBeDefined();
expect(core.TEXT_STYLES).toBeDefined();
expect(core.ZOOM_CONTAINER_STYLES).toBeDefined();
});
});
describe("parser exports", () => {
it("exports GSAP parser functions", () => {
expect(typeof core.parseGsapScript).toBe("function");
expect(typeof core.serializeGsapAnimations).toBe("function");
expect(typeof core.updateAnimationInScript).toBe("function");
expect(typeof core.addAnimationToScript).toBe("function");
expect(typeof core.removeAnimationFromScript).toBe("function");
expect(typeof core.getAnimationsForElement).toBe("function");
expect(typeof core.validateCompositionGsap).toBe("function");
expect(typeof core.keyframesToGsapAnimations).toBe("function");
expect(typeof core.gsapAnimationsToKeyframes).toBe("function");
});
it("exports GSAP constants", () => {
expect(core.SUPPORTED_PROPS).toBeDefined();
expect(Array.isArray(core.SUPPORTED_PROPS)).toBe(true);
expect(core.SUPPORTED_EASES).toBeDefined();
expect(Array.isArray(core.SUPPORTED_EASES)).toBe(true);
});
it("exports HTML parser functions", () => {
expect(typeof core.parseHtml).toBe("function");
expect(typeof core.updateElementInHtml).toBe("function");
expect(typeof core.addElementToHtml).toBe("function");
expect(typeof core.removeElementFromHtml).toBe("function");
expect(typeof core.validateCompositionHtml).toBe("function");
expect(typeof core.extractCompositionMetadata).toBe("function");
});
});
describe("generator exports", () => {
it("exports hyperframes generator functions", () => {
expect(typeof core.generateHyperframesHtml).toBe("function");
expect(typeof core.generateGsapTimelineScript).toBe("function");
expect(typeof core.generateHyperframesStyles).toBe("function");
});
});
describe("compiler exports", () => {
it("exports compiler functions", () => {
expect(typeof core.compileTimingAttrs).toBe("function");
expect(typeof core.injectDurations).toBe("function");
expect(typeof core.extractResolvedMedia).toBe("function");
expect(typeof core.clampDurations).toBe("function");
});
});
describe("lint exports", () => {
it("exports lintHyperframeHtml", () => {
expect(typeof core.lintHyperframeHtml).toBe("function");
});
});
describe("inline-script exports", () => {
it("exports hyperframe runtime artifacts", () => {
expect(core.HYPERFRAME_RUNTIME_ARTIFACTS).toBeDefined();
expect(core.HYPERFRAME_RUNTIME_CONTRACT).toBeDefined();
expect(typeof core.loadHyperframeRuntimeSource).toBe("function");
});
it("exports runtime contract constants", () => {
expect(core.HYPERFRAME_RUNTIME_GLOBALS).toBeDefined();
expect(core.HYPERFRAME_BRIDGE_SOURCES).toBeDefined();
expect(core.HYPERFRAME_CONTROL_ACTIONS).toBeDefined();
});
it("exports buildHyperframesRuntimeScript", () => {
expect(typeof core.buildHyperframesRuntimeScript).toBe("function");
});
it("exports MEDIA_VISUAL_STYLE_PROPERTIES", () => {
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toBeDefined();
expect(Array.isArray(core.MEDIA_VISUAL_STYLE_PROPERTIES)).toBe(true);
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("width");
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("opacity");
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("transform");
});
it("exports copyMediaVisualStyles", () => {
expect(typeof core.copyMediaVisualStyles).toBe("function");
});
it("exports quantizeTimeToFrame", () => {
expect(typeof core.quantizeTimeToFrame).toBe("function");
});
});
describe("adapter exports", () => {
it("exports createGSAPFrameAdapter", () => {
expect(typeof core.createGSAPFrameAdapter).toBe("function");
});
});
});
+146
View File
@@ -0,0 +1,146 @@
// Types
export type {
ExecutionMode,
Orientation,
Asset,
TimelineElement,
TimelineElementBase,
TimelineMediaElement,
TimelineTextElement,
TimelineCompositionElement,
TimelineElementType,
MediaElementType,
CanvasResolution,
MediaFile,
CompositionAPI,
PlayerAPI,
AddElementData,
ValidationResult,
CompositionAsset,
Keyframe,
KeyframeProperties,
ElementKeyframes,
StageZoom,
StageZoomKeyframe,
CompositionVariableType,
CompositionVariableBase,
StringVariable,
NumberVariable,
ColorVariable,
BooleanVariable,
EnumVariable,
CompositionVariable,
CompositionSpec,
WaveformData,
} from "./core.types";
export {
CANVAS_DIMENSIONS,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
isTextElement,
isMediaElement,
isCompositionElement,
getDefaultStageZoom,
isStringVariable,
isNumberVariable,
isColorVariable,
isBooleanVariable,
isEnumVariable,
} from "./core.types";
// Templates
export { generateBaseHtml, getStageStyles } from "./templates/base";
export {
GSAP_CDN,
BASE_STYLES,
ELEMENT_BASE_STYLES,
MEDIA_STYLES,
TEXT_STYLES,
ZOOM_CONTAINER_STYLES,
} from "./templates/constants";
// Parsers
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./parsers/gsapParser";
export {
parseGsapScript,
serializeGsapAnimations,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
getAnimationsForElement,
validateCompositionGsap,
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
SUPPORTED_PROPS,
SUPPORTED_EASES,
} from "./parsers/gsapParser";
export type { ParsedHtml, CompositionMetadata } from "./parsers/htmlParser";
export {
parseHtml,
updateElementInHtml,
addElementToHtml,
removeElementFromHtml,
validateCompositionHtml,
extractCompositionMetadata,
} from "./parsers/htmlParser";
// Generators
export type { SerializeOptions } from "./generators/hyperframes";
export {
generateHyperframesHtml,
generateGsapTimelineScript,
generateHyperframesStyles,
} from "./generators/hyperframes";
// Compiler (timing only — browser-safe, no cheerio/esbuild)
export type { UnresolvedElement, ResolvedDuration, ResolvedMediaElement, CompilationResult } from "./compiler/timingCompiler";
export { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./compiler/timingCompiler";
// Lint
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./lint/types";
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
// Inline scripts
export {
HYPERFRAME_RUNTIME_ARTIFACTS,
HYPERFRAME_RUNTIME_CONTRACT,
loadHyperframeRuntimeSource,
type HyperframeRuntimeContract,
} from "./inline-scripts/hyperframe";
export {
HYPERFRAME_RUNTIME_GLOBALS,
HYPERFRAME_BRIDGE_SOURCES,
HYPERFRAME_CONTROL_ACTIONS,
type HyperframeControlAction,
} from "./inline-scripts/runtimeContract";
export {
buildHyperframesRuntimeScript,
type HyperframesRuntimeBuildOptions,
} from "./inline-scripts/hyperframesRuntime.engine";
export {
MEDIA_VISUAL_STYLE_PROPERTIES,
copyMediaVisualStyles,
quantizeTimeToFrame,
type MediaVisualStyleProperty,
} from "./inline-scripts/parityContract";
export type {
HyperframePickerApi,
HyperframePickerBoundingBox,
HyperframePickerElementInfo,
} from "./inline-scripts/pickerApi";
// Frame adapters
export type { FrameAdapter, FrameAdapterContext } from "./adapters/types";
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./adapters/gsap";
export { createGSAPFrameAdapter } from "./adapters/gsap";
@@ -0,0 +1,22 @@
import { buildHyperframesRuntimeScript } from "./hyperframesRuntime.engine";
import { HYPERFRAME_BRIDGE_SOURCES, HYPERFRAME_RUNTIME_GLOBALS } from "./runtimeContract";
export const HYPERFRAME_RUNTIME_ARTIFACTS = {
iife: "hyperframe.runtime.iife.js",
esm: "hyperframe.runtime.mjs",
manifest: "hyperframe.manifest.json",
} as const;
export type HyperframeRuntimeContract = {
globals: typeof HYPERFRAME_RUNTIME_GLOBALS;
messageSources: typeof HYPERFRAME_BRIDGE_SOURCES;
};
export const HYPERFRAME_RUNTIME_CONTRACT: HyperframeRuntimeContract = {
globals: HYPERFRAME_RUNTIME_GLOBALS,
messageSources: HYPERFRAME_BRIDGE_SOURCES,
};
export function loadHyperframeRuntimeSource(): string {
return buildHyperframesRuntimeScript();
}
@@ -0,0 +1,37 @@
import { buildSync } from "esbuild";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export type HyperframesRuntimeBuildOptions = {
sourceUrl?: string;
defaultParityMode?: boolean;
minify?: boolean;
};
function applyDefaultParityMode(script: string, enabled: boolean): string {
const parityFlagPattern = /var\s+_parityModeEnabled\s*=\s*(?:true|false)\s*;/;
if (!parityFlagPattern.test(script)) return script;
return script.replace(parityFlagPattern, `var _parityModeEnabled = ${enabled ? "true" : "false"};`);
}
export function buildHyperframesRuntimeScript(options: HyperframesRuntimeBuildOptions = {}): string {
const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts");
const result = buildSync({
entryPoints: [entryPath],
bundle: true,
write: false,
platform: "browser",
format: "iife",
target: ["es2020"],
minify: options.minify ?? true,
legalComments: "none",
});
let script = result.outputFiles[0]?.text ?? "";
if (typeof options.defaultParityMode === "boolean") {
script = applyDefaultParityMode(script, options.defaultParityMode);
}
if (options.sourceUrl && options.sourceUrl.trim()) {
script = `${script}\n//# sourceURL=${options.sourceUrl.trim()}`;
}
return script;
}
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "./parityContract.js";
describe("quantizeTimeToFrame", () => {
it("quantizes a time to the nearest frame boundary at 30fps", () => {
// 1.5s at 30fps = frame 45 => 45/30 = 1.5
expect(quantizeTimeToFrame(1.5, 30)).toBe(1.5);
});
it("floors to the previous frame boundary", () => {
// 1.51s at 30fps: floor(1.51*30 + 1e-9) = floor(45.3) = 45 => 45/30 = 1.5
expect(quantizeTimeToFrame(1.51, 30)).toBe(1.5);
});
it("handles exact frame boundaries", () => {
// 1.0s at 24fps: floor(1.0*24 + 1e-9) = 24 => 24/24 = 1.0
expect(quantizeTimeToFrame(1.0, 24)).toBe(1.0);
});
it("returns 0 for zero time", () => {
expect(quantizeTimeToFrame(0, 30)).toBe(0);
});
it("returns 0 for negative time", () => {
expect(quantizeTimeToFrame(-1, 30)).toBe(0);
});
it("defaults to 30fps for invalid fps", () => {
// When fps is invalid, safeFps falls back to 30
// quantizeTimeToFrame(1, 0) => floor(1*30+eps)/30 = 1
expect(quantizeTimeToFrame(1, 0)).toBe(1);
expect(quantizeTimeToFrame(1, NaN)).toBe(1);
expect(quantizeTimeToFrame(1, -10)).toBe(1);
});
it("handles NaN time gracefully", () => {
expect(quantizeTimeToFrame(NaN, 30)).toBe(0);
});
it("handles 60fps correctly", () => {
// 0.5s at 60fps: floor(0.5*60 + 1e-9) = 30 => 30/60 = 0.5
expect(quantizeTimeToFrame(0.5, 60)).toBe(0.5);
});
});
describe("MEDIA_VISUAL_STYLE_PROPERTIES", () => {
it("is a non-empty readonly array", () => {
expect(MEDIA_VISUAL_STYLE_PROPERTIES.length).toBeGreaterThan(0);
});
it("includes core layout properties", () => {
expect(MEDIA_VISUAL_STYLE_PROPERTIES).toContain("width");
expect(MEDIA_VISUAL_STYLE_PROPERTIES).toContain("height");
expect(MEDIA_VISUAL_STYLE_PROPERTIES).toContain("transform");
expect(MEDIA_VISUAL_STYLE_PROPERTIES).toContain("opacity");
});
});
@@ -0,0 +1,50 @@
export const MEDIA_VISUAL_STYLE_PROPERTIES = [
"width",
"height",
"top",
"left",
"right",
"bottom",
"inset",
"object-fit",
"object-position",
"z-index",
"opacity",
"visibility",
"filter",
"mix-blend-mode",
"backdrop-filter",
"border-radius",
"overflow",
"clip-path",
"mask",
"mask-image",
"mask-size",
"mask-position",
"mask-repeat",
"transform",
"transform-origin",
"box-sizing",
] as const;
export type MediaVisualStyleProperty = (typeof MEDIA_VISUAL_STYLE_PROPERTIES)[number];
export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
const frameIndex = Math.floor(safeTime * safeFps + 1e-9);
return frameIndex / safeFps;
}
export function copyMediaVisualStyles(
targetStyle: CSSStyleDeclaration,
sourceStyle: CSSStyleDeclaration,
properties: readonly string[] = MEDIA_VISUAL_STYLE_PROPERTIES,
): void {
for (const property of properties) {
const value = sourceStyle.getPropertyValue(property);
if (value) {
targetStyle.setProperty(property, value);
}
}
}
@@ -0,0 +1,34 @@
export type HyperframePickerBoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
export type HyperframePickerElementInfo = {
id: string | null;
tagName: string;
selector: string;
label: string;
boundingBox: HyperframePickerBoundingBox;
textContent: string | null;
src: string | null;
dataAttributes: Record<string, string>;
};
export type HyperframePickerApi = {
enable: () => void;
disable: () => void;
isActive: () => boolean;
getHovered: () => HyperframePickerElementInfo | null;
getSelected: () => HyperframePickerElementInfo | null;
getCandidatesAtPoint: (clientX: number, clientY: number, limit?: number) => HyperframePickerElementInfo[];
pickAtPoint: (clientX: number, clientY: number, index?: number) => HyperframePickerElementInfo | null;
pickManyAtPoint: (clientX: number, clientY: number, indexes?: number[]) => HyperframePickerElementInfo[];
};
declare global {
interface Window {
__HF_PICKER_API?: HyperframePickerApi;
}
}
@@ -0,0 +1,24 @@
export const HYPERFRAME_RUNTIME_GLOBALS = {
player: "__player",
playerReady: "__playerReady",
renderReady: "__renderReady",
timelines: "__timelines",
clipManifest: "__clipManifest",
} as const;
export const HYPERFRAME_BRIDGE_SOURCES = {
parent: "hf-parent",
preview: "hf-preview",
} as const;
export const HYPERFRAME_CONTROL_ACTIONS = [
"play",
"pause",
"seek",
"set-muted",
"set-playback-rate",
"enable-pick-mode",
"disable-pick-mode",
] as const;
export type HyperframeControlAction = (typeof HYPERFRAME_CONTROL_ACTIONS)[number];
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
describe("lintHyperframeHtml", () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
it("reports no errors for a valid composition", () => {
const result = lintHyperframeHtml(validComposition);
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("reports error when root is missing data-composition-id", () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when root is missing data-width or data-height", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when timeline registry is missing", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("reports error for duplicate media ids", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for composition host missing data-composition-id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="host1" data-composition-src="child.html"></div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
expect(finding).toBeDefined();
});
it("attaches filePath to findings when option is set", () => {
const html = "<html><body><div></div></body></html>";
const result = lintHyperframeHtml(html, { filePath: "test.html" });
for (const finding of result.findings) {
expect(finding.file).toBe("test.html");
}
});
it("deduplicates identical findings", () => {
// Calling with the same HTML should not produce duplicate entries
const html = `
<html><body>
<div id="root"></div>
<script>const tl = gsap.timeline();</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const codes = result.findings.map((f) => `${f.code}|${f.message}`);
const uniqueCodes = [...new Set(codes)];
expect(codes.length).toBe(uniqueCodes.length);
});
});
+699
View File
@@ -0,0 +1,699 @@
import { parseGsapScript } from "../parsers/gsapParser";
import type { HyperframeLintFinding, HyperframeLinterOptions, HyperframeLintResult } from "./types";
type OpenTag = {
raw: string;
name: string;
attrs: string;
index: number;
};
type ExtractedBlock = {
attrs: string;
content: string;
raw: string;
index: number;
};
type GsapWindow = {
targetSelector: string;
position: number;
end: number;
properties: string[];
overwriteAuto: boolean;
raw: string;
};
const TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
const STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
const WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
export function lintHyperframeHtml(html: string, options: HyperframeLinterOptions = {}): HyperframeLintResult {
const source = html || "";
const filePath = options.filePath;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
const pushFinding = (finding: HyperframeLintFinding) => {
const dedupeKey = [
finding.code,
finding.severity,
finding.selector || "",
finding.elementId || "",
finding.message,
].join("|");
if (seen.has(dedupeKey)) {
return;
}
seen.add(dedupeKey);
findings.push(filePath ? { ...finding, file: filePath } : finding);
};
const tags = extractOpenTags(source);
const styles = extractBlocks(source, STYLE_BLOCK_PATTERN);
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source);
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
pushFinding({
code: "root_missing_composition_id",
severity: "error",
message: "Root composition is missing `data-composition-id`.",
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
snippet: truncateSnippet(rootTag?.raw || ""),
});
}
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
pushFinding({
code: "root_missing_dimensions",
severity: "error",
message: "Root composition is missing `data-width` or `data-height`.",
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
snippet: truncateSnippet(rootTag?.raw || ""),
});
}
if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
pushFinding({
code: "missing_timeline_registry",
severity: "error",
message: "Missing `window.__timelines` registration.",
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
});
}
if (INVALID_SCRIPT_CLOSE_PATTERN.test(source)) {
pushFinding({
code: "invalid_inline_script_syntax",
severity: "error",
message: "Detected malformed inline `<script>` closing syntax.",
fixHint: "Close inline scripts with a valid `</script>` tag.",
});
}
for (const script of scripts) {
const attrs = script.attrs || "";
if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["']application\/json["']/.test(attrs)) {
continue;
}
const syntaxError = getInlineScriptSyntaxError(script.content);
if (!syntaxError) {
continue;
}
pushFinding({
code: "invalid_inline_script_syntax",
severity: "error",
message: `Inline script has invalid syntax: ${syntaxError}`,
fixHint: "Fix the inline script syntax before render verification.",
snippet: truncateSnippet(script.content),
});
}
for (const tag of tags) {
const src = readAttr(tag.raw, "data-composition-src");
if (!src) {
continue;
}
const compId = readAttr(tag.raw, "data-composition-id");
if (compId) {
continue;
}
pushFinding({
code: "host_missing_composition_id",
severity: "error",
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
snippet: truncateSnippet(tag.raw),
});
}
const scopedCssCompositionIds = new Set<string>();
for (const style of styles) {
for (const compId of extractCompositionIdsFromCss(style.content)) {
scopedCssCompositionIds.add(compId);
}
}
for (const compId of scopedCssCompositionIds) {
if (compositionIds.has(compId)) {
continue;
}
pushFinding({
code: "scoped_css_missing_wrapper",
severity: "warning",
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
selector: `[data-composition-id="${compId}"]`,
fixHint: "Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
});
}
const mediaById = new Map<string, OpenTag[]>();
const mediaFingerprintCounts = new Map<string, number>();
for (const tag of tags) {
if (!isMediaTag(tag.name)) {
continue;
}
const elementId = readAttr(tag.raw, "id");
if (elementId) {
const existing = mediaById.get(elementId) || [];
existing.push(tag);
mediaById.set(elementId, existing);
}
const fingerprint = [
tag.name,
readAttr(tag.raw, "src") || "",
readAttr(tag.raw, "data-start") || "",
readAttr(tag.raw, "data-duration") || "",
].join("|");
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
}
for (const [elementId, mediaTags] of mediaById) {
if (mediaTags.length < 2) {
continue;
}
pushFinding({
code: "duplicate_media_id",
severity: "error",
message: `Media id "${elementId}" is defined multiple times.`,
elementId,
fixHint: "Give each media element a unique id so preview and producer discover the same media graph.",
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
});
}
for (const [fingerprint, count] of mediaFingerprintCounts) {
if (count < 2) {
continue;
}
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
pushFinding({
code: "duplicate_media_discovery_risk",
severity: "warning",
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
snippet: truncateSnippet(`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`),
});
}
const classUsage = countClassUsage(tags);
for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
const gsapWindows = extractGsapWindows(script.content);
for (let i = 0; i < gsapWindows.length; i++) {
const left = gsapWindows[i];
if (!left) continue;
if (left.end <= left.position) {
continue;
}
for (let j = i + 1; j < gsapWindows.length; j++) {
const right = gsapWindows[j];
if (!right) continue;
if (right.end <= right.position) {
continue;
}
if (left.targetSelector !== right.targetSelector) {
continue;
}
const overlapStart = Math.max(left.position, right.position);
const overlapEnd = Math.min(left.end, right.end);
if (overlapEnd <= overlapStart) {
continue;
}
if (left.overwriteAuto || right.overwriteAuto) {
continue;
}
const sharedProperties = left.properties.filter((prop) => right.properties.includes(prop));
if (sharedProperties.length === 0) {
continue;
}
pushFinding({
code: "overlapping_gsap_tweens",
severity: "warning",
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
selector: left.targetSelector,
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
});
}
}
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
continue;
}
for (const window of gsapWindows) {
if (!isSuspiciousGlobalSelector(window.targetSelector)) {
continue;
}
const className = getSingleClassSelector(window.targetSelector);
if (className && (classUsage.get(className) || 0) < 2) {
continue;
}
pushFinding({
code: "suspicious_global_gsap_selector",
severity: "warning",
message: `Timeline "${localTimelineCompId}" uses a global selector "${window.targetSelector}" that may escape composition scope.`,
selector: window.targetSelector,
fixHint: `Scope the selector like \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
snippet: truncateSnippet(window.raw),
});
}
}
// ── Composition pitfall checks ──────────────────────────────────────────
// #2: Video without muted attribute (audio should come from separate <audio>)
for (const tag of tags) {
if (tag.name !== "video") continue;
const hasMuted = /\bmuted\b/i.test(tag.raw);
if (!hasMuted && readAttr(tag.raw, "data-start")) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "video_missing_muted",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. The framework expects video to be muted with a separate <audio> element for sound.`,
elementId,
fixHint:
"Add the `muted` attribute to the <video> tag and use a separate <audio> element with the same src for audio playback.",
snippet: truncateSnippet(tag.raw),
});
}
}
// #3: Video nested inside a timed element (data-start on ancestor)
// Approximation: check if a <video data-start> appears inside another element with data-start
// by scanning for video tags whose raw position is between another timed element's open/close
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({ name: tag.name, start: tag.index, id: readAttr(tag.raw, "id") || undefined });
}
}
for (const tag of tags) {
if (tag.name !== "video") continue;
if (!readAttr(tag.raw, "data-start")) continue;
// Check if any timed non-media element appears before this video in the source
// and could be an ancestor (heuristic — not a full DOM parse)
for (const parent of timedTagPositions) {
if (parent.start < tag.index) {
// Check if there's a closing tag for the parent between parent.start and tag.index
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
const between = source.substring(parent.start, tag.index);
if (!parentClosePattern.test(between)) {
pushFinding({
code: "video_nested_in_timed_element",
severity: "warning",
message: `<video> with data-start appears to be nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. This can break media sync.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
snippet: truncateSnippet(tag.raw),
});
break; // Only report once per video
}
}
}
}
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const styleAttr = readAttr(tag.raw, "style") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "timed_element_missing_visibility_hidden",
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip" or visibility:hidden. The framework needs elements to start hidden so it can manage their lifecycle.`,
elementId,
fixHint: 'Add class="clip" to the element (with CSS: .clip { visibility: hidden; }).',
snippet: truncateSnippet(tag.raw),
});
}
}
// #5: Deprecated attribute names
for (const tag of tags) {
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "deprecated_data_layer",
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
elementId,
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
snippet: truncateSnippet(tag.raw),
});
}
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "deprecated_data_end",
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
elementId,
fixHint:
"Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
snippet: truncateSnippet(tag.raw),
});
}
}
const errorCount = findings.filter((finding) => finding.severity === "error").length;
const warningCount = findings.length - errorCount;
return {
ok: errorCount === 0,
errorCount,
warningCount,
findings,
};
}
function extractOpenTags(source: string): OpenTag[] {
const tags: OpenTag[] = [];
let match: RegExpExecArray | null;
while ((match = TAG_PATTERN.exec(source)) !== null) {
const raw = match[0];
if (raw.startsWith("</") || raw.startsWith("<!")) {
continue;
}
tags.push({
raw,
name: (match[1] || "").toLowerCase(),
attrs: match[2] || "",
index: match.index,
});
}
return tags;
}
function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[] {
const blocks: ExtractedBlock[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
blocks.push({
attrs: match[1] || "",
content: match[2] || "",
raw: match[0],
index: match.index,
});
}
return blocks;
}
function findRootTag(source: string): OpenTag | null {
const bodyMatch = source.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
const bodyContent = bodyMatch ? bodyMatch[1] ?? source : source;
const bodyTags = extractOpenTags(bodyContent);
for (const tag of bodyTags) {
if (["script", "style", "meta", "link", "title"].includes(tag.name)) {
continue;
}
return tag;
}
return null;
}
function readAttr(tagSource: string, attr: string): string | null {
if (!tagSource) {
return null;
}
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
return match?.[1] || null;
}
function collectCompositionIds(tags: OpenTag[]): Set<string> {
const ids = new Set<string>();
for (const tag of tags) {
const compId = readAttr(tag.raw, "data-composition-id");
if (compId) {
ids.add(compId);
}
}
return ids;
}
function extractCompositionIdsFromCss(css: string): string[] {
const ids = new Set<string>();
let match: RegExpExecArray | null;
while ((match = COMPOSITION_ID_IN_CSS_PATTERN.exec(css)) !== null) {
if (match[1]) {
ids.add(match[1]);
}
}
return [...ids];
}
function getInlineScriptSyntaxError(source: string): string | null {
if (!source.trim()) {
return null;
}
try {
// eslint-disable-next-line no-new-func
new Function(source);
return null;
} catch (error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}
function isMediaTag(tagName: string): boolean {
return tagName === "video" || tagName === "audio" || tagName === "img";
}
function countClassUsage(tags: OpenTag[]): Map<string, number> {
const counts = new Map<string, number>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class");
if (!classAttr) {
continue;
}
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
counts.set(className, (counts.get(className) || 0) + 1);
}
}
return counts;
}
function readRegisteredTimelineCompositionId(script: string): string | null {
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
return match?.[1] || null;
}
function extractGsapWindows(script: string): GsapWindow[] {
if (!/gsap\.timeline/.test(script)) {
return [];
}
const parsed = parseGsapScript(script);
if (parsed.animations.length === 0) {
return [];
}
const windows: GsapWindow[] = [];
const timelineVar = parsed.timelineVar;
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
let match: RegExpExecArray | null;
let index = 0;
while ((match = methodPattern.exec(script)) !== null && index < parsed.animations.length) {
const raw = match[0];
const meta = parseGsapWindowMeta(match[1] ?? "", match[2] ?? "");
const animation = parsed.animations[index];
index += 1;
if (!animation) {
continue;
}
windows.push({
targetSelector: animation.targetSelector,
position: animation.position,
end: animation.position + meta.effectiveDuration,
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
overwriteAuto: meta.overwriteAuto,
raw,
});
}
return windows;
}
function parseGsapWindowMeta(
method: string,
argsStr: string,
): {
effectiveDuration: number;
properties: string[];
overwriteAuto: boolean;
} {
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
if (!selectorMatch) {
return { effectiveDuration: 0, properties: [], overwriteAuto: false };
}
const afterSelector = argsStr.slice(selectorMatch[0].length);
let properties: Record<string, string | number> = {};
let fromProperties: Record<string, string | number> = {};
if (method === "fromTo") {
const firstBrace = afterSelector.indexOf("{");
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
if (firstBrace !== -1 && firstEnd !== -1) {
fromProperties = parseLooseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
const secondPart = afterSelector.slice(firstEnd + 1);
const secondBrace = secondPart.indexOf("{");
const secondEnd = findMatchingBrace(secondPart, secondBrace);
if (secondBrace !== -1 && secondEnd !== -1) {
properties = parseLooseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
}
}
} else {
const braceStart = afterSelector.indexOf("{");
const braceEnd = findMatchingBrace(afterSelector, braceStart);
if (braceStart !== -1 && braceEnd !== -1) {
properties = parseLooseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
}
}
const duration = numberValue(properties.duration) || 0;
const repeat = numberValue(properties.repeat) || 0;
const yoyo = stringValue(properties.yoyo) === "true";
const cycleCount = repeat > 0 ? repeat + 1 : 1;
const effectiveDuration = duration * cycleCount * (yoyo ? 1 : 1);
const overwriteAuto = stringValue(properties.overwrite) === "auto";
const propertyNames = new Set<string>();
for (const key of Object.keys(fromProperties)) {
if (!META_GSAP_KEYS.has(key)) {
propertyNames.add(key);
}
}
for (const key of Object.keys(properties)) {
if (!META_GSAP_KEYS.has(key)) {
propertyNames.add(key);
}
}
return {
effectiveDuration: method === "set" ? 0 : effectiveDuration,
properties: [...propertyNames],
overwriteAuto,
};
}
function parseLooseObjectLiteral(source: string): Record<string, string | number> {
const result: Record<string, string | number> = {};
const cleaned = source.replace(/^\{|\}$/g, "").trim();
if (!cleaned) {
return result;
}
const propertyPattern = /(\w+)\s*:\s*("[^"]*"|'[^']*'|true|false|-?[\d.]+|[a-zA-Z_][\w.]*)/g;
let match: RegExpExecArray | null;
while ((match = propertyPattern.exec(cleaned)) !== null) {
const key = match[1];
const rawValue = match[2];
if (!key || rawValue == null) {
continue;
}
if ((rawValue.startsWith('"') && rawValue.endsWith('"')) || (rawValue.startsWith("'") && rawValue.endsWith("'"))) {
result[key] = rawValue.slice(1, -1);
continue;
}
const numeric = Number(rawValue);
result[key] = Number.isFinite(numeric) ? numeric : rawValue;
}
return result;
}
function findMatchingBrace(source: string, startIndex: number): number {
if (startIndex < 0) {
return -1;
}
let depth = 0;
for (let i = startIndex; i < source.length; i++) {
if (source[i] === "{") {
depth += 1;
} else if (source[i] === "}") {
depth -= 1;
if (depth === 0) {
return i;
}
}
}
return -1;
}
function numberValue(value: string | number | undefined): number | null {
if (typeof value === "number") {
return value;
}
if (typeof value === "string" && value.trim()) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
return null;
}
function stringValue(value: string | number | undefined): string | null {
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
return null;
}
function isSuspiciousGlobalSelector(selector: string): boolean {
if (!selector) {
return false;
}
if (selector.includes("[data-composition-id=")) {
return false;
}
if (selector.startsWith("#")) {
return false;
}
return selector.startsWith(".") || /^[a-z]/i.test(selector);
}
function getSingleClassSelector(selector: string): string | null {
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
return match?.groups?.name || null;
}
function truncateSnippet(value: string, maxLength = 220): string | undefined {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return undefined;
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
+7
View File
@@ -0,0 +1,7 @@
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./types";
export { lintHyperframeHtml } from "./hyperframeLinter";
+23
View File
@@ -0,0 +1,23 @@
export type HyperframeLintSeverity = "error" | "warning";
export type HyperframeLintFinding = {
code: string;
severity: HyperframeLintSeverity;
message: string;
file?: string;
selector?: string;
elementId?: string;
fixHint?: string;
snippet?: string;
};
export type HyperframeLintResult = {
ok: boolean;
errorCount: number;
warningCount: number;
findings: HyperframeLintFinding[];
};
export type HyperframeLinterOptions = {
filePath?: string;
};
@@ -0,0 +1,532 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
gsapAnimationsToKeyframes,
SUPPORTED_PROPS,
SUPPORTED_EASES,
serializeGsapAnimations,
validateCompositionGsap,
getAnimationsForElement,
keyframesToGsapAnimations,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
describe("parseGsapScript", () => {
it("parses a basic timeline with .to()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("tl");
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("to");
expect(result.animations[0].targetSelector).toBe("#el1");
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].duration).toBe(0.5);
expect(result.animations[0].position).toBe(0);
});
it("parses a timeline with .from()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.from("#el2", { x: 100, duration: 1 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("from");
expect(result.animations[0].targetSelector).toBe("#el2");
expect(result.animations[0].properties.x).toBe(100);
expect(result.animations[0].duration).toBe(1);
expect(result.animations[0].position).toBe(0.5);
});
it("parses a timeline with .set()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#el3", { opacity: 0, x: 50 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("set");
expect(result.animations[0].targetSelector).toBe("#el3");
expect(result.animations[0].properties.opacity).toBe(0);
expect(result.animations[0].properties.x).toBe(50);
expect(result.animations[0].duration).toBeUndefined();
});
it("parses a timeline with .fromTo() and position offset", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el4", { opacity: 0, x: 100 }, { opacity: 1, x: 200, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.method).toBe("fromTo");
expect(anim.targetSelector).toBe("#el4");
expect(anim.fromProperties).toBeDefined();
expect(anim.fromProperties?.opacity).toBe(0);
expect(anim.fromProperties?.x).toBe(100);
expect(anim.properties.opacity).toBe(1);
expect(anim.properties.x).toBe(200);
expect(anim.duration).toBe(1);
expect(anim.position).toBe(2);
});
it("parseObjectLiteral does not match negative numbers (known limitation)", () => {
// The regex in parseObjectLiteral only matches [\d.]+, not negative numbers.
// Negative values like x: -100 won't be parsed by the object literal parser.
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el5", { opacity: 0, x: -100 }, { opacity: 1, x: 0, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.fromProperties).toBeDefined();
expect(anim.fromProperties?.opacity).toBe(0);
// -100 is not parseable by the regex, so x won't be in fromProperties
expect(anim.fromProperties?.x).toBeUndefined();
});
it("handles an empty script", () => {
const result = parseGsapScript("");
expect(result.animations).toHaveLength(0);
expect(result.timelineVar).toBe("tl");
expect(result.preamble).toBe("const tl = gsap.timeline({ paused: true });");
expect(result.postamble).toBe("");
});
it("extracts preamble correctly", () => {
const script = `
const myTl = gsap.timeline({ paused: true });
myTl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("myTl");
expect(result.preamble).toContain("const myTl = gsap.timeline");
});
it("extracts postamble correctly", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
console.log("done");
`;
const result = parseGsapScript(script);
expect(result.postamble).toContain('console.log("done");');
});
it("parses multiple animations", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#el1", { opacity: 0 }, 0);
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
expect(result.animations[0].method).toBe("set");
expect(result.animations[1].method).toBe("to");
expect(result.animations[2].method).toBe("to");
});
it("filters out unsupported properties from animations", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, backgroundColor: "red", x: 50, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].properties.x).toBe(50);
// backgroundColor is not in SUPPORTED_PROPS, so it's filtered out
expect(result.animations[0].properties.backgroundColor).toBeUndefined();
});
it("extracts ease from properties", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, ease: "power2.out" }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].ease).toBe("power2.out");
});
it("uses 'let' or 'var' for timeline declaration", () => {
const script = `
let timeline = gsap.timeline({ paused: true });
timeline.to("#el1", { opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("timeline");
expect(result.animations).toHaveLength(1);
});
});
describe("gsapAnimationsToKeyframes", () => {
it("converts animations to keyframes with element start offset", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 2,
properties: { x: 100, y: 200 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 3,
properties: { x: 300, y: 400 },
duration: 1,
ease: "power2.out",
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 2);
expect(keyframes).toHaveLength(2);
// First keyframe: time = 2 - 2 = 0
expect(keyframes[0].time).toBe(0);
expect(keyframes[0].properties.x).toBe(100);
expect(keyframes[0].properties.y).toBe(200);
// Second keyframe: time = 3 - 2 = 1
expect(keyframes[1].time).toBe(1);
expect(keyframes[1].properties.x).toBe(300);
expect(keyframes[1].ease).toBe("power2.out");
});
it("filters supported props only", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 0,
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<string, number | string>,
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 0);
expect(keyframes).toHaveLength(1);
expect(keyframes[0].properties.opacity).toBe(1);
expect(keyframes[0].properties.x).toBe(50);
// String values are skipped (typeof value !== "number" check)
expect((keyframes[0].properties as Record<string, unknown>).someUnsupportedProp).toBeUndefined();
});
it("skips base set keyframes at time 0 when skipBaseSet is true", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 0, y: 0, scale: 1 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 6,
properties: { x: 100 },
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
// The set at position 5 (time=0) with x=0, y=0, scale=1 (base values) should be skipped
expect(keyframes).toHaveLength(1);
expect(keyframes[0].id).toBe("anim-2");
});
it("does NOT skip set keyframes when they have non-base values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 100, y: 0 },
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
// x=100 is non-base, so it should NOT be skipped
expect(keyframes).toHaveLength(1);
expect(keyframes[0].properties.x).toBe(100);
});
it("clamps negative time to zero by default", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 1 },
},
];
// elementStartTime is 5, so relative time = 0 - 5 = -5
const keyframes = gsapAnimationsToKeyframes(animations, 5);
expect(keyframes[0].time).toBe(0); // Clamped to 0
});
it("adjusts x/y/scale relative to base values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 2,
properties: { x: 150, y: 200, scale: 2 },
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 0, {
baseX: 50,
baseY: 100,
baseScale: 2,
});
expect(keyframes[0].properties.x).toBe(100); // 150 - 50
expect(keyframes[0].properties.y).toBe(100); // 200 - 100
expect(keyframes[0].properties.scale).toBe(1); // 2 / 2
});
});
describe("keyframesToGsapAnimations", () => {
it("converts keyframes back to GSAP animations", () => {
const keyframes: Keyframe[] = [
{ id: "kf-1", time: 0, properties: { opacity: 0 } },
{ id: "kf-2", time: 1, properties: { opacity: 1 }, ease: "power2.out" },
];
const animations = keyframesToGsapAnimations("el1", keyframes, 2);
expect(animations).toHaveLength(2);
expect(animations[0].method).toBe("set");
expect(animations[0].position).toBe(2); // elementStartTime + 0
expect(animations[0].properties.opacity).toBe(0);
expect(animations[1].method).toBe("to");
expect(animations[1].position).toBe(2); // position of prev keyframe
expect(animations[1].duration).toBe(1); // kf.time - prevKf.time
expect(animations[1].ease).toBe("power2.out");
});
it("applies base x/y/scale offsets", () => {
const keyframes: Keyframe[] = [
{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } },
];
const animations = keyframesToGsapAnimations("el1", keyframes, 0, {
x: 50,
y: 100,
scale: 0.5,
});
expect(animations[0].properties.x).toBe(60); // baseX + value
expect(animations[0].properties.y).toBe(120); // baseY + value
expect(animations[0].properties.scale).toBe(1); // baseScale * value
});
});
describe("serializeGsapAnimations", () => {
it("serializes animations into a GSAP timeline script", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 0.5,
properties: { opacity: 1 },
duration: 0.5,
ease: "power2.out",
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain("const tl = gsap.timeline({ paused: true });");
expect(result).toContain('tl.set("#el1"');
expect(result).toContain('tl.to("#el1"');
expect(result).toContain("opacity: 0");
expect(result).toContain("opacity: 1");
});
it("sorts animations by position", () => {
const animations: GsapAnimation[] = [
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 2,
properties: { opacity: 1 },
duration: 0.5,
},
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
];
const result = serializeGsapAnimations(animations);
const setIdx = result.indexOf("tl.set");
const toIdx = result.indexOf("tl.to");
expect(setIdx).toBeLessThan(toIdx);
});
it("serializes fromTo animations correctly", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "fromTo",
position: 0,
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
duration: 1,
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain('tl.fromTo("#el1"');
});
it("uses custom timeline variable name", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
];
const result = serializeGsapAnimations(animations, "myTimeline");
expect(result).toContain("const myTimeline = gsap.timeline({ paused: true });");
expect(result).toContain('myTimeline.set("#el1"');
});
});
describe("validateCompositionGsap", () => {
it("returns valid for clean scripts", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("detects forbidden patterns", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, onComplete: function() {} }, 0);
setTimeout(function() {}, 100);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(false);
expect(result.errors).toContain("onComplete callback not allowed");
expect(result.errors).toContain("setTimeout not allowed");
});
it("warns about yoyo and stagger", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { x: 100, stagger: 0.1, yoyo: true, duration: 1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.warnings).toContain("yoyo animations may behave unexpectedly when scrubbing");
expect(result.warnings).toContain("stagger animations may not serialize correctly");
});
it("detects infinite repeat", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, repeat: -1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Infinite repeat (repeat: -1) not allowed");
});
});
describe("getAnimationsForElement", () => {
it("filters animations by element id", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
{ id: "a2", targetSelector: "#el2", method: "to", position: 0, properties: { opacity: 1 }, duration: 1 },
{ id: "a3", targetSelector: "#el1", method: "to", position: 1, properties: { opacity: 1 }, duration: 0.5 },
];
const result = getAnimationsForElement(animations, "el1");
expect(result).toHaveLength(2);
expect(result.every((a) => a.targetSelector === "#el1")).toBe(true);
});
it("returns empty array when no animations match", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
];
const result = getAnimationsForElement(animations, "el99");
expect(result).toHaveLength(0);
});
});
describe("SUPPORTED_PROPS", () => {
it("includes expected properties", () => {
expect(SUPPORTED_PROPS).toContain("opacity");
expect(SUPPORTED_PROPS).toContain("x");
expect(SUPPORTED_PROPS).toContain("y");
expect(SUPPORTED_PROPS).toContain("scale");
expect(SUPPORTED_PROPS).toContain("rotation");
expect(SUPPORTED_PROPS).toContain("width");
expect(SUPPORTED_PROPS).toContain("height");
});
});
describe("SUPPORTED_EASES", () => {
it("includes common easing functions", () => {
expect(SUPPORTED_EASES).toContain("none");
expect(SUPPORTED_EASES).toContain("power2.out");
expect(SUPPORTED_EASES).toContain("bounce.out");
expect(SUPPORTED_EASES).toContain("elastic.inOut");
});
});
+508
View File
@@ -0,0 +1,508 @@
import type { Keyframe, KeyframeProperties } from "../core.types";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
export interface GsapAnimation {
id: string;
targetSelector: string;
method: GsapMethod;
position: number;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
preamble: string;
postamble: string;
}
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
export const SUPPORTED_PROPS = [
"opacity",
"visibility",
"x",
"y",
"scale",
"scaleX",
"scaleY",
"rotation",
"autoAlpha",
"width",
"height",
];
export const SUPPORTED_EASES = [
"none",
"power1.in",
"power1.out",
"power1.inOut",
"power2.in",
"power2.out",
"power2.inOut",
"power3.in",
"power3.out",
"power3.inOut",
"power4.in",
"power4.out",
"power4.inOut",
"back.in",
"back.out",
"back.inOut",
"elastic.in",
"elastic.out",
"elastic.inOut",
"bounce.in",
"bounce.out",
"bounce.inOut",
"expo.in",
"expo.out",
"expo.inOut",
];
function parseObjectLiteral(str: string): Record<string, number | string> {
const result: Record<string, number | string> = {};
const cleaned = str.replace(/^\{|\}$/g, "").trim();
if (!cleaned) return result;
const propRegex = /(\w+)\s*:\s*("[^"]*"|'[^']*'|[\d.]+|[a-zA-Z_][\w.]*)/g;
let match;
while ((match = propRegex.exec(cleaned)) !== null) {
const key = match[1] ?? "";
let value: string | number = match[2] ?? "";
if (typeof value === "string") {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
} else if (!isNaN(Number(value))) {
value = Number(value);
}
}
result[key] = value;
}
return result;
}
function findMatchingBrace(str: string, startIndex: number): number {
let depth = 0;
for (let i = startIndex; i < str.length; i++) {
if (str[i] === "{") depth++;
else if (str[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
export function parseGsapScript(script: string): ParsedGsap {
const animations: GsapAnimation[] = [];
let idCounter = 0;
const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/);
const timelineVar = timelineMatch ? timelineMatch[1] ?? "tl" : "tl";
const preambleMatch = script.match(
new RegExp(`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`),
);
const preamble = preambleMatch ? preambleMatch[0] : `const ${timelineVar} = gsap.timeline({ paused: true });`;
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
let match;
while ((match = methodPattern.exec(script)) !== null) {
const rawMethod = match[1];
if (!rawMethod || !GSAP_METHODS.has(rawMethod)) continue;
const method: GsapMethod = rawMethod as GsapMethod;
const argsStr = match[2] ?? "";
const animation = parseGsapCall(method, argsStr, ++idCounter);
if (animation) {
animations.push(animation);
}
}
const lastAnimIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
if (lastAnimIdx !== -1) {
const afterLastAnim = script.slice(lastAnimIdx);
const endOfCall = afterLastAnim.indexOf(";");
if (endOfCall !== -1) {
postamble = script.slice(lastAnimIdx + endOfCall + 1).trim();
}
}
return { animations, timelineVar, preamble, postamble };
}
function parseGsapCall(method: GsapMethod, argsStr: string, idNum: number): GsapAnimation | null {
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
if (!selectorMatch) return null;
const targetSelector = selectorMatch[1] ?? "";
const afterSelector = argsStr.slice(selectorMatch[0].length);
let properties: Record<string, number | string> = {};
let fromProperties: Record<string, number | string> | undefined;
let position = 0;
if (method === "fromTo") {
const firstBrace = afterSelector.indexOf("{");
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
if (firstBrace === -1 || firstEnd === -1) return null;
fromProperties = parseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
const secondPart = afterSelector.slice(firstEnd + 1);
const secondBrace = secondPart.indexOf("{");
const secondEnd = findMatchingBrace(secondPart, secondBrace);
if (secondBrace === -1 || secondEnd === -1) return null;
properties = parseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
const afterProps = secondPart.slice(secondEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
} else {
const braceStart = afterSelector.indexOf("{");
const braceEnd = findMatchingBrace(afterSelector, braceStart);
if (braceStart !== -1 && braceEnd !== -1) {
properties = parseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
const afterProps = afterSelector.slice(braceEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
}
}
const duration = typeof properties.duration === "number" ? properties.duration : undefined;
const ease = typeof properties.ease === "string" ? properties.ease : undefined;
const filteredProps: Record<string, number | string> = {};
for (const [key, value] of Object.entries(properties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredProps[key] = value;
}
}
let filteredFromProps: Record<string, number | string> | undefined;
if (fromProperties) {
filteredFromProps = {};
for (const [key, value] of Object.entries(fromProperties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredFromProps[key] = value;
}
}
}
return {
id: `anim-${idNum}`,
targetSelector,
method,
position,
properties: filteredProps,
fromProperties: filteredFromProps,
duration,
ease,
};
}
export function serializeGsapAnimations(
animations: GsapAnimation[],
timelineVar = "tl",
options?: { includeMediaSync?: boolean },
): string {
const sorted = [...animations].sort((a, b) => a.position - b.position);
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const propsStr = serializeObject(props);
switch (anim.method) {
case "set":
return ` ${timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`;
case "to":
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`;
case "from":
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`;
case "fromTo": {
const fromStr = serializeObject(anim.fromProperties || {});
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`;
}
}
});
let mediaSync = "";
if (options?.includeMediaSync) {
mediaSync = `
// Sync media playback
${timelineVar}.eventCallback("onUpdate", function() {
const time = ${timelineVar}.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !${timelineVar}.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`;
}
return `
const ${timelineVar} = gsap.timeline({ paused: true });
${lines.join("\n")}${mediaSync}
`;
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
if (typeof value === "string") {
return `${key}: "${value}"`;
}
return `${key}: ${value}`;
});
return `{ ${entries.join(", ")} }`;
}
export function updateAnimationInScript(script: string, animationId: string, updates: Partial<GsapAnimation>): string {
const parsed = parseGsapScript(script);
const updated = parsed.animations.map((anim) => {
if (anim.id === animationId) {
return { ...anim, ...updates };
}
return anim;
});
return serializeGsapAnimations(updated, parsed.timelineVar);
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScript(script);
const id = `anim-${Date.now()}`;
const newAnim: GsapAnimation = { ...animation, id };
parsed.animations.push(newAnim);
return {
script: serializeGsapAnimations(parsed.animations, parsed.timelineVar),
id,
};
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScript(script);
const filtered = parsed.animations.filter((a) => a.id !== animationId);
return serializeGsapAnimations(filtered, parsed.timelineVar);
}
export function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{
pattern: /\.add\s*\(\s*function/,
message: "add(function) not allowed",
},
{
pattern: /\.add\s*\(\s*\(/,
message: "add() with arrow function not allowed",
},
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{
pattern: /onRepeat\s*:/,
message: "onRepeat callback not allowed",
},
{
pattern: /onReverseComplete\s*:/,
message: "onReverseComplete callback not allowed",
},
{
pattern: /repeat\s*:\s*-1/,
message: "Infinite repeat (repeat: -1) not allowed",
},
{
pattern: /Math\.random\s*\(/,
message: "Random values (Math.random) not allowed",
},
{
pattern: /Date\.now\s*\(/,
message: "Date-dependent values (Date.now) not allowed",
},
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
{
pattern: /requestAnimationFrame\s*\(/,
message: "requestAnimationFrame not allowed",
},
];
export function validateCompositionGsap(script: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
if (pattern.test(script)) {
errors.push(message);
}
}
if (/yoyo\s*:\s*true/.test(script)) {
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
}
if (/stagger\s*:/.test(script)) {
warnings.push("stagger animations may not serialize correctly");
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
export function keyframesToGsapAnimations(
elementId: string,
keyframes: Keyframe[],
elementStartTime: number,
base?: { x?: number; y?: number; scale?: number },
): GsapAnimation[] {
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const animations: GsapAnimation[] = [];
const baseX = base?.x ?? 0;
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
const prevKf = i > 0 ? sorted[i - 1] : null;
const duration = prevKf ? kf.time - prevKf.time : undefined;
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(kf.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = baseX + value;
else if (key === "y") properties.y = baseY + value;
else if (key === "scale") properties.scale = baseScale * value;
else properties[key] = value;
}
animations.push({
id: `${elementId}-kf-${kf.id}`,
targetSelector: `#${elementId}`,
method: isFirst ? "set" : "to",
position,
properties,
duration: isFirst ? undefined : duration,
ease: kf.ease,
});
});
return animations;
}
export function gsapAnimationsToKeyframes(
animations: GsapAnimation[],
elementStartTime: number,
options?: {
baseX?: number;
baseY?: number;
baseScale?: number;
clampTimeToZero?: boolean;
skipBaseSet?: boolean;
},
): Keyframe[] {
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
const baseX = options?.baseX ?? 0;
const baseY = options?.baseY ?? 0;
const baseScale = options?.baseScale ?? 1;
const clampTimeToZero = options?.clampTimeToZero ?? true;
const skipBaseSet = options?.skipBaseSet ?? false;
const baseTimeEpsilon = 0.001;
const baseValueEpsilon = 0.00001;
return animations
.filter((a) => validMethods.includes(a.method))
.map((a) => {
const relativeTimeRaw = a.position - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(a.properties)) {
if (SUPPORTED_PROPS.includes(key) && typeof value === "number") {
if (key === "x") {
(properties as Record<string, number>).x = value - baseX;
} else if (key === "y") {
(properties as Record<string, number>).y = value - baseY;
} else if (key === "scale") {
(properties as Record<string, number>).scale = baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
}
}
if (skipBaseSet && a.method === "set" && Math.abs(time) <= baseTimeEpsilon) {
const propKeys = Object.keys(properties);
const isOnlyBaseProps = propKeys.every((k) => k === "x" || k === "y" || k === "scale");
if (isOnlyBaseProps && propKeys.length > 0) {
const hasNonBaseOffset =
(properties.x !== undefined && Math.abs(properties.x) > baseValueEpsilon) ||
(properties.y !== undefined && Math.abs(properties.y) > baseValueEpsilon) ||
(properties.scale !== undefined && Math.abs(properties.scale - 1) > baseValueEpsilon);
if (!hasNonBaseOffset) {
return null;
}
}
}
const kf: Keyframe = { id: a.id, time, properties };
if (a.ease !== undefined) kf.ease = a.ease;
return kf;
})
.filter((kf): kf is Keyframe => kf !== null)
.sort((a, b) => a.time - b.time);
}

Some files were not shown because too many files have changed in this diff Show More