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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

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

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,95 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="16.04">
<div id="caption-container"></div>
<style>
[data-composition-id="captions"] {
width: 1920px;
height: 1080px;
position: relative;
font-family: 'Helvetica', 'Arial', sans-serif;
font-weight: bold;
overflow: hidden;
}
[data-composition-id="captions"] #caption-container {
position: absolute;
bottom: 120px; /* Consistent bottom positioning */
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
[data-composition-id="captions"] .caption-box {
background-color: #0A1E3D; /* Solid navy */
padding: 20px 40px;
display: none; /* Hidden by default, shown via GSAP */
justify-content: center;
align-items: center;
/* Swiss Grid: sharp corners, solid block */
}
[data-composition-id="captions"] .caption-text {
color: #F2F2F2; /* Off-white */
font-size: 72px;
text-transform: uppercase; /* Swiss style often uses uppercase for impact */
letter-spacing: -2px;
line-height: 1;
white-space: nowrap;
text-align: center;
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.86, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics,', 'start': 3.579, 'end': 4.599}, {'text': 'sixty-two', 'start': 4.679, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.079, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.42}, {'text': 'look', 'start': 10.519, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
const container = document.getElementById('caption-container');
const tl = gsap.timeline({ paused: true });
if (!TRANSCRIPT || TRANSCRIPT.length === 0) {
window.__timelines["captions"] = tl;
return;
}
// Group transcript into max 5 words per group
const groups = [];
let currentGroup = [];
TRANSCRIPT.forEach((word, index) => {
currentGroup.push(word);
// Group by 5 words OR if it's the last word
if (currentGroup.length === 5 || index === TRANSCRIPT.length - 1) {
groups.push([...currentGroup]);
currentGroup = [];
}
});
// Create DOM elements and timeline for each group
groups.forEach((group, i) => {
const box = document.createElement('div');
box.className = 'caption-box';
box.id = `group-${i}`;
const text = document.createElement('div');
text.className = 'caption-text';
text.textContent = group.map(w => w.text).join(' ');
box.appendChild(text);
container.appendChild(box);
const startTime = group[0].start;
const endTime = group[group.length - 1].end;
// Hard cut animation (no fades) as requested
tl.set(box, { display: 'flex' }, startTime);
tl.set(box, { display: 'none' }, endTime);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,198 @@
<template id="graphics-template">
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="15">
<div class="grid-container">
<!-- Stat 1: 47% -->
<div id="stat1" class="stat-group">
<div class="stat-value">47%</div>
<div class="stat-label">NEED MOTION GRAPHICS</div>
</div>
<!-- Stat 2: 62% -->
<div id="stat2" class="stat-group">
<div class="stat-value">62%</div>
<div class="stat-label">LOSING ATTENTION</div>
<div class="progress-container">
<div class="progress-bar"></div>
</div>
</div>
<!-- Stat 3: 3 OF 4 -->
<div id="stat3" class="stat-group">
<div class="stat-value">3 OF 4</div>
<div class="stat-label">LACK EDITING SKILLS</div>
<div class="grid-blocks">
<div class="block gold"></div>
<div class="block gold"></div>
<div class="block gold"></div>
<div class="block navy"></div>
</div>
</div>
</div>
<style>
[data-composition-id="graphics"] {
font-family: 'Helvetica', Arial, sans-serif;
color: #0A1E3D;
background: transparent;
width: 1920px;
height: 1080px;
position: relative;
overflow: hidden;
}
[data-composition-id="graphics"] .grid-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: repeat(12, 1fr);
width: 100%;
height: 100%;
padding: 80px;
gap: 20px;
}
[data-composition-id="graphics"] .stat-group {
opacity: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
background: #F2F2F2;
padding: 40px;
border-left: 10px solid #D4A017;
box-shadow: 20px 20px 0px rgba(10, 30, 61, 0.1);
}
/* Positioning Stats on Grid - Adjusted for A-roll structure */
[data-composition-id="graphics"] #stat1 {
grid-column: 2 / 6;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] #stat2 {
grid-column: 2 / 6;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] #stat3 {
grid-column: 7 / 12;
grid-row: 3 / 7;
}
[data-composition-id="graphics"] .stat-value {
font-size: 140px;
font-weight: 900;
color: #D4A017;
line-height: 0.8;
margin-bottom: 15px;
}
[data-composition-id="graphics"] .stat-label {
font-size: 24px;
font-weight: 300; /* Lighter weight */
color: #0A1E3D;
letter-spacing: 2px;
text-transform: uppercase;
}
/* Progress Bar for Stat 2 */
[data-composition-id="graphics"] .progress-container {
width: 100%;
height: 24px;
background: #E0E0E0;
margin-top: 30px;
border: 2px solid #0A1E3D;
position: relative;
}
[data-composition-id="graphics"] .progress-bar {
width: 0%;
height: 100%;
background: #D4A017;
}
/* Grid Blocks for Stat 3 */
[data-composition-id="graphics"] .grid-blocks {
display: flex;
gap: 20px;
margin-top: 30px;
}
[data-composition-id="graphics"] .block {
width: 80px;
height: 80px;
border: 3px solid #0A1E3D;
}
[data-composition-id="graphics"] .block.gold {
background: #D4A017;
}
[data-composition-id="graphics"] .block.navy {
background: #0A1E3D;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Timing Constants
const S1_START = 1.86;
const S1_END = 4.5;
const S2_START = 4.68;
const S2_END = 8.6;
const S3_START = 8.88;
const S3_END = 14.2;
const SLIDE_DUR = 0.25; // 250ms
const SHARP_EASE = "power2.out";
// Stat 1 Animation
tl.fromTo('#stat1',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S1_START
);
tl.to('#stat1',
{ x: 200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S1_END - SLIDE_DUR
);
// Stat 2 Animation
tl.fromTo('#stat2',
{ y: 200, opacity: 0 },
{ y: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S2_START
);
// Progress bar fill
tl.to('#stat2 .progress-bar',
{ width: '62%', duration: S2_END - S2_START - (SLIDE_DUR * 2), ease: "none" },
S2_START + SLIDE_DUR
);
tl.to('#stat2',
{ y: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S2_END - SLIDE_DUR
);
// Stat 3 Animation
tl.fromTo('#stat3',
{ x: 200, opacity: 0 },
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
S3_START
);
// Stagger blocks
tl.from('#stat3 .block',
{ scale: 0, duration: 0.3, stagger: 0.1, ease: "back.out(1.7)" },
S3_START + SLIDE_DUR
);
tl.to('#stat3',
{ x: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
S3_END - SLIDE_DUR
);
window.__timelines["graphics"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,114 @@
<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="1.86">
<div class="container">
<div class="grid-line"></div>
<div class="text-wrapper">
<h1 class="title">HYPERFRAMES</h1>
<h2 class="subtitle">THE SURVEY FINDINGS</h2>
</div>
</div>
<style>
[data-composition-id="intro"] {
background-color: #F5F5F5; /* Off-white */
width: 1920px;
height: 1080px;
display: flex;
align-items: center;
justify-content: center;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow: hidden;
}
[data-composition-id="intro"] .container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 160px; /* Swiss grid alignment */
}
[data-composition-id="intro"] .grid-line {
position: absolute;
left: 160px;
top: 50%;
width: 0px;
height: 12px;
background-color: #0A1E3D; /* Navy */
transform: translateY(-180px); /* Positioned above the text */
}
[data-composition-id="intro"] .text-wrapper {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
[data-composition-id="intro"] .title {
color: #0A1E3D;
font-size: 180px;
font-weight: 900; /* Black weight */
line-height: 0.9;
margin: 0;
letter-spacing: -0.04em;
text-transform: uppercase;
transform: translateX(-100%); /* Start off-screen left */
}
[data-composition-id="intro"] .subtitle {
color: #0A1E3D;
font-size: 64px;
font-weight: 300; /* Lighter weight for contrast */
line-height: 1.2;
margin: 0;
letter-spacing: 0.1em;
text-transform: uppercase;
transform: translateX(-100%); /* Start off-screen left */
margin-top: 20px;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Mechanical timing: 200ms (0.2s)
const MECHANICAL_DURATION = 0.2;
const STAGGER = 0.1;
// 1. Animate the grid line first
tl.to('.grid-line', {
width: '1200px',
duration: 0.4,
ease: 'power4.out'
}, 0.2);
// 2. Slide in the title
tl.to('.title', {
x: 0,
duration: MECHANICAL_DURATION,
ease: 'power2.inOut'
}, 0.5);
// 3. Slide in the subtitle with a slight stagger
tl.to('.subtitle', {
x: 0,
duration: MECHANICAL_DURATION,
ease: 'power2.inOut'
}, 0.5 + STAGGER);
// 4. Subtle ambient motion (slow drift) to keep it alive
tl.to('.text-wrapper', {
x: 30,
duration: 1.16, // Adjusted to fit 1.86s total
ease: 'none'
}, 0.7);
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swiss Grid - Hyperframes</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
background-color: #F2F2F2;
overflow: hidden;
font-family: 'Helvetica', Arial, sans-serif;
}
#master-root {
width: 1920px;
height: 1080px;
position: relative;
}
.background-grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* A-roll styling */
#short_mag_cut_frame {
position: absolute;
border-radius: 8px; /* Required */
z-index: 1;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
overflow: hidden;
background: #000;
}
#short_mag_cut,
#short_mag_cut_frame > img.__render_frame__,
#short_mag_cut_frame > img.__preview_render_frame__ {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* Overlay layers */
.overlay-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#intro-comp { z-index: 2; }
#graphics-comp { z-index: 3; }
#captions-comp { z-index: 4; }
</style>
</head>
<body>
<div id="master-root" data-composition-id="master" data-width="1920" data-height="1080" data-duration="16.04">
<!-- Background Grid -->
<img id="bg-grid"
class="background-grid"
src="assets/swiss-grid.svg"
alt="Grid"
data-start="0"
data-duration="16.04"
data-track-index="0">
<!-- A-roll Video -->
<div id="short_mag_cut_frame">
<video id="short_mag_cut"
src="__VIDEO_SRC__"
data-start="0"
data-duration="16.04"
data-track-index="1">
</video>
</div>
<!-- Intro Sub-composition -->
<div id="intro-comp"
class="overlay-layer"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="1.86"
data-track-index="2">
</div>
<!-- Graphics Sub-composition -->
<div id="graphics-comp"
class="overlay-layer"
data-composition-id="graphics"
data-composition-src="compositions/graphics.html"
data-start="0"
data-duration="16.04"
data-track-index="3">
</div>
<!-- Captions Sub-composition -->
<div id="captions-comp"
class="overlay-layer"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="16.04"
data-track-index="4">
</div>
<!-- Sound Effects -->
<audio id="sfx-intro" data-start="0" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat1" data-start="1.86" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat2" data-start="4.68" data-track-index="5" data-duration="1" src=""></audio>
<audio id="sfx-stat3" data-start="8.88" data-track-index="5" data-duration="1" src=""></audio>
<script>
window.__timelines = window.__timelines || {};
const masterTL = gsap.timeline({ paused: true });
const v = document.getElementById('short_mag_cut_frame');
// --- A-ROLL MOVEMENT (Structured Swiss Layout) ---
// 1. Entrance (0-1.86s): Slide in from left, settle centered for Intro
masterTL.fromTo(v,
{ x: -1000, y: 120, width: 960, height: 840 },
{ x: 480, y: 120, width: 960, height: 840, duration: 0.8, ease: "power2.out" },
0
);
// 2. Stat 1 (1.86s - 4.68s): Move to right side (Graphic is on left)
masterTL.to(v, {
x: 1080, y: 120, width: 720, height: 720,
duration: 0.25, ease: "expo.out"
}, 1.86);
// 3. Stat 2 (4.68s - 8.88s): Stay right, zoom in slightly
masterTL.to(v, {
x: 1080, y: 60, width: 780, height: 780,
duration: 0.25, ease: "expo.out"
}, 4.68);
// 4. Stat 3 (8.88s - 14.2s): Move to left side (Graphic is on right)
masterTL.to(v, {
x: 120, y: 120, width: 720, height: 720,
duration: 0.25, ease: "expo.out"
}, 8.88);
// 5. Conclusion (14.2s - 16.04s): Center large
masterTL.to(v, {
x: 240, y: 60, width: 1440, height: 810,
duration: 0.5, ease: "expo.out"
}, 14.2);
window.__timelines["master"] = masterTL;
</script>
</div>
</body>
</html>
@@ -0,0 +1,122 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1080" data-height="1920" data-duration="13.88">
<div id="captions-container"></div>
<style>
[data-composition-id="captions"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
font-family: "Helvetica", "Helvetica Neue", Arial, sans-serif;
font-weight: 900; /* Helvetica Black/Bold */
text-transform: uppercase;
pointer-events: none;
}
[data-composition-id="captions"] #captions-container {
position: absolute;
inset: 0;
}
[data-composition-id="captions"] .caption-group {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: auto;
max-width: 920px; /* 1080 - 2*80 */
bottom: 672px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
opacity: 0;
z-index: 10;
}
[data-composition-id="captions"] .caption-text {
font-size: 64px;
line-height: 1.1;
color: #000000;
background-color: #FFFFFF;
padding: 15px 30px;
border-top: 8px solid #CC0000; /* Vignelli Red top border accent */
display: inline-block;
letter-spacing: -0.02em; /* Tight Helvetica spacing */
white-space: nowrap; /* Prevent wrapping at the element level */
}
</style>
<script>
(function() {
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
const tl = gsap.timeline({ paused: true });
const container = document.getElementById('captions-container');
// Group words into single lines (max words or based on pauses/sentence ends)
function groupTranscript(transcript, maxWords = 3) {
const groups = [];
let currentGroup = [];
transcript.forEach((word, index) => {
currentGroup.push(word);
const nextWord = transcript[index + 1];
const isPause = nextWord && (nextWord.start - word.end > 0.15);
const isSentenceEnd = word.text.includes('.') || word.text.includes('?') || word.text.includes('!');
if (currentGroup.length >= maxWords || isPause || isSentenceEnd || !nextWord) {
groups.push({
words: currentGroup,
start: currentGroup[0].start,
end: currentGroup[currentGroup.length - 1].end,
text: currentGroup.map(w => w.text).join(' ')
});
currentGroup = [];
}
});
return groups;
}
const captionGroups = groupTranscript(TRANSCRIPT);
captionGroups.forEach((group, index) => {
const div = document.createElement('div');
div.className = 'caption-group';
div.id = `group-${index}`;
const textSpan = document.createElement('span');
textSpan.className = 'caption-text';
textSpan.innerText = group.text;
div.appendChild(textSpan);
container.appendChild(div);
// Animation: Modern "expo.out" transitions
// Entrance
tl.fromTo(div,
{ opacity: 0, y: 20 },
{
opacity: 1,
y: 0,
duration: 0.4,
ease: "expo.out"
},
group.start
);
// Exit
tl.to(div, {
opacity: 0,
y: -10,
duration: 0.3,
ease: "expo.out"
}, group.end - 0.3);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,271 @@
<template id="overlays-template">
<div data-composition-id="overlays" data-width="1080" data-height="1920" data-duration="16.88">
<!-- 6-Column Grid Overlay (for visual reference during development, hidden by default) -->
<div class="vignelli-grid">
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
</div>
<!-- 1. 47% Motion Graphics -->
<div class="overlay-item motion-graphics-stat" id="stat-47">
<div class="red-bar"></div>
<div class="stat-content">
<div class="number">47%</div>
<div class="label">Motion Graphics</div>
</div>
</div>
<!-- 2. 62% Static Content (Full Canvas) -->
<div class="overlay-item static-content-stat" id="stat-62">
<div class="bg-charcoal"></div>
<div class="full-content">
<div class="large-number">62%</div>
<div class="large-label">Static Content</div>
<div class="red-accent"></div>
</div>
</div>
<!-- 3. 3 out of 4 lack editing skills -->
<div class="overlay-item editing-skills-stat" id="stat-3-4">
<div class="text-block">
<span class="highlight">3 out of 4</span>
<br>lack editing skills
</div>
<div class="side-bar"></div>
</div>
<!-- 4. Hyperframes Branding -->
<div class="overlay-item branding-reveal" id="branding">
<div class="logo-container">
<div class="logo-text"><span class="heavy">HYPERFRAMES</span></div>
<div class="logo-underline"></div>
</div>
</div>
<style>
[data-composition-id="overlays"] {
position: relative;
width: 1080px;
height: 1920px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000000;
overflow: hidden;
pointer-events: none;
}
/* Grid System */
[data-composition-id="overlays"] .vignelli-grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(6, 1fr);
pointer-events: none;
z-index: 100;
opacity: 0; /* Hidden */
}
[data-composition-id="overlays"] .vignelli-grid .col {
border-right: 1px solid rgba(204, 0, 0, 0.1);
}
[data-composition-id="overlays"] .overlay-item {
position: absolute;
opacity: 0;
display: flex;
z-index: 10;
}
/* 1. 47% Stat Styles */
[data-composition-id="overlays"] #stat-47 {
bottom: 200px;
left: 0;
width: 540px; /* 3 columns */
height: 240px;
background: #FFFFFF;
flex-direction: row;
align-items: stretch;
}
[data-composition-id="overlays"] #stat-47 .red-bar {
width: 20px;
background: #CC0000;
}
[data-composition-id="overlays"] #stat-47 .stat-content {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
[data-composition-id="overlays"] #stat-47 .number {
font-size: 120px;
font-weight: 900;
line-height: 1;
letter-spacing: -2px;
}
[data-composition-id="overlays"] #stat-47 .label {
font-size: 32px;
font-weight: 700;
text-transform: uppercase;
margin-top: 10px;
}
/* 2. 62% Stat Styles (Full Canvas) */
[data-composition-id="overlays"] #stat-62 {
top: 0;
left: 0;
width: 1080px;
height: 1920px;
z-index: 50;
}
[data-composition-id="overlays"] #stat-62 .bg-charcoal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #1A1A1A;
}
[data-composition-id="overlays"] #stat-62 .full-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 90px; /* Align with grid */
}
[data-composition-id="overlays"] #stat-62 .large-number {
font-size: 400px;
font-weight: 900;
color: #FFFFFF;
line-height: 0.8;
letter-spacing: -10px;
}
[data-composition-id="overlays"] #stat-62 .large-label {
font-size: 80px;
font-weight: 700;
color: #FFFFFF;
text-transform: uppercase;
margin-top: 20px;
max-width: 600px;
}
[data-composition-id="overlays"] #stat-62 .red-accent {
position: absolute;
top: 0;
right: 0;
width: 180px; /* 1 column */
height: 100%;
background: #CC0000;
}
/* 3. 3 out of 4 Stat Styles */
[data-composition-id="overlays"] #stat-3-4 {
top: 400px;
right: 0;
width: 360px; /* 2 columns */
background: #FFFFFF;
padding: 40px;
flex-direction: row-reverse;
}
[data-composition-id="overlays"] #stat-3-4 .text-block {
font-size: 48px;
font-weight: 700;
line-height: 1.1;
text-align: right;
}
[data-composition-id="overlays"] #stat-3-4 .highlight {
color: #CC0000;
font-size: 64px;
font-weight: 900;
}
[data-composition-id="overlays"] #stat-3-4 .side-bar {
width: 10px;
background: #000000;
margin-left: 20px;
}
/* 4. Branding Styles */
[data-composition-id="overlays"] #branding {
bottom: 100px;
left: 90px;
width: 900px;
justify-content: center;
z-index: 60;
}
[data-composition-id="overlays"] #branding .logo-container {
display: flex;
flex-direction: column;
align-items: center;
}
[data-composition-id="overlays"] #branding .logo-text {
font-size: 64px;
letter-spacing: 10px;
color: #000000;
}
[data-composition-id="overlays"] #branding .logo-text .heavy {
font-weight: 900;
}
[data-composition-id="overlays"] #branding .logo-text .light {
font-weight: 300;
}
[data-composition-id="overlays"] #branding .logo-underline {
width: 100%;
height: 8px;
background: #CC0000;
margin-top: 10px;
transform-origin: left;
}
</style>
<script>
const tl = gsap.timeline({ paused: true });
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
// Requirement 1: "47% Motion Graphics" (Start: 1.199, End: 3.759)
// Positioned in bottom half, columns 1-3.
tl.fromTo("#stat-47",
{ x: -540, opacity: 1 },
{ x: 0, duration: 0.8, ease: "expo.out" },
1.199
);
tl.to("#stat-47",
{ x: -540, duration: 0.6, ease: "expo.in" },
3.759 - 0.6
);
// Requirement 2: "62% Static Content" (Start: 3.799, End: 7.299)
// Full canvas moment. Large bold typography across columns.
tl.set("#stat-62", { opacity: 1 }, 3.799);
tl.from("#stat-62 .bg-charcoal", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.799);
tl.from("#stat-62 .red-accent", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.899);
tl.from("#stat-62 .large-number", { y: 100, opacity: 0, duration: 1, ease: "expo.out" }, 4.099);
tl.from("#stat-62 .large-label", { y: 50, opacity: 0, duration: 1, ease: "expo.out" }, 4.299);
tl.to("#stat-62", { x: "-100%", duration: 0.8, ease: "expo.in" }, 7.299 - 0.8);
// Requirement 3: "3 out of 4 lack editing skills" (Start: 7.319, End: 12.239)
// Positioned in columns 5-6 (right side) to avoid face (x: 300-776)
tl.fromTo("#stat-3-4",
{ x: 360, opacity: 1 },
{ x: 0, duration: 0.8, ease: "expo.out" },
7.319
);
tl.to("#stat-3-4",
{ x: 360, duration: 0.6, ease: "expo.in" },
12.239 - 0.6
);
// Requirement 4: "Hyperframes" (Start: 12.84, End: 13.84)
tl.set("#branding", { opacity: 1 }, 12.84);
tl.from("#branding .logo-text", { y: 20, opacity: 0, duration: 0.6, ease: "expo.out" }, 12.84);
tl.from("#branding .logo-underline", { scaleX: 0, duration: 0.8, ease: "expo.out" }, 13.04);
window.__timelines["overlays"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Massimo Vignelli Video</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
background: #FFFFFF;
overflow: hidden;
width: 1080px;
height: 1920px;
font-family: 'Helvetica', Arial, sans-serif;
}
/* 6-Column Grid System */
.grid-container {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
display: grid;
grid-template-columns: repeat(6, 1fr);
pointer-events: none;
z-index: 1000;
opacity: 0; /* Keep grid invisible but present for layout references */
}
.grid-column {
border-right: 1px solid rgba(204, 0, 0, 0.2); /* Vignelli Red accent */
}
#main-composition {
position: relative;
width: 1080px;
height: 1920px;
background: #FFFFFF;
overflow: hidden;
}
/* Layer Hosts */
#a-roll-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
overflow: hidden;
}
#a-roll,
#a-roll-wrapper img.__render_frame__,
#a-roll-wrapper img.__preview_render_frame__ {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#overlays-comp,
#captions-comp {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
pointer-events: none;
}
#overlays-comp {
z-index: 20;
}
#captions-comp {
z-index: 30;
}
/* Curtain Transition Layer */
.curtain {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: #1A1A1A;
z-index: 25;
pointer-events: none;
}
.curtain.red {
background: #CC0000;
z-index: 26;
}
</style>
</head>
<body>
<div id="main-composition" data-composition-id="main-comp" data-width="1080" data-height="1920" data-duration="13.88">
<!-- A-Roll -->
<div id="a-roll-wrapper">
<video id="a-roll"
src="__VIDEO_SRC__"
data-start="0"
data-duration="13.88"
data-track-index="0">
</video>
</div>
<!-- Transitions -->
<div id="curtain-black" class="curtain"></div>
<div id="curtain-red" class="curtain red"></div>
<!-- Overlays Composition -->
<div id="overlays-comp"
data-composition-id="overlays"
data-composition-src="compositions/overlays.html"
data-start="0"
data-duration="13.88"
data-track-index="2">
</div>
<!-- Captions Composition -->
<div id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="13.88"
data-track-index="3">
</div>
<!-- Grid Visualization -->
<div class="grid-container">
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
<div class="grid-column"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
// Transition: Curtain Pull logic
// A-roll leaves the screen at least once.
// Timing: Let's do a curtain pull at 3.7s (transitioning to the 62% stat)
// and return at 7.3s (end of attention stat).
// Curtain Out (A-roll hidden)
tl.to("#curtain-black", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 3.5);
tl.to("#a-roll-wrapper", { opacity: 0, duration: 0 }, 3.7);
tl.to("#curtain-black", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 3.7);
// Curtain In (A-roll return)
tl.set("#curtain-red", { left: "-100%" }, 7.1);
tl.to("#curtain-red", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 7.1);
tl.to("#a-roll-wrapper", { opacity: 1, duration: 0 }, 7.3);
tl.to("#curtain-red", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 7.3);
window.__timelines["main-comp"] = tl;
</script>
</div>
</body>
</html>
@@ -0,0 +1,133 @@
<template id="captions-template">
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="18">
<div class="captions-container">
<div id="caption-box" class="caption-box">
<span id="caption-text" class="caption-text"></span>
</div>
</div>
<style>
[data-composition-id="captions"] .captions-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 230px; /* Position at y: 850 (1080 - 230 = 850) */
pointer-events: none;
}
[data-composition-id="captions"] .caption-box {
background-color: #7A6248;
padding: 12px 32px;
border-radius: 24px;
display: flex;
justify-content: center;
align-items: center;
min-width: 100px;
max-width: 80%;
opacity: 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
[data-composition-id="captions"] .caption-text {
color: #F5F0E0;
font-family: 'Outfit', sans-serif;
font-size: 48px;
font-weight: 700;
text-align: center;
line-height: 1.2;
white-space: nowrap;
}
</style>
<script>
(function() {
const script = [
{"text": "We", "start": 0.119, "end": 0.259},
{"text": "asked", "start": 0.319, "end": 0.479},
{"text": "what", "start": 0.519, "end": 0.659},
{"text": "you", "start": 0.699, "end": 0.819},
{"text": "needed.", "start": 0.859, "end": 1.819},
{"text": "Forty-seven", "start": 1.86, "end": 2.299},
{"text": "percent", "start": 2.399, "end": 2.679},
{"text": "of", "start": 2.7, "end": 2.799},
{"text": "you", "start": 2.839, "end": 2.939},
{"text": "said", "start": 3.039, "end": 3.179},
{"text": "motion", "start": 3.24, "end": 3.559},
{"text": "graphics,", "start": 3.579, "end": 4.599},
{"text": "sixty-two", "start": 4.679, "end": 5.179},
{"text": "percent", "start": 5.299, "end": 5.759},
{"text": "said", "start": 5.859, "end": 5.98},
{"text": "static", "start": 6.079, "end": 6.399},
{"text": "content", "start": 6.46, "end": 6.879},
{"text": "was", "start": 6.92, "end": 7.079},
{"text": "costing", "start": 7.099, "end": 7.48},
{"text": "you", "start": 7.5, "end": 7.579},
{"text": "attention,", "start": 7.679, "end": 8.659},
{"text": "and", "start": 8.699, "end": 8.86},
{"text": "three", "start": 8.88, "end": 9.06},
{"text": "out", "start": 9.079, "end": 9.18},
{"text": "of", "start": 9.199, "end": 9.34},
{"text": "four", "start": 9.38, "end": 9.799},
{"text": "said", "start": 9.84, "end": 10.0},
{"text": "you", "start": 10.019, "end": 10.159},
{"text": "know", "start": 10.179, "end": 10.36},
{"text": "the", "start": 10.38, "end": 10.42},
{"text": "look", "start": 10.519, "end": 10.699},
{"text": "you", "start": 10.739, "end": 10.859},
{"text": "want", "start": 10.98, "end": 11.34},
{"text": "but", "start": 11.359, "end": 11.52},
{"text": "don't", "start": 11.56, "end": 11.779},
{"text": "have", "start": 11.819, "end": 11.94},
{"text": "the", "start": 11.96, "end": 12.06},
{"text": "editing", "start": 12.079, "end": 12.4},
{"text": "skills", "start": 12.52, "end": 12.86},
{"text": "to", "start": 12.88, "end": 13.0},
{"text": "get", "start": 13.019, "end": 13.18},
{"text": "there.", "start": 13.22, "end": 14.22},
{"text": "So", "start": 14.239, "end": 14.399},
{"text": "we", "start": 14.42, "end": 14.52},
{"text": "built", "start": 14.619, "end": 14.88},
{"text": "Hyperframes.", "start": 15.079, "end": 16.02}
];
// Group words into lines (max 5 words per line)
const lines = [];
for (let i = 0; i < script.length; i += 5) {
const lineWords = script.slice(i, i + 5);
lines.push({
text: lineWords.map(w => w.text).join(' '),
start: lineWords[0].start,
end: lineWords[lineWords.length - 1].end
});
}
const tl = gsap.timeline({ paused: true });
const box = document.querySelector('[data-composition-id="captions"] #caption-box');
const textEl = document.querySelector('[data-composition-id="captions"] #caption-text');
lines.forEach((line, index) => {
// Fade in and set text
tl.to(box, {
opacity: 1,
duration: 0.1,
ease: "power2.out",
onStart: () => {
textEl.textContent = line.text;
}
}, line.start);
// Fade out at the end of the line
tl.to(box, {
opacity: 0,
duration: 0.1,
ease: "power2.in"
}, line.end);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,157 @@
<template id="graphics-template">
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="14">
<!-- Moment 1: 47% Stat (1.8s - 4.5s) -->
<div id="moment-1" class="moment circle-stat">
<div class="stat-content">
<div class="stat-text">47%</div>
<div class="stat-desc">Need Motion Graphics</div>
</div>
</div>
<!-- Moment 2: 62% Stat (4.6s - 8.6s) -->
<div id="moment-2" class="moment pill-stat">
<div class="stat-text">62%</div>
</div>
<!-- Moment 3: Editing Skills (8.8s - 14s) -->
<div id="moment-3" class="moment rect-stat">
<div class="icon-shape">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M20,20 Q50,0 80,20 Q100,50 80,80 Q50,100 20,80 Q0,50 20,20" fill="currentColor" />
</svg>
</div>
<div class="stat-text">Editing Skills</div>
</div>
<style>
[data-composition-id="graphics"] {
position: relative;
width: 1920px;
height: 1080px;
font-family: 'Outfit', sans-serif;
color: white;
overflow: hidden;
}
[data-composition-id="graphics"] .moment {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
/* We'll use GSAP to handle the centering and drift */
}
/* Moment 1: Forest Green Circle */
[data-composition-id="graphics"] #moment-1 {
width: 500px;
height: 500px;
background-color: #3B5E3A;
border-radius: 50%;
left: 1400px;
top: 540px;
transform: translate(-50%, -50%);
}
[data-composition-id="graphics"] .stat-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
[data-composition-id="graphics"] .stat-desc {
font-size: 36px;
font-weight: 400;
margin-top: 10px;
max-width: 300px;
line-height: 1.2;
}
/* Moment 2: Ochre Pill */
[data-composition-id="graphics"] #moment-2 {
width: 500px;
height: 220px;
background-color: #CC8832;
border-radius: 110px;
left: 960px;
top: 200px;
transform: translate(-50%, -50%);
}
/* Moment 3: Terracotta Rounded Rect */
[data-composition-id="graphics"] #moment-3 {
width: 550px;
height: 180px;
background-color: #C45D3E;
border-radius: 40px;
left: 400px;
top: 540px;
transform: translate(-50%, -50%);
padding: 0 40px;
justify-content: center;
gap: 30px;
}
[data-composition-id="graphics"] .stat-text {
font-size: 80px;
font-weight: 700;
letter-spacing: -2px;
}
[data-composition-id="graphics"] #moment-3 .stat-text {
font-size: 54px;
letter-spacing: -1px;
}
[data-composition-id="graphics"] .icon-shape {
width: 80px;
height: 80px;
color: rgba(255, 255, 255, 0.9);
}
[data-composition-id="graphics"] .icon-shape svg {
width: 100%;
height: 100%;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Moment 1: 1.8s - 4.5s
// Gentle drift into place and settle
tl.fromTo('#moment-1',
{ opacity: 0, x: '+=40', y: '+=15', scale: 0.8 },
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
1.8
);
// Hold until 4.5s, then fade out
tl.to('#moment-1', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 4.2);
// Moment 2: 4.6s - 8.6s
// Gentle drift into place and settle
tl.fromTo('#moment-2',
{ opacity: 0, y: '-=40', x: '+=10', scale: 0.8 },
{ opacity: 1, y: 0, x: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
4.6
);
// Hold until 8.6s, then fade out
tl.to('#moment-2', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 8.3);
// Moment 3: 8.8s - 14s
// Gentle drift into place and settle
tl.fromTo('#moment-3',
{ opacity: 0, x: '-=40', y: '-=10', scale: 0.8 },
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
8.8
);
// Hold until end (14s)
tl.to('#moment-3', { opacity: 0, duration: 0.5, ease: 'power2.in' }, 13.5);
window.__timelines["graphics"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,77 @@
<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="3">
<div class="container">
<div class="title-card">
<h1 class="title">Hyperframes</h1>
<p class="subtitle">Design simplified.</p>
</div>
</div>
<style>
/* Import a rounded humanist sans-serif font */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap');
[data-composition-id="intro"] .container {
width: 100%;
height: 100%;
display: flex;
justify-content: flex-start; /* Align to left for speaker card */
align-items: center;
padding-left: 5%;
font-family: 'Outfit', sans-serif;
background: transparent;
}
[data-composition-id="intro"] .title-card {
background-color: #3B5E3A; /* Forest Green for contrast */
padding: 40px 60px;
border-radius: 30px;
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
text-align: left;
opacity: 0;
transform: translateX(-100%); /* Start off-screen left */
}
[data-composition-id="intro"] .title {
font-size: 100px;
font-weight: 600;
color: #F5F0E0; /* Cream text on green card */
margin: 0;
line-height: 1.1;
letter-spacing: -2px;
}
[data-composition-id="intro"] .subtitle {
font-size: 50px;
font-weight: 400;
color: #CC8832; /* Ochre accent */
margin: 10px 0 0 0;
line-height: 1.2;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Animation: Speaker card slides in from the left
tl.to('[data-composition-id="intro"] .title-card', {
opacity: 1,
x: 0,
duration: 0.8,
ease: 'power2.out'
}, 0.2);
// Exit: Slide off left at 1.8s (synced with 47% graphic)
tl.to('[data-composition-id="intro"] .title-card', {
x: '-120%',
opacity: 0,
duration: 0.6,
ease: 'power2.in'
}, 1.8);
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warm Grain - Hyperframes</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background-color: #F5F0E0; /* Cream background */
font-family: 'Outfit', 'Lexend', sans-serif;
}
#main-composition {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/* Paper texture and Grain overlay */
#grain-overlay-comp {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
}
/* Grain animation */
@keyframes grain-noise {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-5%, -5%); }
20% { transform: translate(-10%, 5%); }
30% { transform: translate(5%, -10%); }
40% { transform: translate(-5%, 15%); }
50% { transform: translate(-10%, 5%); }
60% { transform: translate(15%, 0); }
70% { transform: translate(0, 10%); }
80% { transform: translate(-15%, 0); }
90% { transform: translate(10%, 5%); }
}
.grain-texture {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: url('https://www.transparenttextures.com/patterns/natural-paper.png');
opacity: 0.15;
animation: grain-noise 0.5s steps(1) infinite;
}
#a-roll {
position: absolute;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.comp-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>
</head>
<body>
<div id="main-composition" data-composition-id="main-video" data-width="1920" data-height="1080" data-duration="17">
<!-- Background Layer -->
<div id="grain-overlay-comp" data-composition-id="grain-overlay" data-width="1920" data-height="1080" data-duration="17" data-track-index="100">
<div class="grain-texture"></div>
<script>
const grainTl = gsap.timeline({ paused: true });
window.__timelines["grain-overlay"] = grainTl;
</script>
</div>
<!-- A-Roll Video -->
<video id="a-roll"
src="__VIDEO_SRC__"
data-start="0"
data-duration="17"
data-track-index="0">
</video>
<!-- Compositions -->
<div id="intro-layer" class="comp-layer"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="2.5"
data-track-index="1">
</div>
<div id="graphics-layer" class="comp-layer"
data-composition-id="graphics"
data-composition-src="compositions/graphics.html"
data-start="0"
data-duration="17"
data-track-index="2">
</div>
<div id="captions-layer" class="comp-layer"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="17"
data-track-index="3">
</div>
<!-- SFX -->
<audio id="sfx-swipe-1" src="" data-start="1.8" data-duration="1" data-track-index="4"></audio>
<audio id="sfx-swipe-2" src="" data-start="4.6" data-duration="1" data-track-index="4"></audio>
<audio id="sfx-swipe-3" src="" data-start="8.8" data-duration="1" data-track-index="4"></audio>
<script>
const tl = gsap.timeline({ paused: true });
// A-Roll Framing Strategy
// 1. 0s-1.8s: Intro scale up from small (60%) to medium (80%) - Shifted right slightly to clear speaker card
// 2. 1.8s-4.6s: Grid view / Shift Left (x: 600, scale: 70%) for 47% graphic
// 3. 4.6s-8.8s: Shift Center-Bottom (y: 800, scale: 70%) for 62% graphic (top)
// 4. 8.8s-14s: Shift Right (x: 1320, scale: 75%) for Editing Skills graphic (left)
// 5. 14s-17s: Final Reveal (Center, scale: 100%)
// Initial state
gsap.set('#a-roll', { x: 1100, y: 540, xPercent: -50, yPercent: -50, width: 1920 * 0.6, height: 1080 * 0.6 });
// 1. Intro Scale Up (0s - 1.8s)
tl.to('#a-roll', {
x: 1200,
width: 1920 * 0.8,
height: 1080 * 0.8,
duration: 1.8,
ease: "power2.inOut"
}, 0);
// 2. Moment 1: 47% Graphic Trigger (1.8s) - Shift Left
tl.to('#a-roll', {
x: 600,
y: 540,
width: 1920 * 0.7,
height: 1080 * 0.7,
duration: 0.5,
ease: "power2.inOut"
}, 1.8);
// 3. Moment 2: 62% Graphic Trigger (4.6s) - Shift Center-Bottom
tl.to('#a-roll', {
x: 960,
y: 750,
width: 1920 * 0.7,
height: 1080 * 0.7,
duration: 0.5,
ease: "power2.inOut"
}, 4.6);
// 4. Moment 3: Editing Skills Trigger (8.8s) - Shift Right
tl.to('#a-roll', {
x: 1320,
y: 540,
width: 1920 * 0.75,
height: 1080 * 0.75,
duration: 0.5,
ease: "power2.inOut"
}, 8.8);
// 5. Final Reveal (14s - 17s)
tl.to('#a-roll', {
x: 960,
y: 540,
width: 1920 * 1.0,
height: 1080 * 1.0,
duration: 1.0,
ease: "power2.inOut"
}, 14);
window.__timelines["main-video"] = tl;
</script>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import pc from "picocolors";
const isColorSupported =
process.stdout.isTTY === true &&
!process.env["NO_COLOR"] &&
process.env["FORCE_COLOR"] !== "0";
function wrap(fn: (s: string) => string): (s: string) => string {
return isColorSupported ? fn : (s: string) => s;
}
export const c = {
success: wrap(pc.green),
error: wrap(pc.red),
warn: wrap(pc.yellow),
dim: wrap(pc.dim),
bold: wrap(pc.bold),
accent: wrap(pc.cyan),
progress: wrap(pc.magenta),
reset: isColorSupported ? pc.reset : (s: string) => s,
};
export { isColorSupported };
+27
View File
@@ -0,0 +1,27 @@
import { c } from "./colors.js";
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDuration(ms: number): string {
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const remaining = seconds - minutes * 60;
return `${minutes}m ${remaining.toFixed(1)}s`;
}
export function label(name: string, value: string): string {
const pad = 14 - name.length;
return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`;
}
export function errorBox(title: string, hint?: string, suggestion?: string): void {
console.error(`\n${c.error("\u2717")} ${c.bold(title)}`);
if (hint) console.error(`\n ${c.dim(hint)}`);
if (suggestion) console.error(` ${c.accent(suggestion)}`);
console.error();
}
+20
View File
@@ -0,0 +1,20 @@
import { c } from "./colors.js";
const { stdout } = process;
export function renderProgress(percent: number, stage: string, row?: number): void {
const width = 25;
const filled = Math.floor(percent / (100 / width));
const empty = width - filled;
const bar =
c.progress("\u2588".repeat(filled)) +
c.dim("\u2591".repeat(empty));
const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`;
if (row !== undefined && stdout.isTTY) {
stdout.write(`\x1b[${row};1H\x1b[2K${line}`);
} else {
stdout.write(`\r\x1b[2K${line}`);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { DOMParser } from "linkedom";
/**
* Polyfill DOMParser on globalThis so @hyperframes/core's parseHtml works in Node.js.
* Safe to call multiple times only sets the global once.
*/
export function ensureDOMParser(): void {
if (typeof globalThis.DOMParser === "undefined") {
(globalThis as Record<string, unknown>).DOMParser = DOMParser;
}
}
+21
View File
@@ -0,0 +1,21 @@
export const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".ico": "image/x-icon",
};
+7
View File
@@ -0,0 +1,7 @@
/**
* Dynamically load the producer module. tsup inlines @hyperframes/producer
* via noExternal so this resolves in the published bundle.
*/
export async function loadProducer() {
return await import("@hyperframes/producer");
}
+30
View File
@@ -0,0 +1,30 @@
import { existsSync, statSync } from "node:fs";
import { resolve, basename } from "node:path";
import { errorBox } from "../ui/format.js";
export interface ProjectDir {
dir: string;
name: string;
indexPath: string;
}
export function resolveProject(dirArg: string | undefined): ProjectDir {
const dir = resolve(dirArg ?? ".");
const name = basename(dir);
const indexPath = resolve(dir, "index.html");
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
errorBox("Not a directory: " + dir);
process.exit(1);
}
if (!existsSync(indexPath)) {
errorBox(
"No composition found in " + dir,
"No index.html file found.",
"Run npx hyperframes init to create a new composition.",
);
process.exit(1);
}
return { dir, name, indexPath };
}
+1
View File
@@ -0,0 +1 @@
export const VERSION = "0.1.0";