mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
committed by
GitHub
co-authored by
Claude Opus 4.6
parent
36972386f6
commit
69ad220e9f
@@ -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 +1,2 @@
|
||||
# Golden baseline videos for regression tests
|
||||
packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
writeFileSync("dist/hyperframe-runtime.js", loadHyperframeRuntimeSource());
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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("");
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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)));
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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."));
|
||||
},
|
||||
});
|
||||
@@ -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">
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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]`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { FrameAdapter, FrameAdapterContext } from "./types";
|
||||
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap";
|
||||
export { createGSAPFrameAdapter } from "./gsap";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, """);
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "'")}'`
|
||||
: "";
|
||||
|
||||
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, "'")}'`
|
||||
: "";
|
||||
|
||||
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, "'")}'`);
|
||||
}
|
||||
|
||||
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, "'")}'`);
|
||||
}
|
||||
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}
|
||||
`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}...`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types";
|
||||
export { lintHyperframeHtml } from "./hyperframeLinter";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user