diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81a300afc..4114d32ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/README.md b/README.md index 8e49c3630..80f3e52a5 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,25 @@ npx hyperframes render # render to MP4 Define your video as HTML with data attributes: ```html -
- - - +
+ + +
``` diff --git a/SECURITY.md b/SECURITY.md index 5a3d44678..6b9b8fba7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/package.json b/package.json index fa05967d1..f9a183dea 100644 --- a/package.json +++ b/package.json @@ -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" + ] } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a3a84309..95be34c34 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 9a0c7bee4..4ca5a4ab4 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -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 { } 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 { * Find or download a browser. * Resolution: env var -> cached download -> system Chrome -> auto-download. */ -export async function ensureBrowser( - options?: EnsureBrowserOptions, -): Promise { +export async function ensureBrowser(options?: EnsureBrowserOptions): Promise { 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({ diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts index e2052614a..beb198fc2 100644 --- a/packages/cli/src/commands/benchmark.ts +++ b/packages/cli/src/commands/benchmark.ts @@ -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.", ); } diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 24d075793..7eaade68a 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -30,9 +30,7 @@ async function runEnsure(): Promise { 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 { 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; diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 499cc7133..a5f32e5fe 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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 { * TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI. */ async function runEmbeddedMode(_dir: string, _port: number): Promise { - console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev ")); + console.error( + c.error( + "Embedded mode not yet available. Run from the monorepo root with: hyperframes dev ", + ), + ); process.exit(1); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 23c966493..8a7da94ce 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -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)}`); } diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 61d561b53..6ccdb48d2 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -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 = {}; @@ -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; } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1537f4b2d..e28d6604b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { 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); }, diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index daaa3e222..59f5a1110 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -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); }, }); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index a07b82f80..46aa3364c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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}`)); diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md index 98a103f53..d9c809a33 100644 --- a/packages/cli/src/docs/compositions.md +++ b/packages/cli/src/docs/compositions.md @@ -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
@@ -11,16 +13,21 @@ Every composition needs a root element with `data-composition-id`: ``` ## Nested Compositions + Embed one composition inside another: + ```html
``` ## Listing Compositions + Use `npx hyperframes compositions` to see all compositions in a project. ## Variables + Compositions can expose variables for dynamic content: + ```html -
+
``` diff --git a/packages/cli/src/docs/data-attributes.md b/packages/cli/src/docs/data-attributes.md index 9eaf5d8ce..c682060ef 100644 --- a/packages/cli/src/docs/data-attributes.md +++ b/packages/cli/src/docs/data-attributes.md @@ -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. diff --git a/packages/cli/src/docs/gsap.md b/packages/cli/src/docs/gsap.md index 4058cb4b5..37dfbe431 100644 --- a/packages/cli/src/docs/gsap.md +++ b/packages/cli/src/docs/gsap.md @@ -3,6 +3,7 @@ HyperFrames uses GSAP for animation. Timelines are paused and controlled by the runtime. ## Setup + ```html -
+ window.__timelines["intro"] = tl; + })(); + +
diff --git a/packages/cli/src/templates/play-mode/compositions/stats.html b/packages/cli/src/templates/play-mode/compositions/stats.html index d96b6acd3..2496c74ef 100644 --- a/packages/cli/src/templates/play-mode/compositions/stats.html +++ b/packages/cli/src/templates/play-mode/compositions/stats.html @@ -2,7 +2,7 @@
-
+
@@ -11,12 +11,18 @@
-
-
+
+
-
+
@@ -25,12 +31,24 @@
-
-
+
+
-
+
@@ -39,8 +57,20 @@
-
-
+
+
@@ -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 @@ + + + +
+ +
+
+ - - - -
- -
-
- - -
+ background-color: #ffffff; + } + + +
- -
-
- -
- - + +
+
+
- - -
-
-
- - - - - - - - - - + +
+ + +
+
+
+ + + + + + + + + + +
- - \ No newline at end of file + + diff --git a/packages/cli/src/templates/swiss-grid/compositions/captions.html b/packages/cli/src/templates/swiss-grid/compositions/captions.html index f2594daf2..198f31f88 100644 --- a/packages/cli/src/templates/swiss-grid/compositions/captions.html +++ b/packages/cli/src/templates/swiss-grid/compositions/captions.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 @@
- \ No newline at end of file + diff --git a/packages/cli/src/templates/swiss-grid/compositions/intro.html b/packages/cli/src/templates/swiss-grid/compositions/intro.html index dd89ed690..9f5881d37 100644 --- a/packages/cli/src/templates/swiss-grid/compositions/intro.html +++ b/packages/cli/src/templates/swiss-grid/compositions/intro.html @@ -10,7 +10,7 @@

THE SURVEY FINDINGS

- - -
- - - Grid - - -
- -
- - -
-
- - -
-
- - -
-
- - - - - - - - + + +
+ + Grid + + +
+ +
+ + +
+ + +
+ + +
+ + + + + + + +
- + diff --git a/packages/cli/src/templates/vignelli/compositions/captions.html b/packages/cli/src/templates/vignelli/compositions/captions.html index 6b3f62f31..40a077d0a 100644 --- a/packages/cli/src/templates/vignelli/compositions/captions.html +++ b/packages/cli/src/templates/vignelli/compositions/captions.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 @@ - - -
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
- - + + +
+ +
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+
+
+
+
+
+
+ +
- + diff --git a/packages/cli/src/templates/warm-grain/compositions/captions.html b/packages/cli/src/templates/warm-grain/compositions/captions.html index 522e8d053..1761d4a24 100644 --- a/packages/cli/src/templates/warm-grain/compositions/captions.html +++ b/packages/cli/src/templates/warm-grain/compositions/captions.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 @@
- \ No newline at end of file + diff --git a/packages/cli/src/templates/warm-grain/compositions/intro.html b/packages/cli/src/templates/warm-grain/compositions/intro.html index dd1f549e8..83e9c181c 100644 --- a/packages/cli/src/templates/warm-grain/compositions/intro.html +++ b/packages/cli/src/templates/warm-grain/compositions/intro.html @@ -9,7 +9,7 @@

Hyperframes

- - -
- - -
-
- -
- - - - - -
-
- -
-
- -
-
- - - - - + 40% { + transform: translate(-5%, 15%); + } + 50% { + transform: translate(-10%, 5%); + } + 60% { + transform: translate(15%, 0); + } + 70% { + transform: translate(0, 10%); + } + 80% { + transform: translate(-15%, 0); + } + 90% { + transform: translate(10%, 5%); + } + } + + .grain-texture { + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: url("https://www.transparenttextures.com/patterns/natural-paper.png"); + opacity: 0.15; + animation: grain-noise 0.5s steps(1) infinite; + } + + #a-roll { + position: absolute; + border-radius: 16px; + object-fit: cover; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + } + .comp-layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + + + +
+ +
+
+
+ + + + + +
+ +
+ +
+ + + + + + +
- + diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts index 5af3996c9..5796932de 100644 --- a/packages/cli/src/ui/colors.ts +++ b/packages/cli/src/ui/colors.ts @@ -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; diff --git a/packages/cli/src/ui/progress.ts b/packages/cli/src/ui/progress.ts index d50fec345..f4aa4c3e4 100644 --- a/packages/cli/src/ui/progress.ts +++ b/packages/cli/src/ui/progress.ts @@ -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)}`; diff --git a/packages/core/docs/core.md b/packages/core/docs/core.md index c1ab6163c..e138d5046 100644 --- a/packages/core/docs/core.md +++ b/packages/core/docs/core.md @@ -217,11 +217,23 @@ Add `+ N` or `- N` after the ID to offset from the end of the referenced clip: ```html - + - + ``` ### 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 - + ``` ## Audio Clips diff --git a/packages/core/docs/quickstart-template.html b/packages/core/docs/quickstart-template.html index 32fbf6a3a..86dea54ab 100644 --- a/packages/core/docs/quickstart-template.html +++ b/packages/core/docs/quickstart-template.html @@ -102,7 +102,14 @@ data-duration is REQUIRED for images (they have no intrinsic duration). --> - + - + - + ``` ### 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 - + ``` ## Audio Clips diff --git a/packages/core/package.json b/packages/core/package.json index a8fff399a..c73cf59ee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } } diff --git a/packages/core/scripts/debug-timeline.ts b/packages/core/scripts/debug-timeline.ts index 4a5cd48ae..86af843fc 100644 --- a/packages/core/scripts/debug-timeline.ts +++ b/packages/core/scripts/debug-timeline.ts @@ -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; diff --git a/packages/core/scripts/test-hyperframe-linter.ts b/packages/core/scripts/test-hyperframe-linter.ts index 6aa488c55..bdb8a5027 100644 --- a/packages/core/scripts/test-hyperframe-linter.ts +++ b/packages/core/scripts/test-hyperframe-linter.ts @@ -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); diff --git a/packages/core/scripts/test-hyperframe-runtime-duration-guards.ts b/packages/core/scripts/test-hyperframe-runtime-duration-guards.ts index 55c1e6df9..9d68a5d35 100644 --- a/packages/core/scripts/test-hyperframe-runtime-duration-guards.ts +++ b/packages/core/scripts/test-hyperframe-runtime-duration-guards.ts @@ -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", diff --git a/packages/core/scripts/test-hyperframe-runtime-security.ts b/packages/core/scripts/test-hyperframe-runtime-security.ts index 6a2350071..fd55c2dff 100644 --- a/packages/core/scripts/test-hyperframe-runtime-security.ts +++ b/packages/core/scripts/test-hyperframe-runtime-security.ts @@ -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( diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index 34ed25c49..e8bcafdda 100644 --- a/packages/core/scripts/test-hyperframe-runtime-seek.ts +++ b/packages/core/scripts/test-hyperframe-runtime-seek.ts @@ -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 { diff --git a/packages/core/src/adapters/gsap.ts b/packages/core/src/adapters/gsap.ts index 392ab2126..d50732332 100644 --- a/packages/core/src/adapters/gsap.ts +++ b/packages/core/src/adapters/gsap.ts @@ -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; }; diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index d46697372..f9a2035a3 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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 { +export async function bundleToSingleHtml( + projectDir: string, + options?: BundleOptions, +): Promise { 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(''); cssAnchorPlaced = true; } else { $(el).remove(); } + if (!cssAnchorPlaced) { + $(el).replaceWith(''); + 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(``); } @@ -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(''); jsAnchorPlaced = true; } else { $(el).remove(); } + if (!jsAnchorPlaced) { + $(el).replaceWith(''); + 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(``); } @@ -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(``); - if (compScriptChunks.length) $("body").append(``); + if (compScriptChunks.length) + $("body").append(``); 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(); } diff --git a/packages/core/src/compiler/htmlCompiler.ts b/packages/core/src/compiler/htmlCompiler.ts index 4efc44659..90f8a1cca 100644 --- a/packages/core/src/compiler/htmlCompiler.ts +++ b/packages/core/src/compiler/htmlCompiler.ts @@ -14,9 +14,7 @@ import { export type MediaDurationProber = (src: string) => Promise; 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); } /** diff --git a/packages/core/src/compiler/timingCompiler.test.ts b/packages/core/src/compiler/timingCompiler.test.ts index 13ec8ff73..ae2a29239 100644 --- a/packages/core/src/compiler/timingCompiler.test.ts +++ b/packages/core/src/compiler/timingCompiler.test.ts @@ -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", () => { diff --git a/packages/core/src/compiler/timingCompiler.ts b/packages/core/src/compiler/timingCompiler.ts index 60a429a0d..843e54f42 100644 --- a/packages/core/src/compiler/timingCompiler.ts +++ b/packages/core/src/compiler/timingCompiler.ts @@ -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; diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index 80806c395..36f3c2dc7 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -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 { diff --git a/packages/core/src/generators/hyperframes.test.ts b/packages/core/src/generators/hyperframes.test.ts index 8e089c665..a8524b144 100644 --- a/packages/core/src/generators/hyperframes.test.ts +++ b/packages/core/src/generators/hyperframes.test.ts @@ -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; }"); }); diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts index bff50dac5..68307921b 100644 --- a/packages/core/src/generators/hyperframes.ts +++ b/packages/core/src/generators/hyperframes.ts @@ -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 { +function generateInitialPositionSets( + elements: TimelineElement[], + keyframes?: Record, +): 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});`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76172e795..bf0bca561 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts index cfa3f5ae5..1d1e94cfc 100644 --- a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts +++ b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts @@ -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], diff --git a/packages/core/src/inline-scripts/pickerApi.ts b/packages/core/src/inline-scripts/pickerApi.ts index 5231bacb4..f58f20326 100644 --- a/packages/core/src/inline-scripts/pickerApi.ts +++ b/packages/core/src/inline-scripts/pickerApi.ts @@ -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 { diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index ec34bea61..adf57e009 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -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 = /]*>[\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(/]*>([\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 { targetSelector: "#el1", method: "to", position: 0, - properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record, + 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).someUnsupportedProp).toBeUndefined(); + expect( + (keyframes[0].properties as Record).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"); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 6ea0949eb..33885a474 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -78,7 +78,10 @@ function parseObjectLiteral(str: string): Record { 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 { return `{ ${entries.join(", ")} }`; } -export function updateAnimationInScript(script: string, animationId: string, updates: Partial): string { +export function updateAnimationInScript( + script: string, + animationId: string, + updates: Partial, +): 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).y = value - baseY; } else if (key === "scale") { - (properties as Record).scale = baseScale !== 0 ? value / baseScale : value; + (properties as Record).scale = + baseScale !== 0 ? value / baseScale : value; } else { (properties as Record)[key] = value; } diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index 8be4dc2ad..a4c79ea63 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -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 element"); + expect(result.errors).toContain( + "Missing data-composition-duration attribute on element", + ); }); it("reports error for missing #stage", () => { diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 9cd92e4f2..2027ee895 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -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 { +function extractPositionsFromGsap( + script: string, +): Map { const positionMap = new Map(); try { @@ -482,7 +503,11 @@ function extractPositionsFromGsap(script: string): Map): string { +export function updateElementInHtml( + html: string, + elementId: string, + updates: Partial, +): 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; } } diff --git a/packages/core/src/runtime/adapters/lottie.test.ts b/packages/core/src/runtime/adapters/lottie.test.ts index 6c8c12f36..11fadb2ba 100644 --- a/packages/core/src/runtime/adapters/lottie.test.ts +++ b/packages/core/src/runtime/adapters/lottie.test.ts @@ -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(), diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index 5870d56d6..cbfb4e2c9 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -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 { diff --git a/packages/core/src/runtime/adapters/waapi.test.ts b/packages/core/src/runtime/adapters/waapi.test.ts index 9efdbc850..15b468f4e 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -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]); diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 2bd025421..1e6481766 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -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(); }); }); diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index 19c8430f9..ca7bcbf69 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -44,9 +44,7 @@ describe("loadExternalCompositions", () => { `; - 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", () => { `; - 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 = `

New

`; - 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", () => { `; - 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({ diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index 776f2dafe..6bd4db02b 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -88,10 +88,13 @@ async function mountCompositionContent(params: { }): Promise { let innerRoot: Element | null = null; if (params.hostCompositionId) { - const candidateRoots = Array.from(params.sourceNode.querySelectorAll("[data-composition-id]")); + const candidateRoots = Array.from( + params.sourceNode.querySelectorAll("[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 { +export async function loadExternalCompositions( + params: LoadExternalCompositionsParams, +): Promise { 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(`template#${CSS.escape(hostCompositionId)}-template`) + ? document.querySelector( + `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(`template#${CSS.escape(hostCompositionId)}-template`) + ? doc.querySelector( + `template#${CSS.escape(hostCompositionId)}-template`, + ) : null) ?? doc.querySelector("template"); const sourceNode = template ? template.content : doc.body; await mountCompositionContent({ diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 71f18f19d..823ed9edf 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -36,7 +36,11 @@ export function initSandboxRuntimeModular(): void { const registerRuntimeCleanup = (callback: () => void) => { runtimeCleanupCallbacks.push(callback); }; - const postRuntimeDiagnosticOnce = (code: string, details: Record, dedupeKey?: string) => { + const postRuntimeDiagnosticOnce = ( + code: string, + details: Record, + 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, + 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, + 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; 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(); 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; } diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 9e2d47d9c..58eca005e 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -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; 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; 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 { diff --git a/packages/core/src/runtime/picker.test.ts b/packages/core/src/runtime/picker.test.ts index d5b0d58af..71f843902 100644 --- a/packages/core/src/runtime/picker.test.ts +++ b/packages/core/src/runtime/picker.test.ts @@ -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", - }) + }), ); }); diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index f45cb76d9..71d0a69f8 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -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; }, diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index 7fa09ddf1..cc071c3d5 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -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(), diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index 7fcc38122..5cfdb35d7 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -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); diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index 884307335..e35f3e089 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -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 }, }); diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index 620a984a9..fe27ec17f 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -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 => { diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 238e3eb21..9d7963cbd 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -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); }); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 5fce145a8..60ff06bb8 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -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; 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; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 7cfcf8a9e..d4422d115 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -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" diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 9813e53e7..59f51266b 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -131,26 +131,44 @@ export function resolveConfig(overrides?: Partial): 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 { 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"), diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 18d327138..a91b6d3c4 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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"; diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 67c73e866..60221e6a9 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -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; diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index c8998f2c8..d12ad7fde 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -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>): string | undefined { +export function resolveHeadlessShellPath( + config?: Partial>, +): 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 + Pick< + EngineConfig, + "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot" + > >, ): Promise { const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool; diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index bfb285b19..273704fb0 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -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, diff --git a/packages/engine/src/services/fileServer.ts b/packages/engine/src/services/fileServer.ts index f57558e93..8b694283e 100644 --- a/packages/engine/src/services/fileServer.ts +++ b/packages/engine/src/services/fileServer.ts @@ -135,7 +135,9 @@ export function createFileServer(options: FileServerOptions): Promise 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 // 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 // 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 { +export async function captureFrame( + session: CaptureSession, + frameIndex: number, + time: number, +): Promise { 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}`; diff --git a/packages/engine/src/services/parallelCoordinator.ts b/packages/engine/src/services/parallelCoordinator.ts index 14d8ee5a7..96c35485e 100644 --- a/packages/engine/src/services/parallelCoordinator.ts +++ b/packages/engine/src/services/parallelCoordinator.ts @@ -57,7 +57,12 @@ const MIN_FRAMES_PER_WORKER = 30; export function calculateOptimalWorkers( totalFrames: number, requested?: number, - config?: Partial>, + 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 { +export async function mergeWorkerFrames( + workDir: string, + tasks: WorkerTask[], + outputDir: string, +): Promise { if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); let totalFrames = 0; diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index c51c32f26..300eea7b4 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -203,7 +203,10 @@ export async function injectVideoFramesBatch( ); } -export async function syncVideoFrameVisibility(page: Page, activeVideoIds: string[]): Promise { +export async function syncVideoFrameVisibility( + page: Page, + activeVideoIds: string[], +): Promise { await page.evaluate((ids: string[]) => { const active = new Set(ids); const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[]; diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 4d6558281..35079c3c5 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -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 { + getActiveFramePayloads( + globalTime: number, + ): Map { const frames = new Map(); 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(); for (const ext of extracted) extractedMap.set(ext.videoId, ext); diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts index c31eb9b44..cfba09e85 100644 --- a/packages/engine/src/services/videoFrameInjector.ts +++ b/packages/engine/src/services/videoFrameInjector.ts @@ -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(); @@ -81,13 +84,16 @@ export function createVideoFrameInjector( const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = []; const activeIds = new Set(); if (activePayloads.size > 0) { - const pendingReads: Array> = []; + const pendingReads: Array> = + []; 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))); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index aae63f1aa..85d44c265 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -59,7 +59,15 @@ export async function extractVideoMetadata(filePath: string): Promise((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 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((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 = ""; diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index d02993ebb..85655cd4f 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -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 { +export async function downloadToTemp( + url: string, + destDir: string, + timeoutMs: number = 300000, +): Promise { const cachedPath = downloadPathCache.get(url); if (cachedPath && existsSync(cachedPath)) { return cachedPath; diff --git a/packages/producer/package.json b/packages/producer/package.json index da36546cc..4d2a52dae 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -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" } } diff --git a/packages/producer/src/benchmark.ts b/packages/producer/src/benchmark.ts index d672f42af..e28f9473d 100644 --- a/packages/producer/src/benchmark.ts +++ b/packages/producer/src/benchmark.ts @@ -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 { 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 { 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 { 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 { 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), ); } diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index f27825ab5..91eb8cb82 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -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, diff --git a/packages/producer/src/logger.ts b/packages/producer/src/logger.ts index 0fd56fd62..09a633470 100644 --- a/packages/producer/src/logger.ts +++ b/packages/producer/src/logger.ts @@ -33,8 +33,7 @@ const LOG_LEVEL_PRIORITY: Record = { 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 => meta ? ` ${JSON.stringify(meta)}` : ""; diff --git a/packages/producer/src/parity-harness.ts b/packages/producer/src/parity-harness.ts index 917e48e6b..5a93f3127 100644 --- a/packages/producer/src/parity-harness.ts +++ b/packages/producer/src/parity-harness.ts @@ -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 "" --producer-url "" [--checkpoints "0,1,2"] [--fps 30] [--width 1920] [--height 1080] [--allow-mismatch-ratio 0]' + 'Missing required args. Usage: --preview-url "" --producer-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 { 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> { - 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 = {}; - 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 = {}; + 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 { - 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 { 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 { 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); diff --git a/packages/producer/src/perf-gate.ts b/packages/producer/src/perf-gate.ts index f5dd13dcf..99aa0395e 100644 --- a/packages/producer/src/perf-gate.ts +++ b/packages/producer/src/perf-gate.ts @@ -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`); diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 910c73f0d..41acc51ee 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -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 { // 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 { 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 { 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 { 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 { // 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("═══════════════════════════════════════", ""); diff --git a/packages/producer/src/runtime-conformance.ts b/packages/producer/src/runtime-conformance.ts index 5c875d28b..7d5c71055 100644 --- a/packages/producer/src/runtime-conformance.ts +++ b/packages/producer/src/runtime-conformance.ts @@ -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, - }) + }), ); - diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 2f18bca63..a439ae727 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -12,7 +12,15 @@ * GET /outputs/:token — download rendered MP4 */ -import { existsSync, mkdirSync, statSync, mkdtempSync, writeFileSync, rmSync, createReadStream } from "node:fs"; +import { + existsSync, + mkdirSync, + statSync, + mkdtempSync, + writeFileSync, + rmSync, + createReadStream, +} from "node:fs"; import { resolve, dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { parseArgs } from "node:util"; @@ -70,7 +78,9 @@ interface PreparedRenderInput { function parseRenderOptions(body: Record): Omit { const fps = ([24, 30, 60].includes(body.fps as number) ? body.fps : 30) as 24 | 30 | 60; - const quality = (["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high") as "draft" | "standard" | "high"; + const quality = ( + ["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high" + ) as "draft" | "standard" | "high"; const workers = typeof body.workers === "number" ? body.workers : undefined; const useGpu = body.gpu === true; const debug = body.debug === true; @@ -84,7 +94,9 @@ function parseRenderOptions(body: Record): Omit): Promise<{ prepared: PreparedRenderInput } | { error: string }> { +async function prepareRenderBody( + body: Record, +): Promise<{ prepared: PreparedRenderInput } | { error: string }> { const options = parseRenderOptions(body); const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined; if (projectDir) { @@ -113,7 +125,9 @@ async function prepareRenderBody(body: Record): Promise<{ prepa } htmlContent = await response.text(); } catch (error) { - return { error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}` }; + return { + error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`, + }; } } @@ -131,7 +145,12 @@ async function prepareRenderBody(body: Record): Promise<{ prepa }; } -function resolveOutputPath(projectDir: string, outputCandidate: string | null | undefined, rendersDir: string, log: ProducerLogger): string { +function resolveOutputPath( + projectDir: string, + outputCandidate: string | null | undefined, + rendersDir: string, + log: ProducerLogger, +): string { try { return resolveRenderPaths(projectDir, outputCandidate, rendersDir).absoluteOutputPath; } catch (error) { @@ -210,15 +229,21 @@ export interface RenderHandlers { */ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandlers { const log = options.logger ?? defaultLogger; - const getRequestId = options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID()); + const getRequestId = + options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID()); const outputUrlPrefix = options.outputUrlPrefix ?? "/outputs"; const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp"; - const artifactTtlMs = options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000); + const artifactTtlMs = + options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000); const store = createArtifactStore(artifactTtlMs); const startTime = Date.now(); const health = (c: Context): Response => - c.json({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000), timestamp: new Date().toISOString() }); + c.json({ + status: "ok", + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + }); const lint = async (c: Context): Promise => { const requestId = getRequestId(c); @@ -270,13 +295,30 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle } const { input, cleanupProjectDir } = preparedResult.prepared; - const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log); + const absoluteOutputPath = resolveOutputPath( + input.projectDir, + input.outputPath, + rendersDir, + log, + ); const outputDir = dirname(absoluteOutputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); - log.info("render started", { requestId, projectDir: input.projectDir, fps: input.fps, quality: input.quality }); + log.info("render started", { + requestId, + projectDir: input.projectDir, + fps: input.fps, + quality: input.quality, + }); - const job = createRenderJob({ fps: input.fps, quality: input.quality, workers: input.workers, useGpu: input.useGpu, debug: input.debug, logger: log }); + const job = createRenderJob({ + fps: input.fps, + quality: input.quality, + workers: input.workers, + useGpu: input.useGpu, + debug: input.debug, + logger: log, + }); let lastLoggedPct = -10; try { @@ -292,14 +334,44 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle const durationMs = Date.now() - t0; const outputToken = store.register(absoluteOutputPath); const outputUrl = `${outputUrlPrefix}/${outputToken}`; - log.info("render completed", { requestId, durationMs, fileSize, perf: job.perfSummary ?? null }); + log.info("render completed", { + requestId, + durationMs, + fileSize, + perf: job.perfSummary ?? null, + }); - return c.json({ success: true, requestId, outputPath: absoluteOutputPath, outputToken, outputUrl, fileSize, durationMs, videoDurationSeconds: job.duration ?? null, perf: job.perfSummary ?? null }); + return c.json({ + success: true, + requestId, + outputPath: absoluteOutputPath, + outputToken, + outputUrl, + fileSize, + durationMs, + videoDurationSeconds: job.duration ?? null, + perf: job.perfSummary ?? null, + }); } catch (error) { const durationMs = Date.now() - t0; const errorMsg = error instanceof Error ? error.message : String(error); - log.error("render failed", { requestId, durationMs, error: errorMsg, stage: job.currentStage }); - return c.json({ success: false, requestId, error: errorMsg, stage: job.currentStage, durationMs, errorDetails: job.errorDetails ?? null }, 500); + log.error("render failed", { + requestId, + durationMs, + error: errorMsg, + stage: job.currentStage, + }); + return c.json( + { + success: false, + requestId, + error: errorMsg, + stage: job.currentStage, + durationMs, + errorDetails: job.errorDetails ?? null, + }, + 500, + ); } finally { cleanupTempDir(cleanupProjectDir, log); } @@ -314,49 +386,91 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle try { body = await c.req.json(); } catch { - await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: "Invalid JSON body", stage: "validation" }) }); + await stream.writeSSE({ + data: JSON.stringify({ + type: "error", + requestId, + error: "Invalid JSON body", + stage: "validation", + }), + }); return; } const preparedResult = await prepareRenderBody(body); if ("error" in preparedResult) { - await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: preparedResult.error, stage: "validation" }) }); + await stream.writeSSE({ + data: JSON.stringify({ + type: "error", + requestId, + error: preparedResult.error, + stage: "validation", + }), + }); return; } const { input, cleanupProjectDir } = preparedResult.prepared; - const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log); + const absoluteOutputPath = resolveOutputPath( + input.projectDir, + input.outputPath, + rendersDir, + log, + ); const outputDir = dirname(absoluteOutputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); log.info("render-stream started", { requestId, projectDir: input.projectDir }); - const job = createRenderJob({ fps: input.fps, quality: input.quality, workers: input.workers, useGpu: input.useGpu, debug: input.debug, logger: log }); + const job = createRenderJob({ + fps: input.fps, + quality: input.quality, + workers: input.workers, + useGpu: input.useGpu, + debug: input.debug, + logger: log, + }); const abortController = new AbortController(); - const onRequestAbort = () => abortController.abort(new RenderCancelledError("request_aborted")); + const onRequestAbort = () => + abortController.abort(new RenderCancelledError("request_aborted")); c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true }); try { - await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => { - await stream.writeSSE({ - data: JSON.stringify({ - type: "progress", - requestId, - stage: j.currentStage, - progress: j.progress, - framesRendered: j.framesRendered ?? 0, - totalFrames: j.totalFrames ?? 0, - message, - }), - }); - }, abortController.signal); + await executeRenderJob( + job, + input.projectDir, + absoluteOutputPath, + async (j, message) => { + await stream.writeSSE({ + data: JSON.stringify({ + type: "progress", + requestId, + stage: j.currentStage, + progress: j.progress, + framesRendered: j.framesRendered ?? 0, + totalFrames: j.totalFrames ?? 0, + message, + }), + }); + }, + abortController.signal, + ); const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0; const outputToken = store.register(absoluteOutputPath); const outputUrl = `${outputUrlPrefix}/${outputToken}`; log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null }); await stream.writeSSE({ - data: JSON.stringify({ type: "complete", requestId, outputPath: absoluteOutputPath, outputToken, outputUrl, fileSize, videoDurationSeconds: job.duration ?? null, perf: job.perfSummary ?? null }), + data: JSON.stringify({ + type: "complete", + requestId, + outputPath: absoluteOutputPath, + outputToken, + outputUrl, + fileSize, + videoDurationSeconds: job.duration ?? null, + perf: job.perfSummary ?? null, + }), }); } catch (error) { if (error instanceof RenderCancelledError) { @@ -372,7 +486,12 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle } const errorMsg = error instanceof Error ? error.message : String(error); const elapsedMs = Date.now() - t0; - log.error("render-stream failed", { requestId, elapsedMs, error: errorMsg, stage: job.currentStage }); + log.error("render-stream failed", { + requestId, + elapsedMs, + error: errorMsg, + stage: job.currentStage, + }); await stream.writeSSE({ data: JSON.stringify({ type: "error", @@ -479,8 +598,8 @@ export function startServer(options: ServerOptions = {}) { // In esbuild bundles, import.meta.url is shared across inlined modules, // so we check argv[1] against known public server filenames. const entryScript = process.argv[1] ? resolve(process.argv[1]) : ""; -const isPublicServerEntry = entryScript.endsWith("/public-server.js") || - entryScript.endsWith("/src/server.ts"); +const isPublicServerEntry = + entryScript.endsWith("/public-server.js") || entryScript.endsWith("/src/server.ts"); if (isPublicServerEntry) { const { values } = parseArgs({ diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index 3cb86f5fa..0fa4fbf90 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -49,22 +49,18 @@ export function parseAudioElements(html: string): AudioElement[] { const durationMatch = fullTag.match(/data-duration=["']([^"']+)["']/); const endMatch = fullTag.match(/data-end=["']([^"']+)["']/); - const mediaStartMatch = fullTag.match( - /data-media-start=["']([^"']+)["']/ - ); + const mediaStartMatch = fullTag.match(/data-media-start=["']([^"']+)["']/); const volumeMatch = fullTag.match(/data-volume=["']([^"']+)["']/); const parsedVolume = volumeMatch ? parseFloat(volumeMatch[1] ?? "") : 1.0; const safeVolume = Number.isFinite(parsedVolume) ? parsedVolume : 1.0; - const durationFromDuration = durationMatch - ? parseFloat(durationMatch[1] ?? "") - : NaN; + const durationFromDuration = durationMatch ? parseFloat(durationMatch[1] ?? "") : NaN; const end = endMatch ? parseFloat(endMatch[1] ?? "") : NaN; const duration = !isNaN(durationFromDuration) && durationFromDuration > 0 ? durationFromDuration : !isNaN(end) && end > start - ? end - start - : 0; + ? end - start + : 0; elements.push({ id: idMatch?.[1] || `media-${elements.length}`, @@ -96,9 +92,7 @@ function runFFmpeg(args: string[]): Promise { if (code === 0) { resolve(); } else { - reject( - new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`) - ); + reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`)); } }); @@ -115,7 +109,7 @@ async function extractAudioTrack( srcPath: string, outputPath: string, playbackStart: number, - duration: number + duration: number, ): Promise { const outputDir = dirname(outputPath); if (!existsSync(outputDir)) { @@ -143,7 +137,7 @@ async function extractAudioTrack( "-ac", "2", "-y", - outputPath + outputPath, ); try { @@ -153,7 +147,7 @@ async function extractAudioTrack( console.warn( `[AudioExtractor] Failed to extract audio from ${srcPath}: ${ err instanceof Error ? err.message : err - }` + }`, ); return false; } @@ -162,10 +156,7 @@ async function extractAudioTrack( /** * Generate a silence audio file. */ -async function generateSilence( - outputPath: string, - duration: number -): Promise { +async function generateSilence(outputPath: string, duration: number): Promise { const outputDir = dirname(outputPath); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); @@ -191,7 +182,7 @@ async function generateSilence( async function mixTracks( tracks: AudioTrack[], outputPath: string, - totalDuration: number + totalDuration: number, ): Promise { if (tracks.length === 0) { await generateSilence(outputPath, totalDuration); @@ -213,7 +204,7 @@ async function mixTracks( const trimDuration = track.duration > 0 ? track.duration : totalDuration; filterParts.push( - `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]` + `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`, ); }); @@ -255,7 +246,7 @@ export async function processAudio( projectDir: string, workDir: string, outputPath: string, - totalDuration: number + totalDuration: number, ): Promise { const html = readFileSync(htmlPath, "utf-8"); const elements = parseAudioElements(html); @@ -265,9 +256,7 @@ export async function processAudio( return false; } - console.log( - `[AudioExtractor] Processing ${elements.length} audio element(s)...` - ); + console.log(`[AudioExtractor] Processing ${elements.length} audio element(s)...`); if (!existsSync(workDir)) { mkdirSync(workDir, { recursive: true }); @@ -292,7 +281,7 @@ export async function processAudio( srcPath, extractedPath, element.mediaStart, - element.duration + element.duration, ); if (success) { @@ -307,9 +296,7 @@ export async function processAudio( } } - console.log( - `[AudioExtractor] Mixing ${tracks.length} track(s) into final audio...` - ); + console.log(`[AudioExtractor] Mixing ${tracks.length} track(s) into final audio...`); await mixTracks(tracks, outputPath, totalDuration); // Clean up work directory diff --git a/packages/producer/src/services/compilationRunner.ts b/packages/producer/src/services/compilationRunner.ts index fa2ea2e0d..ad436f3de 100644 --- a/packages/producer/src/services/compilationRunner.ts +++ b/packages/producer/src/services/compilationRunner.ts @@ -8,10 +8,7 @@ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, mkdirSync import { join } from "path"; import { tmpdir } from "os"; import { compileForRender } from "./htmlCompiler.js"; -import { - validateCompilation, - type CompilationValidationResult, -} from "./compilationTester.js"; +import { validateCompilation, type CompilationValidationResult } from "./compilationTester.js"; export interface CompilationTestResult { testId: string; @@ -35,7 +32,7 @@ interface TestSuite { */ export async function runCompilationTest( suite: TestSuite, - keepTemp: boolean + keepTemp: boolean, ): Promise { const startTime = Date.now(); @@ -49,11 +46,7 @@ export async function runCompilationTest( throw new Error(`Input HTML not found: ${inputHtmlPath}`); } - const compiled = await compileForRender( - suite.srcDir, - inputHtmlPath, - tempDir - ); + const compiled = await compileForRender(suite.srcDir, inputHtmlPath, tempDir); const actualHtml = compiled.html; @@ -86,8 +79,7 @@ export async function runCompilationTest( }; } catch (error) { const compilationTimeMs = Date.now() - startTime; - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error); return { testId: suite.id, @@ -113,9 +105,7 @@ export async function runCompilationTest( * Generate or update compiled.html golden file for a test suite. * Compiles src/index.html and writes to compiled.html. */ -export async function updateCompiledGolden( - suite: TestSuite -): Promise { +export async function updateCompiledGolden(suite: TestSuite): Promise { console.log(`[${suite.id}] Updating compiled.html golden file...`); // Create temp directory for downloads @@ -128,11 +118,7 @@ export async function updateCompiledGolden( } // Compile the input HTML - const compiled = await compileForRender( - suite.srcDir, - inputHtmlPath, - tempDir - ); + const compiled = await compileForRender(suite.srcDir, inputHtmlPath, tempDir); // Write to output/compiled.html const outputDir = join(suite.dir, "output"); @@ -142,10 +128,11 @@ export async function updateCompiledGolden( const goldenPath = join(outputDir, "compiled.html"); writeFileSync(goldenPath, compiled.html, "utf-8"); - console.log(`[${suite.id}] ✓ Updated output/compiled.html (${compiled.videos.length} video(s), ${compiled.audios.length} audio(s))`); + console.log( + `[${suite.id}] ✓ Updated output/compiled.html (${compiled.videos.length} video(s), ${compiled.audios.length} audio(s))`, + ); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[${suite.id}] ✗ Failed to update compiled.html: ${errorMessage}`); throw error; } finally { diff --git a/packages/producer/src/services/compilationTester.ts b/packages/producer/src/services/compilationTester.ts index c28819be4..62f4d8310 100644 --- a/packages/producer/src/services/compilationTester.ts +++ b/packages/producer/src/services/compilationTester.ts @@ -161,7 +161,7 @@ function validateElementTiming(element: CompiledElement, label: string): string[ const computed = element.dataStart + element.dataDuration; if (Math.abs(element.dataEnd - computed) > EPSILON) { errors.push( - `${label} [${element.id}]: data-end (${element.dataEnd}) != data-start (${element.dataStart}) + data-duration (${element.dataDuration}) = ${computed}` + `${label} [${element.id}]: data-end (${element.dataEnd}) != data-start (${element.dataStart}) + data-duration (${element.dataDuration}) = ${computed}`, ); } } @@ -179,16 +179,13 @@ function validateElementTiming(element: CompiledElement, label: string): string[ * Compare two elements and return differences. * Compares timing attributes with epsilon tolerance. */ -function compareElements( - actual: CompiledElement, - golden: CompiledElement -): string[] { +function compareElements(actual: CompiledElement, golden: CompiledElement): string[] { const errors: string[] = []; // Compare tag names if (actual.tagName !== golden.tagName) { errors.push( - `[${actual.id}]: tagName mismatch (actual: ${actual.tagName}, golden: ${golden.tagName})` + `[${actual.id}]: tagName mismatch (actual: ${actual.tagName}, golden: ${golden.tagName})`, ); return errors; // Don't continue if tag mismatch } @@ -196,7 +193,7 @@ function compareElements( // Compare data-start (should be exact) if (Math.abs(actual.dataStart - golden.dataStart) > EPSILON) { errors.push( - `[${actual.id}]: data-start mismatch (actual: ${actual.dataStart}, golden: ${golden.dataStart})` + `[${actual.id}]: data-start mismatch (actual: ${actual.dataStart}, golden: ${golden.dataStart})`, ); } @@ -206,7 +203,7 @@ function compareElements( errors.push(`[${actual.id}]: missing data-end (golden has: ${golden.dataEnd})`); } else if (Math.abs(actual.dataEnd - golden.dataEnd) > EPSILON) { errors.push( - `[${actual.id}]: data-end mismatch (actual: ${actual.dataEnd}, golden: ${golden.dataEnd})` + `[${actual.id}]: data-end mismatch (actual: ${actual.dataEnd}, golden: ${golden.dataEnd})`, ); } } @@ -214,12 +211,10 @@ function compareElements( // Compare data-duration with epsilon tolerance if (golden.dataDuration !== null) { if (actual.dataDuration === null) { - errors.push( - `[${actual.id}]: missing data-duration (golden has: ${golden.dataDuration})` - ); + errors.push(`[${actual.id}]: missing data-duration (golden has: ${golden.dataDuration})`); } else if (Math.abs(actual.dataDuration - golden.dataDuration) > EPSILON) { errors.push( - `[${actual.id}]: data-duration mismatch (actual: ${actual.dataDuration}, golden: ${golden.dataDuration})` + `[${actual.id}]: data-duration mismatch (actual: ${actual.dataDuration}, golden: ${golden.dataDuration})`, ); } } @@ -228,7 +223,7 @@ function compareElements( if (actual.tagName === "video") { if (actual.dataHasAudio !== golden.dataHasAudio) { errors.push( - `[${actual.id}]: data-has-audio mismatch (actual: ${actual.dataHasAudio}, golden: ${golden.dataHasAudio})` + `[${actual.id}]: data-has-audio mismatch (actual: ${actual.dataHasAudio}, golden: ${golden.dataHasAudio})`, ); } } @@ -236,7 +231,7 @@ function compareElements( // Compare composition-src (composition only) if (actual.tagName === "div" && actual.compositionSrc !== golden.compositionSrc) { errors.push( - `[${actual.id}]: data-composition-src mismatch (actual: ${actual.compositionSrc}, golden: ${golden.compositionSrc})` + `[${actual.id}]: data-composition-src mismatch (actual: ${actual.compositionSrc}, golden: ${golden.compositionSrc})`, ); } @@ -249,7 +244,7 @@ function compareElements( */ export function validateCompilation( actualHtml: string, - goldenHtml: string + goldenHtml: string, ): CompilationValidationResult { const actualElements = extractTimedElements(actualHtml); const goldenElements = extractTimedElements(goldenHtml); @@ -269,9 +264,7 @@ export function validateCompilation( for (const element of goldenElements) { const timingErrors = validateElementTiming(element, "golden"); if (timingErrors.length > 0) { - warnings.push( - `Golden file has invalid timing: ${timingErrors.join(", ")}` - ); + warnings.push(`Golden file has invalid timing: ${timingErrors.join(", ")}`); } } @@ -300,9 +293,7 @@ export function validateCompilation( // Check for missing elements (in golden but not in actual) for (const [id] of goldenMap) { if (!actualMap.has(id)) { - errors.push( - `Missing element [${id}] (present in golden, not in actual)` - ); + errors.push(`Missing element [${id}] (present in golden, not in actual)`); } } @@ -310,7 +301,7 @@ export function validateCompilation( for (const [id, actualEl] of actualMap) { if (!goldenMap.has(id)) { warnings.push( - `Extra element [${id}] <${actualEl.tagName}> (present in actual, not in golden)` + `Extra element [${id}] <${actualEl.tagName}> (present in actual, not in golden)`, ); } } diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index c7b8e4ae6..1ba3264e6 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -35,42 +35,23 @@ const GENERIC_FAMILIES = new Set([ const CANONICAL_FONTS: Record = { inter: { packageName: "@fontsource/inter", - faces: [ - { weight: "400" }, - { weight: "700" }, - { weight: "900" }, - ], + faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }], }, montserrat: { packageName: "@fontsource/montserrat", - faces: [ - { weight: "400" }, - { weight: "700" }, - { weight: "900" }, - ], + faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }], }, outfit: { packageName: "@fontsource/outfit", - faces: [ - { weight: "400" }, - { weight: "700" }, - { weight: "900" }, - ], + faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }], }, nunito: { packageName: "@fontsource/nunito", - faces: [ - { weight: "400" }, - { weight: "700" }, - { weight: "900" }, - ], + faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }], }, oswald: { packageName: "@fontsource/oswald", - faces: [ - { weight: "400" }, - { weight: "700" }, - ], + faces: [{ weight: "400" }, { weight: "700" }], }, "league-gothic": { packageName: "@fontsource/league-gothic", @@ -82,31 +63,19 @@ const CANONICAL_FONTS: Record = { }, "space-mono": { packageName: "@fontsource/space-mono", - faces: [ - { weight: "400" }, - { weight: "700" }, - ], + faces: [{ weight: "400" }, { weight: "700" }], }, "ibm-plex-mono": { packageName: "@fontsource/ibm-plex-mono", - faces: [ - { weight: "400" }, - { weight: "700" }, - ], + faces: [{ weight: "400" }, { weight: "700" }], }, "jetbrains-mono": { packageName: "@fontsource/jetbrains-mono", - faces: [ - { weight: "400" }, - { weight: "700" }, - ], + faces: [{ weight: "400" }, { weight: "700" }], }, "eb-garamond": { packageName: "@fontsource/eb-garamond", - faces: [ - { weight: "400" }, - { weight: "700" }, - ], + faces: [{ weight: "400" }, { weight: "700" }], }, }; @@ -139,7 +108,11 @@ const PACKAGE_ROOT_CACHE = new Map(); const FONT_DATA_URI_CACHE = new Map(); function normalizeFamilyName(family: string): string { - return family.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase(); + return family + .trim() + .replace(/^['"]|['"]$/g, "") + .trim() + .toLowerCase(); } function packageRoot(packageName: string): string { @@ -154,7 +127,11 @@ function packageRoot(packageName: string): string { return root; } -function resolveFontFile(packageName: string, weight: string, style: "normal" | "italic" = "normal"): string { +function resolveFontFile( + packageName: string, + weight: string, + style: "normal" | "italic" = "normal", +): string { const root = packageRoot(packageName); const filesDir = join(root, "files"); const slug = packageName.replace("@fontsource/", ""); @@ -172,10 +149,16 @@ function resolveFontFile(packageName: string, weight: string, style: "normal" | return join(filesDir, relaxed); } - throw new Error(`No deterministic font asset found for ${packageName} weight=${weight} style=${style}`); + throw new Error( + `No deterministic font asset found for ${packageName} weight=${weight} style=${style}`, + ); } -function fontDataUri(packageName: string, weight: string, style: "normal" | "italic" = "normal"): string { +function fontDataUri( + packageName: string, + weight: string, + style: "normal" | "italic" = "normal", +): string { const key = `${packageName}:${weight}:${style}`; const cached = FONT_DATA_URI_CACHE.get(key); if (cached) { @@ -206,7 +189,10 @@ function extractRequestedFontFamilies(html: string): Map { const requested = new Map(); const addFamilyList = (value: string) => { for (const family of value.split(",")) { - const originalCase = family.trim().replace(/^['"]|['"]$/g, "").trim(); + const originalCase = family + .trim() + .replace(/^['"]|['"]$/g, "") + .trim(); const normalized = originalCase.toLowerCase(); if (!normalized || GENERIC_FAMILIES.has(normalized)) { continue; @@ -230,7 +216,10 @@ function extractRequestedFontFamilies(html: string): Map { return requested; } -function buildFontFaceCss(requestedFamilies: Map): { css: string; unresolved: string[] } { +function buildFontFaceCss(requestedFamilies: Map): { + css: string; + unresolved: string[]; +} { const rules: string[] = []; const unresolved: string[] = []; @@ -300,7 +289,9 @@ export function injectDeterministicFontFaces(html: string): string { styleEl.textContent = css; head.insertBefore(styleEl, head.firstChild); - console.log(`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`); + console.log( + `[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`, + ); if (unresolved.length > 0) { console.warn(`[Compiler] Unresolved font families left dynamic: ${unresolved.join(", ")}`); } diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index f4f0dfbfe..1d3931d47 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -45,7 +45,10 @@ const RENDER_SEEK_MODE = ? "strict-boundary" : "preview-phase"; const RENDER_SEEK_DIAGNOSTICS = process.env.PRODUCER_DEBUG_SEEK_DIAGNOSTICS === "true"; -const RENDER_SEEK_STEP = Math.max(1 / 600, Number(process.env.PRODUCER_RENDER_SEEK_STEP || 1 / 120)); +const RENDER_SEEK_STEP = Math.max( + 1 / 600, + Number(process.env.PRODUCER_RENDER_SEEK_STEP || 1 / 120), +); const RENDER_SEEK_OFFSET_FRACTION = Math.max( 0, Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)), @@ -273,7 +276,6 @@ export function createFileServer(options: FileServerOptions): Promise { @@ -284,7 +286,7 @@ export function createFileServer(options: FileServerOptions): Promise { let filePath = src; @@ -65,9 +70,10 @@ async function resolveMediaDuration( return { duration: 0, resolvedPath: filePath }; } - const metadata = tagName === "video" - ? await extractVideoMetadata(filePath) - : await extractAudioMetadata(filePath); + const metadata = + tagName === "video" + ? await extractVideoMetadata(filePath) + : await extractAudioMetadata(filePath); const fileDuration = metadata.durationSeconds; const effectiveDuration = fileDuration - mediaStart; @@ -83,30 +89,28 @@ async function resolveMediaDuration( async function compileHtmlFile( html: string, baseDir: string, - downloadDir: string + downloadDir: string, ): Promise<{ html: string; unresolvedCompositions: UnresolvedElement[] }> { const { html: staticCompiled, unresolved } = compileTimingAttrs(html); const mediaUnresolved = unresolved.filter( - (el) => (el.tagName === "video" || el.tagName === "audio") && el.src + (el) => (el.tagName === "video" || el.tagName === "audio") && el.src, ); - const unresolvedCompositions = unresolved.filter( - (el) => el.tagName === "div" - ); + const unresolvedCompositions = unresolved.filter((el) => el.tagName === "div"); // Phase 1: Resolve missing durations (parallel ffprobe) const resolvedResults = await Promise.all( mediaUnresolved.map((el) => - resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName) - .then(({ duration }) => ({ id: el.id, duration })) - ) + resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName).then( + ({ duration }) => ({ id: el.id, duration }), + ), + ), ); const resolutions: ResolvedDuration[] = resolvedResults.filter((r) => r.duration > 0); - let compiledHtml = resolutions.length > 0 - ? injectDurations(staticCompiled, resolutions) - : staticCompiled; + let compiledHtml = + resolutions.length > 0 ? injectDurations(staticCompiled, resolutions) : staticCompiled; // Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration (parallel ffprobe) const preResolved = extractResolvedMedia(compiledHtml); @@ -115,10 +119,14 @@ async function compileHtmlFile( .filter((el) => !!el.src) .map(async (el) => { const { duration: maxDuration } = await resolveMediaDuration( - el.src!, el.mediaStart, baseDir, downloadDir, el.tagName + el.src!, + el.mediaStart, + baseDir, + downloadDir, + el.tagName, ); return { id: el.id, duration: el.duration, maxDuration, src: el.src! }; - }) + }), ); const clampList: ResolvedDuration[] = []; for (const r of clampResults) { @@ -145,7 +153,7 @@ async function parseSubCompositions( downloadDir: string, parentOffset: number = 0, parentEnd: number = Infinity, - visited: Set = new Set() + visited: Set = new Set(), ): Promise<{ videos: VideoElement[]; audios: AudioElement[]; @@ -177,10 +185,7 @@ async function parseSubCompositions( const elEnd = elEndRaw ? parseFloat(elEndRaw) : Infinity; const absoluteStart = parentOffset + elStart; - const absoluteEnd = Math.min( - parentEnd, - isFinite(elEnd) ? parentOffset + elEnd : Infinity - ); + const absoluteEnd = Math.min(parentEnd, isFinite(elEnd) ? parentOffset + elEnd : Infinity); const filePath = resolve(projectDir, srcPath); @@ -203,18 +208,34 @@ async function parseSubCompositions( // Parallelize file compilation + recursive parsing const results = await Promise.all( workItems.map(async (item) => { - const { html: compiledSub } = await compileHtmlFile(item.rawSubHtml, dirname(item.filePath), downloadDir); + const { html: compiledSub } = await compileHtmlFile( + item.rawSubHtml, + dirname(item.filePath), + downloadDir, + ); const nested = await parseSubCompositions( - compiledSub, projectDir, downloadDir, - item.absoluteStart, item.absoluteEnd, item.nestedVisited + compiledSub, + projectDir, + downloadDir, + item.absoluteStart, + item.absoluteEnd, + item.nestedVisited, ); const subVideos = parseVideoElements(compiledSub); const subAudios = parseAudioElements(compiledSub); - return { srcPath: item.srcPath, compiledSub, nested, subVideos, subAudios, absoluteStart: item.absoluteStart, absoluteEnd: item.absoluteEnd }; - }) + return { + srcPath: item.srcPath, + compiledSub, + nested, + subVideos, + subAudios, + absoluteStart: item.absoluteStart, + absoluteEnd: item.absoluteEnd, + }; + }), ); // Merge results @@ -249,7 +270,12 @@ async function parseSubCompositions( } } - if (r.subVideos.length > 0 || r.subAudios.length > 0 || r.nested.videos.length > 0 || r.nested.audios.length > 0) { + if ( + r.subVideos.length > 0 || + r.subAudios.length > 0 || + r.nested.videos.length > 0 || + r.nested.audios.length > 0 + ) { } } @@ -332,28 +358,25 @@ function scopeCssToComposition(css: string, compositionId: string): string { const scope = `[data-composition-id="${compositionId}"]`; // Split on top-level rule boundaries. Simple regex approach: // scope each selector in rule blocks while preserving at-rules. - return css.replace( - /([^{}@]+)\{/g, - (match, selectors: string) => { - const trimmed = selectors.trim(); - // Skip @-rule headers (they don't have selectors to scope) - if (trimmed.startsWith("@")) return match; - // Skip if already scoped to this composition - if (trimmed.includes(`data-composition-id="${compositionId}"`)) return match; - // Scope each comma-separated selector - const scoped = trimmed - .split(",") - .map((s: string) => { - const sel = s.trim(); - if (!sel) return sel; - // Don't scope :root, html, body, or * alone — they're global - if (/^(html|body|:root|\*)$/i.test(sel)) return sel; - return `${scope} ${sel}`; - }) - .join(", "); - return `${scoped} {`; - }, - ); + return css.replace(/([^{}@]+)\{/g, (match, selectors: string) => { + const trimmed = selectors.trim(); + // Skip @-rule headers (they don't have selectors to scope) + if (trimmed.startsWith("@")) return match; + // Skip if already scoped to this composition + if (trimmed.includes(`data-composition-id="${compositionId}"`)) return match; + // Scope each comma-separated selector + const scoped = trimmed + .split(",") + .map((s: string) => { + const sel = s.trim(); + if (!sel) return sel; + // Don't scope :root, html, body, or * alone — they're global + if (/^(html|body|:root|\*)$/i.test(sel)) return sel; + return `${scope} ${sel}`; + }) + .join(", "); + return `${scoped} {`; + }); } function coalesceHeadStylesAndBodyScripts(html: string): string { @@ -434,7 +457,7 @@ function coalesceHeadStylesAndBodyScripts(html: string): string { function inlineSubCompositions( html: string, subCompositions: Map, - projectDir: string + projectDir: string, ): string { const { document } = parseHTML(html); const head = document.querySelector("head"); @@ -467,9 +490,9 @@ function inlineSubCompositions( const templateEl = compDoc.querySelector("template"); const bodyEl = compDoc.querySelector("body"); const contentHtml = templateEl - ? (templateEl.innerHTML || "") + ? templateEl.innerHTML || "" : bodyEl - ? (bodyEl.innerHTML || "") + ? bodyEl.innerHTML || "" : compDoc.toString(); const contentDoc = parseHTML(contentHtml).document; @@ -556,12 +579,13 @@ function inlineSubCompositions( needsPosition ? "position:relative" : "", needsWidth ? `width:${hostW}px` : "", needsHeight ? `height:${hostH}px` : "", - ].filter(Boolean).join(";"); + ] + .filter(Boolean) + .join(";"); if (additions) { host.setAttribute("style", existing ? `${existing};${additions}` : additions); } } - } if (collectedStyles.length && head) { @@ -588,15 +612,21 @@ function inlineSubCompositions( export async function compileForRender( projectDir: string, htmlPath: string, - downloadDir: string + downloadDir: string, ): Promise { - const rawHtml = readFileSync(htmlPath, "utf-8"); - const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(rawHtml, projectDir, downloadDir); + const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile( + rawHtml, + projectDir, + downloadDir, + ); // Parse sub-compositions first (extracts media + compiled HTML for each) - const { videos: subVideos, audios: subAudios, subCompositions } = - await parseSubCompositions(compiledHtml, projectDir, downloadDir); + const { + videos: subVideos, + audios: subAudios, + subCompositions, + } = await parseSubCompositions(compiledHtml, projectDir, downloadDir); // Inline sub-compositions into the main HTML so the runtime takes the same // synchronous code path as the bundled preview (no async fetch of @@ -618,24 +648,28 @@ export async function compileForRender( const { document } = parseHTML(html); const rootEl = document.querySelector("[data-composition-id]"); - const width = rootEl - ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) - : 1080; - const height = rootEl - ? parseInt(rootEl.getAttribute("data-height") || "1920", 10) - : 1920; + const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080; + const height = rootEl ? parseInt(rootEl.getAttribute("data-height") || "1920", 10) : 1920; // Static duration (may be 0 if set at runtime by GSAP) const staticDuration = rootEl ? parseFloat( - rootEl.getAttribute("data-duration") - || rootEl.getAttribute("data-composition-duration") - || "0", - ) + rootEl.getAttribute("data-duration") || + rootEl.getAttribute("data-composition-duration") || + "0", + ) : 0; - - return { html, subCompositions, videos, audios, unresolvedCompositions, width, height, staticDuration }; + return { + html, + subCompositions, + videos, + audios, + unresolvedCompositions, + width, + height, + staticDuration, + }; } /** @@ -656,10 +690,7 @@ export interface BrowserMediaElement { volume: number; } -export async function discoverMediaFromBrowser( - page: Page -): Promise { - +export async function discoverMediaFromBrowser(page: Page): Promise { const elements = await page.evaluate(() => { const results: { id: string; @@ -712,12 +743,11 @@ export async function discoverMediaFromBrowser( */ export async function resolveCompositionDurations( page: Page, - unresolved: UnresolvedElement[] + unresolved: UnresolvedElement[], ): Promise { if (unresolved.length === 0) return []; - - const ids = unresolved.map(el => el.id); + const ids = unresolved.map((el) => el.id); const results = await page.evaluate((compIds: string[]) => { const win = window as unknown as { __timelines?: Record }; @@ -738,7 +768,8 @@ export async function resolveCompositionDurations( // Fallback: check for authored duration on the element itself const el = document.getElementById(id); if (el) { - const compDurAttr = el.getAttribute("data-duration") || el.getAttribute("data-composition-duration"); + const compDurAttr = + el.getAttribute("data-duration") || el.getAttribute("data-composition-duration"); if (compDurAttr) { const dur = parseFloat(compDurAttr); if (dur > 0) { @@ -772,16 +803,18 @@ export async function recompileWithResolutions( compiled: CompiledComposition, resolutions: ResolvedDuration[], projectDir: string, - downloadDir: string + downloadDir: string, ): Promise { if (resolutions.length === 0) return compiled; - const html = injectDurations(compiled.html, resolutions); // Re-parse sub-compositions with the updated parent bounds - const { videos: subVideos, audios: subAudios, subCompositions } = - await parseSubCompositions(html, projectDir, downloadDir); + const { + videos: subVideos, + audios: subAudios, + subCompositions, + } = await parseSubCompositions(html, projectDir, downloadDir); const mainVideos = parseVideoElements(html); const mainAudios = parseAudioElements(html); @@ -790,10 +823,9 @@ export async function recompileWithResolutions( const audios = dedupeElementsById([...subAudios, ...mainAudios]); const remaining = compiled.unresolvedCompositions.filter( - c => !resolutions.some(r => r.id === c.id) + (c) => !resolutions.some((r) => r.id === c.id), ); - return { ...compiled, html, diff --git a/packages/producer/src/services/hyperframeLint.ts b/packages/producer/src/services/hyperframeLint.ts index ee5b3e18c..c1c7a9c22 100644 --- a/packages/producer/src/services/hyperframeLint.ts +++ b/packages/producer/src/services/hyperframeLint.ts @@ -38,7 +38,10 @@ function pickEntryFile(files: Record, preferredEntryFile?: strin return null; } -function readProjectEntryFile(projectDir: string, preferredEntryFile?: string): PreparedHyperframeLintInput | { error: string } { +function readProjectEntryFile( + projectDir: string, + preferredEntryFile?: string, +): PreparedHyperframeLintInput | { error: string } { const absProjectDir = resolve(projectDir); if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) { return { error: `Project directory not found: ${absProjectDir}` }; @@ -62,14 +65,18 @@ function readProjectEntryFile(projectDir: string, preferredEntryFile?: string): } } - return { error: `No HTML entry file found in project directory: ${join(absProjectDir, preferredEntryFile || "index.html")}` }; + return { + error: `No HTML entry file found in project directory: ${join(absProjectDir, preferredEntryFile || "index.html")}`, + }; } export function prepareHyperframeLintBody( body: Record, ): { prepared: PreparedHyperframeLintInput } | { error: string } { const requestedEntryFile = - typeof body.entryFile === "string" && body.entryFile.trim().length > 0 ? body.entryFile.trim() : undefined; + typeof body.entryFile === "string" && body.entryFile.trim().length > 0 + ? body.entryFile.trim() + : undefined; if (typeof body.projectDir === "string" && body.projectDir.trim().length > 0) { const prepared = readProjectEntryFile(body.projectDir, requestedEntryFile); diff --git a/packages/producer/src/services/hyperframeRuntimeLoader.ts b/packages/producer/src/services/hyperframeRuntimeLoader.ts index 3c0c115bd..a2d7831f3 100644 --- a/packages/producer/src/services/hyperframeRuntimeLoader.ts +++ b/packages/producer/src/services/hyperframeRuntimeLoader.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const PRODUCER_DIR = dirname(fileURLToPath(import.meta.url)); const MODULE_RELATIVE_MANIFEST_PATH = resolve( PRODUCER_DIR, - "../../../core/dist/hyperframe.manifest.json" + "../../../core/dist/hyperframe.manifest.json", ); const CWD_RELATIVE_MANIFEST_PATHS = [ resolve(process.cwd(), "../core/dist/hyperframe.manifest.json"), @@ -49,7 +49,7 @@ export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime { const manifestPath = resolveHyperframeManifestPath(); if (!existsSync(manifestPath)) { throw new Error( - `[HyperframeRuntimeLoader] Missing manifest at ${manifestPath}. Build core runtime artifacts before rendering.` + `[HyperframeRuntimeLoader] Missing manifest at ${manifestPath}. Build core runtime artifacts before rendering.`, ); } @@ -58,7 +58,7 @@ export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime { const runtimeFileName = manifest.artifacts?.iife; if (!runtimeFileName || !manifest.sha256) { throw new Error( - `[HyperframeRuntimeLoader] Invalid manifest at ${manifestPath}; missing iife artifact or sha256.` + `[HyperframeRuntimeLoader] Invalid manifest at ${manifestPath}; missing iife artifact or sha256.`, ); } @@ -71,7 +71,7 @@ export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime { const runtimeSha = createHash("sha256").update(runtimeSource, "utf8").digest("hex"); if (runtimeSha !== manifest.sha256) { throw new Error( - `[HyperframeRuntimeLoader] Runtime checksum mismatch. expected=${manifest.sha256} actual=${runtimeSha}` + `[HyperframeRuntimeLoader] Runtime checksum mismatch. expected=${manifest.sha256} actual=${runtimeSha}`, ); } return { @@ -82,4 +82,3 @@ export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime { runtimeSource, }; } - diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 46d0fced9..420265fab 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -51,17 +51,29 @@ import { randomUUID } from "crypto"; import { freemem } from "os"; import { fileURLToPath } from "url"; import { createFileServer, type FileServerHandle } from "./fileServer.js"; -import { compileForRender, resolveCompositionDurations, recompileWithResolutions, discoverMediaFromBrowser, type CompiledComposition } from "./htmlCompiler.js"; +import { + compileForRender, + resolveCompositionDurations, + recompileWithResolutions, + discoverMediaFromBrowser, + type CompiledComposition, +} from "./htmlCompiler.js"; import { defaultLogger, type ProducerLogger } from "../logger.js"; /** * Wrap a cleanup operation so it never throws, but logs any failure. */ -async function safeCleanup(label: string, fn: () => Promise | void, log: ProducerLogger = defaultLogger): Promise { +async function safeCleanup( + label: string, + fn: () => Promise | void, + log: ProducerLogger = defaultLogger, +): Promise { try { await fn(); } catch (err) { - log.debug(`Cleanup failed (${label})`, { error: err instanceof Error ? err.message : String(err) }); + log.debug(`Cleanup failed (${label})`, { + error: err instanceof Error ? err.message : String(err), + }); } } @@ -135,7 +147,10 @@ export type ProgressCallback = (job: RenderJob, message: string) => void; export class RenderCancelledError extends Error { reason: "user_cancelled" | "timeout" | "aborted"; - constructor(message: string = "render_cancelled", reason: "user_cancelled" | "timeout" | "aborted" = "aborted") { + constructor( + message: string = "render_cancelled", + reason: "user_cancelled" | "timeout" | "aborted" = "aborted", + ) { super(message); this.name = "RenderCancelledError"; this.reason = reason; @@ -155,7 +170,7 @@ function updateJobStatus( status: RenderStatus, stage: string, progress: number, - onProgress?: ProgressCallback + onProgress?: ProgressCallback, ): void { job.status = status; job.currentStage = stage; @@ -171,15 +186,29 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger const write = (prefix: string, args: unknown[]) => { const ts = new Date().toISOString(); - const line = `[${ts}] ${prefix} ${args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}\n`; - try { appendFileSync(logPath, line); } catch (err) { - log.debug("Debug log write failed", { logPath, error: err instanceof Error ? err.message : String(err) }); + const line = `[${ts}] ${prefix} ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n`; + try { + appendFileSync(logPath, line); + } catch (err) { + log.debug("Debug log write failed", { + logPath, + error: err instanceof Error ? err.message : String(err), + }); } }; - console.log = (...args: unknown[]) => { write("LOG", args); origLog(...args); }; - console.error = (...args: unknown[]) => { write("ERR", args); origError(...args); }; - console.warn = (...args: unknown[]) => { write("WRN", args); origWarn(...args); }; + console.log = (...args: unknown[]) => { + write("LOG", args); + origLog(...args); + }; + console.error = (...args: unknown[]) => { + write("ERR", args); + origError(...args); + }; + console.warn = (...args: unknown[]) => { + write("WRN", args); + origWarn(...args); + }; return () => { console.log = origLog; @@ -191,7 +220,11 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger /** * Write compiled HTML and sub-compositions to the work directory. */ -function writeCompiledArtifacts(compiled: CompiledComposition, workDir: string, includeSummary: boolean): void { +function writeCompiledArtifacts( + compiled: CompiledComposition, + workDir: string, + includeSummary: boolean, +): void { const compileDir = join(workDir, "compiled"); mkdirSync(compileDir, { recursive: true }); @@ -209,10 +242,18 @@ function writeCompiledArtifacts(compiled: CompiledComposition, workDir: string, height: compiled.height, staticDuration: compiled.staticDuration, videos: compiled.videos.map((v) => ({ - id: v.id, src: v.src, start: v.start, end: v.end, mediaStart: v.mediaStart, + id: v.id, + src: v.src, + start: v.start, + end: v.end, + mediaStart: v.mediaStart, })), audios: compiled.audios.map((a) => ({ - id: a.id, src: a.src, start: a.start, end: a.end, mediaStart: a.mediaStart, + id: a.id, + src: a.src, + start: a.start, + end: a.end, + mediaStart: a.mediaStart, })), subCompositions: Array.from(compiled.subCompositions.keys()), }; @@ -306,13 +347,30 @@ export async function executeRenderJob( if (needsBrowser) { const reasons = []; if (composition.duration <= 0) reasons.push("root duration unknown"); - if (compiled.unresolvedCompositions.length > 0) reasons.push(`${compiled.unresolvedCompositions.length} unresolved composition(s)`); + if (compiled.unresolvedCompositions.length > 0) + reasons.push(`${compiled.unresolvedCompositions.length} unresolved composition(s)`); - fileServer = await createFileServer({ projectDir, compiledDir: join(workDir, "compiled"), port: 0 }); + fileServer = await createFileServer({ + projectDir, + compiledDir: join(workDir, "compiled"), + port: 0, + }); assertNotAborted(); - const captureOpts: CaptureOptions = { width, height, fps: job.config.fps, format: "jpeg", quality: 80 }; - probeSession = await createCaptureSession(fileServer.url, join(workDir, "probe"), captureOpts, null, cfg); + const captureOpts: CaptureOptions = { + width, + height, + fps: job.config.fps, + format: "jpeg", + quality: 80, + }; + probeSession = await createCaptureSession( + fileServer.url, + join(workDir, "probe"), + captureOpts, + null, + cfg, + ); await initializeSession(probeSession); assertNotAborted(); lastBrowserConsole = probeSession.browserConsoleBuffer; @@ -326,10 +384,18 @@ export async function executeRenderJob( // Resolve unresolved composition durations via window.__timelines if (compiled.unresolvedCompositions.length > 0) { - const resolutions = await resolveCompositionDurations(probeSession.page, compiled.unresolvedCompositions); + const resolutions = await resolveCompositionDurations( + probeSession.page, + compiled.unresolvedCompositions, + ); assertNotAborted(); if (resolutions.length > 0) { - compiled = await recompileWithResolutions(compiled, resolutions, projectDir, join(workDir, "downloads")); + compiled = await recompileWithResolutions( + compiled, + resolutions, + projectDir, + join(workDir, "downloads"), + ); assertNotAborted(); // Update composition metadata with re-parsed media composition.videos = compiled.videos; @@ -342,79 +408,87 @@ export async function executeRenderJob( const browserMedia = await discoverMediaFromBrowser(probeSession.page); assertNotAborted(); if (browserMedia.length > 0) { - const existingVideoIds = new Set(composition.videos.map(v => v.id)); - const existingAudioIds = new Set(composition.audios.map(a => a.id)); + const existingVideoIds = new Set(composition.videos.map((v) => v.id)); + const existingAudioIds = new Set(composition.audios.map((a) => a.id)); for (const el of browserMedia) { if (!el.src || el.src === "about:blank") continue; - // Convert absolute localhost URLs back to relative paths - let src = el.src; - if (fileServer && src.startsWith(fileServer.url)) { - src = src.slice(fileServer.url.length).replace(/^\//, ""); - } + // Convert absolute localhost URLs back to relative paths + let src = el.src; + if (fileServer && src.startsWith(fileServer.url)) { + src = src.slice(fileServer.url.length).replace(/^\//, ""); + } - if (el.tagName === "video") { - if (existingVideoIds.has(el.id)) { - // Reconcile to browser/runtime media metadata (runtime src can differ from static HTML). - const existing = composition.videos.find(v => v.id === el.id); - if (existing) { - if (existing.src !== src) { - existing.src = src; - } - if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) { - existing.end = el.end; - } - if (el.mediaStart > 0 && (existing.mediaStart <= 0 || Math.abs(existing.mediaStart - el.mediaStart) > 0.0001)) { - existing.mediaStart = el.mediaStart; - } - if (el.hasAudio && !existing.hasAudio) { - existing.hasAudio = true; - } + if (el.tagName === "video") { + if (existingVideoIds.has(el.id)) { + // Reconcile to browser/runtime media metadata (runtime src can differ from static HTML). + const existing = composition.videos.find((v) => v.id === el.id); + if (existing) { + if (existing.src !== src) { + existing.src = src; + } + if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) { + existing.end = el.end; + } + if ( + el.mediaStart > 0 && + (existing.mediaStart <= 0 || + Math.abs(existing.mediaStart - el.mediaStart) > 0.0001) + ) { + existing.mediaStart = el.mediaStart; + } + if (el.hasAudio && !existing.hasAudio) { + existing.hasAudio = true; } - } else { - // New video discovered from browser - composition.videos.push({ - id: el.id, - src, - start: el.start, - end: el.end, - mediaStart: el.mediaStart, - hasAudio: el.hasAudio, - }); - existingVideoIds.add(el.id); } - } else if (el.tagName === "audio") { - if (existingAudioIds.has(el.id)) { - const existing = composition.audios.find(a => a.id === el.id); - if (existing) { - if (existing.src !== src) { - existing.src = src; - } - if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) { - existing.end = el.end; - } - if (el.mediaStart > 0 && (existing.mediaStart <= 0 || Math.abs(existing.mediaStart - el.mediaStart) > 0.0001)) { - existing.mediaStart = el.mediaStart; - } - if (el.volume > 0 && Math.abs((existing.volume ?? 1) - el.volume) > 0.0001) { - existing.volume = el.volume; - } + } else { + // New video discovered from browser + composition.videos.push({ + id: el.id, + src, + start: el.start, + end: el.end, + mediaStart: el.mediaStart, + hasAudio: el.hasAudio, + }); + existingVideoIds.add(el.id); + } + } else if (el.tagName === "audio") { + if (existingAudioIds.has(el.id)) { + const existing = composition.audios.find((a) => a.id === el.id); + if (existing) { + if (existing.src !== src) { + existing.src = src; + } + if (el.end > 0 && (existing.end <= 0 || Math.abs(existing.end - el.end) > 0.0001)) { + existing.end = el.end; + } + if ( + el.mediaStart > 0 && + (existing.mediaStart <= 0 || + Math.abs(existing.mediaStart - el.mediaStart) > 0.0001) + ) { + existing.mediaStart = el.mediaStart; + } + if (el.volume > 0 && Math.abs((existing.volume ?? 1) - el.volume) > 0.0001) { + existing.volume = el.volume; } - } else { - composition.audios.push({ - id: el.id, - src, - start: el.start, - end: el.end, - mediaStart: el.mediaStart, - layer: 0, - volume: el.volume, - type: "audio", - }); - existingAudioIds.add(el.id); } + } else { + composition.audios.push({ + id: el.id, + src, + start: el.start, + end: el.end, + mediaStart: el.mediaStart, + layer: 0, + volume: el.volume, + type: "audio", + }); + existingAudioIds.add(el.id); } + } } } } @@ -424,7 +498,11 @@ export async function executeRenderJob( job.totalFrames = Math.ceil(composition.duration * job.config.fps); if (job.duration <= 0) { - throw new Error("Invalid composition duration: " + job.duration + ". Check that GSAP timelines are registered."); + throw new Error( + "Invalid composition duration: " + + job.duration + + ". Check that GSAP timelines are registered.", + ); } perfStages.compileMs = Date.now() - stage1Start; @@ -436,7 +514,6 @@ export async function executeRenderJob( let frameLookup: FrameLookupTable | null = null; if (composition.videos.length > 0) { - const extractionResult = await extractAllVideoFrames( composition.videos, projectDir, @@ -446,19 +523,16 @@ export async function executeRenderJob( assertNotAborted(); if (extractionResult.extracted.length > 0) { - frameLookup = createFrameLookupTable( - composition.videos, - extractionResult.extracted, - ); + frameLookup = createFrameLookupTable(composition.videos, extractionResult.extracted); } perfStages.videoExtractMs = Date.now() - stage2Start; // Auto-detect audio from video files via ffprobe metadata - const existingAudioSrcs = new Set(composition.audios.map(a => a.src)); + const existingAudioSrcs = new Set(composition.audios.map((a) => a.src)); for (const ext of extractionResult.extracted) { if (ext.metadata.hasAudio) { - const video = composition.videos.find(v => v.id === ext.videoId); + const video = composition.videos.find((v) => v.id === ext.videoId); if (video && !existingAudioSrcs.has(video.src)) { composition.audios.push({ id: `${video.id}-audio`, @@ -486,7 +560,6 @@ export async function executeRenderJob( let hasAudio = false; if (composition.audios.length > 0) { - const audioResult = await processCompositionAudio( composition.audios, projectDir, @@ -509,7 +582,11 @@ export async function executeRenderJob( // Start file server (may already be running from duration discovery) if (!fileServer) { - fileServer = await createFileServer({ projectDir, compiledDir: join(workDir, "compiled"), port: 0 }); + fileServer = await createFileServer({ + projectDir, + compiledDir: join(workDir, "compiled"), + port: 0, + }); assertNotAborted(); } @@ -529,7 +606,6 @@ export async function executeRenderJob( const videoOnlyPath = join(workDir, "video-only.mp4"); const preset = ENCODER_PRESETS[job.config.quality]; - job.framesRendered = 0; // Streaming encode mode: pipe frame buffers directly to FFmpeg stdin, @@ -590,7 +666,7 @@ export async function executeRenderJob( "rendering", `Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, Math.round(progressPct), - onProgress + onProgress, ); } }, @@ -607,13 +683,15 @@ export async function executeRenderJob( // Sequential capture → streaming encode const videoInjector = createVideoFrameInjector(frameLookup); - const session = probeSession ?? await createCaptureSession( - fileServer.url, - framesDir, - captureOptions, - videoInjector, - cfg, - ); + const session = + probeSession ?? + (await createCaptureSession( + fileServer.url, + framesDir, + captureOptions, + videoInjector, + cfg, + )); if (probeSession) { prepareCaptureSessionForReuse(session, framesDir, videoInjector); probeSession = null; @@ -643,7 +721,7 @@ export async function executeRenderJob( "rendering", `Streaming frame ${i + 1}/${job.totalFrames}`, Math.round(progress), - onProgress + onProgress, ); } } finally { @@ -689,7 +767,7 @@ export async function executeRenderJob( "rendering", `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, Math.round(progressPct), - onProgress + onProgress, ); } }, @@ -707,13 +785,15 @@ export async function executeRenderJob( // Sequential capture const videoInjector = createVideoFrameInjector(frameLookup); - const session = probeSession ?? await createCaptureSession( - fileServer.url, - framesDir, - captureOptions, - videoInjector, - cfg, - ); + const session = + probeSession ?? + (await createCaptureSession( + fileServer.url, + framesDir, + captureOptions, + videoInjector, + cfg, + )); if (probeSession) { prepareCaptureSessionForReuse(session, framesDir, videoInjector); probeSession = null; @@ -740,7 +820,7 @@ export async function executeRenderJob( "rendering", `Capturing frame ${i + 1}/${job.totalFrames}`, Math.round(progress), - onProgress + onProgress, ); } } finally { @@ -854,14 +934,20 @@ export async function executeRenderJob( videoCount: composition.videos.length, audioCount: composition.audios.length, stages: perfStages, - captureAvgMs: job.totalFrames! > 0 ? Math.round((perfStages.captureMs ?? 0) / job.totalFrames!) : undefined, + captureAvgMs: + job.totalFrames! > 0 + ? Math.round((perfStages.captureMs ?? 0) / job.totalFrames!) + : undefined, }; job.perfSummary = perfSummary; if (job.config.debug) { try { writeFileSync(perfOutputPath, JSON.stringify(perfSummary, null, 2), "utf-8"); } catch (err) { - log.debug("Failed to write perf summary", { perfOutputPath, error: err instanceof Error ? err.message : String(err) }); + log.debug("Failed to write perf summary", { + perfOutputPath, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -873,7 +959,13 @@ export async function executeRenderJob( copyFileSync(outputPath, debugOutput); } } else { - await safeCleanup("remove workDir", () => { rmSync(workDir, { recursive: true, force: true }); }, log); + await safeCleanup( + "remove workDir", + () => { + rmSync(workDir, { recursive: true, force: true }); + }, + log, + ); } if (restoreLogger) restoreLogger(); @@ -883,30 +975,36 @@ export async function executeRenderJob( updateJobStatus(job, "cancelled", "Render cancelled", job.progress, onProgress); if (fileServer) { const fs = fileServer; - await safeCleanup("close file server (cancel)", () => { fs.close(); }, log); + await safeCleanup( + "close file server (cancel)", + () => { + fs.close(); + }, + log, + ); } if (probeSession) { const session = probeSession; await safeCleanup("close probe session (cancel)", () => closeCaptureSession(session), log); } if (!job.config.debug) { - await safeCleanup("remove workDir (cancel)", () => { - rmSync(workDir, { recursive: true, force: true }); - }, log); + await safeCleanup( + "remove workDir (cancel)", + () => { + rmSync(workDir, { recursive: true, force: true }); + }, + log, + ); } if (restoreLogger) restoreLogger(); - throw error instanceof RenderCancelledError ? error : new RenderCancelledError("render_cancelled"); + throw error instanceof RenderCancelledError + ? error + : new RenderCancelledError("render_cancelled"); } const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; job.error = errorMessage; - updateJobStatus( - job, - "failed", - `Failed: ${errorMessage}`, - job.progress, - onProgress - ); + updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress); // Diagnostic summary const elapsed = Date.now() - pipelineStart; @@ -923,11 +1021,16 @@ export async function executeRenderJob( perfStages: Object.keys(perfStages).length > 0 ? { ...perfStages } : undefined, }; - // Cleanup if (fileServer) { const fs = fileServer; - await safeCleanup("close file server (error)", () => { fs.close(); }, log); + await safeCleanup( + "close file server (error)", + () => { + fs.close(); + }, + log, + ); } if (probeSession) { const session = probeSession; @@ -935,9 +1038,13 @@ export async function executeRenderJob( } if (!job.config.debug) { - await safeCleanup("remove workDir (error)", () => { - if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true }); - }, log); + await safeCleanup( + "remove workDir (error)", + () => { + if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true }); + }, + log, + ); } if (restoreLogger) restoreLogger(); diff --git a/packages/producer/src/services/timingCompiler.ts b/packages/producer/src/services/timingCompiler.ts index 40611d422..b08720360 100644 --- a/packages/producer/src/services/timingCompiler.ts +++ b/packages/producer/src/services/timingCompiler.ts @@ -35,7 +35,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 { @@ -46,7 +46,10 @@ function injectAttr(tag: string, attr: string, value: string): string { return tag.replace(/>$/, ` ${attr}="${value}">`); } -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; const id = getAttr(result, "id"); @@ -190,18 +193,12 @@ export function clampDurations(html: string, clamps: ResolvedDuration[]): string html = html.replace(idPattern, (tag) => { // Replace data-duration value - tag = tag.replace( - /data-duration=["'][^"']*["']/, - `data-duration="${duration}"` - ); + tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`); // Recompute data-end from data-start + clamped duration const startStr = getAttr(tag, "data-start"); const start = startStr ? parseFloat(startStr) : 0; - tag = tag.replace( - /data-end=["'][^"']*["']/, - `data-end="${start + duration}"` - ); + tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`); return tag; }); @@ -209,4 +206,3 @@ export function clampDurations(html: string, clamps: ResolvedDuration[]): string return html; } - diff --git a/packages/producer/src/utils/parityContract.ts b/packages/producer/src/utils/parityContract.ts index e9fba2ab0..f0fc2cefa 100644 --- a/packages/producer/src/utils/parityContract.ts +++ b/packages/producer/src/utils/parityContract.ts @@ -2,7 +2,4 @@ * Re-exported from @hyperframes/engine. * @see engine/src/utils/parityContract.ts for implementation. */ -export { - quantizeTimeToFrame, - MEDIA_VISUAL_STYLE_PROPERTIES, -} from "@hyperframes/engine"; +export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/engine"; diff --git a/packages/producer/src/utils/paths.ts b/packages/producer/src/utils/paths.ts index 0ec61d7b0..34fc40a58 100644 --- a/packages/producer/src/utils/paths.ts +++ b/packages/producer/src/utils/paths.ts @@ -9,18 +9,18 @@ export interface RenderPaths { absoluteOutputPath: string; } -const DEFAULT_RENDERS_DIR = process.env.PRODUCER_RENDERS_DIR - ?? resolve(new URL(import.meta.url).pathname, "../../..", "renders"); +const DEFAULT_RENDERS_DIR = + process.env.PRODUCER_RENDERS_DIR ?? + resolve(new URL(import.meta.url).pathname, "../../..", "renders"); export function resolveRenderPaths( projectDir: string, outputPath: string | null | undefined, - rendersDir: string = DEFAULT_RENDERS_DIR + rendersDir: string = DEFAULT_RENDERS_DIR, ): RenderPaths { const absoluteProjectDir = resolve(projectDir); const projectName = basename(absoluteProjectDir); - const resolvedOutputPath = - outputPath ?? join(rendersDir, `${projectName}.mp4`); + const resolvedOutputPath = outputPath ?? join(rendersDir, `${projectName}.mp4`); const absoluteOutputPath = resolve(resolvedOutputPath); return { absoluteProjectDir, absoluteOutputPath }; diff --git a/packages/producer/src/utils/urlDownloader.ts b/packages/producer/src/utils/urlDownloader.ts index 23061139e..65d9ee6b1 100644 --- a/packages/producer/src/utils/urlDownloader.ts +++ b/packages/producer/src/utils/urlDownloader.ts @@ -2,7 +2,4 @@ * Re-exported from @hyperframes/engine. * @see engine/src/utils/urlDownloader.ts for implementation. */ -export { - downloadToTemp, - isHttpUrl, -} from "@hyperframes/engine"; +export { downloadToTemp, isHttpUrl } from "@hyperframes/engine"; diff --git a/packages/producer/tests/chat/output/compiled.html b/packages/producer/tests/chat/output/compiled.html index fd220cb40..75f1bb2c4 100644 --- a/packages/producer/tests/chat/output/compiled.html +++ b/packages/producer/tests/chat/output/compiled.html @@ -1,258 +1,328 @@ - + - - - + + + Kinetic Typography Sequence - + - - -
- - + @[data-composition-id="typography"] keyframes flicker {[data-composition-id="typography"] 0% { opacity: 0.5; transform: skew(2deg); }[data-composition-id="typography"] 50% { opacity: 1; transform: skew(-2deg); }[data-composition-id="typography"] 100% { opacity: 0.8; transform: skew(0deg); } + } + + + +
+ + - -
+ +
-
- TEXT - ANIMATION -
-
- WHAT - DO - YOU - WANT - TO - WRITE? -
-
- EVERYTHING - YOU - WANT, -
-
- STANDING - IN - FRONT - OF - YOU. -
-
- VERY - EASY, -
-
- AND - FAST. -
-
- SIT - BACK - AND - RELAX - | -
-
- WHAT - YOU - ARE - LOOKING - FOR? -
-
- THERE - YOU - HAVE - IT! -
-
- THANK - YOU -
+
+ TEXT + ANIMATION +
+
+ WHAT + DO + YOU + WANT + TO + WRITE? +
+
+ EVERYTHING + YOU + WANT, +
+
+ STANDING + IN + FRONT + OF + YOU. +
+
+ VERY + EASY, +
+
+ AND + FAST. +
+
+ SIT + BACK + AND + RELAX + | +
+
+ WHAT + YOU + ARE + LOOKING + FOR? +
+
+ THERE + YOU + HAVE + IT! +
+
+ THANK + YOU +
- - - - -
+
- - + } catch (_err) { + console.error("[Compiler] Composition script failed", __compId, _err); + } + }; + if (!__compId) { + __run(); + return; + } + var __selector = '[data-composition-id="' + (__compId + "").replace(/"/g, '\\"') + '"]'; + var __attempt = 0; + var __tryRun = function () { + if (document.querySelector(__selector)) { + __run(); + return; + } + if (++__attempt >= 8) { + __run(); + return; + } + requestAnimationFrame(__tryRun); + }; + __tryRun(); + })(); + + diff --git a/packages/producer/tests/chat/src/code_review.md b/packages/producer/tests/chat/src/code_review.md index fe6bc69bf..17cc74f51 100644 --- a/packages/producer/tests/chat/src/code_review.md +++ b/packages/producer/tests/chat/src/code_review.md @@ -1,6 +1,7 @@ # HyperFrame Schema Compliance Review ## Executive Summary + - Total files reviewed: 2 - Critical issues: 1 - Overall compliance status: NEEDS_WORK @@ -8,6 +9,7 @@ ## Critical Issues ### Empty Tweens for Duration + - **File**: index.html:26 - **File**: compositions/typography.html:224 - **Violation**: `masterTL.to({}, { duration: 15 });` and `tl.to({}, { duration: 15 });` @@ -15,6 +17,7 @@ - **Impact**: This is a direct violation of the schema's duration management rules. While it might work in some environments, it bypasses the framework's declarative duration system and can lead to rendering inconsistencies or failures in the server-side renderer. ## Compliance Checklist + - [x] All compositions have `data-width` and `data-height` attributes - [x] All timelines are finite with duration > 0 - [x] All compositions registered in `window.__timelines` @@ -32,38 +35,51 @@ - [x] No infinite or zero-duration timelines ### index.html + **Status**: HAS_ISSUES **Issues Found**: + - **Line 26**: Uses empty tween `masterTL.to({}, { duration: 15 });` to set duration. Should use `data-duration="15"` on the `#main` div (line 11) instead. ### compositions/typography.html + **Status**: HAS_ISSUES **Issues Found**: + - **Line 224**: Uses empty tween `tl.to({}, { duration: 15 });` to set duration. Should use `data-duration="15"` on the composition root div (line 2) instead. - **Line 156**: `const sceneEl = document.querySelector(scene.id);` - While not a strict violation, it's better to scope queries to the composition root to avoid conflicts if multiple instances of the same composition are loaded. ## Recommended Fixes ### Fix 1: Declarative Duration in index.html + Replace the empty tween with `data-duration` on the element. ```html -
- - - +
+ + +
``` ### Fix 2: Declarative Duration in typography.html + Replace the empty tween with `data-duration` on the element. ```html
- - - + + +
``` diff --git a/packages/producer/tests/chat/src/compositions/typography.html b/packages/producer/tests/chat/src/compositions/typography.html index c8ac94220..ae1b09244 100644 --- a/packages/producer/tests/chat/src/compositions/typography.html +++ b/packages/producer/tests/chat/src/compositions/typography.html @@ -1,226 +1,266 @@ diff --git a/packages/producer/tests/chat/src/index.html b/packages/producer/tests/chat/src/index.html index 21fb072f1..b508ce23b 100644 --- a/packages/producer/tests/chat/src/index.html +++ b/packages/producer/tests/chat/src/index.html @@ -1,29 +1,43 @@ - + - - - + + + Kinetic Typography Sequence - + - - -
- - + + +
+ + - -
-
+ +
- + diff --git a/packages/producer/tests/chat/src/style.css b/packages/producer/tests/chat/src/style.css index 5045fe174..628254c93 100644 --- a/packages/producer/tests/chat/src/style.css +++ b/packages/producer/tests/chat/src/style.css @@ -1,24 +1,25 @@ * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } -html, body { - width: 100vw; - height: 100vh; - overflow: hidden; - background-color: #000; - font-family: 'Montserrat', sans-serif; +html, +body { + width: 100vw; + height: 100vh; + overflow: hidden; + background-color: #000; + font-family: "Montserrat", sans-serif; } -@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@500;900&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@500;900&display=swap"); #main { - width: 1080px; - height: 1920px; - position: relative; - display: flex; - justify-content: center; - align-items: center; + width: 1080px; + height: 1920px; + position: relative; + display: flex; + justify-content: center; + align-items: center; } diff --git a/packages/producer/tests/dogs-captions/output/compiled.html b/packages/producer/tests/dogs-captions/output/compiled.html index dce184083..0afd42d14 100644 --- a/packages/producer/tests/dogs-captions/output/compiled.html +++ b/packages/producer/tests/dogs-captions/output/compiled.html @@ -1,170 +1,631 @@ - + - - - + @font-face { + font-family: "Arial"; + src: url("data:font/woff2;base64,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") + format("woff2"); + font-style: normal; + font-weight: 900; + font-display: block; + } + + + Dog Safety Video with Captions - + - - -
- - + + + +
+ + - -
+ +
+
- - - -
- - +
- + } catch (_err) { + console.error("[Compiler] Composition script failed", __compId, _err); + } + }; + if (!__compId) { + __run(); + return; + } + var __selector = '[data-composition-id="' + (__compId + "").replace(/"/g, '\\"') + '"]'; + var __attempt = 0; + var __tryRun = function () { + if (document.querySelector(__selector)) { + __run(); + return; + } + if (++__attempt >= 8) { + __run(); + return; + } + requestAnimationFrame(__tryRun); + }; + __tryRun(); + })(); + + diff --git a/packages/producer/tests/dogs-captions/src/compositions/captions.html b/packages/producer/tests/dogs-captions/src/compositions/captions.html index 644de435f..e1f573b2f 100644 --- a/packages/producer/tests/dogs-captions/src/compositions/captions.html +++ b/packages/producer/tests/dogs-captions/src/compositions/captions.html @@ -1,119 +1,539 @@ diff --git a/packages/producer/tests/dogs-captions/src/index.html b/packages/producer/tests/dogs-captions/src/index.html index e368bdea3..d19572e3e 100644 --- a/packages/producer/tests/dogs-captions/src/index.html +++ b/packages/producer/tests/dogs-captions/src/index.html @@ -1,33 +1,41 @@ - + - - - + + + Dog Safety Video with Captions - + - - -
- - + + +
+ + - -
-
+ +
- +
- + diff --git a/packages/producer/tests/dogs-captions/src/style.css b/packages/producer/tests/dogs-captions/src/style.css index f6da388d3..e4238b19d 100644 --- a/packages/producer/tests/dogs-captions/src/style.css +++ b/packages/producer/tests/dogs-captions/src/style.css @@ -1,24 +1,25 @@ * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } -html, body { - width: 100vw; - height: 100vh; - overflow: hidden; - background-color: black; +html, +body { + width: 100vw; + height: 100vh; + overflow: hidden; + background-color: black; } #main { - width: 1080px; - height: 1920px; - position: relative; + width: 1080px; + height: 1920px; + position: relative; } video { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } diff --git a/packages/producer/tests/font-variant-numeric/output/compiled.html b/packages/producer/tests/font-variant-numeric/output/compiled.html index 34e947ab4..3cd931758 100644 --- a/packages/producer/tests/font-variant-numeric/output/compiled.html +++ b/packages/producer/tests/font-variant-numeric/output/compiled.html @@ -1,89 +1,119 @@ - + - - - -Font Variant Numeric Test - - + + + Font Variant Numeric Test + + + + + + +
+
COUNTER
+
0000
+ + +
- .label { - position: absolute; - top: 35%; - left: 50%; - transform: translateX(-50%); - font-size: 24px; - letter-spacing: 4px; - color: #94A3B8; - text-transform: uppercase; - } - - - -
-
COUNTER
-
0000
- - -
+ + const counterEl = document.getElementById("counter-value"); + const obj = { val: 0 }; + tl.to(obj, { + val: 9999, + duration: 3, + ease: "none", + onUpdate: () => { + counterEl.textContent = String(Math.round(obj.val)).padStart(4, "0"); + }, + }); + + diff --git a/packages/producer/tests/font-variant-numeric/src/index.html b/packages/producer/tests/font-variant-numeric/src/index.html index 3abff5aa1..9b44457da 100644 --- a/packages/producer/tests/font-variant-numeric/src/index.html +++ b/packages/producer/tests/font-variant-numeric/src/index.html @@ -1,77 +1,92 @@ - + - - - -Font Variant Numeric Test - - - - -
-
COUNTER
-
0000
- - -
+ .label { + position: absolute; + top: 35%; + left: 50%; + transform: translateX(-50%); + font-size: 24px; + letter-spacing: 4px; + color: #94a3b8; + text-transform: uppercase; + } + + + +
+
COUNTER
+
0000
+ + +
- - + const counterEl = document.getElementById("counter-value"); + const obj = { val: 0 }; + tl.to(obj, { + val: 9999, + duration: 3, + ease: "none", + onUpdate: () => { + counterEl.textContent = String(Math.round(obj.val)).padStart(4, "0"); + }, + }); + + diff --git a/packages/producer/tests/many-cuts/output/compiled.html b/packages/producer/tests/many-cuts/output/compiled.html index 0cdf9836e..5bcc59480 100644 --- a/packages/producer/tests/many-cuts/output/compiled.html +++ b/packages/producer/tests/many-cuts/output/compiled.html @@ -1,47 +1,170 @@ - + - - - + + + Magic Cut Intro - - - - -
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Meet your Editing Agent
-
+ + + + +
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Meet your Editing Agent
+
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + +
- - \ No newline at end of file + + diff --git a/packages/producer/tests/many-cuts/src/code_review.md b/packages/producer/tests/many-cuts/src/code_review.md index 025d1753d..a3f40a52f 100644 --- a/packages/producer/tests/many-cuts/src/code_review.md +++ b/packages/producer/tests/many-cuts/src/code_review.md @@ -1,14 +1,17 @@ # HyperFrame Schema Compliance Review ## Executive Summary + - Total files reviewed: 3 - Critical issues: 0 - Overall compliance status: PASS ## Critical Issues + None. The composition follows the HyperFrame schema correctly. ## Compliance Checklist + - [x] All compositions have `data-width` and `data-height` attributes - [x] All timelines are finite with duration > 0 - [x] All compositions registered in `window.__timelines` @@ -26,24 +29,30 @@ None. The composition follows the HyperFrame schema correctly. - [x] No infinite or zero-duration timelines ### index.html + **Status**: COMPLIANT **Issues Found**: + - None. The root composition is correctly defined with `data-composition-id`, `data-width`, `data-height`, `data-start`, and `data-track-index`. - Audio clips are correctly defined with `id`, `data-start`, `data-duration`, and `data-track-index`. - Tracks are used correctly to avoid overlap for audio clips. ### script.js + **Status**: COMPLIANT **Issues Found**: + - **Determinism**: The script uses a deterministic approach for particle generation (lines 47, 51) instead of `Math.random()`. This is excellent and follows the "CRITICAL: Deterministic Behavior Required" rule. - **Timeline Registration**: The timeline is correctly registered in `window.__timelines["magic-cut-intro"]`. - **Framework Alignment**: The script focuses on visual animations (opacity, scale, particles) and does not attempt to control audio playback or clip lifecycle, which is correct. ### style.css + **Status**: COMPLIANT **Issues Found**: + - The dimensions match the requested Portrait (9:16) orientation (1080x1920). - Layout is handled via CSS, which is the correct approach. diff --git a/packages/producer/tests/many-cuts/src/index.html b/packages/producer/tests/many-cuts/src/index.html index 9f80f1990..5cfe256f6 100644 --- a/packages/producer/tests/many-cuts/src/index.html +++ b/packages/producer/tests/many-cuts/src/index.html @@ -1,47 +1,156 @@ - + - - - + + + Magic Cut Intro - - - - -
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Magic Cut
-
Meet your Editing Agent
-
+ + + + +
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Magic Cut
+
Meet your Editing Agent
+
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + +
- - \ No newline at end of file + + diff --git a/packages/producer/tests/many-cuts/src/script.js b/packages/producer/tests/many-cuts/src/script.js index 063f3f0de..cd58f5de1 100644 --- a/packages/producer/tests/many-cuts/src/script.js +++ b/packages/producer/tests/many-cuts/src/script.js @@ -1,76 +1,114 @@ const masterTL = gsap.timeline({ paused: true }); -const variants = ['#v1', '#v2', '#v3', '#v4', '#v5', '#v6', '#v7', '#v8', '#v9', '#v10', '#v11', '#v12', '#v13']; +const variants = [ + "#v1", + "#v2", + "#v3", + "#v4", + "#v5", + "#v6", + "#v7", + "#v8", + "#v9", + "#v10", + "#v11", + "#v12", + "#v13", +]; const cutDuration = 0.2; variants.forEach((selector, index) => { - // Show current variant - masterTL.set(selector, { - opacity: 1 - }, index * cutDuration); + // Show current variant + masterTL.set( + selector, + { + opacity: 1, + }, + index * cutDuration, + ); - // Hide previous variant - if (index > 0) { - masterTL.set(variants[index - 1], { - opacity: 0 - }, index * cutDuration); - } + // Hide previous variant + if (index > 0) { + masterTL.set( + variants[index - 1], + { + opacity: 0, + }, + index * cutDuration, + ); + } }); const finalStartTime = variants.length * cutDuration; // 2.6s // Hide last variant -masterTL.set(variants[variants.length - 1], { - opacity: 0 -}, finalStartTime); +masterTL.set( + variants[variants.length - 1], + { + opacity: 0, + }, + finalStartTime, +); // Show final message with epic effect -masterTL.to('#final-message', { +masterTL.to( + "#final-message", + { opacity: 1, scale: 1, duration: 0.8, - ease: "elastic.out(1, 0.5)" -}, finalStartTime); + ease: "elastic.out(1, 0.5)", + }, + finalStartTime, +); // Explosion Particles -const particlesContainer = document.getElementById('particles-container'); +const particlesContainer = document.getElementById("particles-container"); const particleCount = 120; // Increased count -const colors = ['#FFD700', '#FF4500', '#FF69B4', '#00FFFF', '#FFF', '#ADFF2F', '#FF8C00']; +const colors = ["#FFD700", "#FF4500", "#FF69B4", "#00FFFF", "#FFF", "#ADFF2F", "#FF8C00"]; for (let i = 0; i < particleCount; i++) { - const particle = document.createElement('div'); - particle.className = 'particle'; - particlesContainer.appendChild(particle); + const particle = document.createElement("div"); + particle.className = "particle"; + particlesContainer.appendChild(particle); - const angle = (i / particleCount) * Math.PI * 2; - // Deterministic "randomness" using modulo and index - const velocity = 400 + 600 * ((i * 7) % 10 / 10); // Increased velocity for bigger explosion - const x = Math.cos(angle) * velocity; - const y = Math.sin(angle) * velocity; - const color = colors[i % colors.length]; - const size = 8 + 12 * ((i * 3) % 5 / 5); // Varied sizes + const angle = (i / particleCount) * Math.PI * 2; + // Deterministic "randomness" using modulo and index + const velocity = 400 + 600 * (((i * 7) % 10) / 10); // Increased velocity for bigger explosion + const x = Math.cos(angle) * velocity; + const y = Math.sin(angle) * velocity; + const color = colors[i % colors.length]; + const size = 8 + 12 * (((i * 3) % 5) / 5); // Varied sizes - masterTL.set(particle, { - x: 540, - y: 960, - width: size, - height: size, - xPercent: -50, - yPercent: -50, - backgroundColor: color, - opacity: 1, - scale: 1 - }, finalStartTime); + masterTL.set( + particle, + { + x: 540, + y: 960, + width: size, + height: size, + xPercent: -50, + yPercent: -50, + backgroundColor: color, + opacity: 1, + scale: 1, + }, + finalStartTime, + ); - masterTL.to(particle, { - x: 540 + x, - y: 960 + y, - opacity: 0, - scale: 0, - rotation: (i % 2 === 0 ? 360 : -360), // Add some rotation - duration: 2.0, // Longer duration for bigger feel - ease: "power3.out" - }, finalStartTime); + masterTL.to( + particle, + { + x: 540 + x, + y: 960 + y, + opacity: 0, + scale: 0, + rotation: i % 2 === 0 ? 360 : -360, // Add some rotation + duration: 2.0, // Longer duration for bigger feel + ease: "power3.out", + }, + finalStartTime, + ); } // Register the timeline diff --git a/packages/producer/tests/many-cuts/src/style.css b/packages/producer/tests/many-cuts/src/style.css index 1c48f2b8f..5055125dd 100644 --- a/packages/producer/tests/many-cuts/src/style.css +++ b/packages/producer/tests/many-cuts/src/style.css @@ -1,147 +1,148 @@ * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } -html, body { - width: 1080px; - height: 1920px; - overflow: hidden; - background-color: #000; /* Black */ +html, +body { + width: 1080px; + height: 1920px; + overflow: hidden; + background-color: #000; /* Black */ } #container { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - position: relative; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + position: relative; } .text-variant { - position: absolute; - width: 100%; - left: 0; - top: 50%; - transform: translateY(-50%); - text-align: center; - opacity: 0; - font-size: 120px; - text-transform: uppercase; - padding: 20px; + position: absolute; + width: 100%; + left: 0; + top: 50%; + transform: translateY(-50%); + text-align: center; + opacity: 0; + font-size: 120px; + text-transform: uppercase; + padding: 20px; } #v1 { - font-family: 'Montserrat', sans-serif; - color: #FFF; - font-weight: 900; + font-family: "Montserrat", sans-serif; + color: #fff; + font-weight: 900; } #v2 { - font-family: 'Bangers', cursive; - color: #FFD700; /* Gold */ + font-family: "Bangers", cursive; + color: #ffd700; /* Gold */ } #v3 { - font-family: 'Press Start 2P', cursive; - color: #00FF00; /* Neon Green */ - font-size: 80px; + font-family: "Press Start 2P", cursive; + color: #00ff00; /* Neon Green */ + font-size: 80px; } #v4 { - font-family: 'Lobster', cursive; - color: #FF69B4; /* Hot Pink */ - text-transform: none; + font-family: "Lobster", cursive; + color: #ff69b4; /* Hot Pink */ + text-transform: none; } #v5 { - font-family: 'Syncopate', sans-serif; - color: #00FFFF; /* Cyan */ - font-weight: 700; + font-family: "Syncopate", sans-serif; + color: #00ffff; /* Cyan */ + font-weight: 700; } #v6 { - font-family: 'Playfair Display', serif; - color: #FF4500; /* OrangeRed */ - font-style: italic; + font-family: "Playfair Display", serif; + color: #ff4500; /* OrangeRed */ + font-style: italic; } #v7 { - font-family: 'Montserrat', sans-serif; - color: #ADFF2F; /* GreenYellow */ - font-weight: 300; + font-family: "Montserrat", sans-serif; + color: #adff2f; /* GreenYellow */ + font-weight: 300; } #v8 { - font-family: 'Bangers', cursive; - color: #DA70D6; /* Orchid */ + font-family: "Bangers", cursive; + color: #da70d6; /* Orchid */ } #v9 { - font-family: 'Press Start 2P', cursive; - color: #F0E68C; /* Khaki */ - font-size: 80px; + font-family: "Press Start 2P", cursive; + color: #f0e68c; /* Khaki */ + font-size: 80px; } #v10 { - font-family: 'Lobster', cursive; - color: #7FFFD4; /* Aquamarine */ - text-transform: none; + font-family: "Lobster", cursive; + color: #7fffd4; /* Aquamarine */ + text-transform: none; } #v11 { - font-family: 'Syncopate', sans-serif; - color: #FFB6C1; /* LightPink */ - font-weight: 700; + font-family: "Syncopate", sans-serif; + color: #ffb6c1; /* LightPink */ + font-weight: 700; } #v12 { - font-family: 'Playfair Display', serif; - color: #E0FFFF; /* LightCyan */ - font-style: normal; - font-weight: 900; + font-family: "Playfair Display", serif; + color: #e0ffff; /* LightCyan */ + font-style: normal; + font-weight: 900; } #v13 { - font-family: 'Montserrat', sans-serif; - color: #FFA500; /* Orange */ - font-weight: 900; + font-family: "Montserrat", sans-serif; + color: #ffa500; /* Orange */ + font-weight: 900; } #final-message { - position: absolute; - width: 100%; - left: 0; - top: 50%; - transform: translateY(-50%) scale(0.8); - text-align: center; - opacity: 0; - font-size: 120px; /* Bigger */ - font-family: 'Montserrat', sans-serif; - color: #FFF; - font-weight: 900; - padding: 40px; - letter-spacing: -2px; - z-index: 10; + position: absolute; + width: 100%; + left: 0; + top: 50%; + transform: translateY(-50%) scale(0.8); + text-align: center; + opacity: 0; + font-size: 120px; /* Bigger */ + font-family: "Montserrat", sans-serif; + color: #fff; + font-weight: 900; + padding: 40px; + letter-spacing: -2px; + z-index: 10; } #particles-container { - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + pointer-events: none; } .particle { - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - opacity: 0; - left: 0; - top: 0; -} \ No newline at end of file + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + opacity: 0; + left: 0; + top: 0; +} diff --git a/packages/producer/tests/missing-host-comp-id/output/compiled.html b/packages/producer/tests/missing-host-comp-id/output/compiled.html index 27efd5d2b..87975bb42 100644 --- a/packages/producer/tests/missing-host-comp-id/output/compiled.html +++ b/packages/producer/tests/missing-host-comp-id/output/compiled.html @@ -1,57 +1,66 @@ - + - - - - Missing Host Composition Id - - + + + Missing Host Composition Id + + - -
- -
-
Scoped Text Should Stay Styled
- - - - -
-
+ + + +
+ +
+
+
Scoped Text Should Stay Styled
+
+
+
- - + window.__timelines["master"] = masterTl; + (function () { + var __compId = "scoped-text"; + var __run = function () { + try { + const tl = gsap.timeline({ paused: true }); + tl.to({}, { duration: 3 }); + window.__timelines = window.__timelines || {}; + window.__timelines["scoped-text"] = tl; + } catch (_err) { + console.error("[Compiler] Composition script failed", __compId, _err); + } + }; + if (!__compId) { + __run(); + return; + } + var __selector = '[data-composition-id="' + (__compId + "").replace(/"/g, '\\"') + '"]'; + var __attempt = 0; + var __tryRun = function () { + if (document.querySelector(__selector)) { + __run(); + return; + } + if (++__attempt >= 8) { + __run(); + return; + } + requestAnimationFrame(__tryRun); + }; + __tryRun(); + })(); + + diff --git a/packages/producer/tests/missing-host-comp-id/src/compositions/scoped-text.html b/packages/producer/tests/missing-host-comp-id/src/compositions/scoped-text.html index a28e63935..bab1fb52d 100644 --- a/packages/producer/tests/missing-host-comp-id/src/compositions/scoped-text.html +++ b/packages/producer/tests/missing-host-comp-id/src/compositions/scoped-text.html @@ -1,10 +1,5 @@