mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
initial code (#2)
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <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:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -0,0 +1,25 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export function findFFmpeg(): string | undefined {
|
||||
try {
|
||||
const result = execSync("which ffmpeg", {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
return result || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFFmpegInstallHint(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "brew install ffmpeg";
|
||||
case "linux":
|
||||
return "sudo apt install ffmpeg";
|
||||
default:
|
||||
return "https://ffmpeg.org/download.html";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
Browser,
|
||||
detectBrowserPlatform,
|
||||
getInstalledBrowsers,
|
||||
install,
|
||||
} from "@puppeteer/browsers";
|
||||
|
||||
const CHROME_VERSION = "131.0.6778.85";
|
||||
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
|
||||
|
||||
/** Override browser path via --browser-path flag. Takes priority over env var. */
|
||||
let _browserPathOverride: string | undefined;
|
||||
export function setBrowserPath(path: string): void {
|
||||
_browserPathOverride = path;
|
||||
}
|
||||
|
||||
export type BrowserSource =
|
||||
| "env"
|
||||
| "cache"
|
||||
| "system"
|
||||
| "download";
|
||||
|
||||
export interface BrowserResult {
|
||||
executablePath: string;
|
||||
source: BrowserSource;
|
||||
}
|
||||
|
||||
export interface EnsureBrowserOptions {
|
||||
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
|
||||
}
|
||||
|
||||
// --- Internal helpers -------------------------------------------------------
|
||||
|
||||
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
|
||||
process.platform === "darwin"
|
||||
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
||||
: [
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
function whichBinary(name: string): string | undefined {
|
||||
try {
|
||||
const result = execSync(`which ${name}`, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
return result || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findFromEnv(): BrowserResult | undefined {
|
||||
// --browser-path flag takes priority
|
||||
if (_browserPathOverride && existsSync(_browserPathOverride)) {
|
||||
return { executablePath: _browserPathOverride, source: "env" };
|
||||
}
|
||||
const envPath = process.env["HYPERFRAMES_BROWSER_PATH"];
|
||||
if (envPath && existsSync(envPath)) {
|
||||
return { executablePath: envPath, source: "env" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function findFromCache(): Promise<BrowserResult | undefined> {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
||||
const match = installed.find(
|
||||
(b) => b.browser === Browser.CHROMEHEADLESSSHELL,
|
||||
);
|
||||
if (match) {
|
||||
return { executablePath: match.executablePath, source: "cache" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFromSystem(): BrowserResult | undefined {
|
||||
for (const p of SYSTEM_CHROME_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
return { executablePath: p, source: "system" };
|
||||
}
|
||||
}
|
||||
|
||||
const fromWhich =
|
||||
whichBinary("google-chrome") ?? whichBinary("chromium");
|
||||
if (fromWhich) {
|
||||
return { executablePath: fromWhich, source: "system" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Public API -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find an existing browser without downloading.
|
||||
* Resolution: env var -> cached download -> system Chrome.
|
||||
*/
|
||||
export async function findBrowser(): Promise<BrowserResult | undefined> {
|
||||
const fromEnv = findFromEnv();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const fromCache = await findFromCache();
|
||||
if (fromCache) return fromCache;
|
||||
|
||||
return findFromSystem();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or download a browser.
|
||||
* Resolution: env var -> cached download -> system Chrome -> auto-download.
|
||||
*/
|
||||
export async function ensureBrowser(
|
||||
options?: EnsureBrowserOptions,
|
||||
): Promise<BrowserResult> {
|
||||
const existing = await findBrowser();
|
||||
if (existing) return existing;
|
||||
|
||||
const platform = detectBrowserPlatform();
|
||||
if (!platform) {
|
||||
throw new Error(
|
||||
`Unsupported platform: ${process.platform} ${process.arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installed = await install({
|
||||
cacheDir: CACHE_DIR,
|
||||
browser: Browser.CHROMEHEADLESSSHELL,
|
||||
buildId: CHROME_VERSION,
|
||||
platform,
|
||||
downloadProgressCallback: options?.onProgress,
|
||||
});
|
||||
|
||||
return { executablePath: installed.executablePath, source: "download" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cached Chrome download directory.
|
||||
* Returns true if anything was removed.
|
||||
*/
|
||||
export function clearBrowser(): boolean {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
return false;
|
||||
}
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export { CHROME_VERSION, CACHE_DIR };
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { VERSION } from "./version.js";
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "hyperframes",
|
||||
version: VERSION,
|
||||
description: "Create and render HTML video compositions",
|
||||
},
|
||||
subCommands: {
|
||||
init: () => import("./commands/init.js").then((m) => m.default),
|
||||
dev: () => import("./commands/dev.js").then((m) => m.default),
|
||||
render: () => import("./commands/render.js").then((m) => m.default),
|
||||
lint: () => import("./commands/lint.js").then((m) => m.default),
|
||||
info: () => import("./commands/info.js").then((m) => m.default),
|
||||
compositions: () => import("./commands/compositions.js").then((m) => m.default),
|
||||
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
|
||||
browser: () => import("./commands/browser.js").then((m) => m.default),
|
||||
docs: () => import("./commands/docs.js").then((m) => m.default),
|
||||
doctor: () => import("./commands/doctor.js").then((m) => m.default),
|
||||
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
@@ -0,0 +1,226 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { loadProducer } from "../utils/producer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
|
||||
interface BenchmarkConfig {
|
||||
label: string;
|
||||
fps: 24 | 30 | 60;
|
||||
quality: "draft" | "standard" | "high";
|
||||
workers: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
elapsedMs: number;
|
||||
fileSize: number | null;
|
||||
}
|
||||
|
||||
interface ConfigResult {
|
||||
config: BenchmarkConfig;
|
||||
runs: RunResult[];
|
||||
failures: number;
|
||||
avgTime: number | null;
|
||||
avgSize: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIGS: BenchmarkConfig[] = [
|
||||
{ label: "30fps \u00B7 draft \u00B7 2w", fps: 30, quality: "draft", workers: 2 },
|
||||
{ label: "30fps \u00B7 standard \u00B7 2w", fps: 30, quality: "standard", workers: 2 },
|
||||
{ label: "30fps \u00B7 high \u00B7 2w", fps: 30, quality: "high", workers: 2 },
|
||||
{ label: "30fps \u00B7 standard \u00B7 4w", fps: 30, quality: "standard", workers: 4 },
|
||||
{ label: "60fps \u00B7 standard \u00B7 4w", fps: 60, quality: "standard", workers: 4 },
|
||||
];
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "benchmark", description: "Run multiple render configurations and compare results" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
runs: { type: "string", description: "Number of runs per config", default: "3" },
|
||||
json: { type: "boolean", description: "Output results as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
// ── Resolve project ──────────────────────────────────────────────────
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
// ── Parse runs ───────────────────────────────────────────────────────
|
||||
const runsPerConfig = parseInt(args.runs ?? "3", 10);
|
||||
if (isNaN(runsPerConfig) || runsPerConfig < 1 || runsPerConfig > 20) {
|
||||
errorBox("Invalid runs", `Got "${args.runs ?? "3"}". Must be between 1 and 20.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jsonOutput = args.json ?? false;
|
||||
|
||||
// ── Temp output for benchmark renders ────────────────────────────────
|
||||
const benchDir = resolve("renders", ".benchmark");
|
||||
|
||||
// ── Load producer ────────────────────────────────────────────────────
|
||||
let producer: Awaited<ReturnType<typeof loadProducer>> | null = null;
|
||||
try {
|
||||
producer = await loadProducer();
|
||||
} catch {
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify({ error: "Producer module not available. Is the project built?" }));
|
||||
} else {
|
||||
errorBox(
|
||||
"Producer module not available",
|
||||
"The rendering pipeline could not be loaded.",
|
||||
"Ensure @hyperframes/producer is built and linked.",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Print header ─────────────────────────────────────────────────────
|
||||
if (!jsonOutput) {
|
||||
console.log("");
|
||||
console.log(
|
||||
c.accent("\u25C6") +
|
||||
" Benchmarking " +
|
||||
c.accent(project.name) +
|
||||
c.dim(` (${runsPerConfig} runs each)`),
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ── Run benchmarks ───────────────────────────────────────────────────
|
||||
const results: ConfigResult[] = [];
|
||||
|
||||
for (const config of DEFAULT_CONFIGS) {
|
||||
const runs: RunResult[] = [];
|
||||
let failures = 0;
|
||||
|
||||
const s = !jsonOutput ? clack.spinner() : undefined;
|
||||
s?.start(`Benchmarking ${config.label}...`);
|
||||
|
||||
for (let i = 0; i < runsPerConfig; i++) {
|
||||
s?.message(`${config.label} — run ${i + 1}/${runsPerConfig}`);
|
||||
const outputPath = join(benchDir, `${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
await producer.renderComposition(project.dir, {
|
||||
output: outputPath,
|
||||
fps: config.fps,
|
||||
quality: config.quality,
|
||||
workers: config.workers,
|
||||
quiet: true,
|
||||
});
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
let fileSize: number | null = null;
|
||||
if (existsSync(outputPath)) {
|
||||
const stat = statSync(outputPath);
|
||||
fileSize = stat.size;
|
||||
}
|
||||
|
||||
runs.push({ elapsedMs, fileSize });
|
||||
} catch {
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
s?.stop(`${config.label} — ${runs.length} runs${failures > 0 ? `, ${failures} failed` : ""}`);
|
||||
|
||||
const successfulRuns = runs.filter((r) => r.elapsedMs > 0);
|
||||
const avgTime =
|
||||
successfulRuns.length > 0
|
||||
? successfulRuns.reduce((sum, r) => sum + r.elapsedMs, 0) / successfulRuns.length
|
||||
: null;
|
||||
const sizesWithValues = runs.map((r) => r.fileSize).filter((s): s is number => s != null);
|
||||
const avgSize =
|
||||
sizesWithValues.length > 0
|
||||
? sizesWithValues.reduce((sum, s) => sum + s, 0) / sizesWithValues.length
|
||||
: null;
|
||||
|
||||
results.push({ config, runs, failures, avgTime, avgSize });
|
||||
}
|
||||
|
||||
// ── Output results ───────────────────────────────────────────────────
|
||||
if (jsonOutput) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
results.map((r) => ({
|
||||
config: r.config.label,
|
||||
fps: r.config.fps,
|
||||
quality: r.config.quality,
|
||||
workers: r.config.workers,
|
||||
avgTimeMs: r.avgTime,
|
||||
avgSizeBytes: r.avgSize,
|
||||
failures: r.failures,
|
||||
runs: r.runs,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Table output ─────────────────────────────────────────────────────
|
||||
const configColWidth = 26;
|
||||
const timeColWidth = 10;
|
||||
const sizeColWidth = 10;
|
||||
|
||||
const header =
|
||||
" " +
|
||||
c.bold("Config".padEnd(configColWidth)) +
|
||||
c.bold("Time".padEnd(timeColWidth)) +
|
||||
c.bold("Size".padEnd(sizeColWidth));
|
||||
const separator = " " + c.dim("\u2500".repeat(configColWidth + timeColWidth + sizeColWidth));
|
||||
|
||||
console.log(header);
|
||||
console.log(separator);
|
||||
|
||||
for (const result of results) {
|
||||
const timeStr =
|
||||
result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed");
|
||||
const sizeStr =
|
||||
result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a");
|
||||
const failStr =
|
||||
result.failures > 0 ? c.warn(` (${result.failures} failed)`) : "";
|
||||
|
||||
console.log(
|
||||
" " +
|
||||
result.config.label.padEnd(configColWidth) +
|
||||
timeStr.padEnd(timeColWidth) +
|
||||
sizeStr.padEnd(sizeColWidth) +
|
||||
failStr,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────
|
||||
const successfulResults = results.filter((r) => r.avgTime != null);
|
||||
if (successfulResults.length > 0) {
|
||||
let fastest = successfulResults[0];
|
||||
for (const r of successfulResults) {
|
||||
if (fastest == null || r.avgTime == null) continue;
|
||||
if (fastest.avgTime == null || r.avgTime < fastest.avgTime) {
|
||||
fastest = r;
|
||||
}
|
||||
}
|
||||
|
||||
if (fastest?.avgTime != null) {
|
||||
console.log("");
|
||||
console.log(
|
||||
c.success("\u25C7") +
|
||||
" Fastest: " +
|
||||
c.accent(fastest.config.label) +
|
||||
c.dim(` (${formatDuration(fastest.avgTime)})`),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
c.error("\u2717") +
|
||||
" All configurations failed. Ensure the rendering pipeline is set up.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes } from "../ui/format.js";
|
||||
import {
|
||||
ensureBrowser,
|
||||
findBrowser,
|
||||
clearBrowser,
|
||||
CHROME_VERSION,
|
||||
CACHE_DIR,
|
||||
} from "../browser/manager.js";
|
||||
|
||||
async function runEnsure(): Promise<void> {
|
||||
clack.intro(c.bold("hyperframes browser ensure"));
|
||||
|
||||
const s = clack.spinner();
|
||||
s.start("Looking for an existing browser...");
|
||||
|
||||
const existing = await findBrowser();
|
||||
if (existing) {
|
||||
s.stop(c.success("Browser found"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`);
|
||||
console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Ready to render."));
|
||||
return;
|
||||
}
|
||||
|
||||
s.stop("No browser found — downloading");
|
||||
|
||||
const downloadSpinner = clack.spinner();
|
||||
downloadSpinner.start(
|
||||
`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`,
|
||||
);
|
||||
|
||||
let lastPct = -1;
|
||||
const result = await ensureBrowser({
|
||||
onProgress: (downloaded, total) => {
|
||||
if (total <= 0) return;
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
if (pct > lastPct) {
|
||||
lastPct = pct;
|
||||
downloadSpinner.message(
|
||||
`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)} — ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
downloadSpinner.stop(c.success("Download complete"));
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`);
|
||||
console.log(` ${c.dim("Path:")} ${c.bold(result.executablePath)}`);
|
||||
console.log();
|
||||
|
||||
clack.outro(c.success("Ready to render."));
|
||||
}
|
||||
|
||||
async function runPath(): Promise<void> {
|
||||
const result = await findBrowser();
|
||||
if (!result) {
|
||||
// Try a full ensure (which includes download) but write only the path
|
||||
try {
|
||||
const ensured = await ensureBrowser();
|
||||
process.stdout.write(ensured.executablePath + "\n");
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
err instanceof Error ? err.message : "Failed to find browser",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
process.stdout.write(result.executablePath + "\n");
|
||||
}
|
||||
|
||||
function runClear(): void {
|
||||
clack.intro(c.bold("hyperframes browser clear"));
|
||||
|
||||
const removed = clearBrowser();
|
||||
if (removed) {
|
||||
clack.outro(
|
||||
c.success("Removed cached browser from ") + c.dim(CACHE_DIR),
|
||||
);
|
||||
} else {
|
||||
clack.outro(c.dim("No cached browser to remove."));
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "browser", description: "Manage the Chrome browser used for rendering" },
|
||||
args: {
|
||||
subcommand: { type: "positional", description: "Subcommand: ensure, path, clear", required: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const subcommand = args.subcommand;
|
||||
|
||||
if (!subcommand || subcommand === "") {
|
||||
console.log(`
|
||||
${c.bold("hyperframes browser")} ${c.dim("<subcommand>")}
|
||||
|
||||
Manage the Chrome browser used for rendering.
|
||||
|
||||
${c.bold("SUBCOMMANDS:")}
|
||||
${c.accent("ensure")} ${c.dim("Find or download Chrome for rendering")}
|
||||
${c.accent("path")} ${c.dim("Print browser executable path (for scripting)")}
|
||||
${c.accent("clear")} ${c.dim("Remove cached Chrome download")}
|
||||
|
||||
${c.bold("EXAMPLES:")}
|
||||
${c.accent("npx hyperframes browser ensure")} ${c.dim("Download Chrome if needed")}
|
||||
${c.accent("npx hyperframes browser path")} ${c.dim("Print path for scripts")}
|
||||
${c.accent("npx hyperframes browser clear")} ${c.dim("Remove cached browser")}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subcommand) {
|
||||
case "ensure":
|
||||
return runEnsure();
|
||||
case "path":
|
||||
return runPath();
|
||||
case "clear":
|
||||
return runClear();
|
||||
default:
|
||||
console.error(
|
||||
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { ensureDOMParser } from "../utils/dom.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
interface CompositionInfo {
|
||||
id: string;
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
elementCount: number;
|
||||
}
|
||||
|
||||
function parseCompositions(html: string): CompositionInfo[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const compositionDivs = doc.querySelectorAll("[data-composition-id]");
|
||||
const compositions: CompositionInfo[] = [];
|
||||
|
||||
compositionDivs.forEach((div) => {
|
||||
const id = div.getAttribute("data-composition-id") ?? "unknown";
|
||||
const width = parseInt(div.getAttribute("data-width") ?? "1920", 10);
|
||||
const height = parseInt(div.getAttribute("data-height") ?? "1080", 10);
|
||||
|
||||
const timedChildren = div.querySelectorAll("[data-start]");
|
||||
let maxEnd = 0;
|
||||
let elementCount = 0;
|
||||
|
||||
timedChildren.forEach((el) => {
|
||||
elementCount++;
|
||||
const start = parseFloat(el.getAttribute("data-start") ?? "0");
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
const durationAttr = el.getAttribute("data-duration");
|
||||
|
||||
let end: number;
|
||||
if (endAttr) {
|
||||
end = parseFloat(endAttr);
|
||||
} else if (durationAttr) {
|
||||
end = start + parseFloat(durationAttr);
|
||||
} else {
|
||||
end = start + 5;
|
||||
}
|
||||
|
||||
if (end > maxEnd) {
|
||||
maxEnd = end;
|
||||
}
|
||||
});
|
||||
|
||||
compositions.push({
|
||||
id,
|
||||
duration: maxEnd,
|
||||
width,
|
||||
height,
|
||||
elementCount,
|
||||
});
|
||||
});
|
||||
|
||||
return compositions;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "compositions", description: "List all compositions in a project" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const html = readFileSync(project.indexPath, "utf-8");
|
||||
|
||||
ensureDOMParser();
|
||||
const compositions = parseCompositions(html);
|
||||
|
||||
if (compositions.length === 0) {
|
||||
console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(compositions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const compositionLabel =
|
||||
compositions.length === 1 ? "1 composition" : `${compositions.length} compositions`;
|
||||
console.log(
|
||||
`${c.success("◇")} ${c.accent(project.name)} ${c.dim("—")} ${c.dim(compositionLabel)}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Calculate padding for alignment
|
||||
const maxIdLen = compositions.reduce((max, comp) => Math.max(max, comp.id.length), 0);
|
||||
|
||||
for (const comp of compositions) {
|
||||
const id = c.accent(comp.id.padEnd(maxIdLen));
|
||||
const duration = c.bold(`${comp.duration.toFixed(1)}s`);
|
||||
const resolution = c.dim(`${comp.width}×${comp.height}`);
|
||||
const elements = c.dim(
|
||||
`${comp.elementCount} ${comp.elementCount === 1 ? "element" : "elements"}`,
|
||||
);
|
||||
|
||||
console.log(` ${id} ${duration} ${resolution} ${elements}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
readlinkSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, dirname, basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
/**
|
||||
* Check if a port is available by trying to listen on it briefly.
|
||||
*/
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const { createServer } = require("node:net") as typeof import("node:net");
|
||||
const server = createServer();
|
||||
server.once("error", () => resolvePromise(false));
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolvePromise(true));
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port.
|
||||
*/
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
for (let port = startPort; port < startPort + 10; port++) {
|
||||
if (await isPortAvailable(port)) return port;
|
||||
}
|
||||
return startPort; // fallback — let the server fail with a clear error
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether we're running from source (monorepo dev) or from the built bundle.
|
||||
* When running via tsx from source, the file is at cli/src/commands/dev.ts.
|
||||
* When running from the built bundle, the file is at cli/dist/cli.js.
|
||||
* We check the filename portion of the URL to avoid false positives from
|
||||
* directory names (e.g., /Users/someone/src/...).
|
||||
*/
|
||||
function isDevMode(): boolean {
|
||||
const url = new URL(import.meta.url);
|
||||
// In dev mode the file is a .ts source file; in production it's a bundled .js
|
||||
return url.pathname.endsWith(".ts");
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "dev", description: "Start the studio for local development" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const dir = resolve(args.dir ?? ".");
|
||||
|
||||
if (isDevMode()) {
|
||||
return runDevMode(dir);
|
||||
}
|
||||
const port = await findAvailablePort(3002);
|
||||
return runEmbeddedMode(dir, port);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dev mode: spawn pnpm studio from the monorepo (existing behavior).
|
||||
*/
|
||||
async function runDevMode(dir: string): Promise<void> {
|
||||
// Find monorepo root by navigating from packages/cli/src/commands/
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
|
||||
|
||||
// Symlink project into the studio's data directory
|
||||
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
|
||||
const projectName = basename(dir);
|
||||
const symlinkPath = join(projectsDir, projectName);
|
||||
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
|
||||
let createdSymlink = false;
|
||||
if (dir !== symlinkPath) {
|
||||
if (existsSync(symlinkPath)) {
|
||||
try {
|
||||
const stat = lstatSync(symlinkPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = readlinkSync(symlinkPath);
|
||||
if (resolve(target) !== resolve(dir)) {
|
||||
unlinkSync(symlinkPath);
|
||||
}
|
||||
}
|
||||
// If it's a real directory, leave it alone
|
||||
} catch {
|
||||
// Not a symlink — don't touch it
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(symlinkPath)) {
|
||||
symlinkSync(dir, symlinkPath, "dir");
|
||||
createdSymlink = true;
|
||||
}
|
||||
}
|
||||
|
||||
clack.intro(c.bold("hyperframes dev"));
|
||||
|
||||
const s = clack.spinner();
|
||||
s.start("Starting studio...");
|
||||
|
||||
// Run the new consolidated studio (single Vite dev server with API plugin)
|
||||
const studioPkgDir = join(repoRoot, "packages", "studio");
|
||||
const child = spawn("pnpm", ["exec", "vite"], {
|
||||
cwd: studioPkgDir,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let frontendUrl = "";
|
||||
|
||||
function handleOutput(data: Buffer): void {
|
||||
const text = data.toString();
|
||||
|
||||
// Detect Vite URL
|
||||
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
|
||||
if (localMatch && !frontendUrl) {
|
||||
frontendUrl = localMatch[1] ?? "";
|
||||
s.stop(c.success("Studio running"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
|
||||
console.log();
|
||||
|
||||
const urlToOpen = `${frontendUrl}#/project/${projectName}`;
|
||||
import("open").then((mod) => mod.default(urlToOpen)).catch(() => {});
|
||||
|
||||
child.stdout?.removeListener("data", handleOutput);
|
||||
child.stderr?.removeListener("data", handleOutput);
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.on("data", handleOutput);
|
||||
child.stderr?.on("data", handleOutput);
|
||||
|
||||
// If child exits before we detect readiness, show what we have
|
||||
child.on("error", (err) => {
|
||||
s.stop(c.error("Failed to start studio"));
|
||||
console.error(c.dim(err.message));
|
||||
});
|
||||
|
||||
function cleanup(): void {
|
||||
if (createdSymlink && existsSync(symlinkPath)) {
|
||||
try {
|
||||
unlinkSync(symlinkPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<void>((resolvePromise) => {
|
||||
// Temporarily ignore SIGINT on the parent so Ctrl+C only kills the child.
|
||||
// The child gets SIGINT from the terminal's process group signal.
|
||||
// When the child exits, we clean up and resolve back to the caller.
|
||||
const noop = (): void => {};
|
||||
process.on("SIGINT", noop);
|
||||
|
||||
child.on("close", () => {
|
||||
process.removeListener("SIGINT", noop);
|
||||
cleanup();
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded mode — not yet available.
|
||||
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
|
||||
*/
|
||||
async function runEmbeddedMode(_dir: string, _port: number): Promise<void> {
|
||||
console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>"));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
interface TopicEntry {
|
||||
file: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const TOPICS: Record<string, TopicEntry> = {
|
||||
"data-attributes": {
|
||||
file: "data-attributes.md",
|
||||
description: "Timing, media, and composition attributes",
|
||||
},
|
||||
templates: {
|
||||
file: "templates.md",
|
||||
description: "Built-in project templates for init",
|
||||
},
|
||||
rendering: {
|
||||
file: "rendering.md",
|
||||
description: "Render compositions to MP4 (local & Docker)",
|
||||
},
|
||||
gsap: {
|
||||
file: "gsap.md",
|
||||
description: "GSAP animation setup and usage",
|
||||
},
|
||||
troubleshooting: {
|
||||
file: "troubleshooting.md",
|
||||
description: "Common issues and fixes",
|
||||
},
|
||||
compositions: {
|
||||
file: "compositions.md",
|
||||
description: "Composition structure, nesting, and variables",
|
||||
},
|
||||
};
|
||||
|
||||
function docsDir(): string {
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const dir = dirname(thisFile);
|
||||
// In dev: cli/src/commands/ → ../docs = cli/src/docs/
|
||||
// In built: cli/dist/ → docs = cli/dist/docs/
|
||||
const devPath = resolve(dir, "..", "docs");
|
||||
const builtPath = resolve(dir, "docs");
|
||||
return existsSync(devPath) ? devPath : builtPath;
|
||||
}
|
||||
|
||||
function formatInlineCode(line: string): string {
|
||||
// Replace inline backtick spans with accented text
|
||||
return line.replace(/`([^`]+)`/g, (_match, code: string) => c.accent(code));
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): void {
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip code fences
|
||||
if (line.trim().startsWith("```")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// H1 heading
|
||||
if (line.startsWith("# ")) {
|
||||
console.log(c.bold(line.slice(2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// H2 subheading
|
||||
if (line.startsWith("## ")) {
|
||||
console.log(c.bold(c.dim(line.slice(3))));
|
||||
continue;
|
||||
}
|
||||
|
||||
// List items
|
||||
if (line.startsWith("- ")) {
|
||||
const rest = formatInlineCode(line.slice(2));
|
||||
console.log(`${c.dim(" \u2022")} ${rest}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything else
|
||||
console.log(formatInlineCode(line));
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "docs", description: "View inline documentation in the terminal" },
|
||||
args: {
|
||||
topic: { type: "positional", description: "Topic to view", required: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const topic = args.topic;
|
||||
|
||||
// No topic: list available topics
|
||||
if (topic === undefined || topic === "") {
|
||||
console.log(c.bold("Available topics:"));
|
||||
console.log();
|
||||
for (const [name, entry] of Object.entries(TOPICS)) {
|
||||
console.log(` ${c.accent(name.padEnd(20))} ${c.dim(entry.description)}`);
|
||||
}
|
||||
console.log();
|
||||
console.log(c.dim(`Run ${c.accent("hyperframes docs <topic>")} to view a topic.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the topic
|
||||
const entry = TOPICS[topic];
|
||||
if (entry === undefined) {
|
||||
console.error(c.error(`Unknown topic: ${topic}`));
|
||||
console.error();
|
||||
console.error("Available topics:");
|
||||
for (const name of Object.keys(TOPICS)) {
|
||||
console.error(` ${c.accent(name)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = join(docsDir(), entry.file);
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(c.error(`Doc file not found: ${filePath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
console.log();
|
||||
renderMarkdown(content);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { findBrowser } from "../browser/manager.js";
|
||||
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function checkFFmpeg(): CheckResult {
|
||||
const path = findFFmpeg();
|
||||
if (path) {
|
||||
try {
|
||||
const version = execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 })
|
||||
.split("\n")[0] ?? "";
|
||||
return { ok: true, detail: version.trim() };
|
||||
} catch {
|
||||
return { ok: true, detail: path };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: process.platform === "darwin" ? "brew install ffmpeg" : "sudo apt install ffmpeg",
|
||||
};
|
||||
}
|
||||
|
||||
function checkFFprobe(): CheckResult {
|
||||
try {
|
||||
const result = execSync("which ffprobe", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
return { ok: true, detail: result };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: "Installed with ffmpeg",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkChrome(): Promise<CheckResult> {
|
||||
const info = await findBrowser();
|
||||
if (info) {
|
||||
return { ok: true, detail: `${info.source}: ${info.executablePath}` };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: "Run: npx hyperframes browser ensure",
|
||||
};
|
||||
}
|
||||
|
||||
function checkDocker(): CheckResult {
|
||||
try {
|
||||
const version = execSync("docker --version", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
return { ok: true, detail: version };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: "https://docs.docker.com/get-docker/",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkDockerRunning(): CheckResult {
|
||||
try {
|
||||
execSync("docker info", { stdio: "pipe", timeout: 5000 });
|
||||
return { ok: true, detail: "Running" };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not running",
|
||||
hint: "Start Docker Desktop or run: sudo systemctl start docker",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(): CheckResult {
|
||||
return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` };
|
||||
}
|
||||
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "doctor", description: "Check system dependencies and environment" },
|
||||
args: {},
|
||||
async run() {
|
||||
console.log();
|
||||
console.log(c.bold("hyperframes doctor"));
|
||||
console.log();
|
||||
|
||||
const checks: Check[] = [
|
||||
{ name: "Node.js", run: checkNode },
|
||||
{ name: "FFmpeg", run: checkFFmpeg },
|
||||
{ name: "FFprobe", run: checkFFprobe },
|
||||
{ name: "Chrome", run: checkChrome },
|
||||
{ name: "Docker", run: checkDocker },
|
||||
{ name: "Docker running", run: checkDockerRunning },
|
||||
];
|
||||
|
||||
let allOk = true;
|
||||
|
||||
for (const check of checks) {
|
||||
const result = await check.run();
|
||||
const icon = result.ok ? c.success("\u2713") : c.error("\u2717");
|
||||
const name = check.name.padEnd(16);
|
||||
console.log(` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`);
|
||||
if (!result.ok && result.hint) {
|
||||
console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`);
|
||||
}
|
||||
if (!result.ok) allOk = false;
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (allOk) {
|
||||
console.log(` ${c.success("\u25C7")} ${c.success("All checks passed")}`);
|
||||
} else {
|
||||
console.log(` ${c.warn("\u25C7")} ${c.warn("Some checks failed — see hints above")}`);
|
||||
}
|
||||
console.log();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parseHtml } from "@hyperframes/core";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes, label } from "../ui/format.js";
|
||||
import { ensureDOMParser } from "../utils/dom.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
function totalSize(dir: string): number {
|
||||
let total = 0;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += totalSize(path);
|
||||
} else {
|
||||
total += statSync(path).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "info", description: "Print project metadata" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const html = readFileSync(project.indexPath, "utf-8");
|
||||
|
||||
ensureDOMParser();
|
||||
const parsed = parseHtml(html);
|
||||
|
||||
const tracks = new Set(parsed.elements.map((el) => el.zIndex));
|
||||
const maxEnd = parsed.elements.reduce(
|
||||
(max, el) => Math.max(max, el.startTime + el.duration),
|
||||
0,
|
||||
);
|
||||
const resolution =
|
||||
parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
|
||||
const size = totalSize(project.dir);
|
||||
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const el of parsed.elements) {
|
||||
typeCounts[el.type] = (typeCounts[el.type] ?? 0) + 1;
|
||||
}
|
||||
const typeStr = Object.entries(typeCounts)
|
||||
.map(([t, count]) => `${count} ${t}`)
|
||||
.join(", ");
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({
|
||||
name: project.name,
|
||||
resolution: parsed.resolution,
|
||||
width: parsed.resolution === "portrait" ? 1080 : 1920,
|
||||
height: parsed.resolution === "portrait" ? 1920 : 1080,
|
||||
duration: maxEnd,
|
||||
elements: parsed.elements.length,
|
||||
tracks: tracks.size,
|
||||
types: typeCounts,
|
||||
size,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${c.success("◇")} ${c.accent(project.name)}`);
|
||||
console.log(label("Resolution", resolution));
|
||||
console.log(label("Duration", `${maxEnd.toFixed(1)}s`));
|
||||
console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`));
|
||||
console.log(label("Tracks", `${tracks.size}`));
|
||||
console.log(label("Size", formatBytes(size)));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, basename, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync, execFileSync, spawn } from "node:child_process";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import {
|
||||
TEMPLATES,
|
||||
type TemplateId,
|
||||
} from "../templates/generators.js";
|
||||
|
||||
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
|
||||
|
||||
interface VideoMeta {
|
||||
durationSeconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
videoCodec: string;
|
||||
}
|
||||
|
||||
const WEB_CODECS = new Set(["h264", "vp8", "vp9", "av1", "theora"]);
|
||||
|
||||
const DEFAULT_META: VideoMeta = {
|
||||
durationSeconds: 5,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
hasAudio: false,
|
||||
videoCodec: "h264",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ffprobe helper — shells out to ffprobe to avoid engine dependency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function probeVideo(filePath: string): VideoMeta | undefined {
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||
{ encoding: "utf-8", timeout: 15_000 },
|
||||
);
|
||||
|
||||
const parsed: {
|
||||
streams?: { codec_type?: string; codec_name?: string; width?: number; height?: number; r_frame_rate?: string; avg_frame_rate?: string }[];
|
||||
format?: { duration?: string };
|
||||
} = JSON.parse(raw);
|
||||
|
||||
const streams = parsed.streams ?? [];
|
||||
const videoStream = streams.find((s) => s.codec_type === "video");
|
||||
if (!videoStream) return undefined;
|
||||
|
||||
const hasAudio = streams.some((s) => s.codec_type === "audio");
|
||||
|
||||
let fps = 30;
|
||||
const fpsStr = videoStream.avg_frame_rate ?? videoStream.r_frame_rate;
|
||||
if (fpsStr) {
|
||||
const parts = fpsStr.split("/");
|
||||
const num = parseFloat(parts[0] ?? "");
|
||||
const den = parseFloat(parts[1] ?? "1");
|
||||
if (den !== 0 && !Number.isNaN(num) && !Number.isNaN(den)) {
|
||||
fps = Math.round((num / den) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
const durationStr = parsed.format?.duration;
|
||||
const durationSeconds =
|
||||
durationStr !== undefined ? parseFloat(durationStr) : 5;
|
||||
|
||||
return {
|
||||
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
|
||||
width: videoStream.width ?? 1920,
|
||||
height: videoStream.height ?? 1080,
|
||||
fps,
|
||||
hasAudio,
|
||||
videoCodec: videoStream.codec_name ?? "unknown",
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isWebCompatible(codec: string): boolean {
|
||||
return WEB_CODECS.has(codec.toLowerCase());
|
||||
}
|
||||
|
||||
function hasFFmpeg(): boolean {
|
||||
try {
|
||||
execSync("ffmpeg -version", { stdio: "ignore", timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn("ffmpeg", [
|
||||
"-i", inputPath,
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-y", outputPath,
|
||||
], { stdio: "pipe" });
|
||||
|
||||
child.on("close", (code) => resolvePromise(code === 0));
|
||||
child.on("error", () => resolvePromise(false));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static template helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getStaticTemplateDir(templateId: string): string {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
// In dev: cli/src/commands/ → ../templates = cli/src/templates/
|
||||
// In built: cli/dist/ → templates = cli/dist/templates/
|
||||
const devPath = resolve(dir, "..", "templates", templateId);
|
||||
const builtPath = resolve(dir, "templates", templateId);
|
||||
return existsSync(devPath) ? devPath : builtPath;
|
||||
}
|
||||
|
||||
function patchVideoSrc(dir: string, videoFilename: string | undefined): void {
|
||||
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
|
||||
.filter(e => e.isFile() && e.name.endsWith(".html"))
|
||||
.map(e => join(e.parentPath ?? e.path, e.name));
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
let content = readFileSync(file, "utf-8");
|
||||
if (videoFilename) {
|
||||
content = content.replaceAll("__VIDEO_SRC__", videoFilename);
|
||||
} else {
|
||||
// Remove video elements with placeholder src
|
||||
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
|
||||
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
|
||||
}
|
||||
writeFileSync(file, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleVideoFile — probe, check codec, optionally transcode, copy to destDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleVideoFile(
|
||||
videoPath: string,
|
||||
destDir: string,
|
||||
interactive: boolean,
|
||||
): Promise<{ meta: VideoMeta; localVideoName: string }> {
|
||||
const probed = probeVideo(videoPath);
|
||||
let meta: VideoMeta = { ...DEFAULT_META };
|
||||
let localVideoName = basename(videoPath);
|
||||
|
||||
if (probed) {
|
||||
meta = probed;
|
||||
if (interactive) {
|
||||
clack.log.info(
|
||||
`Video: ${meta.width}x${meta.height}, ${meta.durationSeconds.toFixed(1)}s, ${meta.fps}fps${meta.hasAudio ? ", has audio" : ""}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const msg =
|
||||
"ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: brew install ffmpeg";
|
||||
if (interactive) {
|
||||
clack.log.warn(msg);
|
||||
} else {
|
||||
console.log(c.warn(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Check codec compatibility
|
||||
if (probed && !isWebCompatible(probed.videoCodec)) {
|
||||
if (interactive) {
|
||||
clack.log.warn(
|
||||
c.warn(`Video codec "${probed.videoCodec}" is not supported by web browsers.`),
|
||||
);
|
||||
} else {
|
||||
console.log(c.warn(`Video codec "${probed.videoCodec}" is not supported by browsers.`));
|
||||
}
|
||||
|
||||
if (hasFFmpeg()) {
|
||||
let shouldTranscode = !interactive; // non-interactive auto-transcodes
|
||||
|
||||
if (interactive) {
|
||||
const transcode = await clack.select({
|
||||
message: "Transcode to H.264 MP4 for browser playback?",
|
||||
options: [
|
||||
{ value: "yes", label: "Yes, transcode", hint: "converts to H.264 MP4" },
|
||||
{ value: "no", label: "No, keep original", hint: "video won't play in browser" },
|
||||
],
|
||||
});
|
||||
if (clack.isCancel(transcode)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
shouldTranscode = transcode === "yes";
|
||||
}
|
||||
|
||||
if (shouldTranscode) {
|
||||
const mp4Name = localVideoName.replace(/\.[^.]+$/, ".mp4");
|
||||
const mp4Path = resolve(destDir, mp4Name);
|
||||
const spin = clack.spinner();
|
||||
spin.start("Transcoding to H.264 MP4...");
|
||||
const ok = await transcodeToMp4(videoPath, mp4Path);
|
||||
if (ok) {
|
||||
spin.stop(c.success(`Transcoded to ${mp4Name}`));
|
||||
localVideoName = mp4Name;
|
||||
} else {
|
||||
spin.stop(c.warn("Transcode failed — copying original file"));
|
||||
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
||||
}
|
||||
} else {
|
||||
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
||||
}
|
||||
} else {
|
||||
if (interactive) {
|
||||
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
|
||||
clack.log.info(c.accent("Install: brew install ffmpeg"));
|
||||
} else {
|
||||
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
|
||||
console.log(c.dim("Install: ") + c.accent("brew install ffmpeg"));
|
||||
}
|
||||
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
||||
}
|
||||
} else {
|
||||
copyFileSync(videoPath, resolve(destDir, localVideoName));
|
||||
}
|
||||
|
||||
return { meta, localVideoName };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scaffoldProject — copy template, patch video refs, write meta.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scaffoldProject(
|
||||
destDir: string,
|
||||
name: string,
|
||||
templateId: TemplateId,
|
||||
localVideoName: string | undefined,
|
||||
): void {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
const templateDir = getStaticTemplateDir(templateId);
|
||||
cpSync(templateDir, destDir, { recursive: true });
|
||||
patchVideoSrc(destDir, localVideoName);
|
||||
|
||||
writeFileSync(
|
||||
resolve(destDir, "meta.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nextStepLoop — "What do you want to do?" loop after scaffolding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nextStepLoop(destDir: string): Promise<void> {
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "What do you want to do?",
|
||||
options: [
|
||||
{ value: "dev", label: "Open in studio", hint: "full editor with timeline" },
|
||||
{ value: "render", label: "Render to MP4", hint: "export video now" },
|
||||
{ value: "done", label: "Done for now" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "done") {
|
||||
clack.outro(c.success("Happy editing!"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand off to the selected command — use explicit imports so the
|
||||
// bundler can resolve them (dynamic import with a variable fails in bundles)
|
||||
try {
|
||||
if (next === "dev") {
|
||||
const devCmd = await import("./dev.js").then((m) => m.default);
|
||||
await runCommand(devCmd, { rawArgs: [destDir] });
|
||||
} else if (next === "render") {
|
||||
const renderCmd = await import("./render.js").then((m) => m.default);
|
||||
await runCommand(renderCmd, { rawArgs: [destDir] });
|
||||
}
|
||||
} catch {
|
||||
// Command may throw on Ctrl+C — that's fine, loop back
|
||||
}
|
||||
|
||||
// Wait a tick so any lingering SIGINT state clears before Clack prompts again
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "init", description: "Scaffold a new composition project" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Project name", required: false },
|
||||
template: { type: "string", description: `Template: ${ALL_TEMPLATE_IDS.join(", ")}`, alias: "t" },
|
||||
video: { type: "string", description: "Path to a source video file", alias: "V" },
|
||||
},
|
||||
async run({ args }) {
|
||||
const templateFlag = args.template;
|
||||
const videoFlag = args.video;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Non-interactive mode: flags provided
|
||||
// -----------------------------------------------------------------------
|
||||
if (templateFlag) {
|
||||
if (!ALL_TEMPLATE_IDS.includes(templateFlag as TemplateId)) {
|
||||
console.error(c.error(`Unknown template: ${templateFlag}`));
|
||||
console.error(`Available: ${ALL_TEMPLATE_IDS.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const templateId = templateFlag as TemplateId;
|
||||
const name = args.name ?? "my-video";
|
||||
const destDir = resolve(name);
|
||||
|
||||
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
|
||||
console.error(
|
||||
c.error(`Directory already exists and is not empty: ${name}`),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
let localVideoName: string | undefined;
|
||||
|
||||
if (videoFlag) {
|
||||
const videoPath = resolve(videoFlag);
|
||||
if (!existsSync(videoPath)) {
|
||||
console.error(c.error(`Video file not found: ${videoFlag}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await handleVideoFile(videoPath, destDir, false);
|
||||
localVideoName = result.localVideoName;
|
||||
}
|
||||
|
||||
scaffoldProject(destDir, basename(destDir), templateId, localVideoName);
|
||||
|
||||
console.log(c.success(`\nCreated ${c.accent(name + "/")}`));
|
||||
for (const f of readdirSync(destDir)) {
|
||||
console.log(` ${c.accent(f)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Interactive mode
|
||||
// -----------------------------------------------------------------------
|
||||
clack.intro("Create a new HyperFrames project");
|
||||
|
||||
// 1. Project name
|
||||
let name: string;
|
||||
const hasPositionalName = args.name !== undefined && args.name !== "";
|
||||
if (hasPositionalName) {
|
||||
name = args.name ?? "my-video";
|
||||
} else {
|
||||
const nameResult = await clack.text({
|
||||
message: "Project name",
|
||||
placeholder: "my-video",
|
||||
defaultValue: "my-video",
|
||||
});
|
||||
if (clack.isCancel(nameResult)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = nameResult;
|
||||
}
|
||||
|
||||
const destDir = resolve(name);
|
||||
|
||||
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
|
||||
const overwrite = await clack.confirm({
|
||||
message: `Directory ${c.accent(name)} already exists and is not empty. Overwrite?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (clack.isCancel(overwrite) || !overwrite) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Got a video?
|
||||
let localVideoName: string | undefined;
|
||||
|
||||
if (videoFlag) {
|
||||
// Video supplied via --video flag even in interactive mode
|
||||
const videoPath = resolve(videoFlag);
|
||||
if (!existsSync(videoPath)) {
|
||||
clack.log.error(`Video file not found: ${videoFlag}`);
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
const result = await handleVideoFile(videoPath, destDir, true);
|
||||
localVideoName = result.localVideoName;
|
||||
} else {
|
||||
const videoChoice = await clack.select({
|
||||
message: "Got a video file?",
|
||||
options: [
|
||||
{ value: "yes", label: "Yes", hint: "MP4 or WebM recommended" },
|
||||
{
|
||||
value: "no",
|
||||
label: "No",
|
||||
hint: "Start with motion graphics or text",
|
||||
},
|
||||
],
|
||||
initialValue: "no" as "yes" | "no",
|
||||
});
|
||||
if (clack.isCancel(videoChoice)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (videoChoice === "yes") {
|
||||
const pathResult = await clack.text({
|
||||
message: "Path to your video file (drag and drop or paste)",
|
||||
placeholder: "/path/to/video.mp4",
|
||||
validate(val) {
|
||||
const trimmed = val?.trim();
|
||||
if (!trimmed) return "Please enter a file path";
|
||||
if (!existsSync(resolve(trimmed))) return "File not found";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (clack.isCancel(pathResult)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const videoPath = resolve(String(pathResult).trim());
|
||||
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
const result = await handleVideoFile(videoPath, destDir, true);
|
||||
localVideoName = result.localVideoName;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pick template — single list for all templates
|
||||
const templateResult = await clack.select({
|
||||
message: "Pick a template",
|
||||
options: TEMPLATES.map((t) => ({
|
||||
value: t.id,
|
||||
label: t.label,
|
||||
hint: t.hint,
|
||||
})),
|
||||
initialValue: TEMPLATES[0]?.id,
|
||||
});
|
||||
if (clack.isCancel(templateResult)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const templateId: TemplateId = templateResult;
|
||||
|
||||
// 4. Copy template and patch
|
||||
scaffoldProject(destDir, name, templateId, localVideoName);
|
||||
|
||||
const files = readdirSync(destDir);
|
||||
clack.note(
|
||||
files.map((f) => c.accent(f)).join("\n"),
|
||||
c.success(`Created ${name}/`),
|
||||
);
|
||||
|
||||
await nextStepLoop(destDir);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { lintHyperframeHtml } from "@hyperframes/core/lint";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "lint", description: "Validate a composition for common mistakes" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
json: { type: "boolean", description: "Output findings as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const html = readFileSync(project.indexPath, "utf-8");
|
||||
const result = lintHyperframeHtml(html, { filePath: project.indexPath });
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/index.html")}`);
|
||||
console.log();
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const finding of result.findings) {
|
||||
const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
|
||||
const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
|
||||
console.log(`${prefix} ${c.bold(finding.code)}${loc}: ${finding.message}`);
|
||||
if (finding.fixHint) {
|
||||
console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryIcon = result.errorCount > 0 ? c.error("◇") : c.success("◇");
|
||||
console.log(`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`);
|
||||
process.exit(result.errorCount > 0 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { loadProducer } from "../utils/producer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
|
||||
import { renderProgress } from "../ui/progress.js";
|
||||
|
||||
const VALID_FPS = new Set([24, 30, 60]);
|
||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "render", description: "Render a composition to MP4" },
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
output: { type: "string", description: "Output path (default: renders/<name>.mp4)" },
|
||||
fps: { type: "string", description: "Frame rate: 24, 30, 60", default: "30" },
|
||||
quality: { type: "string", description: "Quality: draft, standard, high", default: "standard" },
|
||||
workers: { type: "string", description: "Parallel workers 1-8" },
|
||||
docker: { type: "boolean", description: "Use Docker for deterministic render", default: false },
|
||||
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
|
||||
quiet: { type: "boolean", description: "Suppress verbose output", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
// ── Resolve project ────────────────────────────────────────────────────
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
// ── Validate fps ───────────────────────────────────────────────────────
|
||||
const fpsRaw = parseInt(args.fps ?? "30", 10);
|
||||
if (!VALID_FPS.has(fpsRaw)) {
|
||||
errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const fps = fpsRaw as 24 | 30 | 60;
|
||||
|
||||
// ── Validate quality ───────────────────────────────────────────────────
|
||||
const qualityRaw = args.quality ?? "standard";
|
||||
if (!VALID_QUALITY.has(qualityRaw)) {
|
||||
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const quality = qualityRaw as "draft" | "standard" | "high";
|
||||
|
||||
// ── Validate workers ──────────────────────────────────────────────────
|
||||
let workers: number | undefined;
|
||||
if (args.workers != null) {
|
||||
const parsed = parseInt(args.workers, 10);
|
||||
if (isNaN(parsed) || parsed < 1 || parsed > 8) {
|
||||
errorBox("Invalid workers", `Got "${args.workers}". Must be between 1 and 8.`);
|
||||
process.exit(1);
|
||||
}
|
||||
workers = parsed;
|
||||
}
|
||||
|
||||
// ── Resolve output path ───────────────────────────────────────────────
|
||||
const rendersDir = resolve("renders");
|
||||
const outputPath = args.output
|
||||
? resolve(args.output)
|
||||
: join(rendersDir, `${project.name}.mp4`);
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const useDocker = args.docker ?? false;
|
||||
const useGpu = args.gpu ?? false;
|
||||
const quiet = args.quiet ?? false;
|
||||
|
||||
// ── Print render plan ─────────────────────────────────────────────────
|
||||
const workerCount = workers ?? 4;
|
||||
if (!quiet) {
|
||||
console.log("");
|
||||
console.log(c.accent("\u25C6") + " Rendering " + c.accent(project.name) + c.dim(" \u2192 " + outputPath));
|
||||
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ── Check FFmpeg for local renders ───────────────────────────────────
|
||||
if (!useDocker) {
|
||||
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
|
||||
if (!findFFmpeg()) {
|
||||
errorBox(
|
||||
"FFmpeg not found",
|
||||
"Rendering requires FFmpeg for video encoding.",
|
||||
`Install: ${getFFmpegInstallHint()}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure browser for local renders ────────────────────────────────
|
||||
if (!useDocker) {
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const clack = await import("@clack/prompts");
|
||||
const s = clack.spinner();
|
||||
s.start("Checking browser...");
|
||||
try {
|
||||
const info = await ensureBrowser({
|
||||
onProgress: (downloaded, total) => {
|
||||
if (total <= 0) return;
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
s.message(`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`);
|
||||
},
|
||||
});
|
||||
s.stop(c.dim(`Browser: ${info.source}`));
|
||||
} catch (err: unknown) {
|
||||
s.stop(c.error("Browser not available"));
|
||||
errorBox(
|
||||
"Chrome not found",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"Run: npx hyperframes browser ensure",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
if (useDocker) {
|
||||
await renderDocker(project.dir, outputPath, { fps, quality, workers, gpu: useGpu, quiet });
|
||||
} else {
|
||||
await renderLocal(project.dir, outputPath, { fps, quality, workers, gpu: useGpu, quiet });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
interface RenderOptions {
|
||||
fps: 24 | 30 | 60;
|
||||
quality: "draft" | "standard" | "high";
|
||||
workers?: number;
|
||||
gpu: boolean;
|
||||
quiet: boolean;
|
||||
}
|
||||
|
||||
async function renderDocker(
|
||||
projectDir: string,
|
||||
outputPath: string,
|
||||
options: RenderOptions,
|
||||
): Promise<void> {
|
||||
const producer = await loadProducer();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await producer.renderComposition(projectDir, {
|
||||
output: outputPath,
|
||||
fps: options.fps,
|
||||
quality: options.quality,
|
||||
workers: options.workers ?? null,
|
||||
gpu: options.gpu,
|
||||
quiet: options.quiet,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorBox("Render failed", message, "Try --docker for containerized rendering");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
printRenderComplete(outputPath, elapsed, options.quiet);
|
||||
}
|
||||
|
||||
async function renderLocal(
|
||||
projectDir: string,
|
||||
outputPath: string,
|
||||
options: RenderOptions,
|
||||
): Promise<void> {
|
||||
const producer = await loadProducer();
|
||||
const startTime = Date.now();
|
||||
|
||||
const job = producer.createRenderJob({
|
||||
fps: options.fps,
|
||||
quality: options.quality,
|
||||
workers: options.workers,
|
||||
useGpu: options.gpu,
|
||||
});
|
||||
|
||||
const onProgress = options.quiet
|
||||
? undefined
|
||||
: (progressJob: { progress: number }, message: string) => {
|
||||
renderProgress(progressJob.progress, message);
|
||||
};
|
||||
|
||||
try {
|
||||
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorBox("Render failed", message, "Try --docker for containerized rendering");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
printRenderComplete(outputPath, elapsed, options.quiet);
|
||||
}
|
||||
|
||||
function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void {
|
||||
if (quiet) return;
|
||||
|
||||
let fileSize = "unknown";
|
||||
if (existsSync(outputPath)) {
|
||||
const stat = statSync(outputPath);
|
||||
fileSize = formatBytes(stat.size);
|
||||
}
|
||||
|
||||
const duration = formatDuration(elapsedMs);
|
||||
console.log("");
|
||||
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
||||
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed"));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" },
|
||||
args: {},
|
||||
async run() {
|
||||
clack.intro(c.bold("hyperframes upgrade"));
|
||||
|
||||
const s = clack.spinner();
|
||||
s.start("Checking for updates...");
|
||||
|
||||
let latest: string;
|
||||
try {
|
||||
const res = await fetch("https://registry.npmjs.org/hyperframes/latest");
|
||||
if (!res.ok) {
|
||||
s.stop("Could not check for updates");
|
||||
clack.outro(c.dim("Package not yet published to npm."));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { version?: string };
|
||||
latest = data.version ?? VERSION;
|
||||
} catch {
|
||||
s.stop("Could not check for updates");
|
||||
clack.outro(c.dim("Network error. Check your connection."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest === VERSION) {
|
||||
s.stop(c.success("Already up to date"));
|
||||
clack.outro(`${c.success("◇")} ${c.bold("v" + VERSION)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
s.stop("Update available");
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.dim("Current:")} ${c.bold("v" + VERSION)}`);
|
||||
console.log(` ${c.dim("Latest:")} ${c.bold(c.accent("v" + latest))}`);
|
||||
console.log();
|
||||
|
||||
const shouldUpgrade = await clack.confirm({
|
||||
message: "Upgrade now?",
|
||||
});
|
||||
|
||||
if (clack.isCancel(shouldUpgrade) || !shouldUpgrade) {
|
||||
clack.outro(c.dim("Skipped."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.accent("npm install -g hyperframes@" + latest)}`);
|
||||
console.log(` ${c.dim("or")}`);
|
||||
console.log(` ${c.accent("npx hyperframes@" + latest + " --version")}`);
|
||||
console.log();
|
||||
|
||||
clack.outro(c.success("Run one of the commands above to upgrade."));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
# Compositions
|
||||
|
||||
A composition is an HTML document that defines a video timeline.
|
||||
|
||||
## Structure
|
||||
Every composition needs a root element with `data-composition-id`:
|
||||
```html
|
||||
<div id="root" data-composition-id="root" data-width="1920" data-height="1080">
|
||||
<!-- Elements go here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Nested Compositions
|
||||
Embed one composition inside another:
|
||||
```html
|
||||
<div data-composition-src="./intro.html" data-start="0" data-duration="5"></div>
|
||||
```
|
||||
|
||||
## Listing Compositions
|
||||
Use `npx hyperframes compositions` to see all compositions in a project.
|
||||
|
||||
## Variables
|
||||
Compositions can expose variables for dynamic content:
|
||||
```html
|
||||
<div data-composition-id="card" data-var-title="string" data-var-color="color">
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
# Data Attributes
|
||||
|
||||
Core attributes for controlling element timing and behavior.
|
||||
|
||||
## Timing
|
||||
- `data-start="0"` — Start time in seconds
|
||||
- `data-duration="5"` — Duration in seconds
|
||||
- `data-track-index="0"` — Timeline track number (controls z-ordering)
|
||||
|
||||
## Media
|
||||
- `data-media-start="2"` — Media playback offset / trim point (seconds)
|
||||
- `data-volume="0.8"` — Audio/video volume, 0 to 1
|
||||
- `data-has-audio="true"` — Indicates video has an audio track
|
||||
|
||||
## Composition
|
||||
- `data-composition-id="root"` — Unique ID for composition wrapper (required)
|
||||
- `data-width="1920"` — Composition width in pixels
|
||||
- `data-height="1080"` — Composition height in pixels
|
||||
- `data-composition-src="./intro.html"` — Nested composition source
|
||||
|
||||
## Element Visibility
|
||||
Add `class="clip"` to timed elements so the runtime can manage their visibility lifecycle.
|
||||
@@ -0,0 +1,23 @@
|
||||
# GSAP Animation
|
||||
|
||||
HyperFrames uses GSAP for animation. Timelines are paused and controlled by the runtime.
|
||||
|
||||
## Setup
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#title", { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["root"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
- Always create timelines with `{ paused: true }`
|
||||
- Register timelines on `window.__timelines` with the composition ID as key
|
||||
- Position parameter (3rd arg) sets absolute time: `tl.to(el, vars, 1.5)`
|
||||
- Supported methods: `set`, `to`, `from`, `fromTo`
|
||||
|
||||
## Supported Properties
|
||||
opacity, x, y, scale, scaleX, scaleY, rotation, width, height, visibility
|
||||
@@ -0,0 +1,23 @@
|
||||
# Rendering
|
||||
|
||||
Render compositions to MP4 with `npx hyperframes render`.
|
||||
|
||||
## Local Mode (default)
|
||||
Uses Puppeteer (bundled Chromium) + system FFmpeg. Fast for iteration.
|
||||
Requires: FFmpeg installed (`brew install ffmpeg` or `apt install ffmpeg`).
|
||||
|
||||
## Docker Mode (--docker)
|
||||
Deterministic output with exact Chrome version and fonts. For production.
|
||||
Requires: Docker installed and running.
|
||||
|
||||
## Options
|
||||
- `-f, --fps` — 24, 30, or 60 (default: 30)
|
||||
- `-q, --quality` — draft, standard, high (default: standard)
|
||||
- `-w, --workers` — Parallel workers 1-8 (default: auto)
|
||||
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI)
|
||||
- `-o, --output` — Custom output path
|
||||
|
||||
## Tips
|
||||
- Use `draft` quality for fast previews during development
|
||||
- Use `npx hyperframes benchmark` to find optimal settings
|
||||
- 4 workers is usually the sweet spot for most compositions
|
||||
@@ -0,0 +1,15 @@
|
||||
# Templates
|
||||
|
||||
Built-in templates available via `npx hyperframes init --template <name>`.
|
||||
|
||||
## blank
|
||||
Empty 1920x1080 composition with GSAP timeline wired up. Start from scratch.
|
||||
|
||||
## title-card
|
||||
Animated title and subtitle with GSAP fade-in/out. Good for intro cards.
|
||||
|
||||
## video-edit
|
||||
Video element with trimming, audio, and track controls. Starting point for video editing.
|
||||
|
||||
## Custom Templates
|
||||
Any directory with an `index.html` can serve as a template. Copy it manually or build your own init workflow.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Troubleshooting
|
||||
|
||||
## "No composition found"
|
||||
Your directory needs an `index.html`. Run `npx hyperframes init` to create one.
|
||||
|
||||
## "FFmpeg not found"
|
||||
Local rendering requires FFmpeg. Install it:
|
||||
- macOS: `brew install ffmpeg`
|
||||
- Ubuntu: `sudo apt install ffmpeg`
|
||||
- Windows: Download from https://ffmpeg.org/download.html
|
||||
|
||||
## Lint errors
|
||||
Run `npx hyperframes lint` to check for common issues:
|
||||
- Missing `data-composition-id` on root element
|
||||
- Missing `class="clip"` on timed elements
|
||||
- Overlapping timelines or invalid data attributes
|
||||
|
||||
## Preview not updating
|
||||
Make sure you're editing the `index.html` in the project directory. The preview server watches for file changes and auto-reloads.
|
||||
|
||||
## Render looks different from preview
|
||||
Use `--docker` mode for deterministic output. Local renders may differ due to font availability and Chrome version.
|
||||
@@ -0,0 +1,14 @@
|
||||
export type TemplateId = "warm-grain" | "play-mode" | "swiss-grid" | "vignelli";
|
||||
|
||||
export interface TemplateOption {
|
||||
id: TemplateId;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: TemplateOption[] = [
|
||||
{ id: "warm-grain", label: "Warm Grain", hint: "Cream aesthetic with grain texture" },
|
||||
{ id: "play-mode", label: "Play Mode", hint: "Playful elastic animations" },
|
||||
{ id: "swiss-grid", label: "Swiss Grid", hint: "Structured grid layout" },
|
||||
{ id: "vignelli", label: "Vignelli", hint: "Bold typography with red accents" },
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
<template id="captions-template">
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="16.04">
|
||||
<div id="captions-container"></div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] #captions-container {
|
||||
position: absolute;
|
||||
bottom: 100px; /* Position in bottom 1/3 */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 150px; /* Fixed height for the caption box */
|
||||
}
|
||||
|
||||
.caption-box {
|
||||
position: absolute; /* Stacked in the same place */
|
||||
background-color: #FF2D8A;
|
||||
color: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 16px;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-weight: 900; /* Nunito Black */
|
||||
font-size: 64px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: scale(0);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.839, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics.', 'start': 3.579, 'end': 4.639}, {'text': 'Sixty-two', 'start': 4.659, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.099, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.479}, {'text': 'look', 'start': 10.52, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
|
||||
const container = document.getElementById('captions-container');
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Group transcript into lines (max 5 words)
|
||||
const lines = [];
|
||||
for (let i = 0; i < TRANSCRIPT.length; i += 5) {
|
||||
lines.push(TRANSCRIPT.slice(i, i + 5));
|
||||
}
|
||||
|
||||
lines.forEach((lineWords, index) => {
|
||||
const lineText = lineWords.map(w => w.text).join(' ');
|
||||
const startTime = lineWords[0].start;
|
||||
const endTime = lineWords[lineWords.length - 1].end;
|
||||
|
||||
// Create element
|
||||
const el = document.createElement('div');
|
||||
el.className = 'caption-box';
|
||||
el.textContent = lineText;
|
||||
el.id = `caption-line-${index}`;
|
||||
container.appendChild(el);
|
||||
|
||||
// Animation: Pop in with scale-up bounce (0% to 110% to 100%)
|
||||
tl.to(el, {
|
||||
autoAlpha: 1,
|
||||
scale: 1.1,
|
||||
duration: 0.2,
|
||||
ease: "power2.out"
|
||||
}, startTime);
|
||||
|
||||
tl.to(el, {
|
||||
scale: 1,
|
||||
duration: 0.1,
|
||||
ease: "power2.inOut"
|
||||
}, startTime + 0.2);
|
||||
|
||||
// Stay visible until next line or end of its duration
|
||||
// We hide it when the next line starts or at its own end time
|
||||
const hideTime = (index < lines.length - 1) ? Math.min(endTime, lines[index+1][0].start) : endTime;
|
||||
|
||||
tl.to(el, {
|
||||
autoAlpha: 0,
|
||||
scale: 0,
|
||||
duration: 0.15,
|
||||
ease: "power2.in"
|
||||
}, hideTime);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template id="intro-template">
|
||||
<div id="intro-comp" data-composition-id="intro" data-start="0" data-duration="16.04" data-width="1920" data-height="1080">
|
||||
<div class="intro-container">
|
||||
<h1 class="title">HYPERFRAMES</h1>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap');
|
||||
|
||||
[data-composition-id="intro"] .intro-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 180px;
|
||||
color: #FF2D8A; /* Hot Pink */
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-5deg); /* Tilted 5 degrees */
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
/* Layered offset shadow for depth without suffocating letterforms */
|
||||
filter: drop-shadow(8px 8px 0 #FFFFFF) drop-shadow(15px 15px 0 rgba(0,0,0,0.1));
|
||||
opacity: 0;
|
||||
scale: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// --- INTRO SEQUENCE ---
|
||||
// Animation: Scale-up bounce (0% to 110% to 100%) with elastic overshoot
|
||||
// Starts at 0.5s, lasts until 3s
|
||||
tl.to('[data-composition-id="intro"] .title', {
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
duration: 0.6,
|
||||
ease: "back.out(1.7)",
|
||||
delay: 0.5
|
||||
})
|
||||
.to('[data-composition-id="intro"] .title', {
|
||||
scale: 1,
|
||||
duration: 0.4,
|
||||
ease: "elastic.out(1, 0.3)"
|
||||
})
|
||||
// Ambient motion
|
||||
.to('[data-composition-id="intro"] .title', {
|
||||
rotation: "-3deg",
|
||||
duration: 1,
|
||||
repeat: 1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut"
|
||||
}, "-=0.5")
|
||||
// Exit intro
|
||||
.to('[data-composition-id="intro"] .title', {
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 0.3,
|
||||
ease: "power2.in"
|
||||
}, 1.4);
|
||||
|
||||
// --- END CARD SEQUENCE ---
|
||||
// Appears at 14.619s (after Moment 3 finishes exiting at 14.239 + 0.3)
|
||||
tl.to('[data-composition-id="intro"] .title', {
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
duration: 0.6,
|
||||
ease: "back.out(1.7)"
|
||||
}, 14.619)
|
||||
.to('[data-composition-id="intro"] .title', {
|
||||
scale: 1,
|
||||
duration: 0.4,
|
||||
ease: "elastic.out(1, 0.3)"
|
||||
});
|
||||
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,252 @@
|
||||
<template id="stats-template">
|
||||
<div data-composition-id="stats" data-width="1920" data-height="1080" data-duration="16.04">
|
||||
<div id="stats-container">
|
||||
<!-- Moment 1: 47% NEED MOTION GRAPHICS -->
|
||||
<div id="moment-1" class="moment" style="opacity: 0; transform: scale(0) rotate(8deg);">
|
||||
<div class="sticker-stack">
|
||||
<div class="sticker-bg blue-bg"></div>
|
||||
<div class="sticker-content white-bg blue-border">
|
||||
<div class="stat-number blue-text">47%</div>
|
||||
<div class="stat-label blue-text">NEED MOTION GRAPHICS</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative shapes -->
|
||||
<div class="shape circle pink-bg" style="top: -40px; left: -40px; width: 80px; height: 80px;"></div>
|
||||
<div class="shape pill lime-bg" style="bottom: -30px; right: -50px; width: 120px; height: 40px; transform: rotate(-15deg);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Moment 2: 62% STRUGGLE WITH STATIC CONTENT -->
|
||||
<div id="moment-2" class="moment" style="opacity: 0; transform: scale(0) rotate(-5deg);">
|
||||
<div class="sticker-stack">
|
||||
<div class="sticker-bg white-bg"></div>
|
||||
<div class="sticker-content blue-bg white-border">
|
||||
<div class="stat-number white-text">62%</div>
|
||||
<div class="stat-label white-text">STRUGGLE WITH STATIC CONTENT</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative shapes -->
|
||||
<div class="shape blob yellow-bg" style="top: -50px; right: -30px; width: 100px; height: 100px; border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%;"></div>
|
||||
<div class="shape circle pink-bg" style="bottom: -20px; left: -40px; width: 60px; height: 60px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Moment 3: 75% LACK EDITING SKILLS -->
|
||||
<div id="moment-3" class="moment" style="opacity: 0; transform: scale(0) rotate(3deg);">
|
||||
<div class="sticker-stack">
|
||||
<div class="sticker-bg pink-bg"></div>
|
||||
<div class="sticker-content white-bg pink-border">
|
||||
<div class="stat-number pink-text">75%</div>
|
||||
<div class="stat-label pink-text">LACK EDITING SKILLS</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative shapes -->
|
||||
<div class="shape pill blue-bg" style="top: -30px; left: 50%; transform: translateX(-50%) rotate(5deg); width: 150px; height: 45px;"></div>
|
||||
<div class="shape circle yellow-bg" style="bottom: -40px; right: -20px; width: 90px; height: 90px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="stats"] {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] #stats-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .moment {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Layout Positions */
|
||||
[data-composition-id="stats"] #moment-1 {
|
||||
top: 200px;
|
||||
right: 150px; /* Right side when A-roll is Left */
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] #moment-2 {
|
||||
top: 250px;
|
||||
left: 100px; /* Left side when A-roll is Right */
|
||||
width: 700px;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] #moment-3 {
|
||||
bottom: 100px; /* Below A-roll when centered */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
/* Sticker Stack Effect */
|
||||
[data-composition-id="stats"] .sticker-stack {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
filter: drop-shadow(0 15px 0 rgba(0, 87, 255, 0.2));
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .sticker-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 40px;
|
||||
z-index: -1;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .sticker-content {
|
||||
position: relative;
|
||||
padding: 40px;
|
||||
border-radius: 35px;
|
||||
border: 8px solid currentColor;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
[data-composition-id="stats"] .blue-bg { background-color: #0057FF; }
|
||||
[data-composition-id="stats"] .pink-bg { background-color: #FF2D8A; }
|
||||
[data-composition-id="stats"] .lime-bg { background-color: #7FFF00; }
|
||||
[data-composition-id="stats"] .yellow-bg { background-color: #FFE500; }
|
||||
[data-composition-id="stats"] .white-bg { background-color: #FFFFFF; }
|
||||
|
||||
[data-composition-id="stats"] .blue-text { color: #0057FF; }
|
||||
[data-composition-id="stats"] .white-text { color: #FFFFFF; }
|
||||
[data-composition-id="stats"] .pink-text { color: #FF2D8A; }
|
||||
|
||||
[data-composition-id="stats"] .blue-border { border-color: #0057FF; }
|
||||
[data-composition-id="stats"] .white-border { border-color: #FFFFFF; }
|
||||
[data-composition-id="stats"] .pink-border { border-color: #FF2D8A; }
|
||||
|
||||
/* Typography */
|
||||
[data-composition-id="stats"] .stat-number {
|
||||
font-size: 180px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .stat-label {
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Shapes */
|
||||
[data-composition-id="stats"] .shape {
|
||||
position: absolute;
|
||||
z-index: -2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
[data-composition-id="stats"] .pill {
|
||||
border-radius: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.839, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics.', 'start': 3.579, 'end': 4.639}, {'text': 'Sixty-two', 'start': 4.659, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.099, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.479}, {'text': 'look', 'start': 10.52, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Helper to find word timing
|
||||
function getWordTime(wordText) {
|
||||
const word = TRANSCRIPT.find(w =>
|
||||
w.text.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g,"").startsWith(wordText.toLowerCase())
|
||||
);
|
||||
return word ? word.start : null;
|
||||
}
|
||||
|
||||
// Timings from transcript
|
||||
const t1 = getWordTime("Forty-seven") || 1.839;
|
||||
const t2 = getWordTime("Sixty-two") || 4.659;
|
||||
const t3 = getWordTime("three") || 8.88;
|
||||
|
||||
// Moment 1 Animation (approx 5s)
|
||||
tl.to('#moment-1', {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
ease: "elastic.out(1, 0.6)",
|
||||
onStart: () => gsap.set('#moment-1', { transformOrigin: "center center" })
|
||||
}, t1);
|
||||
|
||||
// Moment 1 Exit (before Moment 2)
|
||||
tl.to('#moment-1', {
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 0.3,
|
||||
ease: "back.in(1.7)"
|
||||
}, t2 - 0.4);
|
||||
|
||||
// Moment 2 Animation (approx 9s)
|
||||
tl.to('#moment-2', {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
ease: "elastic.out(1, 0.6)",
|
||||
onStart: () => gsap.set('#moment-2', { transformOrigin: "center center" })
|
||||
}, t2);
|
||||
|
||||
// Moment 2 Exit (before Moment 3)
|
||||
tl.to('#moment-2', {
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 0.3,
|
||||
ease: "back.in(1.7)"
|
||||
}, t3 - 0.4);
|
||||
|
||||
// Moment 3 Animation (approx 13s)
|
||||
tl.to('#moment-3', {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
ease: "elastic.out(1, 0.6)",
|
||||
onStart: () => gsap.set('#moment-3', { transformOrigin: "center center" })
|
||||
}, t3);
|
||||
|
||||
// Moment 3 Exit: 3 words before 'Hyperframes'
|
||||
// 'Editor' is at 15.079. 3 words before is 'So' at 14.239.
|
||||
tl.to('#moment-3', {
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 0.3,
|
||||
ease: "back.in(1.7)"
|
||||
}, 14.239);
|
||||
|
||||
// Ambient Motion (Finite)
|
||||
const totalDuration = 16.04;
|
||||
gsap.utils.toArray('.shape').forEach((shape, i) => {
|
||||
const stepDuration = 2 + (i * 0.2);
|
||||
const steps = Math.ceil(totalDuration / stepDuration);
|
||||
for(let s = 0; s < steps; s++) {
|
||||
tl.to(shape, {
|
||||
y: (s % 2 === 0 ? 15 : -15),
|
||||
rotation: (s % 2 === 0 ? 5 : -5),
|
||||
duration: stepDuration,
|
||||
ease: "sine.inOut"
|
||||
}, s * stepDuration);
|
||||
}
|
||||
});
|
||||
|
||||
window.__timelines["stats"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hyperframes - Play Mode</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background-color: #FFFFFF;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
#main-composition {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#bg-comp,
|
||||
#aroll-comp,
|
||||
#intro-comp,
|
||||
#stats-comp,
|
||||
#captions-comp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#bg-comp {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#aroll-comp {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#intro-comp {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#stats-comp {
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
#captions-comp {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#aroll-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#short_mag_cut {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 8px solid #FFFFFF;
|
||||
}
|
||||
|
||||
#background-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #FFFFFF;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-composition" data-composition-id="main-video" data-start="0" data-duration="16.04" data-width="1920" data-height="1080">
|
||||
<!-- Background Layer Composition -->
|
||||
<div id="bg-comp" data-composition-id="background" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="1">
|
||||
<div id="background-layer"></div>
|
||||
<style>
|
||||
#background-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const bg = document.getElementById('background-layer');
|
||||
// Sync with A-roll framing 3 (4.659s)
|
||||
tl.to(bg, { backgroundColor: "#0057FF", duration: 0.5 }, 4.659);
|
||||
// Sync with A-roll framing 4 (8.88s)
|
||||
tl.to(bg, { backgroundColor: "#FFFFFF", duration: 0.5 }, 8.88);
|
||||
window.__timelines["background"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- A-roll Layer Composition -->
|
||||
<div id="aroll-comp" data-composition-id="aroll-layer" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="10">
|
||||
<div id="aroll-container">
|
||||
<video id="short_mag_cut"
|
||||
src="__VIDEO_SRC__"
|
||||
data-start="0"
|
||||
data-duration="16.04"
|
||||
data-track-index="1"
|
||||
style="width: 100%; height: auto;"></video>
|
||||
</div>
|
||||
<style>
|
||||
#aroll-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
#short_mag_cut {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 8px solid #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const container = document.getElementById('aroll-container');
|
||||
gsap.set(container, { y: -1100, scale: 0.8 });
|
||||
tl.to(container, { y: 0, duration: 1.2, ease: "elastic.out(1, 0.6)" }, 0);
|
||||
tl.to(container, { scale: 1.4, x: -300, duration: 1, ease: "back.out(1.7)" }, 1.839);
|
||||
tl.to(container, { scale: 0.7, x: 400, duration: 1, ease: "back.out(1.7)" }, 4.659);
|
||||
tl.to(container, { scale: 0.9, x: 0, duration: 1, ease: "back.out(1.7)" }, 8.88);
|
||||
window.__timelines["aroll-layer"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Compositions -->
|
||||
<div id="intro-comp" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="100"></div>
|
||||
<div id="captions-comp" data-composition-id="captions" data-composition-src="compositions/captions.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="200"></div>
|
||||
<div id="stats-comp" data-composition-id="stats" data-composition-src="compositions/stats.html" data-start="0" data-duration="16.04" data-width="1920" data-height="1080" data-track-index="150"></div>
|
||||
|
||||
<!-- Audio Clips -->
|
||||
<audio id="sfx-bounce-0" src="" data-start="0" data-track-index="5"></audio>
|
||||
<audio id="sfx-whoosh-1" src="" data-start="1.839" data-track-index="6"></audio>
|
||||
<audio id="sfx-pop-1" src="" data-start="2.039" data-track-index="7"></audio>
|
||||
<audio id="sfx-whoosh-2" src="" data-start="4.659" data-track-index="6"></audio>
|
||||
<audio id="sfx-pop-2" src="" data-start="4.859" data-track-index="7"></audio>
|
||||
<audio id="sfx-whoosh-3" src="" data-start="8.88" data-track-index="6"></audio>
|
||||
<audio id="sfx-pop-3" src="" data-start="9.08" data-track-index="7"></audio>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main-video"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
<svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1920" height="1080" fill="#F2F2F2" />
|
||||
|
||||
<g id="thin-grid-lines" stroke="#0A1E3D" stroke-width="1" opacity="0.1">
|
||||
<line id="v-thin-1" x1="60" y1="0" x2="60" y2="1080" />
|
||||
<line id="v-thin-2" x1="120" y1="0" x2="120" y2="1080" />
|
||||
<line id="v-thin-3" x1="180" y1="0" x2="180" y2="1080" />
|
||||
<line id="v-thin-5" x1="300" y1="0" x2="300" y2="1080" />
|
||||
<line id="v-thin-6" x1="360" y1="0" x2="360" y2="1080" />
|
||||
<line id="v-thin-7" x1="420" y1="0" x2="420" y2="1080" />
|
||||
<line id="v-thin-9" x1="540" y1="0" x2="540" y2="1080" />
|
||||
<line id="v-thin-10" x1="600" y1="0" x2="600" y2="1080" />
|
||||
<line id="v-thin-11" x1="660" y1="0" x2="660" y2="1080" />
|
||||
<line id="v-thin-13" x1="780" y1="0" x2="780" y2="1080" />
|
||||
<line id="v-thin-14" x1="840" y1="0" x2="840" y2="1080" />
|
||||
<line id="v-thin-15" x1="900" y1="0" x2="900" y2="1080" />
|
||||
<line id="v-thin-17" x1="1020" y1="0" x2="1020" y2="1080" />
|
||||
<line id="v-thin-18" x1="1080" y1="0" x2="1080" y2="1080" />
|
||||
<line id="v-thin-19" x1="1140" y1="0" x2="1140" y2="1080" />
|
||||
<line id="v-thin-21" x1="1260" y1="0" x2="1260" y2="1080" />
|
||||
<line id="v-thin-22" x1="1320" y1="0" x2="1320" y2="1080" />
|
||||
<line id="v-thin-23" x1="1380" y1="0" x2="1380" y2="1080" />
|
||||
<line id="v-thin-25" x1="1500" y1="0" x2="1500" y2="1080" />
|
||||
<line id="v-thin-26" x1="1560" y1="0" x2="1560" y2="1080" />
|
||||
<line id="v-thin-27" x1="1620" y1="0" x2="1620" y2="1080" />
|
||||
<line id="v-thin-29" x1="1740" y1="0" x2="1740" y2="1080" />
|
||||
<line id="v-thin-30" x1="1800" y1="0" x2="1800" y2="1080" />
|
||||
<line id="v-thin-31" x1="1860" y1="0" x2="1860" y2="1080" />
|
||||
<line id="h-thin-1" x1="0" y1="60" x2="1920" y2="60" />
|
||||
<line id="h-thin-2" x1="0" y1="120" x2="1920" y2="120" />
|
||||
<line id="h-thin-3" x1="0" y1="180" x2="1920" y2="180" />
|
||||
<line id="h-thin-5" x1="0" y1="300" x2="1920" y2="300" />
|
||||
<line id="h-thin-6" x1="0" y1="360" x2="1920" y2="360" />
|
||||
<line id="h-thin-7" x1="0" y1="420" x2="1920" y2="420" />
|
||||
<line id="h-thin-9" x1="0" y1="540" x2="1920" y2="540" />
|
||||
<line id="h-thin-10" x1="0" y1="600" x2="1920" y2="600" />
|
||||
<line id="h-thin-11" x1="0" y1="660" x2="1920" y2="660" />
|
||||
<line id="h-thin-13" x1="0" y1="780" x2="1920" y2="780" />
|
||||
<line id="h-thin-14" x1="0" y1="840" x2="1920" y2="840" />
|
||||
<line id="h-thin-15" x1="0" y1="900" x2="1920" y2="900" />
|
||||
<line id="h-thin-17" x1="0" y1="1020" x2="1920" y2="1020" />
|
||||
</g>
|
||||
|
||||
<g id="thick-grid-lines" stroke="#0A1E3D" stroke-width="3" opacity="0.2">
|
||||
<line id="v-thick-1" x1="240" y1="0" x2="240" y2="1080" />
|
||||
<line id="v-thick-2" x1="480" y1="0" x2="480" y2="1080" />
|
||||
<line id="v-thick-3" x1="720" y1="0" x2="720" y2="1080" />
|
||||
<line id="v-thick-4" x1="960" y1="0" x2="960" y2="1080" />
|
||||
<line id="v-thick-5" x1="1200" y1="0" x2="1200" y2="1080" />
|
||||
<line id="v-thick-6" x1="1440" y1="0" x2="1440" y2="1080" />
|
||||
<line id="v-thick-7" x1="1680" y1="0" x2="1680" y2="1080" />
|
||||
<line id="h-thick-1" x1="0" y1="240" x2="1920" y2="240" />
|
||||
<line id="h-thick-2" x1="0" y1="480" x2="1920" y2="480" />
|
||||
<line id="h-thick-3" x1="0" y1="720" x2="1920" y2="720" />
|
||||
<line id="h-thick-4" x1="0" y1="960" x2="1920" y2="960" />
|
||||
</g>
|
||||
|
||||
<g id="intersections" stroke="#0A1E3D" stroke-width="2" opacity="0.3">
|
||||
<line x1="230" y1="240" x2="250" y2="240" />
|
||||
<line x1="240" y1="230" x2="240" y2="250" />
|
||||
<line x1="230" y1="480" x2="250" y2="480" />
|
||||
<line x1="240" y1="470" x2="240" y2="490" />
|
||||
<line x1="230" y1="720" x2="250" y2="720" />
|
||||
<line x1="240" y1="710" x2="240" y2="730" />
|
||||
<line x1="230" y1="960" x2="250" y2="960" />
|
||||
<line x1="240" y1="950" x2="240" y2="970" />
|
||||
<line x1="470" y1="240" x2="490" y2="240" />
|
||||
<line x1="480" y1="230" x2="480" y2="250" />
|
||||
<line x1="470" y1="480" x2="490" y2="480" />
|
||||
<line x1="480" y1="470" x2="480" y2="490" />
|
||||
<line x1="470" y1="720" x2="490" y2="720" />
|
||||
<line x1="480" y1="710" x2="480" y2="730" />
|
||||
<line x1="470" y1="960" x2="490" y2="960" />
|
||||
<line x1="480" y1="950" x2="480" y2="970" />
|
||||
<line x1="710" y1="240" x2="730" y2="240" />
|
||||
<line x1="720" y1="230" x2="720" y2="250" />
|
||||
<line x1="710" y1="480" x2="730" y2="480" />
|
||||
<line x1="720" y1="470" x2="720" y2="490" />
|
||||
<line x1="710" y1="720" x2="730" y2="720" />
|
||||
<line x1="720" y1="710" x2="720" y2="730" />
|
||||
<line x1="710" y1="960" x2="730" y2="960" />
|
||||
<line x1="720" y1="950" x2="720" y2="970" />
|
||||
<line x1="950" y1="240" x2="970" y2="240" />
|
||||
<line x1="960" y1="230" x2="960" y2="250" />
|
||||
<line x1="950" y1="480" x2="970" y2="480" />
|
||||
<line x1="960" y1="470" x2="960" y2="490" />
|
||||
<line x1="950" y1="720" x2="970" y2="720" />
|
||||
<line x1="960" y1="710" x2="960" y2="730" />
|
||||
<line x1="950" y1="960" x2="970" y2="960" />
|
||||
<line x1="960" y1="950" x2="960" y2="970" />
|
||||
<line x1="1190" y1="240" x2="1210" y2="240" />
|
||||
<line x1="1200" y1="230" x2="1200" y2="250" />
|
||||
<line x1="1190" y1="480" x2="1210" y2="480" />
|
||||
<line x1="1200" y1="470" x2="1200" y2="490" />
|
||||
<line x1="1190" y1="720" x2="1210" y2="720" />
|
||||
<line x1="1200" y1="710" x2="1200" y2="730" />
|
||||
<line x1="1190" y1="960" x2="1210" y2="960" />
|
||||
<line x1="1200" y1="950" x2="1200" y2="970" />
|
||||
<line x1="1430" y1="240" x2="1450" y2="240" />
|
||||
<line x1="1440" y1="230" x2="1440" y2="250" />
|
||||
<line x1="1430" y1="480" x2="1450" y2="480" />
|
||||
<line x1="1440" y1="470" x2="1440" y2="490" />
|
||||
<line x1="1430" y1="720" x2="1450" y2="720" />
|
||||
<line x1="1440" y1="710" x2="1440" y2="730" />
|
||||
<line x1="1430" y1="960" x2="1450" y2="960" />
|
||||
<line x1="1440" y1="950" x2="1440" y2="970" />
|
||||
<line x1="1670" y1="240" x2="1690" y2="240" />
|
||||
<line x1="1680" y1="230" x2="1680" y2="250" />
|
||||
<line x1="1670" y1="480" x2="1690" y2="480" />
|
||||
<line x1="1680" y1="470" x2="1680" y2="490" />
|
||||
<line x1="1670" y1="720" x2="1690" y2="720" />
|
||||
<line x1="1680" y1="710" x2="1680" y2="730" />
|
||||
<line x1="1670" y1="960" x2="1690" y2="960" />
|
||||
<line x1="1680" y1="950" x2="1680" y2="970" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,95 @@
|
||||
<template id="captions-template">
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="16.04">
|
||||
<div id="caption-container"></div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] #caption-container {
|
||||
position: absolute;
|
||||
bottom: 120px; /* Consistent bottom positioning */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-box {
|
||||
background-color: #0A1E3D; /* Solid navy */
|
||||
padding: 20px 40px;
|
||||
display: none; /* Hidden by default, shown via GSAP */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* Swiss Grid: sharp corners, solid block */
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
color: #F2F2F2; /* Off-white */
|
||||
font-size: 72px;
|
||||
text-transform: uppercase; /* Swiss style often uses uppercase for impact */
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'We', 'start': 0.119, 'end': 0.259}, {'text': 'asked', 'start': 0.319, 'end': 0.479}, {'text': 'what', 'start': 0.519, 'end': 0.659}, {'text': 'you', 'start': 0.699, 'end': 0.819}, {'text': 'needed.', 'start': 0.859, 'end': 1.819}, {'text': 'Forty-seven', 'start': 1.86, 'end': 2.299}, {'text': 'percent', 'start': 2.399, 'end': 2.679}, {'text': 'of', 'start': 2.7, 'end': 2.799}, {'text': 'you', 'start': 2.839, 'end': 2.939}, {'text': 'said', 'start': 3.039, 'end': 3.179}, {'text': 'motion', 'start': 3.24, 'end': 3.559}, {'text': 'graphics,', 'start': 3.579, 'end': 4.599}, {'text': 'sixty-two', 'start': 4.679, 'end': 5.179}, {'text': 'percent', 'start': 5.299, 'end': 5.759}, {'text': 'said', 'start': 5.859, 'end': 5.98}, {'text': 'static', 'start': 6.079, 'end': 6.399}, {'text': 'content', 'start': 6.46, 'end': 6.879}, {'text': 'was', 'start': 6.92, 'end': 7.079}, {'text': 'costing', 'start': 7.099, 'end': 7.48}, {'text': 'you', 'start': 7.5, 'end': 7.579}, {'text': 'attention,', 'start': 7.679, 'end': 8.659}, {'text': 'and', 'start': 8.699, 'end': 8.86}, {'text': 'three', 'start': 8.88, 'end': 9.06}, {'text': 'out', 'start': 9.079, 'end': 9.18}, {'text': 'of', 'start': 9.199, 'end': 9.34}, {'text': 'four', 'start': 9.38, 'end': 9.799}, {'text': 'said', 'start': 9.84, 'end': 10.0}, {'text': 'you', 'start': 10.019, 'end': 10.159}, {'text': 'know', 'start': 10.179, 'end': 10.36}, {'text': 'the', 'start': 10.38, 'end': 10.42}, {'text': 'look', 'start': 10.519, 'end': 10.699}, {'text': 'you', 'start': 10.739, 'end': 10.859}, {'text': 'want', 'start': 10.98, 'end': 11.34}, {'text': 'but', 'start': 11.359, 'end': 11.52}, {'text': "don't", 'start': 11.56, 'end': 11.779}, {'text': 'have', 'start': 11.819, 'end': 11.94}, {'text': 'the', 'start': 11.96, 'end': 12.06}, {'text': 'editing', 'start': 12.079, 'end': 12.4}, {'text': 'skills', 'start': 12.52, 'end': 12.86}, {'text': 'to', 'start': 12.88, 'end': 13.0}, {'text': 'get', 'start': 13.019, 'end': 13.18}, {'text': 'there.', 'start': 13.22, 'end': 14.22}, {'text': 'So', 'start': 14.239, 'end': 14.399}, {'text': 'we', 'start': 14.42, 'end': 14.52}, {'text': 'built', 'start': 14.619, 'end': 14.88}, {'text': 'Hyperframes', 'start': 15.079, 'end': 15.42}];
|
||||
const container = document.getElementById('caption-container');
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
if (!TRANSCRIPT || TRANSCRIPT.length === 0) {
|
||||
window.__timelines["captions"] = tl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group transcript into max 5 words per group
|
||||
const groups = [];
|
||||
let currentGroup = [];
|
||||
|
||||
TRANSCRIPT.forEach((word, index) => {
|
||||
currentGroup.push(word);
|
||||
// Group by 5 words OR if it's the last word
|
||||
if (currentGroup.length === 5 || index === TRANSCRIPT.length - 1) {
|
||||
groups.push([...currentGroup]);
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Create DOM elements and timeline for each group
|
||||
groups.forEach((group, i) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'caption-box';
|
||||
box.id = `group-${i}`;
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'caption-text';
|
||||
text.textContent = group.map(w => w.text).join(' ');
|
||||
|
||||
box.appendChild(text);
|
||||
container.appendChild(box);
|
||||
|
||||
const startTime = group[0].start;
|
||||
const endTime = group[group.length - 1].end;
|
||||
|
||||
// Hard cut animation (no fades) as requested
|
||||
tl.set(box, { display: 'flex' }, startTime);
|
||||
tl.set(box, { display: 'none' }, endTime);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template id="graphics-template">
|
||||
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="15">
|
||||
<div class="grid-container">
|
||||
<!-- Stat 1: 47% -->
|
||||
<div id="stat1" class="stat-group">
|
||||
<div class="stat-value">47%</div>
|
||||
<div class="stat-label">NEED MOTION GRAPHICS</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat 2: 62% -->
|
||||
<div id="stat2" class="stat-group">
|
||||
<div class="stat-value">62%</div>
|
||||
<div class="stat-label">LOSING ATTENTION</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat 3: 3 OF 4 -->
|
||||
<div id="stat3" class="stat-group">
|
||||
<div class="stat-value">3 OF 4</div>
|
||||
<div class="stat-label">LACK EDITING SKILLS</div>
|
||||
<div class="grid-blocks">
|
||||
<div class="block gold"></div>
|
||||
<div class="block gold"></div>
|
||||
<div class="block gold"></div>
|
||||
<div class="block navy"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="graphics"] {
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
color: #0A1E3D;
|
||||
background: transparent;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-template-rows: repeat(12, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 80px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-group {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
background: #F2F2F2;
|
||||
padding: 40px;
|
||||
border-left: 10px solid #D4A017;
|
||||
box-shadow: 20px 20px 0px rgba(10, 30, 61, 0.1);
|
||||
}
|
||||
|
||||
/* Positioning Stats on Grid - Adjusted for A-roll structure */
|
||||
[data-composition-id="graphics"] #stat1 {
|
||||
grid-column: 2 / 6;
|
||||
grid-row: 3 / 7;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] #stat2 {
|
||||
grid-column: 2 / 6;
|
||||
grid-row: 3 / 7;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] #stat3 {
|
||||
grid-column: 7 / 12;
|
||||
grid-row: 3 / 7;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-value {
|
||||
font-size: 140px;
|
||||
font-weight: 900;
|
||||
color: #D4A017;
|
||||
line-height: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-label {
|
||||
font-size: 24px;
|
||||
font-weight: 300; /* Lighter weight */
|
||||
color: #0A1E3D;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Progress Bar for Stat 2 */
|
||||
[data-composition-id="graphics"] .progress-container {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: #E0E0E0;
|
||||
margin-top: 30px;
|
||||
border: 2px solid #0A1E3D;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #D4A017;
|
||||
}
|
||||
|
||||
/* Grid Blocks for Stat 3 */
|
||||
[data-composition-id="graphics"] .grid-blocks {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .block {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 3px solid #0A1E3D;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .block.gold {
|
||||
background: #D4A017;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .block.navy {
|
||||
background: #0A1E3D;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Timing Constants
|
||||
const S1_START = 1.86;
|
||||
const S1_END = 4.5;
|
||||
const S2_START = 4.68;
|
||||
const S2_END = 8.6;
|
||||
const S3_START = 8.88;
|
||||
const S3_END = 14.2;
|
||||
|
||||
const SLIDE_DUR = 0.25; // 250ms
|
||||
const SHARP_EASE = "power2.out";
|
||||
|
||||
// Stat 1 Animation
|
||||
tl.fromTo('#stat1',
|
||||
{ x: -200, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
|
||||
S1_START
|
||||
);
|
||||
tl.to('#stat1',
|
||||
{ x: 200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
|
||||
S1_END - SLIDE_DUR
|
||||
);
|
||||
|
||||
// Stat 2 Animation
|
||||
tl.fromTo('#stat2',
|
||||
{ y: 200, opacity: 0 },
|
||||
{ y: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
|
||||
S2_START
|
||||
);
|
||||
// Progress bar fill
|
||||
tl.to('#stat2 .progress-bar',
|
||||
{ width: '62%', duration: S2_END - S2_START - (SLIDE_DUR * 2), ease: "none" },
|
||||
S2_START + SLIDE_DUR
|
||||
);
|
||||
tl.to('#stat2',
|
||||
{ y: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
|
||||
S2_END - SLIDE_DUR
|
||||
);
|
||||
|
||||
// Stat 3 Animation
|
||||
tl.fromTo('#stat3',
|
||||
{ x: 200, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: SLIDE_DUR, ease: SHARP_EASE },
|
||||
S3_START
|
||||
);
|
||||
// Stagger blocks
|
||||
tl.from('#stat3 .block',
|
||||
{ scale: 0, duration: 0.3, stagger: 0.1, ease: "back.out(1.7)" },
|
||||
S3_START + SLIDE_DUR
|
||||
);
|
||||
tl.to('#stat3',
|
||||
{ x: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
|
||||
S3_END - SLIDE_DUR
|
||||
);
|
||||
|
||||
window.__timelines["graphics"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template id="intro-template">
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="1.86">
|
||||
<div class="container">
|
||||
<div class="grid-line"></div>
|
||||
<div class="text-wrapper">
|
||||
<h1 class="title">HYPERFRAMES</h1>
|
||||
<h2 class="subtitle">THE SURVEY FINDINGS</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="intro"] {
|
||||
background-color: #F5F5F5; /* Off-white */
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-left: 160px; /* Swiss grid alignment */
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .grid-line {
|
||||
position: absolute;
|
||||
left: 160px;
|
||||
top: 50%;
|
||||
width: 0px;
|
||||
height: 12px;
|
||||
background-color: #0A1E3D; /* Navy */
|
||||
transform: translateY(-180px); /* Positioned above the text */
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .text-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title {
|
||||
color: #0A1E3D;
|
||||
font-size: 180px;
|
||||
font-weight: 900; /* Black weight */
|
||||
line-height: 0.9;
|
||||
margin: 0;
|
||||
letter-spacing: -0.04em;
|
||||
text-transform: uppercase;
|
||||
transform: translateX(-100%); /* Start off-screen left */
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .subtitle {
|
||||
color: #0A1E3D;
|
||||
font-size: 64px;
|
||||
font-weight: 300; /* Lighter weight for contrast */
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
transform: translateX(-100%); /* Start off-screen left */
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Mechanical timing: 200ms (0.2s)
|
||||
const MECHANICAL_DURATION = 0.2;
|
||||
const STAGGER = 0.1;
|
||||
|
||||
// 1. Animate the grid line first
|
||||
tl.to('.grid-line', {
|
||||
width: '1200px',
|
||||
duration: 0.4,
|
||||
ease: 'power4.out'
|
||||
}, 0.2);
|
||||
|
||||
// 2. Slide in the title
|
||||
tl.to('.title', {
|
||||
x: 0,
|
||||
duration: MECHANICAL_DURATION,
|
||||
ease: 'power2.inOut'
|
||||
}, 0.5);
|
||||
|
||||
// 3. Slide in the subtitle with a slight stagger
|
||||
tl.to('.subtitle', {
|
||||
x: 0,
|
||||
duration: MECHANICAL_DURATION,
|
||||
ease: 'power2.inOut'
|
||||
}, 0.5 + STAGGER);
|
||||
|
||||
// 4. Subtle ambient motion (slow drift) to keep it alive
|
||||
tl.to('.text-wrapper', {
|
||||
x: 30,
|
||||
duration: 1.16, // Adjusted to fit 1.86s total
|
||||
ease: 'none'
|
||||
}, 0.7);
|
||||
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swiss Grid - Hyperframes</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
background-color: #F2F2F2;
|
||||
overflow: hidden;
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#master-root {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.background-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* A-roll styling */
|
||||
#short_mag_cut_frame {
|
||||
position: absolute;
|
||||
border-radius: 8px; /* Required */
|
||||
z-index: 1;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#short_mag_cut,
|
||||
#short_mag_cut_frame > img.__render_frame__,
|
||||
#short_mag_cut_frame > img.__preview_render_frame__ {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Overlay layers */
|
||||
.overlay-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#intro-comp { z-index: 2; }
|
||||
#graphics-comp { z-index: 3; }
|
||||
#captions-comp { z-index: 4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="master-root" data-composition-id="master" data-width="1920" data-height="1080" data-duration="16.04">
|
||||
|
||||
<!-- Background Grid -->
|
||||
<img id="bg-grid"
|
||||
class="background-grid"
|
||||
src="assets/swiss-grid.svg"
|
||||
alt="Grid"
|
||||
data-start="0"
|
||||
data-duration="16.04"
|
||||
data-track-index="0">
|
||||
|
||||
<!-- A-roll Video -->
|
||||
<div id="short_mag_cut_frame">
|
||||
<video id="short_mag_cut"
|
||||
src="__VIDEO_SRC__"
|
||||
data-start="0"
|
||||
data-duration="16.04"
|
||||
data-track-index="1">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Intro Sub-composition -->
|
||||
<div id="intro-comp"
|
||||
class="overlay-layer"
|
||||
data-composition-id="intro"
|
||||
data-composition-src="compositions/intro.html"
|
||||
data-start="0"
|
||||
data-duration="1.86"
|
||||
data-track-index="2">
|
||||
</div>
|
||||
|
||||
<!-- Graphics Sub-composition -->
|
||||
<div id="graphics-comp"
|
||||
class="overlay-layer"
|
||||
data-composition-id="graphics"
|
||||
data-composition-src="compositions/graphics.html"
|
||||
data-start="0"
|
||||
data-duration="16.04"
|
||||
data-track-index="3">
|
||||
</div>
|
||||
|
||||
<!-- Captions Sub-composition -->
|
||||
<div id="captions-comp"
|
||||
class="overlay-layer"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="16.04"
|
||||
data-track-index="4">
|
||||
</div>
|
||||
|
||||
<!-- Sound Effects -->
|
||||
<audio id="sfx-intro" data-start="0" data-track-index="5" data-duration="1" src=""></audio>
|
||||
<audio id="sfx-stat1" data-start="1.86" data-track-index="5" data-duration="1" src=""></audio>
|
||||
<audio id="sfx-stat2" data-start="4.68" data-track-index="5" data-duration="1" src=""></audio>
|
||||
<audio id="sfx-stat3" data-start="8.88" data-track-index="5" data-duration="1" src=""></audio>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
const masterTL = gsap.timeline({ paused: true });
|
||||
const v = document.getElementById('short_mag_cut_frame');
|
||||
|
||||
// --- A-ROLL MOVEMENT (Structured Swiss Layout) ---
|
||||
|
||||
// 1. Entrance (0-1.86s): Slide in from left, settle centered for Intro
|
||||
masterTL.fromTo(v,
|
||||
{ x: -1000, y: 120, width: 960, height: 840 },
|
||||
{ x: 480, y: 120, width: 960, height: 840, duration: 0.8, ease: "power2.out" },
|
||||
0
|
||||
);
|
||||
|
||||
// 2. Stat 1 (1.86s - 4.68s): Move to right side (Graphic is on left)
|
||||
masterTL.to(v, {
|
||||
x: 1080, y: 120, width: 720, height: 720,
|
||||
duration: 0.25, ease: "expo.out"
|
||||
}, 1.86);
|
||||
|
||||
// 3. Stat 2 (4.68s - 8.88s): Stay right, zoom in slightly
|
||||
masterTL.to(v, {
|
||||
x: 1080, y: 60, width: 780, height: 780,
|
||||
duration: 0.25, ease: "expo.out"
|
||||
}, 4.68);
|
||||
|
||||
// 4. Stat 3 (8.88s - 14.2s): Move to left side (Graphic is on right)
|
||||
masterTL.to(v, {
|
||||
x: 120, y: 120, width: 720, height: 720,
|
||||
duration: 0.25, ease: "expo.out"
|
||||
}, 8.88);
|
||||
|
||||
// 5. Conclusion (14.2s - 16.04s): Center large
|
||||
masterTL.to(v, {
|
||||
x: 240, y: 60, width: 1440, height: 810,
|
||||
duration: 0.5, ease: "expo.out"
|
||||
}, 14.2);
|
||||
|
||||
window.__timelines["master"] = masterTL;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template id="captions-template">
|
||||
<div data-composition-id="captions" data-width="1080" data-height="1920" data-duration="13.88">
|
||||
<div id="captions-container"></div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
font-family: "Helvetica", "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 900; /* Helvetica Black/Bold */
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] #captions-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-group {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
max-width: 920px; /* 1080 - 2*80 */
|
||||
bottom: 672px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
font-size: 64px;
|
||||
line-height: 1.1;
|
||||
color: #000000;
|
||||
background-color: #FFFFFF;
|
||||
padding: 15px 30px;
|
||||
border-top: 8px solid #CC0000; /* Vignelli Red top border accent */
|
||||
display: inline-block;
|
||||
letter-spacing: -0.02em; /* Tight Helvetica spacing */
|
||||
white-space: nowrap; /* Prevent wrapping at the element level */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const container = document.getElementById('captions-container');
|
||||
|
||||
// Group words into single lines (max words or based on pauses/sentence ends)
|
||||
function groupTranscript(transcript, maxWords = 3) {
|
||||
const groups = [];
|
||||
let currentGroup = [];
|
||||
|
||||
transcript.forEach((word, index) => {
|
||||
currentGroup.push(word);
|
||||
|
||||
const nextWord = transcript[index + 1];
|
||||
const isPause = nextWord && (nextWord.start - word.end > 0.15);
|
||||
const isSentenceEnd = word.text.includes('.') || word.text.includes('?') || word.text.includes('!');
|
||||
|
||||
if (currentGroup.length >= maxWords || isPause || isSentenceEnd || !nextWord) {
|
||||
groups.push({
|
||||
words: currentGroup,
|
||||
start: currentGroup[0].start,
|
||||
end: currentGroup[currentGroup.length - 1].end,
|
||||
text: currentGroup.map(w => w.text).join(' ')
|
||||
});
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
const captionGroups = groupTranscript(TRANSCRIPT);
|
||||
|
||||
captionGroups.forEach((group, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'caption-group';
|
||||
div.id = `group-${index}`;
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'caption-text';
|
||||
textSpan.innerText = group.text;
|
||||
|
||||
div.appendChild(textSpan);
|
||||
container.appendChild(div);
|
||||
|
||||
// Animation: Modern "expo.out" transitions
|
||||
// Entrance
|
||||
tl.fromTo(div,
|
||||
{ opacity: 0, y: 20 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.4,
|
||||
ease: "expo.out"
|
||||
},
|
||||
group.start
|
||||
);
|
||||
|
||||
// Exit
|
||||
tl.to(div, {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
duration: 0.3,
|
||||
ease: "expo.out"
|
||||
}, group.end - 0.3);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template id="overlays-template">
|
||||
<div data-composition-id="overlays" data-width="1080" data-height="1920" data-duration="16.88">
|
||||
<!-- 6-Column Grid Overlay (for visual reference during development, hidden by default) -->
|
||||
<div class="vignelli-grid">
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
</div>
|
||||
|
||||
<!-- 1. 47% Motion Graphics -->
|
||||
<div class="overlay-item motion-graphics-stat" id="stat-47">
|
||||
<div class="red-bar"></div>
|
||||
<div class="stat-content">
|
||||
<div class="number">47%</div>
|
||||
<div class="label">Motion Graphics</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 62% Static Content (Full Canvas) -->
|
||||
<div class="overlay-item static-content-stat" id="stat-62">
|
||||
<div class="bg-charcoal"></div>
|
||||
<div class="full-content">
|
||||
<div class="large-number">62%</div>
|
||||
<div class="large-label">Static Content</div>
|
||||
<div class="red-accent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 3 out of 4 lack editing skills -->
|
||||
<div class="overlay-item editing-skills-stat" id="stat-3-4">
|
||||
<div class="text-block">
|
||||
<span class="highlight">3 out of 4</span>
|
||||
<br>lack editing skills
|
||||
</div>
|
||||
<div class="side-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Hyperframes Branding -->
|
||||
<div class="overlay-item branding-reveal" id="branding">
|
||||
<div class="logo-container">
|
||||
<div class="logo-text"><span class="heavy">HYPERFRAMES</span></div>
|
||||
<div class="logo-underline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="overlays"] {
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: #000000;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Grid System */
|
||||
[data-composition-id="overlays"] .vignelli-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
opacity: 0; /* Hidden */
|
||||
}
|
||||
[data-composition-id="overlays"] .vignelli-grid .col {
|
||||
border-right: 1px solid rgba(204, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-composition-id="overlays"] .overlay-item {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 1. 47% Stat Styles */
|
||||
[data-composition-id="overlays"] #stat-47 {
|
||||
bottom: 200px;
|
||||
left: 0;
|
||||
width: 540px; /* 3 columns */
|
||||
height: 240px;
|
||||
background: #FFFFFF;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .red-bar {
|
||||
width: 20px;
|
||||
background: #CC0000;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .stat-content {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .number {
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .label {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 2. 62% Stat Styles (Full Canvas) */
|
||||
[data-composition-id="overlays"] #stat-62 {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
z-index: 50;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .bg-charcoal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1A1A1A;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .full-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 90px; /* Align with grid */
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .large-number {
|
||||
font-size: 400px;
|
||||
font-weight: 900;
|
||||
color: #FFFFFF;
|
||||
line-height: 0.8;
|
||||
letter-spacing: -10px;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .large-label {
|
||||
font-size: 80px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
text-transform: uppercase;
|
||||
margin-top: 20px;
|
||||
max-width: 600px;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .red-accent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 180px; /* 1 column */
|
||||
height: 100%;
|
||||
background: #CC0000;
|
||||
}
|
||||
|
||||
/* 3. 3 out of 4 Stat Styles */
|
||||
[data-composition-id="overlays"] #stat-3-4 {
|
||||
top: 400px;
|
||||
right: 0;
|
||||
width: 360px; /* 2 columns */
|
||||
background: #FFFFFF;
|
||||
padding: 40px;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-3-4 .text-block {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-align: right;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-3-4 .highlight {
|
||||
color: #CC0000;
|
||||
font-size: 64px;
|
||||
font-weight: 900;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-3-4 .side-bar {
|
||||
width: 10px;
|
||||
background: #000000;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
/* 4. Branding Styles */
|
||||
[data-composition-id="overlays"] #branding {
|
||||
bottom: 100px;
|
||||
left: 90px;
|
||||
width: 900px;
|
||||
justify-content: center;
|
||||
z-index: 60;
|
||||
}
|
||||
[data-composition-id="overlays"] #branding .logo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
[data-composition-id="overlays"] #branding .logo-text {
|
||||
font-size: 64px;
|
||||
letter-spacing: 10px;
|
||||
color: #000000;
|
||||
}
|
||||
[data-composition-id="overlays"] #branding .logo-text .heavy {
|
||||
font-weight: 900;
|
||||
}
|
||||
[data-composition-id="overlays"] #branding .logo-text .light {
|
||||
font-weight: 300;
|
||||
}
|
||||
[data-composition-id="overlays"] #branding .logo-underline {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #CC0000;
|
||||
margin-top: 10px;
|
||||
transform-origin: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const TRANSCRIPT = [{'text': 'We', 'start': 0.14, 'end': 0.239}, {'text': 'asked', 'start': 0.28, 'end': 0.459}, {'text': 'what', 'start': 0.5, 'end': 0.619}, {'text': 'you', 'start': 0.659, 'end': 0.779}, {'text': 'needed.', 'start': 0.8, 'end': 1.179}, {'text': 'Forty-seven', 'start': 1.199, 'end': 1.619}, {'text': 'percent', 'start': 1.699, 'end': 1.96}, {'text': 'of', 'start': 1.979, 'end': 2.059}, {'text': 'you', 'start': 2.099, 'end': 2.299}, {'text': 'said', 'start': 2.319, 'end': 2.499}, {'text': 'motion', 'start': 2.559, 'end': 2.839}, {'text': 'graphics,', 'start': 2.899, 'end': 3.759}, {'text': 'sixty-two', 'start': 3.799, 'end': 4.159}, {'text': 'percent', 'start': 4.239, 'end': 4.559}, {'text': 'said', 'start': 4.639, 'end': 4.799}, {'text': 'static', 'start': 4.859, 'end': 5.199}, {'text': 'content', 'start': 5.239, 'end': 5.639}, {'text': 'was', 'start': 5.679, 'end': 5.779}, {'text': 'costing', 'start': 5.859, 'end': 6.219}, {'text': 'you', 'start': 6.259, 'end': 6.339}, {'text': 'attention,', 'start': 6.379, 'end': 7.299}, {'text': 'and', 'start': 7.319, 'end': 7.439}, {'text': 'three', 'start': 7.48, 'end': 7.599}, {'text': 'out', 'start': 7.679, 'end': 7.779}, {'text': 'of', 'start': 7.799, 'end': 7.879}, {'text': 'four', 'start': 7.94, 'end': 8.139}, {'text': 'said', 'start': 8.279, 'end': 8.46}, {'text': 'you', 'start': 8.519, 'end': 8.6}, {'text': 'know', 'start': 8.619, 'end': 8.739}, {'text': 'the', 'start': 8.8, 'end': 8.88}, {'text': 'look', 'start': 8.92, 'end': 9.079}, {'text': 'you', 'start': 9.119, 'end': 9.259}, {'text': 'want', 'start': 9.279, 'end': 9.619}, {'text': 'but', 'start': 9.779, 'end': 9.88}, {'text': "don't", 'start': 9.92, 'end': 10.1}, {'text': 'have', 'start': 10.159, 'end': 10.3}, {'text': 'the', 'start': 10.3, 'end': 10.42}, {'text': 'editing', 'start': 10.5, 'end': 10.779}, {'text': 'skills', 'start': 10.86, 'end': 11.139}, {'text': 'to', 'start': 11.159, 'end': 11.239}, {'text': 'get', 'start': 11.279, 'end': 11.439}, {'text': 'there.', 'start': 11.46, 'end': 12.239}, {'text': 'So', 'start': 12.3, 'end': 12.38}, {'text': 'we', 'start': 12.399, 'end': 12.519}, {'text': 'built', 'start': 12.539, 'end': 12.759}, {'text': 'Hyperframes', 'start': 12.84, 'end': 13.119}];
|
||||
|
||||
// Requirement 1: "47% Motion Graphics" (Start: 1.199, End: 3.759)
|
||||
// Positioned in bottom half, columns 1-3.
|
||||
tl.fromTo("#stat-47",
|
||||
{ x: -540, opacity: 1 },
|
||||
{ x: 0, duration: 0.8, ease: "expo.out" },
|
||||
1.199
|
||||
);
|
||||
tl.to("#stat-47",
|
||||
{ x: -540, duration: 0.6, ease: "expo.in" },
|
||||
3.759 - 0.6
|
||||
);
|
||||
|
||||
// Requirement 2: "62% Static Content" (Start: 3.799, End: 7.299)
|
||||
// Full canvas moment. Large bold typography across columns.
|
||||
tl.set("#stat-62", { opacity: 1 }, 3.799);
|
||||
tl.from("#stat-62 .bg-charcoal", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.799);
|
||||
tl.from("#stat-62 .red-accent", { x: "100%", duration: 0.8, ease: "expo.out" }, 3.899);
|
||||
tl.from("#stat-62 .large-number", { y: 100, opacity: 0, duration: 1, ease: "expo.out" }, 4.099);
|
||||
tl.from("#stat-62 .large-label", { y: 50, opacity: 0, duration: 1, ease: "expo.out" }, 4.299);
|
||||
|
||||
tl.to("#stat-62", { x: "-100%", duration: 0.8, ease: "expo.in" }, 7.299 - 0.8);
|
||||
|
||||
// Requirement 3: "3 out of 4 lack editing skills" (Start: 7.319, End: 12.239)
|
||||
// Positioned in columns 5-6 (right side) to avoid face (x: 300-776)
|
||||
tl.fromTo("#stat-3-4",
|
||||
{ x: 360, opacity: 1 },
|
||||
{ x: 0, duration: 0.8, ease: "expo.out" },
|
||||
7.319
|
||||
);
|
||||
tl.to("#stat-3-4",
|
||||
{ x: 360, duration: 0.6, ease: "expo.in" },
|
||||
12.239 - 0.6
|
||||
);
|
||||
|
||||
// Requirement 4: "Hyperframes" (Start: 12.84, End: 13.84)
|
||||
tl.set("#branding", { opacity: 1 }, 12.84);
|
||||
tl.from("#branding .logo-text", { y: 20, opacity: 0, duration: 0.6, ease: "expo.out" }, 12.84);
|
||||
tl.from("#branding .logo-underline", { scaleX: 0, duration: 0.8, ease: "expo.out" }, 13.04);
|
||||
|
||||
window.__timelines["overlays"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Massimo Vignelli Video</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #FFFFFF;
|
||||
overflow: hidden;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 6-Column Grid System */
|
||||
.grid-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0; /* Keep grid invisible but present for layout references */
|
||||
}
|
||||
|
||||
.grid-column {
|
||||
border-right: 1px solid rgba(204, 0, 0, 0.2); /* Vignelli Red accent */
|
||||
}
|
||||
|
||||
#main-composition {
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
background: #FFFFFF;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Layer Hosts */
|
||||
#a-roll-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#a-roll,
|
||||
#a-roll-wrapper img.__render_frame__,
|
||||
#a-roll-wrapper img.__preview_render_frame__ {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#overlays-comp,
|
||||
#captions-comp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#overlays-comp {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
#captions-comp {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Curtain Transition Layer */
|
||||
.curtain {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1A1A1A;
|
||||
z-index: 25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.curtain.red {
|
||||
background: #CC0000;
|
||||
z-index: 26;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-composition" data-composition-id="main-comp" data-width="1080" data-height="1920" data-duration="13.88">
|
||||
|
||||
<!-- A-Roll -->
|
||||
<div id="a-roll-wrapper">
|
||||
<video id="a-roll"
|
||||
src="__VIDEO_SRC__"
|
||||
data-start="0"
|
||||
data-duration="13.88"
|
||||
data-track-index="0">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Transitions -->
|
||||
<div id="curtain-black" class="curtain"></div>
|
||||
<div id="curtain-red" class="curtain red"></div>
|
||||
|
||||
<!-- Overlays Composition -->
|
||||
<div id="overlays-comp"
|
||||
data-composition-id="overlays"
|
||||
data-composition-src="compositions/overlays.html"
|
||||
data-start="0"
|
||||
data-duration="13.88"
|
||||
data-track-index="2">
|
||||
</div>
|
||||
|
||||
<!-- Captions Composition -->
|
||||
<div id="captions-comp"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="13.88"
|
||||
data-track-index="3">
|
||||
</div>
|
||||
|
||||
<!-- Grid Visualization -->
|
||||
<div class="grid-container">
|
||||
<div class="grid-column"></div>
|
||||
<div class="grid-column"></div>
|
||||
<div class="grid-column"></div>
|
||||
<div class="grid-column"></div>
|
||||
<div class="grid-column"></div>
|
||||
<div class="grid-column"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Transition: Curtain Pull logic
|
||||
// A-roll leaves the screen at least once.
|
||||
// Timing: Let's do a curtain pull at 3.7s (transitioning to the 62% stat)
|
||||
// and return at 7.3s (end of attention stat).
|
||||
|
||||
// Curtain Out (A-roll hidden)
|
||||
tl.to("#curtain-black", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 3.5);
|
||||
tl.to("#a-roll-wrapper", { opacity: 0, duration: 0 }, 3.7);
|
||||
tl.to("#curtain-black", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 3.7);
|
||||
|
||||
// Curtain In (A-roll return)
|
||||
tl.set("#curtain-red", { left: "-100%" }, 7.1);
|
||||
tl.to("#curtain-red", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 7.1);
|
||||
tl.to("#a-roll-wrapper", { opacity: 1, duration: 0 }, 7.3);
|
||||
tl.to("#curtain-red", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 7.3);
|
||||
|
||||
window.__timelines["main-comp"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template id="captions-template">
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080" data-duration="18">
|
||||
<div class="captions-container">
|
||||
<div id="caption-box" class="caption-box">
|
||||
<span id="caption-text" class="caption-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="captions"] .captions-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 230px; /* Position at y: 850 (1080 - 230 = 850) */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-box {
|
||||
background-color: #7A6248;
|
||||
padding: 12px 32px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
max-width: 80%;
|
||||
opacity: 0;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
color: #F5F0E0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const script = [
|
||||
{"text": "We", "start": 0.119, "end": 0.259},
|
||||
{"text": "asked", "start": 0.319, "end": 0.479},
|
||||
{"text": "what", "start": 0.519, "end": 0.659},
|
||||
{"text": "you", "start": 0.699, "end": 0.819},
|
||||
{"text": "needed.", "start": 0.859, "end": 1.819},
|
||||
{"text": "Forty-seven", "start": 1.86, "end": 2.299},
|
||||
{"text": "percent", "start": 2.399, "end": 2.679},
|
||||
{"text": "of", "start": 2.7, "end": 2.799},
|
||||
{"text": "you", "start": 2.839, "end": 2.939},
|
||||
{"text": "said", "start": 3.039, "end": 3.179},
|
||||
{"text": "motion", "start": 3.24, "end": 3.559},
|
||||
{"text": "graphics,", "start": 3.579, "end": 4.599},
|
||||
{"text": "sixty-two", "start": 4.679, "end": 5.179},
|
||||
{"text": "percent", "start": 5.299, "end": 5.759},
|
||||
{"text": "said", "start": 5.859, "end": 5.98},
|
||||
{"text": "static", "start": 6.079, "end": 6.399},
|
||||
{"text": "content", "start": 6.46, "end": 6.879},
|
||||
{"text": "was", "start": 6.92, "end": 7.079},
|
||||
{"text": "costing", "start": 7.099, "end": 7.48},
|
||||
{"text": "you", "start": 7.5, "end": 7.579},
|
||||
{"text": "attention,", "start": 7.679, "end": 8.659},
|
||||
{"text": "and", "start": 8.699, "end": 8.86},
|
||||
{"text": "three", "start": 8.88, "end": 9.06},
|
||||
{"text": "out", "start": 9.079, "end": 9.18},
|
||||
{"text": "of", "start": 9.199, "end": 9.34},
|
||||
{"text": "four", "start": 9.38, "end": 9.799},
|
||||
{"text": "said", "start": 9.84, "end": 10.0},
|
||||
{"text": "you", "start": 10.019, "end": 10.159},
|
||||
{"text": "know", "start": 10.179, "end": 10.36},
|
||||
{"text": "the", "start": 10.38, "end": 10.42},
|
||||
{"text": "look", "start": 10.519, "end": 10.699},
|
||||
{"text": "you", "start": 10.739, "end": 10.859},
|
||||
{"text": "want", "start": 10.98, "end": 11.34},
|
||||
{"text": "but", "start": 11.359, "end": 11.52},
|
||||
{"text": "don't", "start": 11.56, "end": 11.779},
|
||||
{"text": "have", "start": 11.819, "end": 11.94},
|
||||
{"text": "the", "start": 11.96, "end": 12.06},
|
||||
{"text": "editing", "start": 12.079, "end": 12.4},
|
||||
{"text": "skills", "start": 12.52, "end": 12.86},
|
||||
{"text": "to", "start": 12.88, "end": 13.0},
|
||||
{"text": "get", "start": 13.019, "end": 13.18},
|
||||
{"text": "there.", "start": 13.22, "end": 14.22},
|
||||
{"text": "So", "start": 14.239, "end": 14.399},
|
||||
{"text": "we", "start": 14.42, "end": 14.52},
|
||||
{"text": "built", "start": 14.619, "end": 14.88},
|
||||
{"text": "Hyperframes.", "start": 15.079, "end": 16.02}
|
||||
];
|
||||
|
||||
// Group words into lines (max 5 words per line)
|
||||
const lines = [];
|
||||
for (let i = 0; i < script.length; i += 5) {
|
||||
const lineWords = script.slice(i, i + 5);
|
||||
lines.push({
|
||||
text: lineWords.map(w => w.text).join(' '),
|
||||
start: lineWords[0].start,
|
||||
end: lineWords[lineWords.length - 1].end
|
||||
});
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const box = document.querySelector('[data-composition-id="captions"] #caption-box');
|
||||
const textEl = document.querySelector('[data-composition-id="captions"] #caption-text');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Fade in and set text
|
||||
tl.to(box, {
|
||||
opacity: 1,
|
||||
duration: 0.1,
|
||||
ease: "power2.out",
|
||||
onStart: () => {
|
||||
textEl.textContent = line.text;
|
||||
}
|
||||
}, line.start);
|
||||
|
||||
// Fade out at the end of the line
|
||||
tl.to(box, {
|
||||
opacity: 0,
|
||||
duration: 0.1,
|
||||
ease: "power2.in"
|
||||
}, line.end);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template id="graphics-template">
|
||||
<div data-composition-id="graphics" data-width="1920" data-height="1080" data-duration="14">
|
||||
<!-- Moment 1: 47% Stat (1.8s - 4.5s) -->
|
||||
<div id="moment-1" class="moment circle-stat">
|
||||
<div class="stat-content">
|
||||
<div class="stat-text">47%</div>
|
||||
<div class="stat-desc">Need Motion Graphics</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moment 2: 62% Stat (4.6s - 8.6s) -->
|
||||
<div id="moment-2" class="moment pill-stat">
|
||||
<div class="stat-text">62%</div>
|
||||
</div>
|
||||
|
||||
<!-- Moment 3: Editing Skills (8.8s - 14s) -->
|
||||
<div id="moment-3" class="moment rect-stat">
|
||||
<div class="icon-shape">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20,20 Q50,0 80,20 Q100,50 80,80 Q50,100 20,80 Q0,50 20,20" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-text">Editing Skills</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-composition-id="graphics"] {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .moment {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
/* We'll use GSAP to handle the centering and drift */
|
||||
}
|
||||
|
||||
/* Moment 1: Forest Green Circle */
|
||||
[data-composition-id="graphics"] #moment-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background-color: #3B5E3A;
|
||||
border-radius: 50%;
|
||||
left: 1400px;
|
||||
top: 540px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-desc {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
margin-top: 10px;
|
||||
max-width: 300px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Moment 2: Ochre Pill */
|
||||
[data-composition-id="graphics"] #moment-2 {
|
||||
width: 500px;
|
||||
height: 220px;
|
||||
background-color: #CC8832;
|
||||
border-radius: 110px;
|
||||
left: 960px;
|
||||
top: 200px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Moment 3: Terracotta Rounded Rect */
|
||||
[data-composition-id="graphics"] #moment-3 {
|
||||
width: 550px;
|
||||
height: 180px;
|
||||
background-color: #C45D3E;
|
||||
border-radius: 40px;
|
||||
left: 400px;
|
||||
top: 540px;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0 40px;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .stat-text {
|
||||
font-size: 80px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] #moment-3 .stat-text {
|
||||
font-size: 54px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .icon-shape {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .icon-shape svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Moment 1: 1.8s - 4.5s
|
||||
// Gentle drift into place and settle
|
||||
tl.fromTo('#moment-1',
|
||||
{ opacity: 0, x: '+=40', y: '+=15', scale: 0.8 },
|
||||
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
|
||||
1.8
|
||||
);
|
||||
// Hold until 4.5s, then fade out
|
||||
tl.to('#moment-1', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 4.2);
|
||||
|
||||
// Moment 2: 4.6s - 8.6s
|
||||
// Gentle drift into place and settle
|
||||
tl.fromTo('#moment-2',
|
||||
{ opacity: 0, y: '-=40', x: '+=10', scale: 0.8 },
|
||||
{ opacity: 1, y: 0, x: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
|
||||
4.6
|
||||
);
|
||||
// Hold until 8.6s, then fade out
|
||||
tl.to('#moment-2', { opacity: 0, scale: 0.8, duration: 0.3, ease: 'power2.in' }, 8.3);
|
||||
|
||||
// Moment 3: 8.8s - 14s
|
||||
// Gentle drift into place and settle
|
||||
tl.fromTo('#moment-3',
|
||||
{ opacity: 0, x: '-=40', y: '-=10', scale: 0.8 },
|
||||
{ opacity: 1, x: 0, y: 0, scale: 1, duration: 1.2, ease: 'back.out(1.7)' },
|
||||
8.8
|
||||
);
|
||||
// Hold until end (14s)
|
||||
tl.to('#moment-3', { opacity: 0, duration: 0.5, ease: 'power2.in' }, 13.5);
|
||||
|
||||
window.__timelines["graphics"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template id="intro-template">
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080" data-duration="3">
|
||||
<div class="container">
|
||||
<div class="title-card">
|
||||
<h1 class="title">Hyperframes</h1>
|
||||
<p class="subtitle">Design simplified.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Import a rounded humanist sans-serif font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap');
|
||||
|
||||
[data-composition-id="intro"] .container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-start; /* Align to left for speaker card */
|
||||
align-items: center;
|
||||
padding-left: 5%;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title-card {
|
||||
background-color: #3B5E3A; /* Forest Green for contrast */
|
||||
padding: 40px 60px;
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%); /* Start off-screen left */
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title {
|
||||
font-size: 100px;
|
||||
font-weight: 600;
|
||||
color: #F5F0E0; /* Cream text on green card */
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .subtitle {
|
||||
font-size: 50px;
|
||||
font-weight: 400;
|
||||
color: #CC8832; /* Ochre accent */
|
||||
margin: 10px 0 0 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Animation: Speaker card slides in from the left
|
||||
tl.to('[data-composition-id="intro"] .title-card', {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out'
|
||||
}, 0.2);
|
||||
|
||||
// Exit: Slide off left at 1.8s (synced with 47% graphic)
|
||||
tl.to('[data-composition-id="intro"] .title-card', {
|
||||
x: '-120%',
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: 'power2.in'
|
||||
}, 1.8);
|
||||
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warm Grain - Hyperframes</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background-color: #F5F0E0; /* Cream background */
|
||||
font-family: 'Outfit', 'Lexend', sans-serif;
|
||||
}
|
||||
|
||||
#main-composition {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Paper texture and Grain overlay */
|
||||
#grain-overlay-comp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Grain animation */
|
||||
@keyframes grain-noise {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-5%, -5%); }
|
||||
20% { transform: translate(-10%, 5%); }
|
||||
30% { transform: translate(5%, -10%); }
|
||||
40% { transform: translate(-5%, 15%); }
|
||||
50% { transform: translate(-10%, 5%); }
|
||||
60% { transform: translate(15%, 0); }
|
||||
70% { transform: translate(0, 10%); }
|
||||
80% { transform: translate(-15%, 0); }
|
||||
90% { transform: translate(10%, 5%); }
|
||||
}
|
||||
|
||||
.grain-texture {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: url('https://www.transparenttextures.com/patterns/natural-paper.png');
|
||||
opacity: 0.15;
|
||||
animation: grain-noise 0.5s steps(1) infinite;
|
||||
}
|
||||
|
||||
#a-roll {
|
||||
position: absolute;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.comp-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-composition" data-composition-id="main-video" data-width="1920" data-height="1080" data-duration="17">
|
||||
|
||||
<!-- Background Layer -->
|
||||
<div id="grain-overlay-comp" data-composition-id="grain-overlay" data-width="1920" data-height="1080" data-duration="17" data-track-index="100">
|
||||
<div class="grain-texture"></div>
|
||||
<script>
|
||||
const grainTl = gsap.timeline({ paused: true });
|
||||
window.__timelines["grain-overlay"] = grainTl;
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- A-Roll Video -->
|
||||
<video id="a-roll"
|
||||
src="__VIDEO_SRC__"
|
||||
data-start="0"
|
||||
data-duration="17"
|
||||
data-track-index="0">
|
||||
</video>
|
||||
|
||||
<!-- Compositions -->
|
||||
<div id="intro-layer" class="comp-layer"
|
||||
data-composition-id="intro"
|
||||
data-composition-src="compositions/intro.html"
|
||||
data-start="0"
|
||||
data-duration="2.5"
|
||||
data-track-index="1">
|
||||
</div>
|
||||
|
||||
<div id="graphics-layer" class="comp-layer"
|
||||
data-composition-id="graphics"
|
||||
data-composition-src="compositions/graphics.html"
|
||||
data-start="0"
|
||||
data-duration="17"
|
||||
data-track-index="2">
|
||||
</div>
|
||||
|
||||
<div id="captions-layer" class="comp-layer"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="17"
|
||||
data-track-index="3">
|
||||
</div>
|
||||
|
||||
<!-- SFX -->
|
||||
<audio id="sfx-swipe-1" src="" data-start="1.8" data-duration="1" data-track-index="4"></audio>
|
||||
<audio id="sfx-swipe-2" src="" data-start="4.6" data-duration="1" data-track-index="4"></audio>
|
||||
<audio id="sfx-swipe-3" src="" data-start="8.8" data-duration="1" data-track-index="4"></audio>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// A-Roll Framing Strategy
|
||||
// 1. 0s-1.8s: Intro scale up from small (60%) to medium (80%) - Shifted right slightly to clear speaker card
|
||||
// 2. 1.8s-4.6s: Grid view / Shift Left (x: 600, scale: 70%) for 47% graphic
|
||||
// 3. 4.6s-8.8s: Shift Center-Bottom (y: 800, scale: 70%) for 62% graphic (top)
|
||||
// 4. 8.8s-14s: Shift Right (x: 1320, scale: 75%) for Editing Skills graphic (left)
|
||||
// 5. 14s-17s: Final Reveal (Center, scale: 100%)
|
||||
|
||||
// Initial state
|
||||
gsap.set('#a-roll', { x: 1100, y: 540, xPercent: -50, yPercent: -50, width: 1920 * 0.6, height: 1080 * 0.6 });
|
||||
|
||||
// 1. Intro Scale Up (0s - 1.8s)
|
||||
tl.to('#a-roll', {
|
||||
x: 1200,
|
||||
width: 1920 * 0.8,
|
||||
height: 1080 * 0.8,
|
||||
duration: 1.8,
|
||||
ease: "power2.inOut"
|
||||
}, 0);
|
||||
|
||||
// 2. Moment 1: 47% Graphic Trigger (1.8s) - Shift Left
|
||||
tl.to('#a-roll', {
|
||||
x: 600,
|
||||
y: 540,
|
||||
width: 1920 * 0.7,
|
||||
height: 1080 * 0.7,
|
||||
duration: 0.5,
|
||||
ease: "power2.inOut"
|
||||
}, 1.8);
|
||||
|
||||
// 3. Moment 2: 62% Graphic Trigger (4.6s) - Shift Center-Bottom
|
||||
tl.to('#a-roll', {
|
||||
x: 960,
|
||||
y: 750,
|
||||
width: 1920 * 0.7,
|
||||
height: 1080 * 0.7,
|
||||
duration: 0.5,
|
||||
ease: "power2.inOut"
|
||||
}, 4.6);
|
||||
|
||||
// 4. Moment 3: Editing Skills Trigger (8.8s) - Shift Right
|
||||
tl.to('#a-roll', {
|
||||
x: 1320,
|
||||
y: 540,
|
||||
width: 1920 * 0.75,
|
||||
height: 1080 * 0.75,
|
||||
duration: 0.5,
|
||||
ease: "power2.inOut"
|
||||
}, 8.8);
|
||||
|
||||
// 5. Final Reveal (14s - 17s)
|
||||
tl.to('#a-roll', {
|
||||
x: 960,
|
||||
y: 540,
|
||||
width: 1920 * 1.0,
|
||||
height: 1080 * 1.0,
|
||||
duration: 1.0,
|
||||
ease: "power2.inOut"
|
||||
}, 14);
|
||||
|
||||
window.__timelines["main-video"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
const isColorSupported =
|
||||
process.stdout.isTTY === true &&
|
||||
!process.env["NO_COLOR"] &&
|
||||
process.env["FORCE_COLOR"] !== "0";
|
||||
|
||||
function wrap(fn: (s: string) => string): (s: string) => string {
|
||||
return isColorSupported ? fn : (s: string) => s;
|
||||
}
|
||||
|
||||
export const c = {
|
||||
success: wrap(pc.green),
|
||||
error: wrap(pc.red),
|
||||
warn: wrap(pc.yellow),
|
||||
dim: wrap(pc.dim),
|
||||
bold: wrap(pc.bold),
|
||||
accent: wrap(pc.cyan),
|
||||
progress: wrap(pc.magenta),
|
||||
reset: isColorSupported ? pc.reset : (s: string) => s,
|
||||
};
|
||||
|
||||
export { isColorSupported };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { c } from "./colors.js";
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - minutes * 60;
|
||||
return `${minutes}m ${remaining.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function label(name: string, value: string): string {
|
||||
const pad = 14 - name.length;
|
||||
return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`;
|
||||
}
|
||||
|
||||
export function errorBox(title: string, hint?: string, suggestion?: string): void {
|
||||
console.error(`\n${c.error("\u2717")} ${c.bold(title)}`);
|
||||
if (hint) console.error(`\n ${c.dim(hint)}`);
|
||||
if (suggestion) console.error(` ${c.accent(suggestion)}`);
|
||||
console.error();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { c } from "./colors.js";
|
||||
|
||||
const { stdout } = process;
|
||||
|
||||
export function renderProgress(percent: number, stage: string, row?: number): void {
|
||||
const width = 25;
|
||||
const filled = Math.floor(percent / (100 / width));
|
||||
const empty = width - filled;
|
||||
const bar =
|
||||
c.progress("\u2588".repeat(filled)) +
|
||||
c.dim("\u2591".repeat(empty));
|
||||
|
||||
const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`;
|
||||
|
||||
if (row !== undefined && stdout.isTTY) {
|
||||
stdout.write(`\x1b[${row};1H\x1b[2K${line}`);
|
||||
} else {
|
||||
stdout.write(`\r\x1b[2K${line}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DOMParser } from "linkedom";
|
||||
|
||||
/**
|
||||
* Polyfill DOMParser on globalThis so @hyperframes/core's parseHtml works in Node.js.
|
||||
* Safe to call multiple times — only sets the global once.
|
||||
*/
|
||||
export function ensureDOMParser(): void {
|
||||
if (typeof globalThis.DOMParser === "undefined") {
|
||||
(globalThis as Record<string, unknown>).DOMParser = DOMParser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ttf": "font/ttf",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Dynamically load the producer module. tsup inlines @hyperframes/producer
|
||||
* via noExternal so this resolves in the published bundle.
|
||||
*/
|
||||
export async function loadProducer() {
|
||||
return await import("@hyperframes/producer");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve, basename } from "node:path";
|
||||
import { errorBox } from "../ui/format.js";
|
||||
|
||||
export interface ProjectDir {
|
||||
dir: string;
|
||||
name: string;
|
||||
indexPath: string;
|
||||
}
|
||||
|
||||
export function resolveProject(dirArg: string | undefined): ProjectDir {
|
||||
const dir = resolve(dirArg ?? ".");
|
||||
const name = basename(dir);
|
||||
const indexPath = resolve(dir, "index.html");
|
||||
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||
errorBox("Not a directory: " + dir);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(indexPath)) {
|
||||
errorBox(
|
||||
"No composition found in " + dir,
|
||||
"No index.html file found.",
|
||||
"Run npx hyperframes init to create a new composition.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, name, indexPath };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = "0.1.0";
|
||||
Reference in New Issue
Block a user