mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
style: apply oxfmt baseline formatting across all source files (#25)
## Summary - Run `oxfmt .` across the entire codebase to establish formatted baseline - 299 files changed — mechanical formatting only, no logic changes - Double quotes, semicolons, 2-space indent, trailing commas, 100 print width Part 3/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits) ## Test plan - [x] `pnpm format:check` — all 426 files pass - [x] `pnpm -r typecheck` — all packages pass - [x] `pnpm build` — all packages build - [x] All 348 tests pass
This commit is contained in:
parent
323ff8f860
commit
20be2ea1c2
@ -34,13 +34,13 @@ pnpm --filter @hyperframes/core test:hyperframe-runtime-ci # Runtime contract t
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| `@hyperframes/core` | Types, HTML generation, runtime, linter |
|
||||
| `@hyperframes/engine` | Seekable page-to-video capture engine |
|
||||
| `@hyperframes/producer` | Full rendering pipeline (capture + encode) |
|
||||
| `@hyperframes/studio` | Composition editor UI |
|
||||
| `hyperframes` | CLI for creating, previewing, and rendering |
|
||||
| Package | Description |
|
||||
| ----------------------- | ------------------------------------------- |
|
||||
| `@hyperframes/core` | Types, HTML generation, runtime, linter |
|
||||
| `@hyperframes/engine` | Seekable page-to-video capture engine |
|
||||
| `@hyperframes/producer` | Full rendering pipeline (capture + encode) |
|
||||
| `@hyperframes/studio` | Composition editor UI |
|
||||
| `hyperframes` | CLI for creating, previewing, and rendering |
|
||||
|
||||
## Releasing (Maintainers)
|
||||
|
||||
|
||||
27
README.md
27
README.md
@ -25,14 +25,25 @@ npx hyperframes render # render to MP4
|
||||
Define your video as HTML with data attributes:
|
||||
|
||||
```html
|
||||
<div id="stage" data-composition-id="my-video"
|
||||
data-start="0" data-width="1920" data-height="1080">
|
||||
<video id="clip-1" data-start="0" data-duration="5"
|
||||
data-track="0" src="intro.mp4" muted playsinline></video>
|
||||
<img id="overlay" data-start="2" data-duration="3"
|
||||
data-track="1" src="logo.png" />
|
||||
<audio id="bg-music" data-start="0" data-duration="9"
|
||||
data-track="2" data-volume="0.5" src="music.wav"></audio>
|
||||
<div id="stage" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
|
||||
<video
|
||||
id="clip-1"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track="0"
|
||||
src="intro.mp4"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<img id="overlay" data-start="2" data-duration="3" data-track="1" src="logo.png" />
|
||||
<audio
|
||||
id="bg-music"
|
||||
data-start="0"
|
||||
data-duration="9"
|
||||
data-track="2"
|
||||
data-volume="0.5"
|
||||
src="music.wav"
|
||||
></audio>
|
||||
</div>
|
||||
```
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ We will acknowledge receipt within 48 hours and aim to provide a fix or mitigati
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| ------- | --------- |
|
||||
| 0.x | Yes |
|
||||
|
||||
## Scope
|
||||
|
||||
@ -17,9 +17,6 @@
|
||||
"knip": "knip",
|
||||
"prepare": "test -d .git && lefthook install || true"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["lefthook"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
@ -31,5 +28,10 @@
|
||||
"oxlint": "^1.56.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"lefthook"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
"name": "hyperframes",
|
||||
"version": "0.1.1",
|
||||
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/cli.ts",
|
||||
"build": "pnpm build:studio && tsup && pnpm build:runtime && pnpm build:copy",
|
||||
@ -30,9 +30,9 @@
|
||||
"puppeteer-core": "^24.39.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@hono/node-server": "^1.0.0",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/engine": "workspace:*",
|
||||
"@hyperframes/producer": "workspace:*",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
|
||||
@ -2,12 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
Browser,
|
||||
detectBrowserPlatform,
|
||||
getInstalledBrowsers,
|
||||
install,
|
||||
} from "@puppeteer/browsers";
|
||||
import { Browser, detectBrowserPlatform, getInstalledBrowsers, install } from "@puppeteer/browsers";
|
||||
|
||||
const CHROME_VERSION = "131.0.6778.85";
|
||||
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
|
||||
@ -18,11 +13,7 @@ export function setBrowserPath(path: string): void {
|
||||
_browserPathOverride = path;
|
||||
}
|
||||
|
||||
export type BrowserSource =
|
||||
| "env"
|
||||
| "cache"
|
||||
| "system"
|
||||
| "download";
|
||||
export type BrowserSource = "env" | "cache" | "system" | "download";
|
||||
|
||||
export interface BrowserResult {
|
||||
executablePath: string;
|
||||
@ -76,9 +67,7 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
|
||||
}
|
||||
|
||||
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
||||
const match = installed.find(
|
||||
(b) => b.browser === Browser.CHROMEHEADLESSSHELL,
|
||||
);
|
||||
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
|
||||
if (match) {
|
||||
return { executablePath: match.executablePath, source: "cache" };
|
||||
}
|
||||
@ -93,8 +82,7 @@ function findFromSystem(): BrowserResult | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const fromWhich =
|
||||
whichBinary("google-chrome") ?? whichBinary("chromium");
|
||||
const fromWhich = whichBinary("google-chrome") ?? whichBinary("chromium");
|
||||
if (fromWhich) {
|
||||
return { executablePath: fromWhich, source: "system" };
|
||||
}
|
||||
@ -122,17 +110,13 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
|
||||
* Find or download a browser.
|
||||
* Resolution: env var -> cached download -> system Chrome -> auto-download.
|
||||
*/
|
||||
export async function ensureBrowser(
|
||||
options?: EnsureBrowserOptions,
|
||||
): Promise<BrowserResult> {
|
||||
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}`,
|
||||
);
|
||||
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
|
||||
}
|
||||
|
||||
const installed = await install({
|
||||
|
||||
@ -36,7 +36,10 @@ const DEFAULT_CONFIGS: BenchmarkConfig[] = [
|
||||
];
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "benchmark", description: "Run multiple render configurations and compare results" },
|
||||
meta: {
|
||||
name: "benchmark",
|
||||
description: "Run multiple render configurations and compare results",
|
||||
},
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
runs: { type: "string", description: "Number of runs per config", default: "3" },
|
||||
@ -64,7 +67,9 @@ export default defineCommand({
|
||||
producer = await loadProducer();
|
||||
} catch {
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify({ error: "Producer module not available. Is the project built?" }));
|
||||
console.log(
|
||||
JSON.stringify({ error: "Producer module not available. Is the project built?" }),
|
||||
);
|
||||
} else {
|
||||
errorBox(
|
||||
"Producer module not available",
|
||||
@ -99,7 +104,10 @@ export default defineCommand({
|
||||
|
||||
for (let i = 0; i < runsPerConfig; i++) {
|
||||
s?.message(`${config.label} — run ${i + 1}/${runsPerConfig}`);
|
||||
const outputPath = join(benchDir, `${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`);
|
||||
const outputPath = join(
|
||||
benchDir,
|
||||
`${config.label.replace(/[^a-zA-Z0-9]/g, "_")}_run${i}.mp4`,
|
||||
);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
@ -176,12 +184,9 @@ export default defineCommand({
|
||||
console.log(separator);
|
||||
|
||||
for (const result of results) {
|
||||
const timeStr =
|
||||
result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed");
|
||||
const sizeStr =
|
||||
result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a");
|
||||
const failStr =
|
||||
result.failures > 0 ? c.warn(` (${result.failures} failed)`) : "";
|
||||
const timeStr = result.avgTime != null ? formatDuration(result.avgTime) : c.dim("failed");
|
||||
const sizeStr = result.avgSize != null ? formatBytes(result.avgSize) : c.dim("n/a");
|
||||
const failStr = result.failures > 0 ? c.warn(` (${result.failures} failed)`) : "";
|
||||
|
||||
console.log(
|
||||
" " +
|
||||
@ -215,8 +220,7 @@ export default defineCommand({
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
c.error("\u2717") +
|
||||
" All configurations failed. Ensure the rendering pipeline is set up.",
|
||||
c.error("\u2717") + " All configurations failed. Ensure the rendering pipeline is set up.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -30,9 +30,7 @@ async function runEnsure(): Promise<void> {
|
||||
s.stop("No browser found — downloading");
|
||||
|
||||
const downloadSpinner = clack.spinner();
|
||||
downloadSpinner.start(
|
||||
`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`,
|
||||
);
|
||||
downloadSpinner.start(`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`);
|
||||
|
||||
let lastPct = -1;
|
||||
const result = await ensureBrowser({
|
||||
@ -66,9 +64,7 @@ async function runPath(): Promise<void> {
|
||||
const ensured = await ensureBrowser();
|
||||
process.stdout.write(ensured.executablePath + "\n");
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
err instanceof Error ? err.message : "Failed to find browser",
|
||||
);
|
||||
console.error(err instanceof Error ? err.message : "Failed to find browser");
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
@ -81,9 +77,7 @@ function runClear(): void {
|
||||
|
||||
const removed = clearBrowser();
|
||||
if (removed) {
|
||||
clack.outro(
|
||||
c.success("Removed cached browser from ") + c.dim(CACHE_DIR),
|
||||
);
|
||||
clack.outro(c.success("Removed cached browser from ") + c.dim(CACHE_DIR));
|
||||
} else {
|
||||
clack.outro(c.dim("No cached browser to remove."));
|
||||
}
|
||||
@ -92,7 +86,11 @@ function runClear(): void {
|
||||
export default defineCommand({
|
||||
meta: { name: "browser", description: "Manage the Chrome browser used for rendering" },
|
||||
args: {
|
||||
subcommand: { type: "positional", description: "Subcommand: ensure, path, clear", required: false },
|
||||
subcommand: {
|
||||
type: "positional",
|
||||
description: "Subcommand: ensure, path, clear",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const subcommand = args.subcommand;
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
readlinkSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname, basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as clack from "@clack/prompts";
|
||||
@ -181,6 +174,10 @@ async function runDevMode(dir: string): Promise<void> {
|
||||
* 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>"));
|
||||
console.error(
|
||||
c.error(
|
||||
"Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -19,8 +19,8 @@ function checkFFmpeg(): CheckResult {
|
||||
const path = findFFmpeg();
|
||||
if (path) {
|
||||
try {
|
||||
const version = execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 })
|
||||
.split("\n")[0] ?? "";
|
||||
const version =
|
||||
execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
|
||||
return { ok: true, detail: version.trim() };
|
||||
} catch {
|
||||
return { ok: true, detail: path };
|
||||
@ -88,7 +88,6 @@ function checkNode(): CheckResult {
|
||||
return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` };
|
||||
}
|
||||
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "doctor", description: "Check system dependencies and environment" },
|
||||
args: {},
|
||||
@ -112,7 +111,9 @@ export default defineCommand({
|
||||
const result = await check.run();
|
||||
const icon = result.ok ? c.success("\u2713") : c.error("\u2717");
|
||||
const name = check.name.padEnd(16);
|
||||
console.log(` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`);
|
||||
console.log(
|
||||
` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`,
|
||||
);
|
||||
if (!result.ok && result.hint) {
|
||||
console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`);
|
||||
}
|
||||
|
||||
@ -38,8 +38,7 @@ export default defineCommand({
|
||||
(max, el) => Math.max(max, el.startTime + el.duration),
|
||||
0,
|
||||
);
|
||||
const resolution =
|
||||
parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
|
||||
const resolution = parsed.resolution === "portrait" ? "1080x1920" : "1920x1080";
|
||||
const size = totalSize(project.dir);
|
||||
|
||||
const typeCounts: Record<string, number> = {};
|
||||
@ -51,17 +50,23 @@ export default defineCommand({
|
||||
.join(", ");
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({
|
||||
name: project.name,
|
||||
resolution: parsed.resolution,
|
||||
width: parsed.resolution === "portrait" ? 1080 : 1920,
|
||||
height: parsed.resolution === "portrait" ? 1920 : 1080,
|
||||
duration: maxEnd,
|
||||
elements: parsed.elements.length,
|
||||
tracks: tracks.size,
|
||||
types: typeCounts,
|
||||
size,
|
||||
}, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: project.name,
|
||||
resolution: parsed.resolution,
|
||||
width: parsed.resolution === "portrait" ? 1080 : 1920,
|
||||
height: parsed.resolution === "portrait" ? 1920 : 1080,
|
||||
duration: maxEnd,
|
||||
elements: parsed.elements.length,
|
||||
tracks: tracks.size,
|
||||
types: typeCounts,
|
||||
size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -13,10 +13,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { execSync, execFileSync, spawn } from "node:child_process";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import {
|
||||
TEMPLATES,
|
||||
type TemplateId,
|
||||
} from "../templates/generators.js";
|
||||
import { TEMPLATES, type TemplateId } from "../templates/generators.js";
|
||||
|
||||
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
|
||||
|
||||
@ -53,7 +50,14 @@ function probeVideo(filePath: string): VideoMeta | undefined {
|
||||
);
|
||||
|
||||
const parsed: {
|
||||
streams?: { codec_type?: string; codec_name?: string; width?: number; height?: number; r_frame_rate?: string; avg_frame_rate?: string }[];
|
||||
streams?: {
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
r_frame_rate?: string;
|
||||
avg_frame_rate?: string;
|
||||
}[];
|
||||
format?: { duration?: string };
|
||||
} = JSON.parse(raw);
|
||||
|
||||
@ -75,8 +79,7 @@ function probeVideo(filePath: string): VideoMeta | undefined {
|
||||
}
|
||||
|
||||
const durationStr = parsed.format?.duration;
|
||||
const durationSeconds =
|
||||
durationStr !== undefined ? parseFloat(durationStr) : 5;
|
||||
const durationSeconds = durationStr !== undefined ? parseFloat(durationStr) : 5;
|
||||
|
||||
return {
|
||||
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
|
||||
@ -106,12 +109,26 @@ function hasFFmpeg(): boolean {
|
||||
|
||||
function transcodeToMp4(inputPath: string, outputPath: string): Promise<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" });
|
||||
const child = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-i",
|
||||
inputPath,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
child.on("close", (code) => resolvePromise(code === 0));
|
||||
child.on("error", () => resolvePromise(false));
|
||||
@ -133,8 +150,8 @@ function getStaticTemplateDir(templateId: string): string {
|
||||
|
||||
function patchVideoSrc(dir: string, videoFilename: string | undefined): void {
|
||||
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
|
||||
.filter(e => e.isFile() && e.name.endsWith(".html"))
|
||||
.map(e => join(e.parentPath ?? e.path, e.name));
|
||||
.filter((e) => e.isFile() && e.name.endsWith(".html"))
|
||||
.map((e) => join(e.parentPath ?? e.path, e.name));
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
let content = readFileSync(file, "utf-8");
|
||||
@ -319,7 +336,11 @@ export default defineCommand({
|
||||
meta: { name: "init", description: "Scaffold a new composition project" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Project name", required: false },
|
||||
template: { type: "string", description: `Template: ${ALL_TEMPLATE_IDS.join(", ")}`, alias: "t" },
|
||||
template: {
|
||||
type: "string",
|
||||
description: `Template: ${ALL_TEMPLATE_IDS.join(", ")}`,
|
||||
alias: "t",
|
||||
},
|
||||
video: { type: "string", description: "Path to a source video file", alias: "V" },
|
||||
},
|
||||
async run({ args }) {
|
||||
@ -340,9 +361,7 @@ export default defineCommand({
|
||||
const destDir = resolve(name);
|
||||
|
||||
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
|
||||
console.error(
|
||||
c.error(`Directory already exists and is not empty: ${name}`),
|
||||
);
|
||||
console.error(c.error(`Directory already exists and is not empty: ${name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -482,10 +501,7 @@ export default defineCommand({
|
||||
scaffoldProject(destDir, name, templateId, localVideoName);
|
||||
|
||||
const files = readdirSync(destDir);
|
||||
clack.note(
|
||||
files.map((f) => c.accent(f)).join("\n"),
|
||||
c.success(`Created ${name}/`),
|
||||
);
|
||||
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
|
||||
|
||||
await nextStepLoop(destDir);
|
||||
},
|
||||
|
||||
@ -38,7 +38,9 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const summaryIcon = result.errorCount > 0 ? c.error("◇") : c.success("◇");
|
||||
console.log(`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`);
|
||||
console.log(
|
||||
`\n${summaryIcon} ${result.errorCount} error(s), ${result.warningCount} warning(s)`,
|
||||
);
|
||||
process.exit(result.errorCount > 0 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
@ -55,9 +55,7 @@ export default defineCommand({
|
||||
|
||||
// ── Resolve output path ───────────────────────────────────────────────
|
||||
const rendersDir = resolve("renders");
|
||||
const outputPath = args.output
|
||||
? resolve(args.output)
|
||||
: join(rendersDir, `${project.name}.mp4`);
|
||||
const outputPath = args.output ? resolve(args.output) : join(rendersDir, `${project.name}.mp4`);
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = dirname(outputPath);
|
||||
@ -73,8 +71,15 @@ export default defineCommand({
|
||||
const workerCount = workers ?? 4;
|
||||
if (!quiet) {
|
||||
console.log("");
|
||||
console.log(c.accent("\u25C6") + " Rendering " + c.accent(project.name) + c.dim(" \u2192 " + outputPath));
|
||||
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"));
|
||||
console.log(
|
||||
c.accent("\u25C6") +
|
||||
" Rendering " +
|
||||
c.accent(project.name) +
|
||||
c.dim(" \u2192 " + outputPath),
|
||||
);
|
||||
console.log(
|
||||
c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"),
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@ -102,7 +107,9 @@ export default defineCommand({
|
||||
onProgress: (downloaded, total) => {
|
||||
if (total <= 0) return;
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
s.message(`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`);
|
||||
s.message(
|
||||
`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
s.stop(c.dim(`Browser: ${info.source}`));
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
A composition is an HTML document that defines a video timeline.
|
||||
|
||||
## Structure
|
||||
|
||||
Every composition needs a root element with `data-composition-id`:
|
||||
|
||||
```html
|
||||
<div id="root" data-composition-id="root" data-width="1920" data-height="1080">
|
||||
<!-- Elements go here -->
|
||||
@ -11,16 +13,21 @@ Every composition needs a root element with `data-composition-id`:
|
||||
```
|
||||
|
||||
## 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">
|
||||
<div data-composition-id="card" data-var-title="string" data-var-color="color"></div>
|
||||
```
|
||||
|
||||
@ -3,20 +3,24 @@
|
||||
Core attributes for controlling element timing and behavior.
|
||||
|
||||
## Timing
|
||||
|
||||
- `data-start="0"` — Start time in seconds
|
||||
- `data-duration="5"` — Duration in seconds
|
||||
- `data-track-index="0"` — Timeline track number (controls z-ordering)
|
||||
|
||||
## Media
|
||||
|
||||
- `data-media-start="2"` — Media playback offset / trim point (seconds)
|
||||
- `data-volume="0.8"` — Audio/video volume, 0 to 1
|
||||
- `data-has-audio="true"` — Indicates video has an audio track
|
||||
|
||||
## Composition
|
||||
|
||||
- `data-composition-id="root"` — Unique ID for composition wrapper (required)
|
||||
- `data-width="1920"` — Composition width in pixels
|
||||
- `data-height="1080"` — Composition height in pixels
|
||||
- `data-composition-src="./intro.html"` — Nested composition source
|
||||
|
||||
## Element Visibility
|
||||
|
||||
Add `class="clip"` to timed elements so the runtime can manage their visibility lifecycle.
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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>
|
||||
@ -14,10 +15,12 @@ HyperFrames uses GSAP for animation. Timelines are paused and controlled by the
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
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)
|
||||
@ -18,6 +21,7 @@ Requires: Docker installed and running.
|
||||
- `-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
|
||||
|
||||
@ -3,13 +3,17 @@
|
||||
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.
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
# 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.
|
||||
|
||||
@ -23,11 +23,11 @@
|
||||
|
||||
.caption-box {
|
||||
position: absolute; /* Stacked in the same place */
|
||||
background-color: #FF2D8A;
|
||||
background-color: #ff2d8a;
|
||||
color: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 16px;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-family: "Nunito", sans-serif;
|
||||
font-weight: 900; /* Nunito Black */
|
||||
font-size: 64px;
|
||||
text-align: center;
|
||||
@ -41,9 +41,56 @@
|
||||
</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');
|
||||
(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)
|
||||
@ -53,41 +100,54 @@
|
||||
}
|
||||
|
||||
lines.forEach((lineWords, index) => {
|
||||
const lineText = lineWords.map(w => w.text).join(' ');
|
||||
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';
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
|
||||
@ -1,88 +1,106 @@
|
||||
<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>
|
||||
<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');
|
||||
<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"] .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>
|
||||
[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', {
|
||||
<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)",
|
||||
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);
|
||||
},
|
||||
14.619,
|
||||
).to('[data-composition-id="intro"] .title', {
|
||||
scale: 1,
|
||||
duration: 0.4,
|
||||
ease: "elastic.out(1, 0.3)",
|
||||
});
|
||||
|
||||
// --- 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>
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<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 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">
|
||||
@ -11,12 +11,18 @@
|
||||
</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
|
||||
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 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">
|
||||
@ -25,12 +31,24 @@
|
||||
</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
|
||||
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 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">
|
||||
@ -39,8 +57,20 @@
|
||||
</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
|
||||
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>
|
||||
|
||||
@ -49,7 +79,7 @@
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-family: "Nunito", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -120,19 +150,41 @@
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
[data-composition-id="stats"] .blue-bg { background-color: #0057FF; }
|
||||
[data-composition-id="stats"] .pink-bg { background-color: #FF2D8A; }
|
||||
[data-composition-id="stats"] .lime-bg { background-color: #7FFF00; }
|
||||
[data-composition-id="stats"] .yellow-bg { background-color: #FFE500; }
|
||||
[data-composition-id="stats"] .white-bg { background-color: #FFFFFF; }
|
||||
[data-composition-id="stats"] .blue-bg {
|
||||
background-color: #0057ff;
|
||||
}
|
||||
[data-composition-id="stats"] .pink-bg {
|
||||
background-color: #ff2d8a;
|
||||
}
|
||||
[data-composition-id="stats"] .lime-bg {
|
||||
background-color: #7fff00;
|
||||
}
|
||||
[data-composition-id="stats"] .yellow-bg {
|
||||
background-color: #ffe500;
|
||||
}
|
||||
[data-composition-id="stats"] .white-bg {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .blue-text { color: #0057FF; }
|
||||
[data-composition-id="stats"] .white-text { color: #FFFFFF; }
|
||||
[data-composition-id="stats"] .pink-text { color: #FF2D8A; }
|
||||
[data-composition-id="stats"] .blue-text {
|
||||
color: #0057ff;
|
||||
}
|
||||
[data-composition-id="stats"] .white-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
[data-composition-id="stats"] .pink-text {
|
||||
color: #ff2d8a;
|
||||
}
|
||||
|
||||
[data-composition-id="stats"] .blue-border { border-color: #0057FF; }
|
||||
[data-composition-id="stats"] .white-border { border-color: #FFFFFF; }
|
||||
[data-composition-id="stats"] .pink-border { border-color: #FF2D8A; }
|
||||
[data-composition-id="stats"] .blue-border {
|
||||
border-color: #0057ff;
|
||||
}
|
||||
[data-composition-id="stats"] .white-border {
|
||||
border-color: #ffffff;
|
||||
}
|
||||
[data-composition-id="stats"] .pink-border {
|
||||
border-color: #ff2d8a;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
[data-composition-id="stats"] .stat-number {
|
||||
@ -161,14 +213,64 @@
|
||||
</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}];
|
||||
(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())
|
||||
const word = TRANSCRIPT.find((w) =>
|
||||
w.text
|
||||
.toLowerCase()
|
||||
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "")
|
||||
.startsWith(wordText.toLowerCase()),
|
||||
);
|
||||
return word ? word.start : null;
|
||||
}
|
||||
@ -179,69 +281,97 @@
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,173 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
#main-composition {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#bg-comp,
|
||||
#aroll-comp,
|
||||
#intro-comp,
|
||||
#stats-comp,
|
||||
#captions-comp {
|
||||
#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>
|
||||
|
||||
#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 {
|
||||
<!-- 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;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#short_mag_cut {
|
||||
}
|
||||
#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>
|
||||
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
border: 8px solid #ffffff;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main-video"] = tl;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-box {
|
||||
background-color: #0A1E3D; /* Solid navy */
|
||||
background-color: #0a1e3d; /* Solid navy */
|
||||
padding: 20px 40px;
|
||||
display: none; /* Hidden by default, shown via GSAP */
|
||||
justify-content: center;
|
||||
@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
color: #F2F2F2; /* Off-white */
|
||||
color: #f2f2f2; /* Off-white */
|
||||
font-size: 72px;
|
||||
text-transform: uppercase; /* Swiss style often uses uppercase for impact */
|
||||
letter-spacing: -2px;
|
||||
@ -44,9 +44,56 @@
|
||||
</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');
|
||||
(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) {
|
||||
@ -57,7 +104,7 @@
|
||||
// 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
|
||||
@ -69,14 +116,14 @@
|
||||
|
||||
// Create DOM elements and timeline for each group
|
||||
groups.forEach((group, i) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'caption-box';
|
||||
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(' ');
|
||||
|
||||
|
||||
const text = document.createElement("div");
|
||||
text.className = "caption-text";
|
||||
text.textContent = group.map((w) => w.text).join(" ");
|
||||
|
||||
box.appendChild(text);
|
||||
container.appendChild(box);
|
||||
|
||||
@ -84,8 +131,8 @@
|
||||
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);
|
||||
tl.set(box, { display: "flex" }, startTime);
|
||||
tl.set(box, { display: "none" }, endTime);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
|
||||
@ -31,8 +31,8 @@
|
||||
|
||||
<style>
|
||||
[data-composition-id="graphics"] {
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
color: #0A1E3D;
|
||||
font-family: "Helvetica", Arial, sans-serif;
|
||||
color: #0a1e3d;
|
||||
background: transparent;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
@ -56,9 +56,9 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
background: #F2F2F2;
|
||||
background: #f2f2f2;
|
||||
padding: 40px;
|
||||
border-left: 10px solid #D4A017;
|
||||
border-left: 10px solid #d4a017;
|
||||
box-shadow: 20px 20px 0px rgba(10, 30, 61, 0.1);
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
[data-composition-id="graphics"] .stat-value {
|
||||
font-size: 140px;
|
||||
font-weight: 900;
|
||||
color: #D4A017;
|
||||
color: #d4a017;
|
||||
line-height: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@ -89,7 +89,7 @@
|
||||
[data-composition-id="graphics"] .stat-label {
|
||||
font-size: 24px;
|
||||
font-weight: 300; /* Lighter weight */
|
||||
color: #0A1E3D;
|
||||
color: #0a1e3d;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -98,16 +98,16 @@
|
||||
[data-composition-id="graphics"] .progress-container {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: #E0E0E0;
|
||||
background: #e0e0e0;
|
||||
margin-top: 30px;
|
||||
border: 2px solid #0A1E3D;
|
||||
border: 2px solid #0a1e3d;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #D4A017;
|
||||
background: #d4a017;
|
||||
}
|
||||
|
||||
/* Grid Blocks for Stat 3 */
|
||||
@ -120,23 +120,23 @@
|
||||
[data-composition-id="graphics"] .block {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 3px solid #0A1E3D;
|
||||
border: 3px solid #0a1e3d;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .block.gold {
|
||||
background: #D4A017;
|
||||
background: #d4a017;
|
||||
}
|
||||
|
||||
[data-composition-id="graphics"] .block.navy {
|
||||
background: #0A1E3D;
|
||||
background: #0a1e3d;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
|
||||
// Timing Constants
|
||||
const S1_START = 1.86;
|
||||
const S1_END = 4.5;
|
||||
@ -144,55 +144,63 @@
|
||||
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.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
|
||||
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
|
||||
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 .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
|
||||
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
|
||||
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.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
|
||||
tl.to(
|
||||
"#stat3",
|
||||
{ x: -200, opacity: 0, duration: SLIDE_DUR, ease: "power2.in" },
|
||||
S3_END - SLIDE_DUR,
|
||||
);
|
||||
|
||||
window.__timelines["graphics"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
<style>
|
||||
[data-composition-id="intro"] {
|
||||
background-color: #F5F5F5; /* Off-white */
|
||||
background-color: #f5f5f5; /* Off-white */
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: flex;
|
||||
@ -36,7 +36,7 @@
|
||||
top: 50%;
|
||||
width: 0px;
|
||||
height: 12px;
|
||||
background-color: #0A1E3D; /* Navy */
|
||||
background-color: #0a1e3d; /* Navy */
|
||||
transform: translateY(-180px); /* Positioned above the text */
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title {
|
||||
color: #0A1E3D;
|
||||
color: #0a1e3d;
|
||||
font-size: 180px;
|
||||
font-weight: 900; /* Black weight */
|
||||
line-height: 0.9;
|
||||
@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .subtitle {
|
||||
color: #0A1E3D;
|
||||
color: #0a1e3d;
|
||||
font-size: 64px;
|
||||
font-weight: 300; /* Lighter weight for contrast */
|
||||
line-height: 1.2;
|
||||
@ -72,7 +72,7 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Mechanical timing: 200ms (0.2s)
|
||||
@ -80,32 +80,48 @@
|
||||
const STAGGER = 0.1;
|
||||
|
||||
// 1. Animate the grid line first
|
||||
tl.to('.grid-line', {
|
||||
width: '1200px',
|
||||
duration: 0.4,
|
||||
ease: 'power4.out'
|
||||
}, 0.2);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
tl.to(
|
||||
".text-wrapper",
|
||||
{
|
||||
x: 30,
|
||||
duration: 1.16, // Adjusted to fit 1.86s total
|
||||
ease: "none",
|
||||
},
|
||||
0.7,
|
||||
);
|
||||
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
|
||||
@ -1,172 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
#master-root {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.background-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
/* 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; }
|
||||
#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">
|
||||
</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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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');
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
// --- 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
|
||||
);
|
||||
const masterTL = gsap.timeline({ paused: true });
|
||||
const v = document.getElementById("short_mag_cut_frame");
|
||||
|
||||
// 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);
|
||||
// --- A-ROLL MOVEMENT (Structured Swiss Layout) ---
|
||||
|
||||
// 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);
|
||||
// 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,
|
||||
);
|
||||
|
||||
// 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);
|
||||
// 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,
|
||||
);
|
||||
|
||||
// 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);
|
||||
// 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,
|
||||
);
|
||||
|
||||
window.__timelines["master"] = masterTL;
|
||||
</script>
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -39,9 +39,9 @@
|
||||
font-size: 64px;
|
||||
line-height: 1.1;
|
||||
color: #000000;
|
||||
background-color: #FFFFFF;
|
||||
background-color: #ffffff;
|
||||
padding: 15px 30px;
|
||||
border-top: 8px solid #CC0000; /* Vignelli Red top border accent */
|
||||
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 */
|
||||
@ -49,29 +49,77 @@
|
||||
</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}];
|
||||
(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');
|
||||
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('!');
|
||||
|
||||
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(' ')
|
||||
text: currentGroup.map((w) => w.text).join(" "),
|
||||
});
|
||||
currentGroup = [];
|
||||
}
|
||||
@ -82,37 +130,42 @@
|
||||
const captionGroups = groupTranscript(TRANSCRIPT);
|
||||
|
||||
captionGroups.forEach((group, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'caption-group';
|
||||
const div = document.createElement("div");
|
||||
div.className = "caption-group";
|
||||
div.id = `group-${index}`;
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'caption-text';
|
||||
|
||||
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,
|
||||
tl.fromTo(
|
||||
div,
|
||||
{ opacity: 0, y: 20 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.4,
|
||||
ease: "expo.out"
|
||||
},
|
||||
group.start
|
||||
ease: "expo.out",
|
||||
},
|
||||
group.start,
|
||||
);
|
||||
|
||||
// Exit
|
||||
tl.to(div, {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
duration: 0.3,
|
||||
ease: "expo.out"
|
||||
}, group.end - 0.3);
|
||||
tl.to(
|
||||
div,
|
||||
{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
duration: 0.3,
|
||||
ease: "expo.out",
|
||||
},
|
||||
group.end - 0.3,
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
<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
|
||||
<br />lack editing skills
|
||||
</div>
|
||||
<div class="side-bar"></div>
|
||||
</div>
|
||||
@ -51,7 +51,7 @@
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #000000;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
@ -87,13 +87,13 @@
|
||||
left: 0;
|
||||
width: 540px; /* 3 columns */
|
||||
height: 240px;
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .red-bar {
|
||||
width: 20px;
|
||||
background: #CC0000;
|
||||
background: #cc0000;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-47 .stat-content {
|
||||
padding: 40px;
|
||||
@ -128,7 +128,7 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1A1A1A;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-62 .full-content {
|
||||
position: relative;
|
||||
@ -142,14 +142,14 @@
|
||||
[data-composition-id="overlays"] #stat-62 .large-number {
|
||||
font-size: 400px;
|
||||
font-weight: 900;
|
||||
color: #FFFFFF;
|
||||
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;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
margin-top: 20px;
|
||||
max-width: 600px;
|
||||
@ -160,7 +160,7 @@
|
||||
right: 0;
|
||||
width: 180px; /* 1 column */
|
||||
height: 100%;
|
||||
background: #CC0000;
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* 3. 3 out of 4 Stat Styles */
|
||||
@ -168,7 +168,7 @@
|
||||
top: 400px;
|
||||
right: 0;
|
||||
width: 360px; /* 2 columns */
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
padding: 40px;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
@ -179,7 +179,7 @@
|
||||
text-align: right;
|
||||
}
|
||||
[data-composition-id="overlays"] #stat-3-4 .highlight {
|
||||
color: #CC0000;
|
||||
color: #cc0000;
|
||||
font-size: 64px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@ -216,7 +216,7 @@
|
||||
[data-composition-id="overlays"] #branding .logo-underline {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #CC0000;
|
||||
background: #cc0000;
|
||||
margin-top: 10px;
|
||||
transform-origin: left;
|
||||
}
|
||||
@ -224,45 +224,96 @@
|
||||
|
||||
<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}];
|
||||
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
|
||||
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-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
|
||||
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-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;
|
||||
|
||||
@ -1,171 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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;
|
||||
}
|
||||
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 */
|
||||
}
|
||||
/* 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 */
|
||||
}
|
||||
.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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
#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,
|
||||
#captions-comp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#overlays-comp {
|
||||
z-index: 20;
|
||||
}
|
||||
#overlays-comp {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
#captions-comp {
|
||||
z-index: 30;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
/* 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>
|
||||
</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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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 });
|
||||
<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).
|
||||
// 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 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);
|
||||
// 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>
|
||||
window.__timelines["main-comp"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-box {
|
||||
background-color: #7A6248;
|
||||
background-color: #7a6248;
|
||||
padding: 12px 32px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
@ -31,8 +31,8 @@
|
||||
}
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
color: #F5F0E0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: #f5f0e0;
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
@ -42,54 +42,54 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
(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}
|
||||
{ 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)
|
||||
@ -97,9 +97,9 @@
|
||||
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(' '),
|
||||
text: lineWords.map((w) => w.text).join(" "),
|
||||
start: lineWords[0].start,
|
||||
end: lineWords[lineWords.length - 1].end
|
||||
end: lineWords[lineWords.length - 1].end,
|
||||
});
|
||||
}
|
||||
|
||||
@ -109,21 +109,29 @@
|
||||
|
||||
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);
|
||||
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);
|
||||
tl.to(
|
||||
box,
|
||||
{
|
||||
opacity: 0,
|
||||
duration: 0.1,
|
||||
ease: "power2.in",
|
||||
},
|
||||
line.end,
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
|
||||
@ -17,7 +17,10 @@
|
||||
<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" />
|
||||
<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>
|
||||
@ -28,7 +31,7 @@
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-family: "Outfit", sans-serif;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -46,7 +49,7 @@
|
||||
[data-composition-id="graphics"] #moment-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background-color: #3B5E3A;
|
||||
background-color: #3b5e3a;
|
||||
border-radius: 50%;
|
||||
left: 1400px;
|
||||
top: 540px;
|
||||
@ -72,7 +75,7 @@
|
||||
[data-composition-id="graphics"] #moment-2 {
|
||||
width: 500px;
|
||||
height: 220px;
|
||||
background-color: #CC8832;
|
||||
background-color: #cc8832;
|
||||
border-radius: 110px;
|
||||
left: 960px;
|
||||
top: 200px;
|
||||
@ -83,7 +86,7 @@
|
||||
[data-composition-id="graphics"] #moment-3 {
|
||||
width: 550px;
|
||||
height: 180px;
|
||||
background-color: #C45D3E;
|
||||
background-color: #c45d3e;
|
||||
border-radius: 40px;
|
||||
left: 400px;
|
||||
top: 540px;
|
||||
@ -117,41 +120,44 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
(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
|
||||
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);
|
||||
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
|
||||
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);
|
||||
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
|
||||
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);
|
||||
tl.to("#moment-3", { opacity: 0, duration: 0.5, ease: "power2.in" }, 13.5);
|
||||
|
||||
window.__timelines["graphics"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
<style>
|
||||
/* Import a rounded humanist sans-serif font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap');
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap");
|
||||
|
||||
[data-composition-id="intro"] .container {
|
||||
width: 100%;
|
||||
@ -18,15 +18,15 @@
|
||||
justify-content: flex-start; /* Align to left for speaker card */
|
||||
align-items: center;
|
||||
padding-left: 5%;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-family: "Outfit", sans-serif;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-composition-id="intro"] .title-card {
|
||||
background-color: #3B5E3A; /* Forest Green for contrast */
|
||||
background-color: #3b5e3a; /* Forest Green for contrast */
|
||||
padding: 40px 60px;
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%); /* Start off-screen left */
|
||||
@ -35,7 +35,7 @@
|
||||
[data-composition-id="intro"] .title {
|
||||
font-size: 100px;
|
||||
font-weight: 600;
|
||||
color: #F5F0E0; /* Cream text on green card */
|
||||
color: #f5f0e0; /* Cream text on green card */
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -2px;
|
||||
@ -44,31 +44,39 @@
|
||||
[data-composition-id="intro"] .subtitle {
|
||||
font-size: 50px;
|
||||
font-weight: 400;
|
||||
color: #CC8832; /* Ochre accent */
|
||||
color: #cc8832; /* Ochre accent */
|
||||
margin: 10px 0 0 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
(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);
|
||||
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);
|
||||
tl.to(
|
||||
'[data-composition-id="intro"] .title-card',
|
||||
{
|
||||
x: "-120%",
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: "power2.in",
|
||||
},
|
||||
1.8,
|
||||
);
|
||||
|
||||
window.__timelines["intro"] = tl;
|
||||
})();
|
||||
|
||||
@ -1,195 +1,281 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
/* 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 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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
#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;
|
||||
}
|
||||
.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>
|
||||
|
||||
</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 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;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
const isColorSupported =
|
||||
process.stdout.isTTY === true &&
|
||||
!process.env["NO_COLOR"] &&
|
||||
process.env["FORCE_COLOR"] !== "0";
|
||||
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;
|
||||
|
||||
@ -6,9 +6,7 @@ export function renderProgress(percent: number, stage: string, row?: number): vo
|
||||
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 bar = c.progress("\u2588".repeat(filled)) + c.dim("\u2591".repeat(empty));
|
||||
|
||||
const line = ` ${bar} ${c.bold(String(Math.round(percent)) + "%")} ${c.dim(stage)}`;
|
||||
|
||||
|
||||
@ -217,11 +217,23 @@ Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
|
||||
|
||||
```html
|
||||
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
|
||||
<video id="scene-a" data-start="intro + 2" data-duration="20" data-track-index="0" src="..."></video>
|
||||
<video
|
||||
id="scene-a"
|
||||
data-start="intro + 2"
|
||||
data-duration="20"
|
||||
data-track-index="0"
|
||||
src="..."
|
||||
></video>
|
||||
|
||||
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
|
||||
<!-- Different track because clips on the same track cannot overlap -->
|
||||
<video id="scene-b" data-start="intro - 0.5" data-duration="20" data-track-index="1" src="..."></video>
|
||||
<video
|
||||
id="scene-b"
|
||||
data-start="intro - 0.5"
|
||||
data-duration="20"
|
||||
data-track-index="1"
|
||||
src="..."
|
||||
></video>
|
||||
```
|
||||
|
||||
### Rules
|
||||
@ -253,13 +265,7 @@ Full-screen or positioned video clips. Videos sync their playback to the timelin
|
||||
Static images that appear for a duration.
|
||||
|
||||
```html
|
||||
<img
|
||||
id="el-2"
|
||||
data-start="5"
|
||||
data-duration="4"
|
||||
data-track-index="1"
|
||||
src="./assets/video.mp4"
|
||||
/>
|
||||
<img id="el-2" data-start="5" data-duration="4" data-track-index="1" src="./assets/video.mp4" />
|
||||
```
|
||||
|
||||
## Audio Clips
|
||||
|
||||
@ -102,7 +102,14 @@
|
||||
|
||||
data-duration is REQUIRED for images (they have no intrinsic duration).
|
||||
-->
|
||||
<img id="el-image" class="clip" data-start="10" data-duration="5" data-track-index="0" src="my-image.png" />
|
||||
<img
|
||||
id="el-image"
|
||||
class="clip"
|
||||
data-start="10"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
src="my-image.png"
|
||||
/>
|
||||
|
||||
<!--
|
||||
════════════════════════════════════════════════════
|
||||
@ -142,7 +149,9 @@
|
||||
data-track-index="2"
|
||||
style="display: flex; align-items: center; justify-content: center; z-index: 10"
|
||||
>
|
||||
<h1 style="font-family: sans-serif; font-size: 72px; color: white; opacity: 0">Hello World</h1>
|
||||
<h1 style="font-family: sans-serif; font-size: 72px; color: white; opacity: 0">
|
||||
Hello World
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
24
packages/core/docs/versions/v0.1/core.md
vendored
24
packages/core/docs/versions/v0.1/core.md
vendored
@ -118,11 +118,23 @@ Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
|
||||
|
||||
```html
|
||||
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
|
||||
<video id="scene-a" data-start="intro + 2" data-duration="20" data-track-index="0" src="..."></video>
|
||||
<video
|
||||
id="scene-a"
|
||||
data-start="intro + 2"
|
||||
data-duration="20"
|
||||
data-track-index="0"
|
||||
src="..."
|
||||
></video>
|
||||
|
||||
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
|
||||
<!-- Different track because clips on the same track cannot overlap -->
|
||||
<video id="scene-b" data-start="intro - 0.5" data-duration="20" data-track-index="1" src="..."></video>
|
||||
<video
|
||||
id="scene-b"
|
||||
data-start="intro - 0.5"
|
||||
data-duration="20"
|
||||
data-track-index="1"
|
||||
src="..."
|
||||
></video>
|
||||
```
|
||||
|
||||
### Rules
|
||||
@ -154,13 +166,7 @@ Full-screen or positioned video clips. Videos sync their playback to the timelin
|
||||
Static images that appear for a duration.
|
||||
|
||||
```html
|
||||
<img
|
||||
id="el-2"
|
||||
data-start="5"
|
||||
data-duration="4"
|
||||
data-track-index="1"
|
||||
src="./assets/video.mp4"
|
||||
/>
|
||||
<img id="el-2" data-start="5" data-duration="4" data-track-index="1" src="./assets/video.mp4" />
|
||||
```
|
||||
|
||||
## Audio Clips
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
{
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.1.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"docs",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
@ -19,15 +24,8 @@
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"docs",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@ -42,7 +40,9 @@
|
||||
"types": "./dist/compiler/index.d.ts"
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && pnpm build:hyperframes-runtime",
|
||||
@ -66,10 +66,6 @@
|
||||
"debug:timeline": "tsx scripts/debug-timeline.ts",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cheerio": "^1.2.0",
|
||||
"esbuild": "^0.25.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
@ -78,5 +74,9 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cheerio": "^1.2.0",
|
||||
"esbuild": "^0.25.12"
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +108,8 @@ function normalizeDurationSeconds(
|
||||
maxDuration: number,
|
||||
): number {
|
||||
const safeFallback = fallbackDuration != null && fallbackDuration > 0 ? fallbackDuration : 0;
|
||||
const safeRaw = rawDuration != null && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
|
||||
const safeRaw =
|
||||
rawDuration != null && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
|
||||
if (safeRaw > 0) return Math.min(safeRaw, maxDuration);
|
||||
if (safeFallback > 0) return Math.min(safeFallback, maxDuration);
|
||||
return 0;
|
||||
@ -116,10 +117,19 @@ function normalizeDurationSeconds(
|
||||
|
||||
function shouldIncludeTimelineNode(tag: ParsedTag, rootCompositionId: string | null): boolean {
|
||||
const attrs = tag.attrs;
|
||||
if (attrs["data-composition-id"] && rootCompositionId && attrs["data-composition-id"] === rootCompositionId) {
|
||||
if (
|
||||
attrs["data-composition-id"] &&
|
||||
rootCompositionId &&
|
||||
attrs["data-composition-id"] === rootCompositionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (tag.tagName === "script" || tag.tagName === "style" || tag.tagName === "link" || tag.tagName === "meta") {
|
||||
if (
|
||||
tag.tagName === "script" ||
|
||||
tag.tagName === "style" ||
|
||||
tag.tagName === "link" ||
|
||||
tag.tagName === "meta"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((attrs.class || "").split(/\s+/).includes("__preview_render_frame__")) return false;
|
||||
@ -133,7 +143,8 @@ function shouldIncludeTimelineNode(tag: ParsedTag, rootCompositionId: string | n
|
||||
function inferClipDuration(tag: ParsedTag, start: number, maxDuration: number): number | null {
|
||||
const attrs = tag.attrs;
|
||||
const durationAttr = parseNum(attrs["data-duration"]);
|
||||
if (durationAttr != null && durationAttr > 0) return normalizeDurationSeconds(durationAttr, null, maxDuration);
|
||||
if (durationAttr != null && durationAttr > 0)
|
||||
return normalizeDurationSeconds(durationAttr, null, maxDuration);
|
||||
|
||||
const endAttr = parseNum(attrs["data-end"]);
|
||||
if (endAttr != null && endAttr > start) {
|
||||
@ -142,9 +153,14 @@ function inferClipDuration(tag: ParsedTag, start: number, maxDuration: number):
|
||||
|
||||
if (tag.tagName === "video" || tag.tagName === "audio") {
|
||||
const sourceDuration = parseNum(attrs["data-source-duration"]);
|
||||
const playbackStart = parseNum(attrs["data-playback-start"]) ?? parseNum(attrs["data-playbackStart"]) ?? 0;
|
||||
const playbackStart =
|
||||
parseNum(attrs["data-playback-start"]) ?? parseNum(attrs["data-playbackStart"]) ?? 0;
|
||||
if (sourceDuration != null && sourceDuration > 0) {
|
||||
return normalizeDurationSeconds(Math.max(0, sourceDuration - playbackStart), null, maxDuration);
|
||||
return normalizeDurationSeconds(
|
||||
Math.max(0, sourceDuration - playbackStart),
|
||||
null,
|
||||
maxDuration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,7 +222,9 @@ function main() {
|
||||
const rootDurationRaw =
|
||||
parseNum(root?.attrs["data-composition-duration"]) ??
|
||||
parseNum(root?.attrs["data-duration"]) ??
|
||||
parseNum(tags.find((tag) => tag.tagName === "html")?.attrs["data-composition-duration"] ?? null);
|
||||
parseNum(
|
||||
tags.find((tag) => tag.tagName === "html")?.attrs["data-composition-duration"] ?? null,
|
||||
);
|
||||
const rootDuration = normalizeDurationSeconds(rootDurationRaw, null, maxDuration);
|
||||
|
||||
const nodes = tags.filter((tag) => shouldIncludeTimelineNode(tag, rootCompositionId));
|
||||
@ -220,7 +238,9 @@ function main() {
|
||||
const start = Math.max(0, parseNum(attrs["data-start"]) ?? 0);
|
||||
const inferredDuration = inferClipDuration(node, start, maxDuration);
|
||||
const hasDeterministicDuration = inferredDuration != null && inferredDuration > 0;
|
||||
let duration = hasDeterministicDuration ? normalizeDurationSeconds(inferredDuration, 0, maxDuration) : 0;
|
||||
let duration = hasDeterministicDuration
|
||||
? normalizeDurationSeconds(inferredDuration, 0, maxDuration)
|
||||
: 0;
|
||||
let durationSource: "deterministic" | "fallback" = "deterministic";
|
||||
if (duration <= 0 && rootDuration > start) {
|
||||
duration = normalizeDurationSeconds(rootDuration - start, 0, maxDuration);
|
||||
@ -261,7 +281,8 @@ function main() {
|
||||
|
||||
let effectiveDuration = 0;
|
||||
if (maxEnd > 0) effectiveDuration = normalizeDurationSeconds(maxEnd, 0, maxDuration);
|
||||
if (effectiveDuration <= 0) effectiveDuration = normalizeDurationSeconds(rootDuration, 1, maxDuration);
|
||||
if (effectiveDuration <= 0)
|
||||
effectiveDuration = normalizeDurationSeconds(rootDuration, 1, maxDuration);
|
||||
if (effectiveDuration <= 0) effectiveDuration = 1;
|
||||
|
||||
const compositionWidth = parseNum(root?.attrs["data-width"]) ?? 1920;
|
||||
|
||||
@ -58,7 +58,9 @@ function testDetectsOverlappingGsapTweens() {
|
||||
`;
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const overlapFinding = result.findings.find((finding) => finding.code === "overlapping_gsap_tweens");
|
||||
const overlapFinding = result.findings.find(
|
||||
(finding) => finding.code === "overlapping_gsap_tweens",
|
||||
);
|
||||
|
||||
assert.ok(overlapFinding, "expected an overlapping GSAP tween warning");
|
||||
assert.equal(overlapFinding?.severity, "warning");
|
||||
@ -67,10 +69,14 @@ function testDetectsOverlappingGsapTweens() {
|
||||
function testCliJsonOutput() {
|
||||
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
|
||||
const tsxBin = path.join(ROOT, "node_modules/.bin/tsx");
|
||||
const stdout = execFileSync(tsxBin, ["scripts/check-hyperframe-static.ts", "--json", fixturePath], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
["scripts/check-hyperframe-static.ts", "--json", fixturePath],
|
||||
{
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout);
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
|
||||
@ -16,7 +16,10 @@ const initSource = readFileSync(initPath, "utf8");
|
||||
const timelineSource = readFileSync(timelinePath, "utf8");
|
||||
|
||||
// Guard against regressions where preview duration gets capped by earliest video.
|
||||
assert(!initSource.includes("resolveMainVideoDurationSeconds"), "init.ts should not use first-video duration helper");
|
||||
assert(
|
||||
!initSource.includes("resolveMainVideoDurationSeconds"),
|
||||
"init.ts should not use first-video duration helper",
|
||||
);
|
||||
assert(
|
||||
!initSource.includes("Math.max(0, Math.min(safeDuration, mediaFloor))"),
|
||||
"init.ts should not hard-clamp safe duration to media floor",
|
||||
|
||||
@ -78,11 +78,17 @@ const blockedMessages = [
|
||||
];
|
||||
|
||||
for (const fixture of allowedMessages) {
|
||||
assert(isGuardedPreviewMessage(fixture), `Expected message fixture to pass guard: ${JSON.stringify(fixture)}`);
|
||||
assert(
|
||||
isGuardedPreviewMessage(fixture),
|
||||
`Expected message fixture to pass guard: ${JSON.stringify(fixture)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const fixture of blockedMessages) {
|
||||
assert(!isGuardedPreviewMessage(fixture), `Expected message fixture to fail guard: ${JSON.stringify(fixture)}`);
|
||||
assert(
|
||||
!isGuardedPreviewMessage(fixture),
|
||||
`Expected message fixture to fail guard: ${JSON.stringify(fixture)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
@ -65,7 +65,8 @@ function createPlayer(timeline: RuntimeTimelineLike) {
|
||||
|
||||
function testSeekUsesDeterministicGsapPath(): void {
|
||||
const { calls, timeline } = createTimeline(true);
|
||||
const { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls } = createPlayer(timeline);
|
||||
const { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls } =
|
||||
createPlayer(timeline);
|
||||
const quantizedTime = 2;
|
||||
|
||||
player.seek(2.017);
|
||||
@ -81,7 +82,11 @@ function testSeekUsesDeterministicGsapPath(): void {
|
||||
"player.seek() should notify adapters with the quantized time",
|
||||
);
|
||||
assert.deepEqual(syncMediaCalls, [quantizedTime], "media sync should use quantized time");
|
||||
assert.deepEqual(renderFrameSeekCalls, [quantizedTime], "render frame seek should use quantized time");
|
||||
assert.deepEqual(
|
||||
renderFrameSeekCalls,
|
||||
[quantizedTime],
|
||||
"render frame seek should use quantized time",
|
||||
);
|
||||
}
|
||||
|
||||
function testGsapAdapterPreservesTotalTime(): void {
|
||||
|
||||
@ -20,7 +20,8 @@ export function createGSAPFrameAdapter(options: CreateGSAPFrameAdapterOptions):
|
||||
const adapterId = options.id ?? "gsap";
|
||||
|
||||
const getDurationSeconds = (): number => {
|
||||
const totalDuration = typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
|
||||
const totalDuration =
|
||||
typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
|
||||
return Number.isFinite(totalDuration) && totalDuration > 0 ? totalDuration : 0;
|
||||
};
|
||||
|
||||
|
||||
@ -71,17 +71,31 @@ function injectInterceptor(html: string): string {
|
||||
|
||||
function isRelativeUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
return !url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//") && !url.startsWith("data:") && !isAbsolute(url);
|
||||
return (
|
||||
!url.startsWith("http://") &&
|
||||
!url.startsWith("https://") &&
|
||||
!url.startsWith("//") &&
|
||||
!url.startsWith("data:") &&
|
||||
!isAbsolute(url)
|
||||
);
|
||||
}
|
||||
|
||||
function safeReadFile(filePath: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try { return readFileSync(filePath, "utf-8"); } catch { return null; }
|
||||
try {
|
||||
return readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadFileBuffer(filePath: string): Buffer | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try { return readFileSync(filePath); } catch { return null; }
|
||||
try {
|
||||
return readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function splitUrlSuffix(urlValue: string): { basePath: string; suffix: string } {
|
||||
@ -99,7 +113,8 @@ function appendSuffixToUrl(baseUrl: string, suffix: string): string {
|
||||
const queryWithOptionalHash = suffix.slice(1);
|
||||
if (!queryWithOptionalHash) return baseUrl;
|
||||
const hashIdx = queryWithOptionalHash.indexOf("#");
|
||||
const queryPart = hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
|
||||
const queryPart =
|
||||
hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
|
||||
const hashPart = hashIdx >= 0 ? queryWithOptionalHash.slice(hashIdx) : "";
|
||||
if (!queryPart) return `${baseUrl}${hashPart}`;
|
||||
const joiner = baseUrl.includes("?") ? "&" : "?";
|
||||
@ -137,15 +152,18 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
|
||||
|
||||
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
|
||||
if (!srcsetValue) return srcsetValue;
|
||||
return srcsetValue.split(",").map((rawCandidate) => {
|
||||
const candidate = rawCandidate.trim();
|
||||
if (!candidate) return candidate;
|
||||
const parts = candidate.split(/\s+/);
|
||||
if (parts.length === 0) return candidate;
|
||||
const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir);
|
||||
if (maybeInlined) parts[0] = maybeInlined;
|
||||
return parts.join(" ");
|
||||
}).join(", ");
|
||||
return srcsetValue
|
||||
.split(",")
|
||||
.map((rawCandidate) => {
|
||||
const candidate = rawCandidate.trim();
|
||||
if (!candidate) return candidate;
|
||||
const parts = candidate.split(/\s+/);
|
||||
if (parts.length === 0) return candidate;
|
||||
const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir);
|
||||
if (maybeInlined) parts[0] = maybeInlined;
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): string {
|
||||
@ -178,9 +196,14 @@ function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
|
||||
let modified = false;
|
||||
for (const [compId, { w, h }] of sizeMap) {
|
||||
const escaped = compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const blockRe = new RegExp(`(\\[data-composition-id=["']${escaped}["']\\]\\s*\\{)([^}]*)(})`, "g");
|
||||
const blockRe = new RegExp(
|
||||
`(\\[data-composition-id=["']${escaped}["']\\]\\s*\\{)([^}]*)(})`,
|
||||
"g",
|
||||
);
|
||||
css = css.replace(blockRe, (_, open, body, close) => {
|
||||
const newBody = body.replace(/(\bwidth\s*:\s*)100%/g, `$1${w}px`).replace(/(\bheight\s*:\s*)100%/g, `$1${h}px`);
|
||||
const newBody = body
|
||||
.replace(/(\bwidth\s*:\s*)100%/g, `$1${w}px`)
|
||||
.replace(/(\bheight\s*:\s*)100%/g, `$1${h}px`);
|
||||
if (newBody !== body) modified = true;
|
||||
return open + newBody + close;
|
||||
});
|
||||
@ -234,7 +257,10 @@ function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
|
||||
if (!raw) continue;
|
||||
const nonImportCss = raw.replace(importRe, (match) => {
|
||||
const cleaned = match.trim();
|
||||
if (!seenImports.has(cleaned)) { seenImports.add(cleaned); imports.push(cleaned); }
|
||||
if (!seenImports.has(cleaned)) {
|
||||
seenImports.add(cleaned);
|
||||
imports.push(cleaned);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const trimmed = nonImportCss.trim();
|
||||
@ -247,14 +273,20 @@ function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
|
||||
}
|
||||
}
|
||||
|
||||
const bodyInlineScripts = $("body script").toArray().filter((el) => {
|
||||
const src = ($(el).attr("src") || "").trim();
|
||||
if (src) return false;
|
||||
const type = ($(el).attr("type") || "").trim().toLowerCase();
|
||||
return !type || type === "text/javascript" || type === "application/javascript";
|
||||
});
|
||||
const bodyInlineScripts = $("body script")
|
||||
.toArray()
|
||||
.filter((el) => {
|
||||
const src = ($(el).attr("src") || "").trim();
|
||||
if (src) return false;
|
||||
const type = ($(el).attr("type") || "").trim().toLowerCase();
|
||||
return !type || type === "text/javascript" || type === "application/javascript";
|
||||
});
|
||||
if (bodyInlineScripts.length > 0) {
|
||||
const mergedJs = bodyInlineScripts.map((el) => ($(el).html() || "").trim()).filter(Boolean).join("\n;\n").trim();
|
||||
const mergedJs = bodyInlineScripts
|
||||
.map((el) => ($(el).html() || "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n;\n")
|
||||
.trim();
|
||||
for (const el of bodyInlineScripts) $(el).remove();
|
||||
if (mergedJs) {
|
||||
const stripped = stripJsCommentsParserSafe(mergedJs);
|
||||
@ -268,7 +300,9 @@ function stripJsCommentsParserSafe(source: string): string {
|
||||
try {
|
||||
const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" });
|
||||
return result.code.trim();
|
||||
} catch { return source; }
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BundleOptions {
|
||||
@ -285,7 +319,10 @@ export interface BundleOptions {
|
||||
* - Inlines sub-composition HTML fragments (data-composition-src)
|
||||
* - Inlines small textual assets as data URLs
|
||||
*/
|
||||
export async function bundleToSingleHtml(projectDir: string, options?: BundleOptions): Promise<string> {
|
||||
export async function bundleToSingleHtml(
|
||||
projectDir: string,
|
||||
options?: BundleOptions,
|
||||
): Promise<string> {
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
if (!existsSync(indexPath)) throw new Error("index.html not found in project directory");
|
||||
|
||||
@ -294,7 +331,9 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
|
||||
const staticGuard = validateHyperframeHtmlContract(compiled);
|
||||
if (!staticGuard.isValid) {
|
||||
console.warn(`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`);
|
||||
console.warn(
|
||||
`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const withInterceptor = injectInterceptor(compiled);
|
||||
@ -310,11 +349,17 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
const css = cssPath ? safeReadFile(cssPath) : null;
|
||||
if (css == null) return;
|
||||
localCssChunks.push(css);
|
||||
if (!cssAnchorPlaced) { $(el).replaceWith('<style data-hf-bundled-local-css="1"></style>'); cssAnchorPlaced = true; } else { $(el).remove(); }
|
||||
if (!cssAnchorPlaced) {
|
||||
$(el).replaceWith('<style data-hf-bundled-local-css="1"></style>');
|
||||
cssAnchorPlaced = true;
|
||||
} else {
|
||||
$(el).remove();
|
||||
}
|
||||
});
|
||||
if (localCssChunks.length > 0) {
|
||||
const $anchor = $('style[data-hf-bundled-local-css="1"]').first();
|
||||
if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
|
||||
if ($anchor.length)
|
||||
$anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
|
||||
else $("head").append(`<style>${localCssChunks.join("\n\n")}</style>`);
|
||||
}
|
||||
|
||||
@ -328,11 +373,17 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js == null) return;
|
||||
localJsChunks.push(js);
|
||||
if (!jsAnchorPlaced) { $(el).replaceWith('<script data-hf-bundled-local-js="1"></script>'); jsAnchorPlaced = true; } else { $(el).remove(); }
|
||||
if (!jsAnchorPlaced) {
|
||||
$(el).replaceWith('<script data-hf-bundled-local-js="1"></script>');
|
||||
jsAnchorPlaced = true;
|
||||
} else {
|
||||
$(el).remove();
|
||||
}
|
||||
});
|
||||
if (localJsChunks.length > 0) {
|
||||
const $anchor = $('script[data-hf-bundled-local-js="1"]').first();
|
||||
if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
|
||||
if ($anchor.length)
|
||||
$anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
|
||||
else $("body").append(`<script>${localJsChunks.join("\n;\n")}</script>`);
|
||||
}
|
||||
|
||||
@ -344,18 +395,30 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
if (!src || !isRelativeUrl(src)) return;
|
||||
const compPath = safePath(projectDir, src);
|
||||
const compHtml = compPath ? safeReadFile(compPath) : null;
|
||||
if (compHtml == null) { console.warn(`[Bundler] Composition file not found: ${src}`); return; }
|
||||
if (compHtml == null) {
|
||||
console.warn(`[Bundler] Composition file not found: ${src}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const $comp = cheerio.load(compHtml);
|
||||
const compId = $(hostEl).attr("data-composition-id");
|
||||
const $contentRoot = $comp("template").first();
|
||||
const contentHtml = $contentRoot.length ? $contentRoot.html() || "" : $comp("body").html() || "";
|
||||
const contentHtml = $contentRoot.length
|
||||
? $contentRoot.html() || ""
|
||||
: $comp("body").html() || "";
|
||||
const $content = cheerio.load(contentHtml);
|
||||
const $innerRoot = compId ? $content(`[data-composition-id="${compId}"]`).first() : $content("[data-composition-id]").first();
|
||||
const $innerRoot = compId
|
||||
? $content(`[data-composition-id="${compId}"]`).first()
|
||||
: $content("[data-composition-id]").first();
|
||||
|
||||
$content("style").each((_, s) => { compStyleChunks.push($content(s).html() || ""); $content(s).remove(); });
|
||||
$content("style").each((_, s) => {
|
||||
compStyleChunks.push($content(s).html() || "");
|
||||
$content(s).remove();
|
||||
});
|
||||
$content("script").each((_, s) => {
|
||||
compScriptChunks.push(`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`);
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
$content(s).remove();
|
||||
});
|
||||
|
||||
@ -363,7 +426,8 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
const innerCompId = $innerRoot.attr("data-composition-id");
|
||||
const innerW = $innerRoot.attr("data-width");
|
||||
const innerH = $innerRoot.attr("data-height");
|
||||
if (innerCompId && !$(hostEl).attr("data-composition-id")) $(hostEl).attr("data-composition-id", innerCompId);
|
||||
if (innerCompId && !$(hostEl).attr("data-composition-id"))
|
||||
$(hostEl).attr("data-composition-id", innerCompId);
|
||||
if (innerW && !$(hostEl).attr("data-width")) $(hostEl).attr("data-width", innerW);
|
||||
if (innerH && !$(hostEl).attr("data-height")) $(hostEl).attr("data-height", innerH);
|
||||
$innerRoot.find("style, script").remove();
|
||||
@ -376,7 +440,8 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
});
|
||||
|
||||
if (compStyleChunks.length) $("head").append(`<style>${compStyleChunks.join("\n\n")}</style>`);
|
||||
if (compScriptChunks.length) $("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
|
||||
if (compScriptChunks.length)
|
||||
$("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
|
||||
|
||||
enforceCompositionPixelSizing($);
|
||||
autoHealMissingCompositionIds($);
|
||||
@ -395,8 +460,12 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
|
||||
const srcset = $(el).attr("srcset");
|
||||
if (srcset) $(el).attr("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir));
|
||||
});
|
||||
$("style").each((_, el) => { $(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir)); });
|
||||
$("[style]").each((_, el) => { $(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir)); });
|
||||
$("style").each((_, el) => {
|
||||
$(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir));
|
||||
});
|
||||
$("[style]").each((_, el) => {
|
||||
$(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir));
|
||||
});
|
||||
|
||||
return $.html();
|
||||
}
|
||||
|
||||
@ -14,9 +14,7 @@ import {
|
||||
export type MediaDurationProber = (src: string) => Promise<number>;
|
||||
|
||||
function resolveMediaSrc(src: string, projectDir: string): string {
|
||||
return src.startsWith("http://") || src.startsWith("https://")
|
||||
? src
|
||||
: resolve(projectDir, src);
|
||||
return src.startsWith("http://") || src.startsWith("https://") ? src : resolve(projectDir, src);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./timingCompiler.js";
|
||||
import {
|
||||
compileTimingAttrs,
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
} from "./timingCompiler.js";
|
||||
|
||||
describe("compileTimingAttrs", () => {
|
||||
it("adds data-end when data-start and data-duration are present on a video", () => {
|
||||
|
||||
@ -50,7 +50,7 @@ export interface CompilationResult {
|
||||
|
||||
function getAttr(tag: string, attr: string): string | null {
|
||||
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
|
||||
return match ? match[1] ?? null : null;
|
||||
return match ? (match[1] ?? null) : null;
|
||||
}
|
||||
|
||||
function hasAttr(tag: string, attr: string): boolean {
|
||||
@ -63,7 +63,10 @@ function injectAttr(tag: string, attr: string, value: string): string {
|
||||
|
||||
// ── Core compilation ─────────────────────────────────────────────────────
|
||||
|
||||
function compileTag(tag: string, isVideo: boolean): { tag: string; unresolved: UnresolvedElement | null } {
|
||||
function compileTag(
|
||||
tag: string,
|
||||
isVideo: boolean,
|
||||
): { tag: string; unresolved: UnresolvedElement | null } {
|
||||
let result = tag;
|
||||
let unresolved: UnresolvedElement | null = null;
|
||||
|
||||
|
||||
@ -127,7 +127,12 @@ export interface EnumVariable extends CompositionVariableBase {
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export type CompositionVariable = StringVariable | NumberVariable | ColorVariable | BooleanVariable | EnumVariable;
|
||||
export type CompositionVariable =
|
||||
| StringVariable
|
||||
| NumberVariable
|
||||
| ColorVariable
|
||||
| BooleanVariable
|
||||
| EnumVariable;
|
||||
|
||||
export interface CompositionSpec {
|
||||
id: string;
|
||||
@ -155,7 +160,10 @@ export function isEnumVariable(v: CompositionVariable): v is EnumVariable {
|
||||
return v.type === "enum";
|
||||
}
|
||||
|
||||
export type TimelineElement = TimelineMediaElement | TimelineTextElement | TimelineCompositionElement;
|
||||
export type TimelineElement =
|
||||
| TimelineMediaElement
|
||||
| TimelineTextElement
|
||||
| TimelineCompositionElement;
|
||||
|
||||
export function isTextElement(el: TimelineElement): el is TimelineTextElement {
|
||||
return el.type === "text";
|
||||
@ -225,7 +233,7 @@ export interface PlayerAPI {
|
||||
id: string;
|
||||
time: number;
|
||||
properties: { x?: number; y?: number };
|
||||
}> | null
|
||||
}> | null,
|
||||
): void;
|
||||
setElementScale(elementId: string, scale: number): void;
|
||||
setElementFontSize(elementId: string, fontSize: number): void;
|
||||
@ -235,7 +243,13 @@ export interface PlayerAPI {
|
||||
setElementTextFontWeight(elementId: string, weight: number): void;
|
||||
setElementTextFontFamily(elementId: string, fontFamily: string): void;
|
||||
setElementTextOutline(elementId: string, enabled: boolean, color?: string, width?: number): void;
|
||||
setElementTextHighlight(elementId: string, enabled: boolean, color?: string, padding?: number, radius?: number): void;
|
||||
setElementTextHighlight(
|
||||
elementId: string,
|
||||
enabled: boolean,
|
||||
color?: string,
|
||||
padding?: number,
|
||||
radius?: number,
|
||||
): void;
|
||||
setElementVolume(elementId: string, volume: number): void;
|
||||
setStageZoom(scale: number, focusX: number, focusY: number): void;
|
||||
getStageZoom(): { scale: number; focusX: number; focusY: number };
|
||||
@ -245,7 +259,7 @@ export interface PlayerAPI {
|
||||
time: number;
|
||||
zoom: { scale: number; focusX: number; focusY: number };
|
||||
ease?: string;
|
||||
}> | null
|
||||
}> | null,
|
||||
): void;
|
||||
getStageZoomKeyframes(): Array<{
|
||||
id: string;
|
||||
@ -256,7 +270,12 @@ export interface PlayerAPI {
|
||||
addElement(data: AddElementData): boolean;
|
||||
removeElement(elementId: string): boolean;
|
||||
updateElementTiming(elementId: string, start?: number, end?: number): boolean;
|
||||
setElementTiming(elementId: string, startTime: number, duration: number, mediaStartTime?: number): void;
|
||||
setElementTiming(
|
||||
elementId: string,
|
||||
startTime: number,
|
||||
duration: number,
|
||||
mediaStartTime?: number,
|
||||
): void;
|
||||
updateElementSrc(elementId: string, src: string): boolean;
|
||||
updateElementLayer(elementId: string, zIndex: number): boolean;
|
||||
updateElementBasePosition(elementId: string, x?: number, y?: number, scale?: number): boolean;
|
||||
@ -269,7 +288,13 @@ export interface PlayerAPI {
|
||||
renderSeek(time: number): void;
|
||||
getElementVisibility(elementId: string): { visible: boolean; opacity?: number };
|
||||
getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>;
|
||||
getRenderState(): { time: number; duration: number; isPlaying: boolean; renderMode: boolean; timelineDirty: boolean };
|
||||
getRenderState(): {
|
||||
time: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
renderMode: boolean;
|
||||
timelineDirty: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddElementData {
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateHyperframesHtml, generateGsapTimelineScript, generateHyperframesStyles } from "./hyperframes.js";
|
||||
import {
|
||||
generateHyperframesHtml,
|
||||
generateGsapTimelineScript,
|
||||
generateHyperframesStyles,
|
||||
} from "./hyperframes.js";
|
||||
import { GSAP_CDN } from "../templates/constants.js";
|
||||
import type { TimelineTextElement, TimelineMediaElement } from "../core.types";
|
||||
|
||||
@ -274,7 +278,11 @@ describe("generateHyperframesStyles", () => {
|
||||
|
||||
it("includes custom CSS when provided", () => {
|
||||
const elements = [makeTextElement()];
|
||||
const { customCss } = generateHyperframesStyles(elements, "landscape", ".custom { color: blue; }");
|
||||
const { customCss } = generateHyperframesStyles(
|
||||
elements,
|
||||
"landscape",
|
||||
".custom { color: blue; }",
|
||||
);
|
||||
|
||||
expect(customCss).toContain(".custom { color: blue; }");
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import type {
|
||||
TimelineElement,
|
||||
CanvasResolution,
|
||||
Keyframe,
|
||||
StageZoomKeyframe,
|
||||
} from "../core.types";
|
||||
import type { TimelineElement, CanvasResolution, Keyframe, StageZoomKeyframe } from "../core.types";
|
||||
import {
|
||||
CANVAS_DIMENSIONS,
|
||||
isTextElement,
|
||||
@ -130,7 +125,8 @@ function generateElementStyles(element: TimelineElement): string {
|
||||
const fontWeight = element.fontWeight ?? 700;
|
||||
const fontFamily = element.fontFamily ?? "Inter";
|
||||
const color = element.color ?? "white";
|
||||
const textShadow = element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
|
||||
const textShadow =
|
||||
element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
|
||||
|
||||
// Text outline using -webkit-text-stroke
|
||||
const textOutline = element.textOutline
|
||||
@ -191,12 +187,18 @@ export function generateGsapTimelineScript(
|
||||
for (const element of sortedElements) {
|
||||
const elementKeyframes = keyframes[element.id];
|
||||
if (elementKeyframes && elementKeyframes.length > 0) {
|
||||
const baseScale = isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
|
||||
const converted = keyframesToGsapAnimations(element.id, elementKeyframes, element.startTime, {
|
||||
x: element.x ?? 0,
|
||||
y: element.y ?? 0,
|
||||
scale: baseScale,
|
||||
});
|
||||
const baseScale =
|
||||
isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
|
||||
const converted = keyframesToGsapAnimations(
|
||||
element.id,
|
||||
elementKeyframes,
|
||||
element.startTime,
|
||||
{
|
||||
x: element.x ?? 0,
|
||||
y: element.y ?? 0,
|
||||
scale: baseScale,
|
||||
},
|
||||
);
|
||||
keyframeAnimations = keyframeAnimations.concat(converted);
|
||||
}
|
||||
}
|
||||
@ -211,7 +213,10 @@ export function generateGsapTimelineScript(
|
||||
|
||||
// Generate visibility animations for elements without keyframes
|
||||
// When using keyframes path, elements without keyframes need explicit visibility
|
||||
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(sortedElements, keyframes);
|
||||
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(
|
||||
sortedElements,
|
||||
keyframes,
|
||||
);
|
||||
|
||||
let gsapScript: string;
|
||||
if (animations && animations.length > 0) {
|
||||
@ -221,7 +226,9 @@ export function generateGsapTimelineScript(
|
||||
includeMediaSync: hasMedia,
|
||||
});
|
||||
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
|
||||
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
|
||||
const prependAnimations = [initialPositionSets, visibilityAnimations]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (prependAnimations) {
|
||||
gsapScript = gsapScript.replace(
|
||||
"const tl = gsap.timeline({ paused: true });",
|
||||
@ -237,7 +244,9 @@ export function generateGsapTimelineScript(
|
||||
includeMediaSync: hasMedia,
|
||||
});
|
||||
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
|
||||
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
|
||||
const prependAnimations = [initialPositionSets, visibilityAnimations]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (prependAnimations) {
|
||||
gsapScript = gsapScript.replace(
|
||||
"const tl = gsap.timeline({ paused: true });",
|
||||
@ -248,7 +257,13 @@ export function generateGsapTimelineScript(
|
||||
gsapScript += "\n" + zoomAnimations;
|
||||
}
|
||||
} else if (generateDefaultAnimations) {
|
||||
gsapScript = generateDefaultGsapAnimations(sortedElements, totalDuration, stageZoomKeyframes, width, height);
|
||||
gsapScript = generateDefaultGsapAnimations(
|
||||
sortedElements,
|
||||
totalDuration,
|
||||
stageZoomKeyframes,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
} else {
|
||||
gsapScript = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
@ -282,7 +297,9 @@ export function generateHyperframesHtml(
|
||||
|
||||
// Include zoom keyframes in duration calculation
|
||||
const maxZoomTime =
|
||||
stageZoomKeyframes && stageZoomKeyframes.length > 0 ? Math.max(...stageZoomKeyframes.map((kf) => kf.time)) : 0;
|
||||
stageZoomKeyframes && stageZoomKeyframes.length > 0
|
||||
? Math.max(...stageZoomKeyframes.map((kf) => kf.time))
|
||||
: 0;
|
||||
|
||||
const calculatedDuration =
|
||||
elements.length > 0
|
||||
@ -291,7 +308,9 @@ export function generateHyperframesHtml(
|
||||
|
||||
const sortedElements = sortElements(elements);
|
||||
|
||||
const elementsHtml = sortedElements.map((el) => generateElementHtml(el, keyframes?.[el.id])).join("\n ");
|
||||
const elementsHtml = sortedElements
|
||||
.map((el) => generateElementHtml(el, keyframes?.[el.id]))
|
||||
.join("\n ");
|
||||
|
||||
const customStyles = styles || "";
|
||||
|
||||
@ -301,7 +320,11 @@ export function generateHyperframesHtml(
|
||||
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "'")}'`
|
||||
: "";
|
||||
|
||||
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(sortedElements, resolution, customStyles);
|
||||
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(
|
||||
sortedElements,
|
||||
resolution,
|
||||
customStyles,
|
||||
);
|
||||
|
||||
const gsapScript = includeScripts
|
||||
? generateGsapTimelineScript(sortedElements, totalDuration, {
|
||||
@ -575,7 +598,10 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]):
|
||||
* _initializeElementCentering(), so we only set x, y, scale here.
|
||||
* This keeps generated timeline code clean (no repeated xPercent/yPercent).
|
||||
*/
|
||||
function generateInitialPositionSets(elements: TimelineElement[], keyframes?: Record<string, Keyframe[]>): string {
|
||||
function generateInitialPositionSets(
|
||||
elements: TimelineElement[],
|
||||
keyframes?: Record<string, Keyframe[]>,
|
||||
): string {
|
||||
const sets: string[] = [];
|
||||
const timeEpsilon = 0.001;
|
||||
|
||||
@ -584,7 +610,9 @@ function generateInitialPositionSets(elements: TimelineElement[], keyframes?: Re
|
||||
const hasBaseKeyframe = elementKeyframes?.some(
|
||||
(kf) =>
|
||||
Math.abs(kf.time) <= timeEpsilon &&
|
||||
(kf.properties.x !== undefined || kf.properties.y !== undefined || kf.properties.scale !== undefined),
|
||||
(kf.properties.x !== undefined ||
|
||||
kf.properties.y !== undefined ||
|
||||
kf.properties.scale !== undefined),
|
||||
);
|
||||
|
||||
const xVal = el.x ?? 0;
|
||||
@ -629,7 +657,8 @@ function generateVisibilityForElementsWithoutKeyframes(
|
||||
|
||||
for (const el of elements) {
|
||||
const elementKeyframes = keyframes?.[el.id];
|
||||
const opacityKeyframes = elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
|
||||
const opacityKeyframes =
|
||||
elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
|
||||
const start = el.startTime;
|
||||
const end = el.startTime + el.duration;
|
||||
|
||||
@ -647,7 +676,9 @@ function generateVisibilityForElementsWithoutKeyframes(
|
||||
// Only include opacity in visibility bookend if non-default or has opacity keyframes
|
||||
const needsOpacity = elementOpacity !== 1 || opacityKeyframes.length > 0;
|
||||
if (needsOpacity) {
|
||||
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
|
||||
animations.push(
|
||||
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
|
||||
);
|
||||
} else {
|
||||
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
|
||||
}
|
||||
@ -690,7 +721,9 @@ function generateDefaultGsapAnimations(
|
||||
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
|
||||
// Only include opacity if non-default
|
||||
if (elementOpacity !== 1) {
|
||||
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
|
||||
animations.push(
|
||||
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
|
||||
);
|
||||
} else {
|
||||
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
|
||||
}
|
||||
|
||||
@ -98,9 +98,19 @@ export {
|
||||
} from "./generators/hyperframes";
|
||||
|
||||
// Compiler (timing only — browser-safe, no cheerio/esbuild)
|
||||
export type { UnresolvedElement, ResolvedDuration, ResolvedMediaElement, CompilationResult } from "./compiler/timingCompiler";
|
||||
export type {
|
||||
UnresolvedElement,
|
||||
ResolvedDuration,
|
||||
ResolvedMediaElement,
|
||||
CompilationResult,
|
||||
} from "./compiler/timingCompiler";
|
||||
|
||||
export { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./compiler/timingCompiler";
|
||||
export {
|
||||
compileTimingAttrs,
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
} from "./compiler/timingCompiler";
|
||||
|
||||
// Lint
|
||||
export type {
|
||||
|
||||
@ -11,10 +11,15 @@ export type HyperframesRuntimeBuildOptions = {
|
||||
function applyDefaultParityMode(script: string, enabled: boolean): string {
|
||||
const parityFlagPattern = /var\s+_parityModeEnabled\s*=\s*(?:true|false)\s*;/;
|
||||
if (!parityFlagPattern.test(script)) return script;
|
||||
return script.replace(parityFlagPattern, `var _parityModeEnabled = ${enabled ? "true" : "false"};`);
|
||||
return script.replace(
|
||||
parityFlagPattern,
|
||||
`var _parityModeEnabled = ${enabled ? "true" : "false"};`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildHyperframesRuntimeScript(options: HyperframesRuntimeBuildOptions = {}): string {
|
||||
export function buildHyperframesRuntimeScript(
|
||||
options: HyperframesRuntimeBuildOptions = {},
|
||||
): string {
|
||||
const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts");
|
||||
const result = buildSync({
|
||||
entryPoints: [entryPath],
|
||||
|
||||
@ -22,9 +22,21 @@ export type HyperframePickerApi = {
|
||||
isActive: () => boolean;
|
||||
getHovered: () => HyperframePickerElementInfo | null;
|
||||
getSelected: () => HyperframePickerElementInfo | null;
|
||||
getCandidatesAtPoint: (clientX: number, clientY: number, limit?: number) => HyperframePickerElementInfo[];
|
||||
pickAtPoint: (clientX: number, clientY: number, index?: number) => HyperframePickerElementInfo | null;
|
||||
pickManyAtPoint: (clientX: number, clientY: number, indexes?: number[]) => HyperframePickerElementInfo[];
|
||||
getCandidatesAtPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
limit?: number,
|
||||
) => HyperframePickerElementInfo[];
|
||||
pickAtPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
index?: number,
|
||||
) => HyperframePickerElementInfo | null;
|
||||
pickManyAtPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
indexes?: number[],
|
||||
) => HyperframePickerElementInfo[];
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@ -32,11 +32,15 @@ const TIMELINE_REGISTRY_INIT_PATTERN =
|
||||
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
|
||||
const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
|
||||
const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
||||
const WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
|
||||
const WINDOW_TIMELINE_ASSIGN_PATTERN =
|
||||
/window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
|
||||
|
||||
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
|
||||
|
||||
export function lintHyperframeHtml(html: string, options: HyperframeLinterOptions = {}): HyperframeLintResult {
|
||||
export function lintHyperframeHtml(
|
||||
html: string,
|
||||
options: HyperframeLinterOptions = {},
|
||||
): HyperframeLintResult {
|
||||
const source = html || "";
|
||||
const filePath = options.filePath;
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
@ -86,7 +90,10 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
|
||||
});
|
||||
}
|
||||
|
||||
if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
|
||||
if (
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)
|
||||
) {
|
||||
pushFinding({
|
||||
code: "missing_timeline_registry",
|
||||
severity: "error",
|
||||
@ -157,7 +164,8 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
|
||||
severity: "warning",
|
||||
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
|
||||
selector: `[data-composition-id="${compId}"]`,
|
||||
fixHint: "Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
|
||||
fixHint:
|
||||
"Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
|
||||
});
|
||||
}
|
||||
|
||||
@ -191,7 +199,8 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
|
||||
severity: "error",
|
||||
message: `Media id "${elementId}" is defined multiple times.`,
|
||||
elementId,
|
||||
fixHint: "Give each media element a unique id so preview and producer discover the same media graph.",
|
||||
fixHint:
|
||||
"Give each media element a unique id so preview and producer discover the same media graph.",
|
||||
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
|
||||
});
|
||||
}
|
||||
@ -206,7 +215,9 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
|
||||
severity: "warning",
|
||||
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
|
||||
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
|
||||
snippet: truncateSnippet(`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`),
|
||||
snippet: truncateSnippet(
|
||||
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@ -302,7 +313,11 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "video" || tag.name === "audio") continue;
|
||||
if (readAttr(tag.raw, "data-start")) {
|
||||
timedTagPositions.push({ name: tag.name, start: tag.index, id: readAttr(tag.raw, "id") || undefined });
|
||||
timedTagPositions.push({
|
||||
name: tag.name,
|
||||
start: tag.index,
|
||||
id: readAttr(tag.raw, "id") || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tag of tags) {
|
||||
@ -424,7 +439,7 @@ function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[] {
|
||||
|
||||
function findRootTag(source: string): OpenTag | null {
|
||||
const bodyMatch = source.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
||||
const bodyContent = bodyMatch ? bodyMatch[1] ?? source : source;
|
||||
const bodyContent = bodyMatch ? (bodyMatch[1] ?? source) : source;
|
||||
const bodyTags = extractOpenTags(bodyContent);
|
||||
for (const tag of bodyTags) {
|
||||
if (["script", "style", "meta", "link", "title"].includes(tag.name)) {
|
||||
@ -517,7 +532,10 @@ function extractGsapWindows(script: string): GsapWindow[] {
|
||||
|
||||
const windows: GsapWindow[] = [];
|
||||
const timelineVar = parsed.timelineVar;
|
||||
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
|
||||
const methodPattern = new RegExp(
|
||||
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let index = 0;
|
||||
@ -620,7 +638,10 @@ function parseLooseObjectLiteral(source: string): Record<string, string | number
|
||||
if (!key || rawValue == null) {
|
||||
continue;
|
||||
}
|
||||
if ((rawValue.startsWith('"') && rawValue.endsWith('"')) || (rawValue.startsWith("'") && rawValue.endsWith("'"))) {
|
||||
if (
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
) {
|
||||
result[key] = rawValue.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -218,7 +218,10 @@ describe("gsapAnimationsToKeyframes", () => {
|
||||
targetSelector: "#el1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<string, number | string>,
|
||||
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<
|
||||
string,
|
||||
number | string
|
||||
>,
|
||||
duration: 1,
|
||||
},
|
||||
];
|
||||
@ -229,7 +232,9 @@ describe("gsapAnimationsToKeyframes", () => {
|
||||
expect(keyframes[0].properties.opacity).toBe(1);
|
||||
expect(keyframes[0].properties.x).toBe(50);
|
||||
// String values are skipped (typeof value !== "number" check)
|
||||
expect((keyframes[0].properties as Record<string, unknown>).someUnsupportedProp).toBeUndefined();
|
||||
expect(
|
||||
(keyframes[0].properties as Record<string, unknown>).someUnsupportedProp,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips base set keyframes at time 0 when skipBaseSet is true", () => {
|
||||
@ -337,9 +342,7 @@ describe("keyframesToGsapAnimations", () => {
|
||||
});
|
||||
|
||||
it("applies base x/y/scale offsets", () => {
|
||||
const keyframes: Keyframe[] = [
|
||||
{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } },
|
||||
];
|
||||
const keyframes: Keyframe[] = [{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } }];
|
||||
|
||||
const animations = keyframesToGsapAnimations("el1", keyframes, 0, {
|
||||
x: 50,
|
||||
@ -491,8 +494,22 @@ describe("getAnimationsForElement", () => {
|
||||
it("filters animations by element id", () => {
|
||||
const animations: GsapAnimation[] = [
|
||||
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
|
||||
{ id: "a2", targetSelector: "#el2", method: "to", position: 0, properties: { opacity: 1 }, duration: 1 },
|
||||
{ id: "a3", targetSelector: "#el1", method: "to", position: 1, properties: { opacity: 1 }, duration: 0.5 },
|
||||
{
|
||||
id: "a2",
|
||||
targetSelector: "#el2",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { opacity: 1 },
|
||||
duration: 1,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
targetSelector: "#el1",
|
||||
method: "to",
|
||||
position: 1,
|
||||
properties: { opacity: 1 },
|
||||
duration: 0.5,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getAnimationsForElement(animations, "el1");
|
||||
|
||||
@ -78,7 +78,10 @@ function parseObjectLiteral(str: string): Record<string, number | string> {
|
||||
let value: string | number = match[2] ?? "";
|
||||
|
||||
if (typeof value === "string") {
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
value = Number(value);
|
||||
@ -108,14 +111,21 @@ export function parseGsapScript(script: string): ParsedGsap {
|
||||
let idCounter = 0;
|
||||
|
||||
const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/);
|
||||
const timelineVar = timelineMatch ? timelineMatch[1] ?? "tl" : "tl";
|
||||
const timelineVar = timelineMatch ? (timelineMatch[1] ?? "tl") : "tl";
|
||||
|
||||
const preambleMatch = script.match(
|
||||
new RegExp(`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`),
|
||||
new RegExp(
|
||||
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
|
||||
),
|
||||
);
|
||||
const preamble = preambleMatch ? preambleMatch[0] : `const ${timelineVar} = gsap.timeline({ paused: true });`;
|
||||
const preamble = preambleMatch
|
||||
? preambleMatch[0]
|
||||
: `const ${timelineVar} = gsap.timeline({ paused: true });`;
|
||||
|
||||
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
|
||||
const methodPattern = new RegExp(
|
||||
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = methodPattern.exec(script)) !== null) {
|
||||
@ -286,7 +296,11 @@ function serializeObject(obj: Record<string, number | string>): string {
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
export function updateAnimationInScript(script: string, animationId: string, updates: Partial<GsapAnimation>): string {
|
||||
export function updateAnimationInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
updates: Partial<GsapAnimation>,
|
||||
): string {
|
||||
const parsed = parseGsapScript(script);
|
||||
|
||||
const updated = parsed.animations.map((anim) => {
|
||||
@ -322,7 +336,10 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
return serializeGsapAnimations(filtered, parsed.timelineVar);
|
||||
}
|
||||
|
||||
export function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
|
||||
export function getAnimationsForElement(
|
||||
animations: GsapAnimation[],
|
||||
elementId: string,
|
||||
): GsapAnimation[] {
|
||||
const selector = `#${elementId}`;
|
||||
return animations.filter((a) => a.targetSelector === selector);
|
||||
}
|
||||
@ -478,7 +495,8 @@ export function gsapAnimationsToKeyframes(
|
||||
} else if (key === "y") {
|
||||
(properties as Record<string, number>).y = value - baseY;
|
||||
} else if (key === "scale") {
|
||||
(properties as Record<string, number>).scale = baseScale !== 0 ? value / baseScale : value;
|
||||
(properties as Record<string, number>).scale =
|
||||
baseScale !== 0 ? value / baseScale : value;
|
||||
} else {
|
||||
(properties as Record<string, number>)[key] = value;
|
||||
}
|
||||
|
||||
@ -2,7 +2,14 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, validateCompositionHtml, extractCompositionMetadata } from "./htmlParser.js";
|
||||
import {
|
||||
parseHtml,
|
||||
updateElementInHtml,
|
||||
addElementToHtml,
|
||||
removeElementFromHtml,
|
||||
validateCompositionHtml,
|
||||
extractCompositionMetadata,
|
||||
} from "./htmlParser.js";
|
||||
|
||||
describe("parseHtml", () => {
|
||||
it("extracts elements with data-start and data-end", () => {
|
||||
@ -457,7 +464,9 @@ describe("validateCompositionHtml", () => {
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Missing data-composition-duration attribute on <html> element");
|
||||
expect(result.errors).toContain(
|
||||
"Missing data-composition-duration attribute on <html> element",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports error for missing #stage", () => {
|
||||
|
||||
@ -40,7 +40,14 @@ function getElementType(el: Element): TimelineElementType | null {
|
||||
if (dataType === "composition") return "composition";
|
||||
if (dataType === "text") return "text";
|
||||
// Fall back to tag-based detection for backwards compatibility
|
||||
if (tag === "div" || tag === "p" || tag === "h1" || tag === "h2" || tag === "h3" || tag === "span") {
|
||||
if (
|
||||
tag === "div" ||
|
||||
tag === "p" ||
|
||||
tag === "h1" ||
|
||||
tag === "h2" ||
|
||||
tag === "h3" ||
|
||||
tag === "span"
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
return null;
|
||||
@ -89,13 +96,17 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
|
||||
}
|
||||
|
||||
if (cssText) {
|
||||
const stageMatch = cssText.match(/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/);
|
||||
const stageMatch = cssText.match(
|
||||
/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/,
|
||||
);
|
||||
if (stageMatch) {
|
||||
const w = parseInt(stageMatch[1] ?? "", 10);
|
||||
const h = parseInt(stageMatch[2] ?? "", 10);
|
||||
return w > h ? "landscape" : "portrait";
|
||||
}
|
||||
const stageMatchReverse = cssText.match(/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/);
|
||||
const stageMatchReverse = cssText.match(
|
||||
/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/,
|
||||
);
|
||||
if (stageMatchReverse) {
|
||||
const h = parseInt(stageMatchReverse[1] ?? "", 10);
|
||||
const w = parseInt(stageMatchReverse[2] ?? "", 10);
|
||||
@ -205,16 +216,22 @@ export function parseHtml(html: string): ParsedHtml {
|
||||
const textOutline = textOutlineAttr === "true" ? true : undefined;
|
||||
const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined;
|
||||
const textOutlineWidthAttr = el.getAttribute("data-text-outline-width");
|
||||
const textOutlineWidth = textOutlineWidthAttr ? parseInt(textOutlineWidthAttr, 10) : undefined;
|
||||
const textOutlineWidth = textOutlineWidthAttr
|
||||
? parseInt(textOutlineWidthAttr, 10)
|
||||
: undefined;
|
||||
|
||||
// Parse highlight properties
|
||||
const textHighlightAttr = el.getAttribute("data-text-highlight");
|
||||
const textHighlight = textHighlightAttr === "true" ? true : undefined;
|
||||
const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined;
|
||||
const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding");
|
||||
const textHighlightPadding = textHighlightPaddingAttr ? parseInt(textHighlightPaddingAttr, 10) : undefined;
|
||||
const textHighlightPadding = textHighlightPaddingAttr
|
||||
? parseInt(textHighlightPaddingAttr, 10)
|
||||
: undefined;
|
||||
const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius");
|
||||
const textHighlightRadius = textHighlightRadiusAttr ? parseInt(textHighlightRadiusAttr, 10) : undefined;
|
||||
const textHighlightRadius = textHighlightRadiusAttr
|
||||
? parseInt(textHighlightRadiusAttr, 10)
|
||||
: undefined;
|
||||
|
||||
const textElement: TimelineTextElement = {
|
||||
id,
|
||||
@ -375,7 +392,9 @@ export function parseHtml(html: string): ParsedHtml {
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || null;
|
||||
|
||||
const customStyleTags = Array.from(styleTags).filter((s) => s.getAttribute("data-hf-custom") === "true");
|
||||
const customStyleTags = Array.from(styleTags).filter(
|
||||
(s) => s.getAttribute("data-hf-custom") === "true",
|
||||
);
|
||||
const customStylesFromTags =
|
||||
customStyleTags
|
||||
.map((s) => s.textContent?.trim())
|
||||
@ -463,7 +482,9 @@ function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] {
|
||||
* Extract x/y positions and scale from GSAP set() calls at position 0
|
||||
* Returns a map of elementId -> { x, y, scale }
|
||||
*/
|
||||
function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?: number; scale?: number }> {
|
||||
function extractPositionsFromGsap(
|
||||
script: string,
|
||||
): Map<string, { x?: number; y?: number; scale?: number }> {
|
||||
const positionMap = new Map<string, { x?: number; y?: number; scale?: number }>();
|
||||
|
||||
try {
|
||||
@ -482,7 +503,11 @@ function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?:
|
||||
const scale = typeof anim.properties.scale === "number" ? anim.properties.scale : undefined;
|
||||
|
||||
// Only add to map if x, y, or scale is defined and non-default
|
||||
if ((x !== undefined && x !== 0) || (y !== undefined && y !== 0) || (scale !== undefined && scale !== 1)) {
|
||||
if (
|
||||
(x !== undefined && x !== 0) ||
|
||||
(y !== undefined && y !== 0) ||
|
||||
(scale !== undefined && scale !== 1)
|
||||
) {
|
||||
const existing = positionMap.get(elementId) || {};
|
||||
positionMap.set(elementId, {
|
||||
x: x !== undefined ? x : existing.x,
|
||||
@ -499,7 +524,12 @@ function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?:
|
||||
return positionMap;
|
||||
}
|
||||
|
||||
function normalizeKeyframes(keyframes: Keyframe[], baseX: number, baseY: number, baseScale: number): Keyframe[] {
|
||||
function normalizeKeyframes(
|
||||
keyframes: Keyframe[],
|
||||
baseX: number,
|
||||
baseY: number,
|
||||
baseScale: number,
|
||||
): Keyframe[] {
|
||||
const timeEpsilon = 0.001;
|
||||
const valueEpsilon = 0.00001;
|
||||
|
||||
@ -543,7 +573,11 @@ function normalizeKeyframes(keyframes: Keyframe[], baseX: number, baseY: number,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateElementInHtml(html: string, elementId: string, updates: Partial<TimelineElement>): string {
|
||||
export function updateElementInHtml(
|
||||
html: string,
|
||||
elementId: string,
|
||||
updates: Partial<TimelineElement>,
|
||||
): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
@ -732,7 +766,8 @@ export function extractCompositionMetadata(html: string): CompositionMetadata {
|
||||
|
||||
return {
|
||||
compositionId,
|
||||
compositionDuration: compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
|
||||
compositionDuration:
|
||||
compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
|
||||
variables,
|
||||
};
|
||||
}
|
||||
@ -833,7 +868,11 @@ function extractGsapScript(doc: Document): string | null {
|
||||
const scripts = doc.querySelectorAll("script");
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent || "";
|
||||
if (content.includes("gsap.timeline") || content.includes(".set(") || content.includes(".to(")) {
|
||||
if (
|
||||
content.includes("gsap.timeline") ||
|
||||
content.includes(".set(") ||
|
||||
content.includes(".to(")
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,11 @@ function createLottieWebAnim(opts?: { totalFrames?: number; frameRate?: number }
|
||||
};
|
||||
}
|
||||
|
||||
function createDotLottiePlayer(opts?: { totalFrames?: number; frameRate?: number; duration?: number }) {
|
||||
function createDotLottiePlayer(opts?: {
|
||||
totalFrames?: number;
|
||||
frameRate?: number;
|
||||
duration?: number;
|
||||
}) {
|
||||
return {
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
|
||||
@ -156,7 +156,11 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter {
|
||||
// ── Type guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isLottieWebAnimation(anim: unknown): anim is LottieWebAnimation {
|
||||
return typeof anim === "object" && anim !== null && typeof (anim as LottieWebAnimation).goToAndStop === "function";
|
||||
return (
|
||||
typeof anim === "object" &&
|
||||
anim !== null &&
|
||||
typeof (anim as LottieWebAnimation).goToAndStop === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function isDotLottiePlayer(anim: unknown): anim is DotLottiePlayer {
|
||||
|
||||
@ -54,7 +54,9 @@ describe("waapi adapter", () => {
|
||||
|
||||
it("handles animation that throws on pause", () => {
|
||||
const mockAnim = {
|
||||
pause: vi.fn(() => { throw new Error("invalid state"); }),
|
||||
pause: vi.fn(() => {
|
||||
throw new Error("invalid state");
|
||||
}),
|
||||
currentTime: 0,
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
|
||||
@ -86,18 +86,22 @@ describe("installRuntimeControlBridge", () => {
|
||||
it("ignores messages from wrong source", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(new MessageEvent("message", {
|
||||
data: { source: "other", type: "control", action: "play" },
|
||||
}));
|
||||
handler(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "other", type: "control", action: "play" },
|
||||
}),
|
||||
);
|
||||
expect(deps.onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores messages with wrong type", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(new MessageEvent("message", {
|
||||
data: { source: "hf-parent", type: "state", action: "play" },
|
||||
}));
|
||||
handler(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "hf-parent", type: "state", action: "play" },
|
||||
}),
|
||||
);
|
||||
expect(deps.onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -112,7 +116,7 @@ describe("installRuntimeControlBridge", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
expect(() =>
|
||||
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 }))
|
||||
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 })),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -44,9 +44,7 @@ describe("loadExternalCompositions", () => {
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 })
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
@ -68,9 +66,7 @@ describe("loadExternalCompositions", () => {
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 })
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
const injectedStyles: HTMLStyleElement[] = [];
|
||||
await loadExternalCompositions({
|
||||
@ -102,7 +98,7 @@ describe("loadExternalCompositions", () => {
|
||||
hostCompositionSrc: "https://example.com/broken.html",
|
||||
errorMessage: "Network error",
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -111,9 +107,7 @@ describe("loadExternalCompositions", () => {
|
||||
host.setAttribute("data-composition-src", "https://example.com/404.html");
|
||||
document.body.appendChild(host);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Not Found", { status: 404 })
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
|
||||
|
||||
const onDiagnostic = vi.fn();
|
||||
await loadExternalCompositions({
|
||||
@ -124,7 +118,7 @@ describe("loadExternalCompositions", () => {
|
||||
expect(onDiagnostic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: "external_composition_load_failed",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -165,9 +159,7 @@ describe("loadExternalCompositions", () => {
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `<html><body><p>New</p></body></html>`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 })
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
expect(host.querySelector("span")).toBeNull();
|
||||
@ -186,9 +178,7 @@ describe("loadExternalCompositions", () => {
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 })
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
await loadExternalCompositions({
|
||||
|
||||
@ -88,10 +88,13 @@ async function mountCompositionContent(params: {
|
||||
}): Promise<void> {
|
||||
let innerRoot: Element | null = null;
|
||||
if (params.hostCompositionId) {
|
||||
const candidateRoots = Array.from(params.sourceNode.querySelectorAll<Element>("[data-composition-id]"));
|
||||
const candidateRoots = Array.from(
|
||||
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
|
||||
);
|
||||
innerRoot =
|
||||
candidateRoots.find((candidate) => candidate.getAttribute("data-composition-id") === params.hostCompositionId) ??
|
||||
null;
|
||||
candidateRoots.find(
|
||||
(candidate) => candidate.getAttribute("data-composition-id") === params.hostCompositionId,
|
||||
) ?? null;
|
||||
}
|
||||
const contentNode = innerRoot ?? params.sourceNode;
|
||||
|
||||
@ -188,7 +191,9 @@ async function mountCompositionContent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadExternalCompositions(params: LoadExternalCompositionsParams): Promise<void> {
|
||||
export async function loadExternalCompositions(
|
||||
params: LoadExternalCompositionsParams,
|
||||
): Promise<void> {
|
||||
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
@ -207,7 +212,9 @@ export async function loadExternalCompositions(params: LoadExternalCompositionsP
|
||||
const hostCompositionId = host.getAttribute("data-composition-id");
|
||||
const localTemplate =
|
||||
hostCompositionId != null
|
||||
? document.querySelector<HTMLTemplateElement>(`template#${CSS.escape(hostCompositionId)}-template`)
|
||||
? document.querySelector<HTMLTemplateElement>(
|
||||
`template#${CSS.escape(hostCompositionId)}-template`,
|
||||
)
|
||||
: null;
|
||||
if (localTemplate) {
|
||||
await mountCompositionContent({
|
||||
@ -234,7 +241,9 @@ export async function loadExternalCompositions(params: LoadExternalCompositionsP
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const template =
|
||||
(hostCompositionId
|
||||
? doc.querySelector<HTMLTemplateElement>(`template#${CSS.escape(hostCompositionId)}-template`)
|
||||
? doc.querySelector<HTMLTemplateElement>(
|
||||
`template#${CSS.escape(hostCompositionId)}-template`,
|
||||
)
|
||||
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
|
||||
const sourceNode = template ? template.content : doc.body;
|
||||
await mountCompositionContent({
|
||||
|
||||
@ -36,7 +36,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
const registerRuntimeCleanup = (callback: () => void) => {
|
||||
runtimeCleanupCallbacks.push(callback);
|
||||
};
|
||||
const postRuntimeDiagnosticOnce = (code: string, details: Record<string, RuntimeJson>, dedupeKey?: string) => {
|
||||
const postRuntimeDiagnosticOnce = (
|
||||
code: string,
|
||||
details: Record<string, RuntimeJson>,
|
||||
dedupeKey?: string,
|
||||
) => {
|
||||
const key = dedupeKey ?? `${code}:${JSON.stringify(details)}`;
|
||||
if (postedDiagnosticKeys.has(key)) {
|
||||
return;
|
||||
@ -157,7 +161,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
category: string;
|
||||
} => {
|
||||
const message = rawMessage.toLowerCase();
|
||||
if (message.includes("cannot read properties of null") || message.includes("cannot set properties of null")) {
|
||||
if (
|
||||
message.includes("cannot read properties of null") ||
|
||||
message.includes("cannot set properties of null")
|
||||
) {
|
||||
return { code: "runtime_null_dom_access", category: "dom-null-access" };
|
||||
}
|
||||
if (message.includes("failed to execute 'queryselector'")) {
|
||||
@ -185,10 +192,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (explicitRoot instanceof HTMLElement) {
|
||||
return explicitRoot;
|
||||
}
|
||||
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
|
||||
const compositionNodes = Array.from(
|
||||
document.querySelectorAll("[data-composition-id]"),
|
||||
) as HTMLElement[];
|
||||
if (compositionNodes.length === 0) return null;
|
||||
return (
|
||||
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? compositionNodes[0]
|
||||
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
|
||||
compositionNodes[0]
|
||||
);
|
||||
};
|
||||
|
||||
@ -278,12 +288,18 @@ export function initSandboxRuntimeModular(): void {
|
||||
el.style.position = "absolute";
|
||||
}
|
||||
const hasExplicitVerticalAnchor =
|
||||
Boolean(el.style.top) || Boolean(el.style.bottom) || computed.top !== "auto" || computed.bottom !== "auto";
|
||||
Boolean(el.style.top) ||
|
||||
Boolean(el.style.bottom) ||
|
||||
computed.top !== "auto" ||
|
||||
computed.bottom !== "auto";
|
||||
if (!hasExplicitVerticalAnchor) {
|
||||
el.style.top = "0";
|
||||
}
|
||||
const hasExplicitHorizontalAnchor =
|
||||
Boolean(el.style.left) || Boolean(el.style.right) || computed.left !== "auto" || computed.right !== "auto";
|
||||
Boolean(el.style.left) ||
|
||||
Boolean(el.style.right) ||
|
||||
computed.left !== "auto" ||
|
||||
computed.right !== "auto";
|
||||
if (!hasExplicitHorizontalAnchor) {
|
||||
el.style.left = "0";
|
||||
}
|
||||
@ -312,14 +328,20 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const resolveStartForElement = (element: Element, fallback = 0): number => {
|
||||
const resolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
|
||||
timelineRegistry: (window.__timelines ?? {}) as Record<
|
||||
string,
|
||||
RuntimeTimelineLike | undefined
|
||||
>,
|
||||
});
|
||||
return resolver.resolveStartForElement(element, fallback);
|
||||
};
|
||||
|
||||
const resolveDurationForElement = (element: Element): number | null => {
|
||||
const resolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
|
||||
timelineRegistry: (window.__timelines ?? {}) as Record<
|
||||
string,
|
||||
RuntimeTimelineLike | undefined
|
||||
>,
|
||||
});
|
||||
return resolver.resolveDurationForElement(element);
|
||||
};
|
||||
@ -399,13 +421,20 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!isUsableTimelineDuration(mediaDurationFloorSeconds)) {
|
||||
return MIN_VALID_TIMELINE_DURATION_SECONDS;
|
||||
}
|
||||
return Math.max(MIN_VALID_TIMELINE_DURATION_SECONDS, mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO);
|
||||
return Math.max(
|
||||
MIN_VALID_TIMELINE_DURATION_SECONDS,
|
||||
mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO,
|
||||
);
|
||||
};
|
||||
|
||||
const getSafeTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null, fallback = 0): number => {
|
||||
const getSafeTimelineDurationSeconds = (
|
||||
timeline: RuntimeTimelineLike | null,
|
||||
fallback = 0,
|
||||
): number => {
|
||||
const timelineDuration = getTimelineDurationSeconds(timeline);
|
||||
const mediaFloor = resolveMediaDurationFloorSeconds();
|
||||
const fallbackDuration = Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
|
||||
const fallbackDuration =
|
||||
Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
|
||||
let safeDuration = 0;
|
||||
// Timeline is the source of truth for authored composition duration.
|
||||
if (isUsableTimelineDuration(timelineDuration)) {
|
||||
@ -423,20 +452,30 @@ export function initSandboxRuntimeModular(): void {
|
||||
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
||||
const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines });
|
||||
const mediaDurationFloorSeconds = resolveMediaDurationFloorSeconds();
|
||||
const minCandidateDurationSeconds = resolveMinCandidateDurationSeconds(mediaDurationFloorSeconds);
|
||||
const minCandidateDurationSeconds =
|
||||
resolveMinCandidateDurationSeconds(mediaDurationFloorSeconds);
|
||||
const resolveCompositionStartSeconds = (compositionId: string): number => {
|
||||
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`) as Element | null;
|
||||
const node = document.querySelector(
|
||||
`[data-composition-id="${CSS.escape(compositionId)}"]`,
|
||||
) as Element | null;
|
||||
if (!node) return 0;
|
||||
return startResolver.resolveStartForElement(node, 0);
|
||||
};
|
||||
const createCompositeTimelineFromCandidates = (
|
||||
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
|
||||
candidates: Array<{
|
||||
compositionId: string;
|
||||
timeline: RuntimeTimelineLike;
|
||||
durationSeconds: number;
|
||||
}>,
|
||||
): RuntimeTimelineLike | null => {
|
||||
const gsapApi = window.gsap;
|
||||
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
|
||||
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
|
||||
for (const candidate of candidates) {
|
||||
compositeTimeline.add(candidate.timeline, resolveCompositionStartSeconds(candidate.compositionId));
|
||||
compositeTimeline.add(
|
||||
candidate.timeline,
|
||||
resolveCompositionStartSeconds(candidate.compositionId),
|
||||
);
|
||||
}
|
||||
return compositeTimeline;
|
||||
};
|
||||
@ -469,7 +508,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
const addMissingChildCandidatesToRootTimeline = (
|
||||
rootTimeline: RuntimeTimelineLike,
|
||||
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
|
||||
candidates: Array<{
|
||||
compositionId: string;
|
||||
timeline: RuntimeTimelineLike;
|
||||
durationSeconds: number;
|
||||
}>,
|
||||
): string[] => {
|
||||
const rootWithChildren = rootTimeline as RuntimeTimelineLike & {
|
||||
getChildren?: (...args: unknown[]) => unknown[];
|
||||
@ -509,7 +552,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!rootCompositionNode) return [];
|
||||
const seen = new Set<string>();
|
||||
const childNodes = Array.from(rootCompositionNode.querySelectorAll("[data-composition-id]"));
|
||||
const candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }> = [];
|
||||
const candidates: Array<{
|
||||
compositionId: string;
|
||||
timeline: RuntimeTimelineLike;
|
||||
durationSeconds: number;
|
||||
}> = [];
|
||||
for (const childNode of childNodes) {
|
||||
const childId = childNode.getAttribute("data-composition-id");
|
||||
if (!childId || childId === rootCompositionId) continue;
|
||||
@ -517,7 +564,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
seen.add(childId);
|
||||
const candidateTimeline = timelines[childId] ?? null;
|
||||
if (!candidateTimeline) continue;
|
||||
if (typeof candidateTimeline.play !== "function" || typeof candidateTimeline.pause !== "function") {
|
||||
if (
|
||||
typeof candidateTimeline.play !== "function" ||
|
||||
typeof candidateTimeline.pause !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const candidateDuration = getTimelineDurationSeconds(candidateTimeline);
|
||||
@ -531,7 +581,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
const rootChildCandidates = collectRootChildCandidates();
|
||||
const ensureChildCandidatesActive = (
|
||||
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
|
||||
candidates: Array<{
|
||||
compositionId: string;
|
||||
timeline: RuntimeTimelineLike;
|
||||
durationSeconds: number;
|
||||
}>,
|
||||
): void => {
|
||||
for (const candidate of candidates) {
|
||||
const timelineWithPaused = candidate.timeline as RuntimeTimelineLike & {
|
||||
@ -554,7 +608,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates)
|
||||
: [];
|
||||
// Mark children as bound so the polling loop stops re-resolving
|
||||
if (rootChildCandidates.length > 0 || !document.querySelector("[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])")) {
|
||||
if (
|
||||
rootChildCandidates.length > 0 ||
|
||||
!document.querySelector(
|
||||
"[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])",
|
||||
)
|
||||
) {
|
||||
childrenBound = true;
|
||||
}
|
||||
|
||||
@ -564,7 +623,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
try {
|
||||
const currentTime = rootTimeline.time();
|
||||
rootTimeline.seek(currentTime, false); // false = don't suppress events
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline);
|
||||
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) {
|
||||
@ -592,7 +653,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
},
|
||||
};
|
||||
}
|
||||
const durationFloorTimeline = createDurationFloorTimeline(mediaDurationFloorSeconds ?? 0, rootTimeline);
|
||||
const durationFloorTimeline = createDurationFloorTimeline(
|
||||
mediaDurationFloorSeconds ?? 0,
|
||||
rootTimeline,
|
||||
);
|
||||
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
|
||||
return {
|
||||
@ -616,7 +680,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length === 0) {
|
||||
const durationFloorTimeline = createDurationFloorTimeline(mediaDurationFloorSeconds ?? 0, rootTimeline);
|
||||
const durationFloorTimeline = createDurationFloorTimeline(
|
||||
mediaDurationFloorSeconds ?? 0,
|
||||
rootTimeline,
|
||||
);
|
||||
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
|
||||
return {
|
||||
@ -750,9 +817,15 @@ export function initSandboxRuntimeModular(): void {
|
||||
const declaredHeight = Number(rootNode.getAttribute("data-height"));
|
||||
const computedStyle = window.getComputedStyle(rootNode);
|
||||
const hasDeclaredDimensions =
|
||||
Number.isFinite(declaredWidth) && declaredWidth > 0 && Number.isFinite(declaredHeight) && declaredHeight > 0;
|
||||
Number.isFinite(declaredWidth) &&
|
||||
declaredWidth > 0 &&
|
||||
Number.isFinite(declaredHeight) &&
|
||||
declaredHeight > 0;
|
||||
const looksCollapsed =
|
||||
rect.width <= 0 || rect.height <= 0 || rootNode.clientWidth <= 0 || rootNode.clientHeight <= 0;
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
rootNode.clientWidth <= 0 ||
|
||||
rootNode.clientHeight <= 0;
|
||||
if (!hasDeclaredDimensions || !looksCollapsed) {
|
||||
return;
|
||||
}
|
||||
@ -811,7 +884,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
});
|
||||
};
|
||||
runtimeUnhandledRejectionListener = (event: PromiseRejectionEvent) => {
|
||||
const normalized = normalizeDiagnosticMessage(event.reason).slice(0, MAX_DIAGNOSTIC_MESSAGE_LENGTH);
|
||||
const normalized = normalizeDiagnosticMessage(event.reason).slice(
|
||||
0,
|
||||
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
|
||||
);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@ -831,22 +907,31 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
|
||||
const installAssetFailureDiagnostics = () => {
|
||||
const assetNodes = Array.from(document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"));
|
||||
const assetNodes = Array.from(
|
||||
document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"),
|
||||
);
|
||||
for (const node of assetNodes) {
|
||||
const onError = () => {
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
const assetUrl = node.getAttribute("src") ?? node.getAttribute("href") ?? node.getAttribute("poster") ?? null;
|
||||
const diagnosticCode = tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
|
||||
const assetUrl =
|
||||
node.getAttribute("src") ??
|
||||
node.getAttribute("href") ??
|
||||
node.getAttribute("poster") ??
|
||||
null;
|
||||
const diagnosticCode =
|
||||
tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
|
||||
postRuntimeDiagnosticOnce(
|
||||
diagnosticCode,
|
||||
{
|
||||
tagName,
|
||||
assetUrl,
|
||||
currentSrc:
|
||||
node instanceof HTMLImageElement || node instanceof HTMLMediaElement ? node.currentSrc || null : null,
|
||||
node instanceof HTMLImageElement || node instanceof HTMLMediaElement
|
||||
? node.currentSrc || null
|
||||
: null,
|
||||
readyState: node instanceof HTMLMediaElement ? node.readyState : null,
|
||||
networkState: node instanceof HTMLMediaElement ? node.networkState : null,
|
||||
},
|
||||
@ -890,7 +975,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
});
|
||||
};
|
||||
|
||||
const rebindTimelineFromResolution = (resolution: TimelineResolution, reason: "loop_guard" | "manual"): boolean => {
|
||||
const rebindTimelineFromResolution = (
|
||||
resolution: TimelineResolution,
|
||||
reason: "loop_guard" | "manual",
|
||||
): boolean => {
|
||||
if (!resolution.timeline) return false;
|
||||
const previousTimeline = state.capturedTimeline;
|
||||
if (previousTimeline && previousTimeline === resolution.timeline) {
|
||||
@ -940,7 +1028,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
metadataRebindDebounceTimerId = null;
|
||||
const resolution = resolveRootTimelineFromDocument();
|
||||
if (!resolution.timeline) return;
|
||||
const hasResolvedMediaFloor = isUsableTimelineDuration(resolution.mediaDurationFloorSeconds ?? null);
|
||||
const hasResolvedMediaFloor = isUsableTimelineDuration(
|
||||
resolution.mediaDurationFloorSeconds ?? null,
|
||||
);
|
||||
if (!hasResolvedMediaFloor) return;
|
||||
if (!state.capturedTimeline) {
|
||||
if (bindRootTimelineIfAvailable()) {
|
||||
@ -951,7 +1041,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
if (metadataRebindApplied) return;
|
||||
const currentDuration = getTimelineDurationSeconds(state.capturedTimeline);
|
||||
const nextDuration = resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
|
||||
const nextDuration =
|
||||
resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
|
||||
const isBetterCandidate =
|
||||
isUsableTimelineDuration(nextDuration) &&
|
||||
(!isUsableTimelineDuration(currentDuration) ||
|
||||
@ -1005,7 +1096,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
playing: state.isPlaying,
|
||||
playbackRate: state.playbackRate,
|
||||
});
|
||||
const rootCompId = document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
|
||||
const rootCompId =
|
||||
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
|
||||
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
|
||||
for (const rawNode of visibilityNodes) {
|
||||
if (!(rawNode instanceof HTMLElement)) continue;
|
||||
@ -1038,7 +1130,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (compDur > 0) computedEnd = start + compDur;
|
||||
}
|
||||
}
|
||||
const isVisibleNow = state.currentTime >= start && (Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
|
||||
const isVisibleNow =
|
||||
state.currentTime >= start &&
|
||||
(Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
|
||||
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
|
||||
}
|
||||
};
|
||||
@ -1203,12 +1297,19 @@ export function initSandboxRuntimeModular(): void {
|
||||
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
|
||||
emitAnalyticsEvent("composition_loaded", {
|
||||
duration: player.getDuration(),
|
||||
compositionId: document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
|
||||
compositionId:
|
||||
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
|
||||
});
|
||||
|
||||
state.controlBridgeHandler = installRuntimeControlBridge({
|
||||
onPlay: () => { player.play(); emitAnalyticsEvent("composition_played", { time: player.getTime() }); },
|
||||
onPause: () => { player.pause(); emitAnalyticsEvent("composition_paused", { time: player.getTime() }); },
|
||||
onPlay: () => {
|
||||
player.play();
|
||||
emitAnalyticsEvent("composition_played", { time: player.getTime() });
|
||||
},
|
||||
onPause: () => {
|
||||
player.pause();
|
||||
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
|
||||
},
|
||||
onSeek: (frame, _seekMode) => {
|
||||
const time = Math.max(0, frame) / state.canonicalFps;
|
||||
player.seek(time);
|
||||
@ -1280,7 +1381,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
state.isPlaying &&
|
||||
state.capturedTimeline != null &&
|
||||
Math.max(0, state.currentTime || 0) < PLAY_REBIND_HOLD_SECONDS;
|
||||
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay ? false : bindRootTimelineIfAvailable();
|
||||
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay
|
||||
? false
|
||||
: bindRootTimelineIfAvailable();
|
||||
if (state.capturedTimeline && !player._timeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
|
||||
@ -7,15 +7,17 @@ export type RuntimeMediaClip = {
|
||||
volume: number | null;
|
||||
};
|
||||
|
||||
export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number }): {
|
||||
export function refreshRuntimeMediaCache(params?: {
|
||||
resolveStartSeconds?: (element: Element) => number;
|
||||
}): {
|
||||
timedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
mediaClips: RuntimeMediaClip[];
|
||||
videoClips: RuntimeMediaClip[];
|
||||
maxMediaEnd: number;
|
||||
} {
|
||||
const mediaEls = Array.from(document.querySelectorAll("video[data-start], audio[data-start]")) as Array<
|
||||
HTMLVideoElement | HTMLAudioElement
|
||||
>;
|
||||
const mediaEls = Array.from(
|
||||
document.querySelectorAll("video[data-start], audio[data-start]"),
|
||||
) as Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
const mediaClips: RuntimeMediaClip[] = [];
|
||||
const videoClips: RuntimeMediaClip[] = [];
|
||||
let maxMediaEnd = 0;
|
||||
@ -24,12 +26,18 @@ export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (eleme
|
||||
? params.resolveStartSeconds(el)
|
||||
: Number.parseFloat(el.dataset.start ?? "0");
|
||||
if (!Number.isFinite(start)) continue;
|
||||
const mediaStart = Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
|
||||
const mediaStart =
|
||||
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
|
||||
let duration = Number.parseFloat(el.dataset.duration ?? "");
|
||||
if ((!Number.isFinite(duration) || duration <= 0) && Number.isFinite(el.duration) && el.duration > 0) {
|
||||
if (
|
||||
(!Number.isFinite(duration) || duration <= 0) &&
|
||||
Number.isFinite(el.duration) &&
|
||||
el.duration > 0
|
||||
) {
|
||||
duration = Math.max(0, el.duration - mediaStart);
|
||||
}
|
||||
const end = Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
||||
const end =
|
||||
Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
||||
const volumeRaw = Number.parseFloat(el.dataset.volume ?? "");
|
||||
const clip: RuntimeMediaClip = {
|
||||
el,
|
||||
@ -56,7 +64,8 @@ export function syncRuntimeMedia(params: {
|
||||
const { el } = clip;
|
||||
if (!el.isConnected) continue;
|
||||
const relTime = params.timeSeconds - clip.start + clip.mediaStart;
|
||||
const isActive = params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
|
||||
const isActive =
|
||||
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
|
||||
if (isActive) {
|
||||
if (clip.volume != null) el.volume = clip.volume;
|
||||
try {
|
||||
|
||||
@ -8,7 +8,7 @@ function createMockPostMessage() {
|
||||
describe("createPickerModule", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
document.head.querySelectorAll("style").forEach(s => s.remove());
|
||||
document.head.querySelectorAll("style").forEach((s) => s.remove());
|
||||
document.body.classList.remove("__hf-pick-active");
|
||||
});
|
||||
|
||||
@ -33,15 +33,15 @@ describe("createPickerModule", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.enablePickMode();
|
||||
const styles = document.head.querySelectorAll("style");
|
||||
const hasPickStyle = Array.from(styles).some(s =>
|
||||
s.textContent?.includes("__hf-pick-highlight")
|
||||
const hasPickStyle = Array.from(styles).some((s) =>
|
||||
s.textContent?.includes("__hf-pick-highlight"),
|
||||
);
|
||||
expect(hasPickStyle).toBe(true);
|
||||
|
||||
picker.disablePickMode();
|
||||
const stylesAfter = document.head.querySelectorAll("style");
|
||||
const hasPickStyleAfter = Array.from(stylesAfter).some(s =>
|
||||
s.textContent?.includes("__hf-pick-highlight")
|
||||
const hasPickStyleAfter = Array.from(stylesAfter).some((s) =>
|
||||
s.textContent?.includes("__hf-pick-highlight"),
|
||||
);
|
||||
expect(hasPickStyleAfter).toBe(false);
|
||||
});
|
||||
@ -137,7 +137,7 @@ describe("createPickerModule", () => {
|
||||
expect.objectContaining({
|
||||
source: "hf-preview",
|
||||
type: "pick-mode-cancelled",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -77,7 +77,8 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const trimLabel = (value: string, maxChars: number) =>
|
||||
value.length > maxChars ? `${value.slice(0, maxChars - 1)}…` : value;
|
||||
if (tag === "h1" || tag === "h2" || tag === "h3") return "Heading";
|
||||
if (tag === "p" || tag === "span" || tag === "div") return text.length > 0 ? trimLabel(text, 56) : "Text";
|
||||
if (tag === "p" || tag === "span" || tag === "div")
|
||||
return text.length > 0 ? trimLabel(text, 56) : "Text";
|
||||
if (tag === "img") return "Image";
|
||||
if (tag === "video") return "Video";
|
||||
if (tag === "audio") return "Audio";
|
||||
@ -132,7 +133,11 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
};
|
||||
}
|
||||
|
||||
function getPickInfosFromPoint(clientX: number, clientY: number, limit?: number): RuntimePickerElementInfo[] {
|
||||
function getPickInfosFromPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
limit?: number,
|
||||
): RuntimePickerElementInfo[] {
|
||||
return getPickCandidatesFromPoint(clientX, clientY, limit).map(extractElementInfo);
|
||||
}
|
||||
|
||||
@ -217,7 +222,9 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
getHovered: () => pickLastHoveredInfo,
|
||||
getSelected: () => pickLastSelectedInfo,
|
||||
getCandidatesAtPoint: (clientX, clientY, limit) =>
|
||||
Number.isFinite(clientX) && Number.isFinite(clientY) ? getPickInfosFromPoint(clientX, clientY, limit) : [],
|
||||
Number.isFinite(clientX) && Number.isFinite(clientY)
|
||||
? getPickInfosFromPoint(clientX, clientY, limit)
|
||||
: [],
|
||||
pickAtPoint: (clientX, clientY, index) => {
|
||||
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
|
||||
const infos = getPickInfosFromPoint(clientX, clientY, 8);
|
||||
@ -240,12 +247,18 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const idx = Math.max(0, Math.min(infos.length - 1, Math.floor(Number(rawIndex))));
|
||||
const info = infos[idx];
|
||||
if (!info) continue;
|
||||
const duplicate = selected.some((item) => item.selector === info.selector && item.tagName === info.tagName);
|
||||
const duplicate = selected.some(
|
||||
(item) => item.selector === info.selector && item.tagName === info.tagName,
|
||||
);
|
||||
if (!duplicate) selected.push(info);
|
||||
}
|
||||
if (!selected.length) return [];
|
||||
setLastSelectedInfo(selected[0] ?? null);
|
||||
deps.postMessage({ source: "hf-preview", type: "element-picked-many", elementInfos: selected });
|
||||
deps.postMessage({
|
||||
source: "hf-preview",
|
||||
type: "element-picked-many",
|
||||
elementInfos: selected,
|
||||
});
|
||||
disablePickMode();
|
||||
return selected;
|
||||
},
|
||||
|
||||
@ -5,14 +5,24 @@ import type { RuntimeTimelineLike } from "./types";
|
||||
function createMockTimeline(opts?: { time?: number; duration?: number }): RuntimeTimelineLike {
|
||||
const state = { time: opts?.time ?? 0, duration: opts?.duration ?? 10, paused: false };
|
||||
return {
|
||||
play: vi.fn(() => { state.paused = false; }),
|
||||
pause: vi.fn(() => { state.paused = true; }),
|
||||
seek: vi.fn((t: number) => { state.time = t; }),
|
||||
totalTime: vi.fn((t: number) => { state.time = t; }),
|
||||
play: vi.fn(() => {
|
||||
state.paused = false;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
state.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
}),
|
||||
time: vi.fn(() => state.time),
|
||||
duration: vi.fn(() => state.duration),
|
||||
add: vi.fn(),
|
||||
paused: vi.fn((p?: boolean) => { if (p !== undefined) state.paused = p; }),
|
||||
paused: vi.fn((p?: boolean) => {
|
||||
if (p !== undefined) state.paused = p;
|
||||
}),
|
||||
timeScale: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
@ -25,9 +35,13 @@ function createMockDeps(timeline?: RuntimeTimelineLike | null) {
|
||||
getTimeline: vi.fn(() => timeline ?? null),
|
||||
setTimeline: vi.fn(),
|
||||
getIsPlaying: vi.fn(() => isPlaying),
|
||||
setIsPlaying: vi.fn((v: boolean) => { isPlaying = v; }),
|
||||
setIsPlaying: vi.fn((v: boolean) => {
|
||||
isPlaying = v;
|
||||
}),
|
||||
getPlaybackRate: vi.fn(() => playbackRate),
|
||||
setPlaybackRate: vi.fn((v: number) => { playbackRate = v; }),
|
||||
setPlaybackRate: vi.fn((v: number) => {
|
||||
playbackRate = v;
|
||||
}),
|
||||
getCanonicalFps: vi.fn(() => 30),
|
||||
onSyncMedia: vi.fn(),
|
||||
onStatePost: vi.fn(),
|
||||
|
||||
@ -40,7 +40,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
play: () => {
|
||||
const timeline = deps.getTimeline();
|
||||
if (!timeline || deps.getIsPlaying()) return;
|
||||
const safeDuration = Math.max(0, Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0);
|
||||
const safeDuration = Math.max(
|
||||
0,
|
||||
Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0,
|
||||
);
|
||||
if (safeDuration > 0) {
|
||||
const currentTime = Math.max(0, Number(timeline.time()) || 0);
|
||||
if (currentTime >= safeDuration) {
|
||||
@ -87,7 +90,11 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
renderSeek: (timeSeconds: number) => {
|
||||
const timeline = deps.getTimeline();
|
||||
if (!timeline) return;
|
||||
const quantized = seekTimelineDeterministically(timeline, timeSeconds, deps.getCanonicalFps());
|
||||
const quantized = seekTimelineDeterministically(
|
||||
timeline,
|
||||
timeSeconds,
|
||||
deps.getCanonicalFps(),
|
||||
);
|
||||
deps.onDeterministicSeek(quantized);
|
||||
deps.setIsPlaying(false);
|
||||
deps.onSyncMedia(quantized, false);
|
||||
|
||||
@ -7,8 +7,7 @@ beforeAll(() => {
|
||||
(globalThis as any).CSS = {};
|
||||
}
|
||||
if (typeof CSS.escape !== "function") {
|
||||
CSS.escape = (value: string) =>
|
||||
value.replace(/([^\w-])/g, "\\$1");
|
||||
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
|
||||
}
|
||||
});
|
||||
|
||||
@ -191,7 +190,16 @@ describe("createRuntimeStartTimeResolver", () => {
|
||||
el.setAttribute("data-composition-id", "comp-1");
|
||||
document.body.appendChild(el);
|
||||
|
||||
const mockTimeline = { duration: () => 12, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} };
|
||||
const mockTimeline = {
|
||||
duration: () => 12,
|
||||
time: () => 0,
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: () => {},
|
||||
add: () => {},
|
||||
paused: () => {},
|
||||
set: () => {},
|
||||
};
|
||||
const resolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: { "comp-1": mockTimeline as any },
|
||||
});
|
||||
@ -204,7 +212,16 @@ describe("createRuntimeStartTimeResolver", () => {
|
||||
el.setAttribute("data-duration", "5");
|
||||
document.body.appendChild(el);
|
||||
|
||||
const mockTimeline = { duration: () => 12, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} };
|
||||
const mockTimeline = {
|
||||
duration: () => 12,
|
||||
time: () => 0,
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: () => {},
|
||||
add: () => {},
|
||||
paused: () => {},
|
||||
set: () => {},
|
||||
};
|
||||
const resolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: { "comp-1": mockTimeline as any },
|
||||
});
|
||||
|
||||
@ -49,7 +49,10 @@ export function createRuntimeStartTimeResolver(params: {
|
||||
const findReferenceTarget = (refId: string): Element | null => {
|
||||
const byId = document.getElementById(refId);
|
||||
if (byId) return byId;
|
||||
return (document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ?? null;
|
||||
return (
|
||||
(document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveDurationForElement = (element: Element): number | null => {
|
||||
|
||||
@ -194,7 +194,10 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
clip.setAttribute("data-duration", "5000");
|
||||
root.appendChild(clip);
|
||||
|
||||
const result = collectRuntimeTimelinePayload({ canonicalFps: 30, maxTimelineDurationSeconds: 60 });
|
||||
const result = collectRuntimeTimelinePayload({
|
||||
canonicalFps: 30,
|
||||
maxTimelineDurationSeconds: 60,
|
||||
});
|
||||
expect(result.durationInFrames).toBeLessThanOrEqual(60 * 30);
|
||||
});
|
||||
|
||||
@ -263,13 +266,31 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
root.appendChild(comp);
|
||||
|
||||
(window as any).__timelines = {
|
||||
"main": { duration: () => 15, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} },
|
||||
"scene-1": { duration: () => 8, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} },
|
||||
main: {
|
||||
duration: () => 15,
|
||||
time: () => 0,
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: () => {},
|
||||
add: () => {},
|
||||
paused: () => {},
|
||||
set: () => {},
|
||||
},
|
||||
"scene-1": {
|
||||
duration: () => 8,
|
||||
time: () => 0,
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: () => {},
|
||||
add: () => {},
|
||||
paused: () => {},
|
||||
set: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
// scene-1 should get duration 8 from timeline registry
|
||||
const sceneClip = result.clips.find(c => c.compositionId === "scene-1");
|
||||
const sceneClip = result.clips.find((c) => c.compositionId === "scene-1");
|
||||
expect(sceneClip).toBeDefined();
|
||||
expect(sceneClip?.duration).toBe(8);
|
||||
});
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import type { RuntimeTimelineClip, RuntimeTimelineMessage, RuntimeTimelineScene, RuntimeTimelineLike } from "./types";
|
||||
import type {
|
||||
RuntimeTimelineClip,
|
||||
RuntimeTimelineMessage,
|
||||
RuntimeTimelineScene,
|
||||
RuntimeTimelineLike,
|
||||
} from "./types";
|
||||
import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
|
||||
function parseNum(value: string | null | undefined): number | null {
|
||||
@ -50,22 +55,26 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const resolveMediaElementDurationSeconds = (mediaEl: HTMLVideoElement | HTMLAudioElement): number | null => {
|
||||
const resolveMediaElementDurationSeconds = (
|
||||
mediaEl: HTMLVideoElement | HTMLAudioElement,
|
||||
): number | null => {
|
||||
const declaredDuration = parseNum(mediaEl.getAttribute("data-duration"));
|
||||
if (declaredDuration != null && declaredDuration > 0) {
|
||||
return declaredDuration;
|
||||
}
|
||||
const playbackStart =
|
||||
parseNum(mediaEl.getAttribute("data-playback-start")) ?? parseNum(mediaEl.getAttribute("data-media-start")) ?? 0;
|
||||
parseNum(mediaEl.getAttribute("data-playback-start")) ??
|
||||
parseNum(mediaEl.getAttribute("data-media-start")) ??
|
||||
0;
|
||||
if (Number.isFinite(mediaEl.duration) && mediaEl.duration > playbackStart) {
|
||||
return Math.max(0, mediaEl.duration - playbackStart);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const resolveMediaWindowEndSeconds = (): number | null => {
|
||||
const mediaNodes = Array.from(document.querySelectorAll("video[data-start], audio[data-start]")) as Array<
|
||||
HTMLVideoElement | HTMLAudioElement
|
||||
>;
|
||||
const mediaNodes = Array.from(
|
||||
document.querySelectorAll("video[data-start], audio[data-start]"),
|
||||
) as Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
if (mediaNodes.length === 0) return null;
|
||||
let maxWindowEndSeconds = 0;
|
||||
for (const mediaNode of mediaNodes) {
|
||||
@ -137,11 +146,15 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
? rootDurationFromTimeline
|
||||
: null;
|
||||
const attrDurationCandidate =
|
||||
typeof rootDurationFromAttr === "number" && Number.isFinite(rootDurationFromAttr) && rootDurationFromAttr > 0
|
||||
typeof rootDurationFromAttr === "number" &&
|
||||
Number.isFinite(rootDurationFromAttr) &&
|
||||
rootDurationFromAttr > 0
|
||||
? rootDurationFromAttr
|
||||
: null;
|
||||
const mediaWindowDurationCandidate =
|
||||
typeof mediaWindowDuration === "number" && Number.isFinite(mediaWindowDuration) && mediaWindowDuration > 0
|
||||
typeof mediaWindowDuration === "number" &&
|
||||
Number.isFinite(mediaWindowDuration) &&
|
||||
mediaWindowDuration > 0
|
||||
? mediaWindowDuration
|
||||
: null;
|
||||
const timelineLooksLoopInflated =
|
||||
@ -156,8 +169,11 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
? mediaWindowDurationCandidate
|
||||
: (timelineDurationCandidate ?? mediaWindowDurationCandidate));
|
||||
const rootCompositionDuration =
|
||||
preferredRootDuration != null ? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds) : null;
|
||||
const rootCompositionEnd = rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
|
||||
preferredRootDuration != null
|
||||
? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds)
|
||||
: null;
|
||||
const rootCompositionEnd =
|
||||
rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
|
||||
const timelineWindowEnd =
|
||||
rootCompositionEnd ??
|
||||
(typeof mediaWindowEnd === "number" && Number.isFinite(mediaWindowEnd) && mediaWindowEnd > 0
|
||||
@ -177,17 +193,27 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i];
|
||||
if (node === root) continue;
|
||||
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName)) continue;
|
||||
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName))
|
||||
continue;
|
||||
const compositionContext = resolveNearestCompositionContext(node, root);
|
||||
const start = startResolver.resolveStartForElement(node, compositionContext.inheritedStart ?? 0);
|
||||
const start = startResolver.resolveStartForElement(
|
||||
node,
|
||||
compositionContext.inheritedStart ?? 0,
|
||||
);
|
||||
const nodeCompositionId = node.getAttribute("data-composition-id");
|
||||
let duration = parseNum(node.getAttribute("data-duration"));
|
||||
if ((duration == null || duration <= 0) && nodeCompositionId && nodeCompositionId !== rootCompositionId) {
|
||||
if (
|
||||
(duration == null || duration <= 0) &&
|
||||
nodeCompositionId &&
|
||||
nodeCompositionId !== rootCompositionId
|
||||
) {
|
||||
duration = resolveTimelineDurationSeconds(nodeCompositionId);
|
||||
}
|
||||
if ((duration == null || duration <= 0) && node instanceof HTMLMediaElement) {
|
||||
const mediaStart =
|
||||
parseNum(node.getAttribute("data-playback-start")) ?? parseNum(node.getAttribute("data-media-start")) ?? 0;
|
||||
parseNum(node.getAttribute("data-playback-start")) ??
|
||||
parseNum(node.getAttribute("data-media-start")) ??
|
||||
0;
|
||||
if (Number.isFinite(node.duration) && node.duration > 0) {
|
||||
duration = Math.max(0, node.duration - mediaStart);
|
||||
}
|
||||
@ -228,7 +254,10 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
start,
|
||||
duration,
|
||||
track:
|
||||
Number.parseInt(node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), 10) || 0,
|
||||
Number.parseInt(
|
||||
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
|
||||
10,
|
||||
) || 0,
|
||||
kind,
|
||||
tagName: tag,
|
||||
compositionId: node.getAttribute("data-composition-id"),
|
||||
@ -250,7 +279,8 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
const start = startResolver.resolveStartForElement(compositionNode, 0);
|
||||
const durationFromAttr = parseNum(compositionNode.getAttribute("data-duration"));
|
||||
const durationFromTimeline = resolveTimelineDurationSeconds(compositionId);
|
||||
const duration = durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
|
||||
const duration =
|
||||
durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
|
||||
if (duration == null || duration <= 0) continue;
|
||||
const clampedDuration = clampDurationToRootWindow(start, duration);
|
||||
if (clampedDuration <= 0) continue;
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
export type RuntimeJson = string | number | boolean | null | RuntimeJson[] | { [key: string]: RuntimeJson };
|
||||
export type RuntimeJson =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| RuntimeJson[]
|
||||
| { [key: string]: RuntimeJson };
|
||||
|
||||
export type RuntimeBridgeControlAction =
|
||||
| "play"
|
||||
|
||||
@ -131,26 +131,44 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
concurrency: env("PRODUCER_MAX_WORKERS") ? Number(env("PRODUCER_MAX_WORKERS")) : undefined,
|
||||
coresPerWorker: envNum("PRODUCER_CORES_PER_WORKER", DEFAULT_CONFIG.coresPerWorker),
|
||||
minParallelFrames: envNum("PRODUCER_MIN_PARALLEL_FRAMES", DEFAULT_CONFIG.minParallelFrames),
|
||||
largeRenderThreshold: envNum("PRODUCER_LARGE_RENDER_THRESHOLD", DEFAULT_CONFIG.largeRenderThreshold),
|
||||
largeRenderThreshold: envNum(
|
||||
"PRODUCER_LARGE_RENDER_THRESHOLD",
|
||||
DEFAULT_CONFIG.largeRenderThreshold,
|
||||
),
|
||||
|
||||
chromePath: env("PRODUCER_HEADLESS_SHELL_PATH"),
|
||||
disableGpu: envBool("PRODUCER_DISABLE_GPU", DEFAULT_CONFIG.disableGpu),
|
||||
enableBrowserPool: envBool("PRODUCER_ENABLE_BROWSER_POOL", DEFAULT_CONFIG.enableBrowserPool),
|
||||
browserTimeout: envNum("PRODUCER_PUPPETEER_LAUNCH_TIMEOUT_MS", DEFAULT_CONFIG.browserTimeout),
|
||||
protocolTimeout: envNum("PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS", DEFAULT_CONFIG.protocolTimeout),
|
||||
protocolTimeout: envNum(
|
||||
"PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS",
|
||||
DEFAULT_CONFIG.protocolTimeout,
|
||||
),
|
||||
expectedChromiumMajor: env("PRODUCER_EXPECTED_CHROMIUM_MAJOR")
|
||||
? Number(env("PRODUCER_EXPECTED_CHROMIUM_MAJOR"))
|
||||
: undefined,
|
||||
|
||||
forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
|
||||
|
||||
enableChunkedEncode: envBool("PRODUCER_ENABLE_CHUNKED_ENCODE", DEFAULT_CONFIG.enableChunkedEncode),
|
||||
chunkSizeFrames: Math.max(120, envNum("PRODUCER_CHUNK_SIZE_FRAMES", DEFAULT_CONFIG.chunkSizeFrames)),
|
||||
enableStreamingEncode: envBool("PRODUCER_ENABLE_STREAMING_ENCODE", DEFAULT_CONFIG.enableStreamingEncode),
|
||||
enableChunkedEncode: envBool(
|
||||
"PRODUCER_ENABLE_CHUNKED_ENCODE",
|
||||
DEFAULT_CONFIG.enableChunkedEncode,
|
||||
),
|
||||
chunkSizeFrames: Math.max(
|
||||
120,
|
||||
envNum("PRODUCER_CHUNK_SIZE_FRAMES", DEFAULT_CONFIG.chunkSizeFrames),
|
||||
),
|
||||
enableStreamingEncode: envBool(
|
||||
"PRODUCER_ENABLE_STREAMING_ENCODE",
|
||||
DEFAULT_CONFIG.enableStreamingEncode,
|
||||
),
|
||||
|
||||
ffmpegEncodeTimeout: envNum("FFMPEG_ENCODE_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegEncodeTimeout),
|
||||
ffmpegProcessTimeout: envNum("FFMPEG_PROCESS_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegProcessTimeout),
|
||||
ffmpegStreamingTimeout: envNum("FFMPEG_STREAMING_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegStreamingTimeout),
|
||||
ffmpegStreamingTimeout: envNum(
|
||||
"FFMPEG_STREAMING_TIMEOUT_MS",
|
||||
DEFAULT_CONFIG.ffmpegStreamingTimeout,
|
||||
),
|
||||
|
||||
audioGain: envNum("PRODUCER_AUDIO_GAIN", DEFAULT_CONFIG.audioGain),
|
||||
frameDataUriCacheLimit: Math.max(
|
||||
@ -158,8 +176,14 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
envNum("PRODUCER_FRAME_DATA_URI_CACHE_LIMIT", DEFAULT_CONFIG.frameDataUriCacheLimit),
|
||||
),
|
||||
|
||||
playerReadyTimeout: envNum("PRODUCER_PLAYER_READY_TIMEOUT_MS", DEFAULT_CONFIG.playerReadyTimeout),
|
||||
renderReadyTimeout: envNum("PRODUCER_RENDER_READY_TIMEOUT_MS", DEFAULT_CONFIG.renderReadyTimeout),
|
||||
playerReadyTimeout: envNum(
|
||||
"PRODUCER_PLAYER_READY_TIMEOUT_MS",
|
||||
DEFAULT_CONFIG.playerReadyTimeout,
|
||||
),
|
||||
renderReadyTimeout: envNum(
|
||||
"PRODUCER_RENDER_READY_TIMEOUT_MS",
|
||||
DEFAULT_CONFIG.renderReadyTimeout,
|
||||
),
|
||||
|
||||
verifyRuntime: env("PRODUCER_VERIFY_HYPERFRAME_RUNTIME") !== "false",
|
||||
runtimeManifestPath: env("PRODUCER_HYPERFRAME_MANIFEST_PATH"),
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
* - **Optional lookups return `T | undefined` or `T | null`.**
|
||||
* Functions that may legitimately find nothing (resolveHeadlessShellPath,
|
||||
* getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// ── Protocol types ─────────────────────────────────────────────────────────────
|
||||
@ -90,11 +90,7 @@ export {
|
||||
ENCODER_PRESETS,
|
||||
type GpuEncoder,
|
||||
} from "./services/chunkEncoder.js";
|
||||
export type {
|
||||
EncoderOptions,
|
||||
EncodeResult,
|
||||
MuxResult,
|
||||
} from "./services/chunkEncoder.types.js";
|
||||
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
|
||||
|
||||
export {
|
||||
spawnStreamingEncoder,
|
||||
@ -121,15 +117,8 @@ export {
|
||||
|
||||
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
||||
|
||||
export {
|
||||
parseAudioElements,
|
||||
processCompositionAudio,
|
||||
} from "./services/audioMixer.js";
|
||||
export type {
|
||||
AudioElement,
|
||||
AudioTrack,
|
||||
MixResult,
|
||||
} from "./services/audioMixer.types.js";
|
||||
export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
|
||||
export type { AudioElement, AudioTrack, MixResult } from "./services/audioMixer.types.js";
|
||||
|
||||
// ── Parallel rendering ─────────────────────────────────────────────────────────
|
||||
export {
|
||||
@ -144,11 +133,20 @@ export {
|
||||
} from "./services/parallelCoordinator.js";
|
||||
|
||||
// ── File server ────────────────────────────────────────────────────────────────
|
||||
export { createFileServer, type FileServerOptions, type FileServerHandle } from "./services/fileServer.js";
|
||||
export {
|
||||
createFileServer,
|
||||
type FileServerOptions,
|
||||
type FileServerHandle,
|
||||
} from "./services/fileServer.js";
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────────
|
||||
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
||||
|
||||
export { extractVideoMetadata, extractAudioMetadata, type VideoMetadata, type AudioMetadata } from "./utils/ffprobe.js";
|
||||
export {
|
||||
extractVideoMetadata,
|
||||
extractAudioMetadata,
|
||||
type VideoMetadata,
|
||||
type AudioMetadata,
|
||||
} from "./utils/ffprobe.js";
|
||||
|
||||
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
|
||||
@ -98,14 +98,20 @@ async function extractAudioFromVideo(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { success: false, outputPath, durationMs: result.durationMs, error: "Audio extract cancelled" };
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Audio extract cancelled",
|
||||
};
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
error:
|
||||
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
};
|
||||
}
|
||||
return { success: true, outputPath, durationMs: result.durationMs };
|
||||
@ -143,7 +149,12 @@ async function prepareAudioTrack(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { success: false, outputPath, durationMs: result.durationMs, error: "Audio prepare cancelled" };
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Audio prepare cancelled",
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: result.success,
|
||||
@ -183,7 +194,12 @@ async function generateSilence(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { success: false, outputPath, durationMs: result.durationMs, error: "Silence generation cancelled" };
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Silence generation cancelled",
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: result.success,
|
||||
@ -272,10 +288,16 @@ async function mixAudioTracks(
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
tracksProcessed: 0,
|
||||
error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
error:
|
||||
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
};
|
||||
}
|
||||
return { success: true, outputPath, durationMs: result.durationMs, tracksProcessed: tracks.length };
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
tracksProcessed: tracks.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function processCompositionAudio(
|
||||
@ -309,7 +331,9 @@ export async function processCompositionAudio(
|
||||
try {
|
||||
srcPath = await downloadToTemp(srcPath, workDir);
|
||||
} catch (err: unknown) {
|
||||
errors.push(`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
|
||||
errors.push(
|
||||
`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -323,7 +347,8 @@ export async function processCompositionAudio(
|
||||
if (element.end - element.start <= 0) {
|
||||
const metadata = await extractAudioMetadata(srcPath);
|
||||
const effectiveDuration = metadata.durationSeconds - element.mediaStart;
|
||||
element.end = element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
|
||||
element.end =
|
||||
element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
|
||||
}
|
||||
|
||||
let audioSrcPath = srcPath;
|
||||
|
||||
@ -40,7 +40,9 @@ export interface AcquiredBrowser {
|
||||
* Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
|
||||
* then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
|
||||
*/
|
||||
export function resolveHeadlessShellPath(config?: Partial<Pick<EngineConfig, "chromePath">>): string | undefined {
|
||||
export function resolveHeadlessShellPath(
|
||||
config?: Partial<Pick<EngineConfig, "chromePath">>,
|
||||
): string | undefined {
|
||||
if (config?.chromePath) {
|
||||
return config.chromePath;
|
||||
}
|
||||
@ -78,7 +80,10 @@ export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
|
||||
export async function acquireBrowser(
|
||||
chromeArgs: string[],
|
||||
config?: Partial<
|
||||
Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot">
|
||||
Pick<
|
||||
EngineConfig,
|
||||
"browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot"
|
||||
>
|
||||
>,
|
||||
): Promise<AcquiredBrowser> {
|
||||
const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
|
||||
|
||||
@ -289,7 +289,18 @@ export async function encodeFramesChunkedConcat(
|
||||
const concatInput = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
|
||||
writeFileSync(concatListPath, concatInput, "utf-8");
|
||||
|
||||
const concatArgs = ["-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-y", outputPath];
|
||||
const concatArgs = [
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const ffmpeg = spawn("ffmpeg", concatArgs);
|
||||
let stderr = "";
|
||||
@ -358,7 +369,12 @@ export async function muxVideoWithAudio(
|
||||
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg mux cancelled" };
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "FFmpeg mux cancelled",
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: result.success,
|
||||
@ -384,7 +400,12 @@ export async function applyFaststart(
|
||||
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg faststart cancelled" };
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "FFmpeg faststart cancelled",
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: result.success,
|
||||
|
||||
@ -135,7 +135,9 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
|
||||
// Remove leading slash
|
||||
const relativePath = requestPath.replace(/^\//, "");
|
||||
const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
|
||||
const hasCompiledFile = Boolean(compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile());
|
||||
const hasCompiledFile = Boolean(
|
||||
compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile(),
|
||||
);
|
||||
const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
|
||||
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
|
||||
@ -22,7 +22,12 @@ import {
|
||||
} from "./browserManager.js";
|
||||
import { beginFrameCapture, getCdpSession, pageScreenshotCapture } from "./screenshotService.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary } from "../types.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
CaptureBufferResult,
|
||||
CapturePerfSummary,
|
||||
} from "../types.js";
|
||||
|
||||
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
|
||||
|
||||
@ -72,8 +77,12 @@ export async function createCaptureSession(
|
||||
const headlessShell = resolveHeadlessShellPath(config);
|
||||
const isLinux = process.platform === "linux";
|
||||
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
|
||||
const preMode: CaptureMode = headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
|
||||
const chromeArgs = buildChromeArgs({ width: options.width, height: options.height, captureMode: preMode }, config);
|
||||
const preMode: CaptureMode =
|
||||
headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
|
||||
const chromeArgs = buildChromeArgs(
|
||||
{ width: options.width, height: options.height, captureMode: preMode },
|
||||
config,
|
||||
);
|
||||
|
||||
const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
|
||||
|
||||
@ -81,7 +90,10 @@ export async function createCaptureSession(
|
||||
const browserVersion = await browser.version();
|
||||
const expectedMajor = config?.expectedChromiumMajor;
|
||||
if (Number.isFinite(expectedMajor)) {
|
||||
const actualChromiumMajor = Number.parseInt((browserVersion.match(/(\d+)\./) || [])[1] || "", 10);
|
||||
const actualChromiumMajor = Number.parseInt(
|
||||
(browserVersion.match(/(\d+)\./) || [])[1] || "",
|
||||
10,
|
||||
);
|
||||
if (Number.isFinite(actualChromiumMajor) && actualChromiumMajor !== expectedMajor) {
|
||||
throw new Error(
|
||||
`[FrameCapture] Chromium major mismatch expected=${expectedMajor} actual=${actualChromiumMajor} raw=${browserVersion}`,
|
||||
@ -127,7 +139,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
const prefix = type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
|
||||
const prefix =
|
||||
type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
|
||||
console.log(`${prefix} ${text}`);
|
||||
|
||||
session.browserConsoleBuffer.push(`${prefix} ${text}`);
|
||||
@ -151,7 +164,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
// Screenshot mode: standard navigation, rAF works normally
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
|
||||
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
|
||||
const pageReadyTimeout =
|
||||
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
|
||||
await page.waitForFunction(
|
||||
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
|
||||
{ timeout: pageReadyTimeout },
|
||||
@ -230,7 +244,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
|
||||
// Wait for all video elements to have loaded metadata (dimensions + duration).
|
||||
// Without this, frame 0 captures videos at their 300x150 default size.
|
||||
const videoDeadline = Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
|
||||
const videoDeadline =
|
||||
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
|
||||
while (Date.now() < videoDeadline) {
|
||||
const videosReady = await page.evaluate(
|
||||
`document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
|
||||
@ -341,14 +356,24 @@ async function captureFrameCore(
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
|
||||
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
|
||||
session,
|
||||
frameIndex,
|
||||
time,
|
||||
);
|
||||
|
||||
const screenshotStart = Date.now();
|
||||
let screenshotBuffer: Buffer;
|
||||
|
||||
if (session.captureMode === "beginframe") {
|
||||
const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
|
||||
const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
|
||||
const frameTimeTicks =
|
||||
session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
|
||||
const result = await beginFrameCapture(
|
||||
page,
|
||||
options,
|
||||
frameTimeTicks,
|
||||
session.beginFrameIntervalMs,
|
||||
);
|
||||
if (result.hasDamage) session.beginFrameHasDamageCount++;
|
||||
else session.beginFrameNoDamageCount++;
|
||||
screenshotBuffer = result.buffer;
|
||||
@ -379,9 +404,17 @@ async function captureFrameCore(
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureFrame(session: CaptureSession, frameIndex: number, time: number): Promise<CaptureResult> {
|
||||
export async function captureFrame(
|
||||
session: CaptureSession,
|
||||
frameIndex: number,
|
||||
time: number,
|
||||
): Promise<CaptureResult> {
|
||||
const { options, outputDir } = session;
|
||||
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
|
||||
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(
|
||||
session,
|
||||
frameIndex,
|
||||
time,
|
||||
);
|
||||
|
||||
const ext = options.format === "png" ? "png" : "jpg";
|
||||
const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
|
||||
|
||||
@ -57,7 +57,12 @@ const MIN_FRAMES_PER_WORKER = 30;
|
||||
export function calculateOptimalWorkers(
|
||||
totalFrames: number,
|
||||
requested?: number,
|
||||
config?: Partial<Pick<EngineConfig, "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold">>,
|
||||
config?: Partial<
|
||||
Pick<
|
||||
EngineConfig,
|
||||
"concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold"
|
||||
>
|
||||
>,
|
||||
): number {
|
||||
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
||||
const effectiveMaxWorkers = (() => {
|
||||
@ -69,7 +74,8 @@ export function calculateOptimalWorkers(
|
||||
})();
|
||||
const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
|
||||
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
||||
const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
||||
const effectiveLargeRenderThreshold =
|
||||
config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
||||
|
||||
if (requested !== undefined) {
|
||||
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
||||
@ -107,7 +113,11 @@ export function calculateOptimalWorkers(
|
||||
return finalWorkers;
|
||||
}
|
||||
|
||||
export function distributeFrames(totalFrames: number, workerCount: number, workDir: string): WorkerTask[] {
|
||||
export function distributeFrames(
|
||||
totalFrames: number,
|
||||
workerCount: number,
|
||||
workDir: string,
|
||||
): WorkerTask[] {
|
||||
const tasks: WorkerTask[] = [];
|
||||
const framesPerWorker = Math.ceil(totalFrames / workerCount);
|
||||
|
||||
@ -146,7 +156,13 @@ async function executeWorkerTask(
|
||||
let perf: CapturePerfSummary | undefined;
|
||||
|
||||
try {
|
||||
session = await createCaptureSession(serverUrl, task.outputDir, captureOptions, createBeforeCaptureHook(), config);
|
||||
session = await createCaptureSession(
|
||||
serverUrl,
|
||||
task.outputDir,
|
||||
captureOptions,
|
||||
createBeforeCaptureHook(),
|
||||
config,
|
||||
);
|
||||
await initializeSession(session);
|
||||
|
||||
for (let i = task.startFrame; i < task.endFrame; i++) {
|
||||
@ -248,7 +264,11 @@ export async function executeParallelCapture(
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function mergeWorkerFrames(workDir: string, tasks: WorkerTask[], outputDir: string): Promise<number> {
|
||||
export async function mergeWorkerFrames(
|
||||
workDir: string,
|
||||
tasks: WorkerTask[],
|
||||
outputDir: string,
|
||||
): Promise<number> {
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
let totalFrames = 0;
|
||||
|
||||
@ -203,7 +203,10 @@ export async function injectVideoFramesBatch(
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncVideoFrameVisibility(page: Page, activeVideoIds: string[]): Promise<void> {
|
||||
export async function syncVideoFrameVisibility(
|
||||
page: Page,
|
||||
activeVideoIds: string[],
|
||||
): Promise<void> {
|
||||
await page.evaluate((ids: string[]) => {
|
||||
const active = new Set(ids);
|
||||
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
||||
|
||||
@ -252,10 +252,20 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, extracted, errors, totalFramesExtracted, durationMs: Date.now() - startTime };
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
extracted,
|
||||
errors,
|
||||
totalFramesExtracted,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFrameAtTime(extracted: ExtractedFrames, globalTime: number, videoStart: number): string | null {
|
||||
export function getFrameAtTime(
|
||||
extracted: ExtractedFrames,
|
||||
globalTime: number,
|
||||
videoStart: number,
|
||||
): string | null {
|
||||
const localTime = globalTime - videoStart;
|
||||
if (localTime < 0) return null;
|
||||
const frameIndex = Math.floor(localTime * extracted.fps);
|
||||
@ -344,7 +354,9 @@ export class FrameLookupTable {
|
||||
this.lastTime = globalTime;
|
||||
}
|
||||
|
||||
getActiveFramePayloads(globalTime: number): Map<string, { framePath: string; frameIndex: number }> {
|
||||
getActiveFramePayloads(
|
||||
globalTime: number,
|
||||
): Map<string, { framePath: string; frameIndex: number }> {
|
||||
const frames = new Map<string, { framePath: string; frameIndex: number }>();
|
||||
this.refreshActiveSet(globalTime);
|
||||
for (const videoId of this.activeVideoIds) {
|
||||
@ -381,7 +393,10 @@ export class FrameLookupTable {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFrameLookupTable(videos: VideoElement[], extracted: ExtractedFrames[]): FrameLookupTable {
|
||||
export function createFrameLookupTable(
|
||||
videos: VideoElement[],
|
||||
extracted: ExtractedFrames[],
|
||||
): FrameLookupTable {
|
||||
const table = new FrameLookupTable();
|
||||
const extractedMap = new Map<string, ExtractedFrames>();
|
||||
for (const ext of extracted) extractedMap.set(ext.videoId, ext);
|
||||
|
||||
@ -71,7 +71,10 @@ export function createVideoFrameInjector(
|
||||
): BeforeCaptureHook | null {
|
||||
if (!frameLookup) return null;
|
||||
|
||||
const cacheLimit = Math.max(32, config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit);
|
||||
const cacheLimit = Math.max(
|
||||
32,
|
||||
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
|
||||
);
|
||||
const frameCache = createFrameDataUriCache(cacheLimit);
|
||||
const lastInjectedFrameByVideo = new Map<string, number>();
|
||||
|
||||
@ -81,13 +84,16 @@ export function createVideoFrameInjector(
|
||||
const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
|
||||
const activeIds = new Set<string>();
|
||||
if (activePayloads.size > 0) {
|
||||
const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> = [];
|
||||
const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
|
||||
[];
|
||||
for (const [videoId, payload] of activePayloads) {
|
||||
activeIds.add(videoId);
|
||||
const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
|
||||
if (lastFrameIndex === payload.frameIndex) continue;
|
||||
pendingReads.push(
|
||||
frameCache.get(payload.framePath).then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
|
||||
frameCache
|
||||
.get(payload.framePath)
|
||||
.then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
|
||||
);
|
||||
}
|
||||
updates.push(...(await Promise.all(pendingReads)));
|
||||
|
||||
@ -59,7 +59,15 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
}
|
||||
|
||||
const probePromise = new Promise<VideoMetadata>((resolve, reject) => {
|
||||
const args = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
|
||||
const args = [
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
|
||||
const ffprobe = spawn("ffprobe", args);
|
||||
let stdout = "";
|
||||
@ -87,7 +95,8 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
}
|
||||
|
||||
const hasAudio = output.streams.some((s) => s.codec_type === "audio");
|
||||
const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
|
||||
const fps =
|
||||
parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
|
||||
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
|
||||
const metadata: VideoMetadata = {
|
||||
@ -100,7 +109,11 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
};
|
||||
resolve(metadata);
|
||||
} catch (parseError: unknown) {
|
||||
reject(new Error(`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`));
|
||||
reject(
|
||||
new Error(
|
||||
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -128,7 +141,15 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
||||
}
|
||||
|
||||
const probePromise = new Promise<AudioMetadata>((resolve, reject) => {
|
||||
const args = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
|
||||
const args = [
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
|
||||
const ffprobe = spawn("ffprobe", args);
|
||||
let stdout = "";
|
||||
|
||||
@ -14,7 +14,11 @@ function getFilenameFromUrl(url: string): string {
|
||||
return `download_${hash}${ext}`;
|
||||
}
|
||||
|
||||
export async function downloadToTemp(url: string, destDir: string, timeoutMs: number = 300000): Promise<string> {
|
||||
export async function downloadToTemp(
|
||||
url: string,
|
||||
destDir: string,
|
||||
timeoutMs: number = 300000,
|
||||
): Promise<string> {
|
||||
const cachedPath = downloadPathCache.get(url);
|
||||
if (cachedPath && existsSync(cachedPath)) {
|
||||
return cachedPath;
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.1.1",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@ -14,16 +17,10 @@
|
||||
"import": "./dist/public-server.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -w build:hyperframes-runtime:modular && node build.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@ -42,8 +39,6 @@
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:^",
|
||||
"@hyperframes/engine": "workspace:^",
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
@ -56,6 +51,8 @@
|
||||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
"@hyperframes/engine": "workspace:^",
|
||||
"hono": "^4.6.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"puppeteer": "^24.0.0",
|
||||
@ -66,5 +63,8 @@
|
||||
"esbuild": "^0.27.2",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,23 @@
|
||||
* pnpm benchmark -- --exclude-tags slow
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, rmSync } from "node:fs";
|
||||
import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRenderJob, executeRenderJob, type RenderPerfSummary } from "./services/renderOrchestrator.js";
|
||||
import {
|
||||
createRenderJob,
|
||||
executeRenderJob,
|
||||
type RenderPerfSummary,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const testsDir = resolve(scriptDir, "../tests");
|
||||
@ -73,7 +85,10 @@ function parseArgs(): { runs: number; only: string | null; excludeTags: string[]
|
||||
return { runs, only, excludeTags };
|
||||
}
|
||||
|
||||
function discoverFixtures(only: string | null, excludeTags: string[]): Array<{ id: string; dir: string; meta: TestMeta }> {
|
||||
function discoverFixtures(
|
||||
only: string | null,
|
||||
excludeTags: string[],
|
||||
): Array<{ id: string; dir: string; meta: TestMeta }> {
|
||||
const fixtures: Array<{ id: string; dir: string; meta: TestMeta }> = [];
|
||||
|
||||
for (const entry of readdirSync(testsDir)) {
|
||||
@ -139,13 +154,17 @@ async function runBenchmark(): Promise<void> {
|
||||
console.error(` ❌ Run ${r + 1} failed: ${err instanceof Error ? err.message : err}`);
|
||||
continue;
|
||||
} finally {
|
||||
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
||||
try {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (job.perfSummary) {
|
||||
fixtureRuns.push({ run: r + 1, perfSummary: job.perfSummary });
|
||||
const ps = job.perfSummary;
|
||||
console.log(` ✓ ${ps.totalElapsedMs}ms total | capture avg ${ps.captureAvgMs ?? "?"}ms/frame | ${ps.totalFrames} frames`);
|
||||
console.log(
|
||||
` ✓ ${ps.totalElapsedMs}ms total | capture avg ${ps.captureAvgMs ?? "?"}ms/frame | ${ps.totalFrames} frames`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,7 +192,12 @@ async function runBenchmark(): Promise<void> {
|
||||
runs: fixtureRuns,
|
||||
averages: {
|
||||
totalElapsedMs: avg(fixtureRuns.map((r) => r.perfSummary.totalElapsedMs)),
|
||||
captureAvgMs: avg(fixtureRuns.filter((r) => r.perfSummary.captureAvgMs != null).map((r) => r.perfSummary.captureAvgMs!)) || null,
|
||||
captureAvgMs:
|
||||
avg(
|
||||
fixtureRuns
|
||||
.filter((r) => r.perfSummary.captureAvgMs != null)
|
||||
.map((r) => r.perfSummary.captureAvgMs!),
|
||||
) || null,
|
||||
stages: avgStages,
|
||||
},
|
||||
};
|
||||
@ -205,12 +229,12 @@ async function runBenchmark(): Promise<void> {
|
||||
console.log("═".repeat(80));
|
||||
console.log(
|
||||
"Fixture".padEnd(25) +
|
||||
"Total".padStart(10) +
|
||||
"Compile".padStart(10) +
|
||||
"Extract".padStart(10) +
|
||||
"Audio".padStart(10) +
|
||||
"Capture".padStart(10) +
|
||||
"Encode".padStart(10)
|
||||
"Total".padStart(10) +
|
||||
"Compile".padStart(10) +
|
||||
"Extract".padStart(10) +
|
||||
"Audio".padStart(10) +
|
||||
"Capture".padStart(10) +
|
||||
"Encode".padStart(10),
|
||||
);
|
||||
console.log("─".repeat(80));
|
||||
|
||||
@ -218,12 +242,12 @@ async function runBenchmark(): Promise<void> {
|
||||
const s = f.averages.stages;
|
||||
console.log(
|
||||
f.fixture.padEnd(25) +
|
||||
`${f.averages.totalElapsedMs}ms`.padStart(10) +
|
||||
`${s.compileMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.videoExtractMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.audioProcessMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.captureMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.encodeMs ?? "-"}ms`.padStart(10)
|
||||
`${f.averages.totalElapsedMs}ms`.padStart(10) +
|
||||
`${s.compileMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.videoExtractMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.audioProcessMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.captureMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.encodeMs ?? "-"}ms`.padStart(10),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -46,11 +46,7 @@ export {
|
||||
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────────
|
||||
export {
|
||||
resolveConfig,
|
||||
DEFAULT_CONFIG,
|
||||
type ProducerConfig,
|
||||
} from "./config.js";
|
||||
export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js";
|
||||
|
||||
// ── Logger ──────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
@ -72,10 +68,7 @@ export {
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────────────────
|
||||
export { quantizeTimeToFrame } from "./utils/parityContract.js";
|
||||
export {
|
||||
resolveRenderPaths,
|
||||
type RenderPaths,
|
||||
} from "./utils/paths.js";
|
||||
export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";
|
||||
|
||||
export {
|
||||
prepareHyperframeLintBody,
|
||||
|
||||
@ -33,8 +33,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
export function createConsoleLogger(level: LogLevel = "info"): ProducerLogger {
|
||||
const threshold = LOG_LEVEL_PRIORITY[level];
|
||||
|
||||
const shouldLog = (msgLevel: LogLevel): boolean =>
|
||||
LOG_LEVEL_PRIORITY[msgLevel] <= threshold;
|
||||
const shouldLog = (msgLevel: LogLevel): boolean => LOG_LEVEL_PRIORITY[msgLevel] <= threshold;
|
||||
|
||||
const formatMeta = (meta?: Record<string, unknown>): string =>
|
||||
meta ? ` ${JSON.stringify(meta)}` : "";
|
||||
|
||||
@ -44,7 +44,7 @@ function parseArgs(argv: string[]): ParityHarnessOptions {
|
||||
const producerUrl = args.get("producer-url") || "";
|
||||
if (!previewUrl || !producerUrl) {
|
||||
throw new Error(
|
||||
'Missing required args. Usage: --preview-url "<url>" --producer-url "<url>" [--checkpoints "0,1,2"] [--fps 30] [--width 1920] [--height 1080] [--allow-mismatch-ratio 0]'
|
||||
'Missing required args. Usage: --preview-url "<url>" --producer-url "<url>" [--checkpoints "0,1,2"] [--fps 30] [--width 1920] [--height 1080] [--allow-mismatch-ratio 0]',
|
||||
);
|
||||
}
|
||||
|
||||
@ -66,11 +66,10 @@ function parseArgs(argv: string[]): ParityHarnessOptions {
|
||||
checkpoints,
|
||||
allowMismatchRatio: Math.max(
|
||||
0,
|
||||
Math.min(1, parseNumberArg(args.get("allow-mismatch-ratio"), 0))
|
||||
Math.min(1, parseNumberArg(args.get("allow-mismatch-ratio"), 0)),
|
||||
),
|
||||
artifactsDir: resolve(args.get("artifacts-dir") || ".debug/parity-harness"),
|
||||
emulateProducerSwap:
|
||||
(args.get("emulate-producer-swap") || "false").toLowerCase() === "true",
|
||||
emulateProducerSwap: (args.get("emulate-producer-swap") || "false").toLowerCase() === "true",
|
||||
};
|
||||
}
|
||||
|
||||
@ -80,7 +79,7 @@ async function waitForParityReady(page: Page): Promise<void> {
|
||||
const win = window as unknown as { __playerReady?: boolean; __renderReady?: boolean };
|
||||
return Boolean(win.__playerReady && win.__renderReady);
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
@ -126,99 +125,109 @@ function writeImageDiff(basePath: string, comparePath: string, outputPath: strin
|
||||
}
|
||||
|
||||
async function captureStyleSnapshot(page: Page): Promise<Record<string, unknown>> {
|
||||
return page.evaluate((properties: string[]) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll("video[data-start], img.__render_frame__, img.__preview_render_frame__, img.__parity_render_frame__"),
|
||||
) as HTMLElement[];
|
||||
return {
|
||||
location: window.location.href,
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
},
|
||||
media: targets.map((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const values: Record<string, string> = {};
|
||||
for (const property of properties) {
|
||||
values[property] = style.getPropertyValue(property);
|
||||
}
|
||||
return {
|
||||
id: el.id || null,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
className: el.className || null,
|
||||
values,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [...MEDIA_VISUAL_STYLE_PROPERTIES]);
|
||||
return page.evaluate(
|
||||
(properties: string[]) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(
|
||||
"video[data-start], img.__render_frame__, img.__preview_render_frame__, img.__parity_render_frame__",
|
||||
),
|
||||
) as HTMLElement[];
|
||||
return {
|
||||
location: window.location.href,
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
},
|
||||
media: targets.map((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const values: Record<string, string> = {};
|
||||
for (const property of properties) {
|
||||
values[property] = style.getPropertyValue(property);
|
||||
}
|
||||
return {
|
||||
id: el.id || null,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
className: el.className || null,
|
||||
values,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
);
|
||||
}
|
||||
|
||||
async function emulateProducerVideoSwap(page: Page): Promise<void> {
|
||||
await page.evaluate((properties: string[]) => {
|
||||
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
let img = video.nextElementSibling as HTMLImageElement | null;
|
||||
if (!img || !img.classList.contains("__parity_render_frame__")) {
|
||||
img = document.createElement("img");
|
||||
img.className = "__parity_render_frame__";
|
||||
video.parentNode?.insertBefore(img, video.nextSibling);
|
||||
}
|
||||
await page.evaluate(
|
||||
(properties: string[]) => {
|
||||
const videos = Array.from(
|
||||
document.querySelectorAll("video[data-start]"),
|
||||
) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
let img = video.nextElementSibling as HTMLImageElement | null;
|
||||
if (!img || !img.classList.contains("__parity_render_frame__")) {
|
||||
img = document.createElement("img");
|
||||
img.className = "__parity_render_frame__";
|
||||
video.parentNode?.insertBefore(img, video.nextSibling);
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(video);
|
||||
const sourceIsStatic = !style.position || style.position === "static";
|
||||
if (!sourceIsStatic) {
|
||||
img.style.position = style.position;
|
||||
img.style.top = style.top;
|
||||
img.style.left = style.left;
|
||||
img.style.right = style.right;
|
||||
img.style.bottom = style.bottom;
|
||||
} else {
|
||||
img.style.position = "absolute";
|
||||
img.style.top = "0px";
|
||||
img.style.left = "0px";
|
||||
img.style.right = "0px";
|
||||
img.style.bottom = "0px";
|
||||
}
|
||||
for (const property of properties) {
|
||||
if (
|
||||
sourceIsStatic &&
|
||||
(property === "top" ||
|
||||
property === "left" ||
|
||||
property === "right" ||
|
||||
property === "bottom" ||
|
||||
property === "inset")
|
||||
) {
|
||||
continue;
|
||||
const style = window.getComputedStyle(video);
|
||||
const sourceIsStatic = !style.position || style.position === "static";
|
||||
if (!sourceIsStatic) {
|
||||
img.style.position = style.position;
|
||||
img.style.top = style.top;
|
||||
img.style.left = style.left;
|
||||
img.style.right = style.right;
|
||||
img.style.bottom = style.bottom;
|
||||
} else {
|
||||
img.style.position = "absolute";
|
||||
img.style.top = "0px";
|
||||
img.style.left = "0px";
|
||||
img.style.right = "0px";
|
||||
img.style.bottom = "0px";
|
||||
}
|
||||
const value = style.getPropertyValue(property);
|
||||
if (value) {
|
||||
img.style.setProperty(property, value);
|
||||
for (const property of properties) {
|
||||
if (
|
||||
sourceIsStatic &&
|
||||
(property === "top" ||
|
||||
property === "left" ||
|
||||
property === "right" ||
|
||||
property === "bottom" ||
|
||||
property === "inset")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = style.getPropertyValue(property);
|
||||
if (value) {
|
||||
img.style.setProperty(property, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
img.style.pointerEvents = "none";
|
||||
img.style.visibility = "visible";
|
||||
img.style.pointerEvents = "none";
|
||||
img.style.visibility = "visible";
|
||||
|
||||
try {
|
||||
const width = Math.max(2, video.videoWidth || video.clientWidth || 2);
|
||||
const height = Math.max(2, video.videoHeight || video.clientHeight || 2);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) {
|
||||
continue;
|
||||
try {
|
||||
const width = Math.max(2, video.videoWidth || video.clientWidth || 2);
|
||||
const height = Math.max(2, video.videoHeight || video.clientHeight || 2);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) {
|
||||
continue;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
img.src = canvas.toDataURL("image/png");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
} catch {
|
||||
video.style.removeProperty("visibility");
|
||||
video.style.removeProperty("opacity");
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
img.src = canvas.toDataURL("image/png");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
} catch {
|
||||
video.style.removeProperty("visibility");
|
||||
video.style.removeProperty("opacity");
|
||||
}
|
||||
}
|
||||
}, [...MEDIA_VISUAL_STYLE_PROPERTIES]);
|
||||
},
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
);
|
||||
}
|
||||
|
||||
async function captureCheckpoint(
|
||||
@ -254,8 +263,7 @@ async function captureCheckpoint(
|
||||
await emulateProducerVideoSwap(page);
|
||||
}
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
);
|
||||
return (await page.screenshot({ type: "png" })) as Buffer;
|
||||
}
|
||||
@ -297,8 +305,14 @@ async function run(): Promise<void> {
|
||||
const producerPage = await browser.newPage();
|
||||
|
||||
await Promise.all([
|
||||
previewPage.goto(options.previewUrl, { waitUntil: ["load", "networkidle2"], timeout: 60_000 }),
|
||||
producerPage.goto(options.producerUrl, { waitUntil: ["load", "networkidle2"], timeout: 60_000 }),
|
||||
previewPage.goto(options.previewUrl, {
|
||||
waitUntil: ["load", "networkidle2"],
|
||||
timeout: 60_000,
|
||||
}),
|
||||
producerPage.goto(options.producerUrl, {
|
||||
waitUntil: ["load", "networkidle2"],
|
||||
timeout: 60_000,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([waitForParityReady(previewPage), waitForParityReady(producerPage)]);
|
||||
|
||||
@ -321,12 +335,7 @@ async function run(): Promise<void> {
|
||||
ensureDir(artifactDir);
|
||||
const [previewBuffer, producerBuffer] = await Promise.all([
|
||||
captureCheckpoint(previewPage, checkpointSec, options.fps, false),
|
||||
captureCheckpoint(
|
||||
producerPage,
|
||||
checkpointSec,
|
||||
options.fps,
|
||||
options.emulateProducerSwap,
|
||||
),
|
||||
captureCheckpoint(producerPage, checkpointSec, options.fps, options.emulateProducerSwap),
|
||||
]);
|
||||
const previewHash = sha256(previewBuffer);
|
||||
const producerHash = sha256(producerBuffer);
|
||||
|
||||
@ -17,7 +17,12 @@ function main(): void {
|
||||
const baselineRaw = readFileSync(baselinePath, "utf-8");
|
||||
const baseline = JSON.parse(baselineRaw) as PerfBaseline;
|
||||
const maxMs = Math.round(baseline.parityFixtureMaxMs * (1 + baseline.allowedRegressionRatio));
|
||||
const payload = { baselinePath, measuredMs, parityFixtureMaxMs: baseline.parityFixtureMaxMs, maxMs };
|
||||
const payload = {
|
||||
baselinePath,
|
||||
measuredMs,
|
||||
parityFixtureMaxMs: baseline.parityFixtureMaxMs,
|
||||
maxMs,
|
||||
};
|
||||
console.log(`[PerfGate] ${JSON.stringify(payload)}`);
|
||||
if (measuredMs > maxMs) {
|
||||
throw new Error(`[PerfGate] Regression detected measured=${measuredMs}ms max=${maxMs}ms`);
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, statSync, cpSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
cpSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
@ -118,7 +128,11 @@ function validateMetadata(meta: unknown): TestMetadata {
|
||||
if (typeof m.maxFrameFailures !== "number" || m.maxFrameFailures < 0) {
|
||||
throw new Error("meta.json: 'maxFrameFailures' must be a non-negative number");
|
||||
}
|
||||
if (typeof m.minAudioCorrelation !== "number" || m.minAudioCorrelation < 0 || m.minAudioCorrelation > 1) {
|
||||
if (
|
||||
typeof m.minAudioCorrelation !== "number" ||
|
||||
m.minAudioCorrelation < 0 ||
|
||||
m.minAudioCorrelation > 1
|
||||
) {
|
||||
throw new Error("meta.json: 'minAudioCorrelation' must be between 0 and 1");
|
||||
}
|
||||
if (typeof m.maxAudioLagWindows !== "number" || m.maxAudioLagWindows < 1) {
|
||||
@ -140,7 +154,11 @@ function validateMetadata(meta: unknown): TestMetadata {
|
||||
return m as TestMetadata;
|
||||
}
|
||||
|
||||
function discoverTestSuites(testsDir: string, filterNames: string[], excludeTags: string[] = []): TestSuite[] {
|
||||
function discoverTestSuites(
|
||||
testsDir: string,
|
||||
filterNames: string[],
|
||||
excludeTags: string[] = [],
|
||||
): TestSuite[] {
|
||||
if (!existsSync(testsDir)) {
|
||||
throw new Error(`Tests directory not found: ${testsDir}`);
|
||||
}
|
||||
@ -181,13 +199,18 @@ function discoverTestSuites(testsDir: string, filterNames: string[], excludeTags
|
||||
const metaRaw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
||||
meta = validateMetadata(metaRaw);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Skipping ${entry}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.warn(
|
||||
`⚠️ Skipping ${entry}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip tests with excluded tags
|
||||
if (excludeTags.length > 0 && meta.tags.some(t => excludeTags.includes(t))) {
|
||||
logPretty(`Skipping ${entry}: excluded by tags [${meta.tags.filter(t => excludeTags.includes(t)).join(", ")}]`, "⏭️");
|
||||
if (excludeTags.length > 0 && meta.tags.some((t) => excludeTags.includes(t))) {
|
||||
logPretty(
|
||||
`Skipping ${entry}: excluded by tags [${meta.tags.filter((t) => excludeTags.includes(t)).join(", ")}]`,
|
||||
"⏭️",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -238,7 +261,7 @@ function extractFrameAsImage(
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
`Frame extraction at ${timeSeconds}s`
|
||||
`Frame extraction at ${timeSeconds}s`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -392,7 +415,7 @@ function saveFailureDetails(
|
||||
"=== COMPILATION FAILURE ===",
|
||||
"",
|
||||
"Errors:",
|
||||
...result.compilation.errors.map(e => ` - ${e}`),
|
||||
...result.compilation.errors.map((e) => ` - ${e}`),
|
||||
"",
|
||||
"Files saved for comparison:",
|
||||
` - actual.html (what was compiled)`,
|
||||
@ -410,7 +433,7 @@ function saveFailureDetails(
|
||||
|
||||
// Save visual failures
|
||||
if (result.visual && !result.visual.passed && result.visual.checkpoints.length > 0) {
|
||||
const failedCheckpoints = result.visual.checkpoints.filter(c => !c.passed);
|
||||
const failedCheckpoints = result.visual.checkpoints.filter((c) => !c.passed);
|
||||
|
||||
const visualReport = {
|
||||
summary: {
|
||||
@ -418,7 +441,7 @@ function saveFailureDetails(
|
||||
failedCheckpoints: failedCheckpoints.length,
|
||||
threshold: suite.meta.minPsnr,
|
||||
},
|
||||
failedFrames: failedCheckpoints.map(c => ({
|
||||
failedFrames: failedCheckpoints.map((c) => ({
|
||||
time: c.time,
|
||||
psnr: c.psnr,
|
||||
belowThresholdBy: suite.meta.minPsnr - c.psnr,
|
||||
@ -428,7 +451,7 @@ function saveFailureDetails(
|
||||
writeFileSync(
|
||||
join(failuresDir, "visual-failures.json"),
|
||||
JSON.stringify(visualReport, null, 2),
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Extract images for first 10 failed frames
|
||||
@ -448,13 +471,13 @@ function saveFailureDetails(
|
||||
renderedVideoPath,
|
||||
checkpoint.time,
|
||||
join(framesDir, `actual_${timeStr}s.png`),
|
||||
suite.meta.renderConfig.fps
|
||||
suite.meta.renderConfig.fps,
|
||||
);
|
||||
extractFrameAsImage(
|
||||
snapshotVideoPath,
|
||||
checkpoint.time,
|
||||
join(framesDir, `expected_${timeStr}s.png`),
|
||||
suite.meta.renderConfig.fps
|
||||
suite.meta.renderConfig.fps,
|
||||
);
|
||||
} catch {
|
||||
logPretty(` Warning: Could not extract frame at ${checkpoint.time}s`, "⚠️");
|
||||
@ -483,7 +506,7 @@ function saveFailureDetails(
|
||||
writeFileSync(
|
||||
join(failuresDir, "audio-failures.json"),
|
||||
JSON.stringify(audioReport, null, 2),
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
logPretty(`Saved audio failure details to ${failuresDir}/`, "💾");
|
||||
@ -497,7 +520,7 @@ async function runTestSuite(
|
||||
options: {
|
||||
update: boolean;
|
||||
keepTemp: boolean;
|
||||
}
|
||||
},
|
||||
): Promise<TestResult> {
|
||||
// Use predictable temp location: /tmp/hyperframes-tests/{test-id}/
|
||||
const testsRoot = join(tmpdir(), "hyperframes-tests");
|
||||
@ -545,12 +568,20 @@ async function runTestSuite(
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(snapshotCompiledPath, compiled.html, "utf-8");
|
||||
console.log(JSON.stringify({ event: "snapshot_updated", suite: suite.id, file: "output/compiled.html" }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "snapshot_updated",
|
||||
suite: suite.id,
|
||||
file: "output/compiled.html",
|
||||
}),
|
||||
);
|
||||
result.compilation = { passed: true, errors: [], warnings: [] };
|
||||
} else {
|
||||
// Test mode: compare against snapshot
|
||||
if (!existsSync(snapshotCompiledPath)) {
|
||||
throw new Error(`Snapshot not found: ${snapshotCompiledPath}. Run with --update to create it.`);
|
||||
throw new Error(
|
||||
`Snapshot not found: ${snapshotCompiledPath}. Run with --update to create it.`,
|
||||
);
|
||||
}
|
||||
|
||||
snapshotHtml = readFileSync(snapshotCompiledPath, "utf-8");
|
||||
@ -562,20 +593,24 @@ async function runTestSuite(
|
||||
warnings: validation.warnings,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "compilation_complete",
|
||||
suite: suite.id,
|
||||
passed: validation.passed,
|
||||
errors: validation.errors.length,
|
||||
warnings: validation.warnings.length,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "compilation_complete",
|
||||
suite: suite.id,
|
||||
passed: validation.passed,
|
||||
errors: validation.errors.length,
|
||||
warnings: validation.warnings.length,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!validation.passed) {
|
||||
console.error(JSON.stringify({
|
||||
event: "compilation_failed",
|
||||
suite: suite.id,
|
||||
errors: validation.errors,
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "compilation_failed",
|
||||
suite: suite.id,
|
||||
errors: validation.errors,
|
||||
}),
|
||||
);
|
||||
result.passed = false;
|
||||
return result;
|
||||
}
|
||||
@ -607,7 +642,9 @@ async function runTestSuite(
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
}
|
||||
copyFileSync(renderedOutputPath, snapshotVideoPath);
|
||||
console.log(JSON.stringify({ event: "snapshot_updated", suite: suite.id, file: "output/output.mp4" }));
|
||||
console.log(
|
||||
JSON.stringify({ event: "snapshot_updated", suite: suite.id, file: "output/output.mp4" }),
|
||||
);
|
||||
result.visual = { passed: true, failedFrames: 0, checkpoints: [] };
|
||||
result.audio = { passed: true, correlation: 1, lagWindows: 0 };
|
||||
result.passed = true;
|
||||
@ -627,7 +664,12 @@ async function runTestSuite(
|
||||
const visualCheckpoints: Array<{ time: number; psnr: number; passed: boolean }> = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const time = (videoDuration * i) / 100;
|
||||
const psnr = psnrAtCheckpoint(renderedOutputPath, snapshotVideoPath, time, suite.meta.renderConfig.fps);
|
||||
const psnr = psnrAtCheckpoint(
|
||||
renderedOutputPath,
|
||||
snapshotVideoPath,
|
||||
time,
|
||||
suite.meta.renderConfig.fps,
|
||||
);
|
||||
visualCheckpoints.push({
|
||||
time,
|
||||
psnr,
|
||||
@ -649,18 +691,26 @@ async function runTestSuite(
|
||||
checkpoints: visualCheckpoints,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "visual_comparison_complete",
|
||||
suite: suite.id,
|
||||
passed: visualPassed,
|
||||
failedFrames,
|
||||
checkpoints: visualCheckpoints,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "visual_comparison_complete",
|
||||
suite: suite.id,
|
||||
passed: visualPassed,
|
||||
failedFrames,
|
||||
checkpoints: visualCheckpoints,
|
||||
}),
|
||||
);
|
||||
|
||||
if (visualPassed) {
|
||||
logPretty(`Visual quality: PASSED (${failedFrames} failed frames, threshold: ${suite.meta.maxFrameFailures})`, "✓");
|
||||
logPretty(
|
||||
`Visual quality: PASSED (${failedFrames} failed frames, threshold: ${suite.meta.maxFrameFailures})`,
|
||||
"✓",
|
||||
);
|
||||
} else {
|
||||
logPretty(`Visual quality: FAILED (${failedFrames} failed frames, threshold: ${suite.meta.maxFrameFailures})`, "✗");
|
||||
logPretty(
|
||||
`Visual quality: FAILED (${failedFrames} failed frames, threshold: ${suite.meta.maxFrameFailures})`,
|
||||
"✗",
|
||||
);
|
||||
}
|
||||
|
||||
// Audio comparison
|
||||
@ -675,7 +725,11 @@ async function runTestSuite(
|
||||
if (renderedAudio.length > 0 && snapshotAudio.length > 0) {
|
||||
const renderedEnvelope = buildRmsEnvelope(renderedAudio);
|
||||
const snapshotEnvelope = buildRmsEnvelope(snapshotAudio);
|
||||
const audio = bestEnvelopeCorrelation(renderedEnvelope, snapshotEnvelope, suite.meta.maxAudioLagWindows);
|
||||
const audio = bestEnvelopeCorrelation(
|
||||
renderedEnvelope,
|
||||
snapshotEnvelope,
|
||||
suite.meta.maxAudioLagWindows,
|
||||
);
|
||||
audioCorrelation = audio.correlation;
|
||||
audioLagWindows = audio.lagWindows;
|
||||
audioPassed = audio.correlation >= suite.meta.minAudioCorrelation;
|
||||
@ -687,18 +741,26 @@ async function runTestSuite(
|
||||
lagWindows: audioLagWindows,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "audio_comparison_complete",
|
||||
suite: suite.id,
|
||||
passed: audioPassed,
|
||||
correlation: audioCorrelation,
|
||||
lagWindows: audioLagWindows,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "audio_comparison_complete",
|
||||
suite: suite.id,
|
||||
passed: audioPassed,
|
||||
correlation: audioCorrelation,
|
||||
lagWindows: audioLagWindows,
|
||||
}),
|
||||
);
|
||||
|
||||
if (audioPassed) {
|
||||
logPretty(`Audio quality: PASSED (correlation: ${audioCorrelation.toFixed(3)}, lag: ${audioLagWindows})`, "✓");
|
||||
logPretty(
|
||||
`Audio quality: PASSED (correlation: ${audioCorrelation.toFixed(3)}, lag: ${audioLagWindows})`,
|
||||
"✓",
|
||||
);
|
||||
} else {
|
||||
logPretty(`Audio quality: FAILED (correlation: ${audioCorrelation.toFixed(3)}, threshold: ${suite.meta.minAudioCorrelation})`, "✗");
|
||||
logPretty(
|
||||
`Audio quality: FAILED (correlation: ${audioCorrelation.toFixed(3)}, threshold: ${suite.meta.minAudioCorrelation})`,
|
||||
"✗",
|
||||
);
|
||||
}
|
||||
|
||||
// Overall test passes if all checks passed
|
||||
@ -716,11 +778,13 @@ async function runTestSuite(
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
result.passed = false;
|
||||
|
||||
console.error(JSON.stringify({
|
||||
event: "test_error",
|
||||
suite: suite.id,
|
||||
error: errorMessage,
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "test_error",
|
||||
suite: suite.id,
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
@ -733,10 +797,13 @@ async function runTestSuite(
|
||||
renderedOutputPath,
|
||||
snapshotVideoPath,
|
||||
compiledHtml,
|
||||
snapshotHtml
|
||||
snapshotHtml,
|
||||
);
|
||||
} catch (error) {
|
||||
logPretty(`Warning: Could not save failure details: ${error instanceof Error ? error.message : String(error)}`, "⚠️");
|
||||
logPretty(
|
||||
`Warning: Could not save failure details: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"⚠️",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -766,13 +833,18 @@ async function run(): Promise<void> {
|
||||
throw new Error(`No test suites found in ${testsDir}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "test_suite_start",
|
||||
totalSuites: suites.length,
|
||||
parallel: !options.sequential
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "test_suite_start",
|
||||
totalSuites: suites.length,
|
||||
parallel: !options.sequential,
|
||||
}),
|
||||
);
|
||||
|
||||
logPretty(`Starting ${suites.length} test suite(s) - ${options.sequential ? "sequential" : "parallel"} mode`, "🚀");
|
||||
logPretty(
|
||||
`Starting ${suites.length} test suite(s) - ${options.sequential ? "sequential" : "parallel"} mode`,
|
||||
"🚀",
|
||||
);
|
||||
|
||||
let results: TestResult[] = [];
|
||||
|
||||
@ -783,18 +855,20 @@ async function run(): Promise<void> {
|
||||
const result = await runTestSuite(suite, options);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
event: "test_failed",
|
||||
suite: suite.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "test_failed",
|
||||
suite: suite.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parallel execution (default)
|
||||
const settledResults = await Promise.allSettled(
|
||||
suites.map(suite => runTestSuite(suite, options))
|
||||
suites.map((suite) => runTestSuite(suite, options)),
|
||||
);
|
||||
|
||||
results = settledResults.map((settled, index) => {
|
||||
@ -802,11 +876,14 @@ async function run(): Promise<void> {
|
||||
if (settled.status === "fulfilled") {
|
||||
return settled.value;
|
||||
} else {
|
||||
console.error(JSON.stringify({
|
||||
event: "test_failed",
|
||||
suite: matchingSuite?.id ?? "unknown",
|
||||
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "test_failed",
|
||||
suite: matchingSuite?.id ?? "unknown",
|
||||
error:
|
||||
settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
if (!matchingSuite) {
|
||||
throw new Error(`No matching suite at index ${index}`);
|
||||
@ -821,35 +898,41 @@ async function run(): Promise<void> {
|
||||
|
||||
// Summary
|
||||
if (options.update) {
|
||||
console.log(JSON.stringify({
|
||||
event: "snapshots_updated",
|
||||
total: results.length,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "snapshots_updated",
|
||||
total: results.length,
|
||||
}),
|
||||
);
|
||||
logPretty(`Updated ${results.length} snapshot(s)`, "📸");
|
||||
} else {
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
const failedAtCompilation = results.filter((r) => r.compilation && !r.compilation.passed).length;
|
||||
const failedAtCompilation = results.filter(
|
||||
(r) => r.compilation && !r.compilation.passed,
|
||||
).length;
|
||||
const failedAtVisual = results.filter((r) => r.visual && !r.visual.passed).length;
|
||||
const failedAtAudio = results.filter((r) => r.audio && !r.audio.passed).length;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "test_suite_summary",
|
||||
total: results.length,
|
||||
passed,
|
||||
failed,
|
||||
failedAtCompilation,
|
||||
failedAtVisual,
|
||||
failedAtAudio,
|
||||
results: results.map((r) => ({
|
||||
suite: r.suite.id,
|
||||
name: r.suite.meta.name,
|
||||
passed: r.passed,
|
||||
compilation: r.compilation?.passed,
|
||||
visual: r.visual?.passed,
|
||||
audio: r.audio?.passed,
|
||||
})),
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "test_suite_summary",
|
||||
total: results.length,
|
||||
passed,
|
||||
failed,
|
||||
failedAtCompilation,
|
||||
failedAtVisual,
|
||||
failedAtAudio,
|
||||
results: results.map((r) => ({
|
||||
suite: r.suite.id,
|
||||
name: r.suite.meta.name,
|
||||
passed: r.passed,
|
||||
compilation: r.compilation?.passed,
|
||||
visual: r.visual?.passed,
|
||||
audio: r.audio?.passed,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
// Pretty summary
|
||||
logPretty("═══════════════════════════════════════", "");
|
||||
|
||||
@ -28,11 +28,11 @@ const servicesDir = resolve(fileURLToPath(new URL("./services", import.meta.url)
|
||||
const fileServerSource = readFileSync(resolve(servicesDir, "fileServer.ts"), "utf8");
|
||||
assert(
|
||||
fileServerSource.includes("getVerifiedHyperframeRuntimeSource"),
|
||||
"Producer file server must inject runtime via getVerifiedHyperframeRuntimeSource"
|
||||
"Producer file server must inject runtime via getVerifiedHyperframeRuntimeSource",
|
||||
);
|
||||
assert(
|
||||
!fileServerSource.includes("loadHyperframeRuntimeSource"),
|
||||
"Producer file server must not inject runtime via loadHyperframeRuntimeSource"
|
||||
"Producer file server must not inject runtime via loadHyperframeRuntimeSource",
|
||||
);
|
||||
|
||||
console.log(
|
||||
@ -40,6 +40,5 @@ console.log(
|
||||
event: "producer_runtime_conformance_ok",
|
||||
manifestPath,
|
||||
runtimeSha256: sourceSha,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user