mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix: harden CLI edge-case repros (#591)
## Problem I reproduced the selected open issue batch one by one and confirmed the reports were valid. The fixes all touch the CLI/runtime capture boundary, then the follow-up regression run exposed one over-broad runtime change in sub-composition host visibility and one CI-only baseline trap. Closes #590, #589, #588, #587, #586, and #584. ## What this fixes ### CLI/runtime edge cases - Makes the GSAP infinite-repeat lint rule ignore JavaScript comments, so literal `repeat:-1` text in comments is not flagged. - Lets the compositions CLI inspect `<template>` content, count visual-only template descendants, estimate simple GSAP durations, and suppress root `data-start` warnings in sub-composition lint mode. - Preserves runtime bootstrap scripts when body scripts are coalesced, and injects the runtime into a real `<head>` when source HTML has no head. - Keeps #589 fixed by loading and rendering template-wrapped sub-composition content, while restoring host visibility to the shorter of the authored parent clip window and the child composition live timeline. - Resolves snapshot/validate viewport size from root `data-width` / `data-height` instead of falling back to 1920x1080. - Skips fully off-frame text boxes during contrast sampling and bounds-checks ring samples so contrast output no longer emits `null:1` / `NaN:1`. - Marks muted videos as `data-has-audio="false"` in the core timing compiler, which fixes the same-src muted `<video>` + separate `<audio>` StaticGuard case. - Keeps user-authored `hf-seek` listeners reachable during capture by preventing author scripts from being merged into the runtime bootstrap path. ### Shared helper cleanup - Removes the stale producer-local timing compiler duplicate; producer compilation now consumes the core timing compiler. - Centralizes HTML document helpers in core: fragment parsing, embedded runtime stripping, head/body script injection, and early-head injection. - Centralizes the CLI layout/snapshot static HTML server. - Adds browser-safe core subpath helpers for Lottie readiness and CLI screenshot clip calculation; Studio's Vite config keeps the screenshot clip helper self-contained so clean-checkout test startup does not value-import core `.ts` source. - Replaces the engine parity-contract copy with a core re-export. - De-duplicates render-job cleanup and Studio static file-serving callbacks. ### Regression hardening - Replaces the embedded-runtime script stripping regex with a script-tag scanner that handles closing tags like `</script >`. - Escapes inline script bodies before wrapping them in `<script>` tags, so authored `</script` and `<!--` text cannot break out of the injected wrapper script. - Shares media-duration clamping between core and producer, with a 50 ms tolerance for ffprobe precision drift between local and CI media stacks. - Pins the affected style fixture SFX durations in source so style-1 and style-9 compile deterministically. - Restores the `vfr-screen-recording` video golden to the CI-stable baseline; the current CI failure showed the Linux render matches the old golden, while the locally refreshed macOS golden was the mismatch. ## Root cause The CLI paths had accumulated assumptions that held for simple direct-root landscape compositions but not for current composition patterns: DOM queries did not enter template content, snapshot/validate used a fixed viewport, runtime and author scripts shared a coalescing bucket, and timing compilation treated every video as audio-bearing unless authors manually overrode it. The style shard failures were not product regressions. Local and CI media probing disagreed on the short SFX clip duration by about 45 ms, and the compiler was clamping authored durations to the locally probed value. The shared clamp tolerance preserves explicit author/source durations for small probe precision differences while still clamping real overflows. The vfr fast-shard failure was a bad baseline refresh: CI actual frames matched the old `vfr-screen-recording` baseline at 40+ dB PSNR, but mismatched the macOS-refreshed golden at ~18-22 dB. The fix is to keep the Docker/Linux-stable video golden and only retain the deterministic compiled snapshot change. The sub-composition regression came from treating a host's authored parent window as the only visibility boundary. That made settled child overlays stay visible after their own live GSAP timeline ended. The corrected runtime behavior respects both contracts: parent clips still bound where the host can appear, and the child live timeline can end the host earlier. ## Verification ### Local checks - `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts` - `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts` - `bun run --cwd packages/core test src/runtime/init.test.ts` - `bun run --cwd packages/cli test src/commands/compositions.test.ts src/utils/compositionViewport.test.ts` - `bun run build:hyperframes-runtime` - `bun run --cwd packages/producer test --keep-temp --sequential style-12-prod style-5-prod` - `bun run --cwd packages/producer test --sequential vfr-screen-recording hdr-hlg-regression style-7-prod` - `bun run --cwd packages/core test src/compiler/htmlCompiler.test.ts src/compiler/timingCompiler.test.ts src/index.test.ts` - `bunx oxfmt --check packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts` - `bunx oxlint packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts` - `bun run --cwd packages/core typecheck` - `bun run --cwd packages/producer typecheck` - `bun run --cwd packages/producer test --sequential style-1-prod style-9-prod` - `bun run --filter @hyperframes/studio test` with `packages/core/dist` temporarily hidden to simulate clean-checkout config loading - `git diff --check` ### CI artifact checks - Inspected failed run `25225854394` job `73969147096`: style-1 failed only on `click-sfx` `1.044898` vs `1` duration/end. - Inspected failed run `25225854394` job `73969147061`: style-9 failed only on SFX `1.044898`-based duration/end mismatches. - Inspected failed run `25225854394` job `73969147048`: `vfr-screen-recording` compilation/audio passed, visual failed after comparing against the macOS-refreshed golden. - Compared the first 10 uploaded CI vfr failure frames against the restored old baseline; minimum PSNR was `40.444705`, above the fixture threshold of `28`. ### Repro checks - `bun packages/cli/src/cli.ts lint /tmp/hf-590-repro` now passes without `gsap_infinite_repeat`. - `bun packages/cli/src/cli.ts snapshot /tmp/hf-587-repro --at 0.5 --timeout 1000` now writes a 1080x1920 PNG. - `bun packages/cli/src/cli.ts validate /tmp/hf-588-repro --timeout 500` no longer emits `null:1` / `NaN:1` contrast output. - `bun packages/cli/src/cli.ts validate /tmp/hf-586-repro --timeout 500 --contrast false` no longer emits the muted-video StaticGuard contract error. - `bun packages/cli/src/cli.ts compositions /tmp/hf-589-gsap-repro` now reports `foo 0.5s 1920x1080 1 element`. - `bun packages/cli/src/cli.ts snapshot /tmp/hf-589-gsap-repro --at 0.25 --timeout 2000` captures the expected template-backed red frame. - `bun packages/cli/src/cli.ts snapshot /tmp/hf-584-repro --at 0.5,1.5 --timeout 500` captures the expected post-seek green frame. ### Browser verification - Refreshed the local side-by-side comparison page at `qa-artifacts/pr-591-video-compare/index.html`. - Served the comparison page locally and used `agent-browser` to load `style-12-prod`, play both videos quickly to the failed window, pause, and inspect the side-by-side frame. - Browser proof screenshot: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12-labeled.png`. - Browser proof recording: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12.webm`. - Earlier Studio proof artifacts remain local-only: `qa-artifacts/dedupe-refactor-preview.png`, `qa-artifacts/dedupe-refactor-preview-after-play.png`, `qa-artifacts/dedupe-refactor-preview.webm`. ## Notes - Browser proof and CI diagnostic artifacts are intentionally local-only and not committed. - Studio's Vite config intentionally keeps the thumbnail clip helper inline because Vite/Vitest config startup runs through Node's loader before package source `.ts` imports are transformed. - The committed PR diff changes `vfr-screen-recording/output/compiled.html` but no longer changes `vfr-screen-recording/output/output.mp4` relative to `main`. - I attempted a local `linux/amd64` Docker validation to mirror CI, but the local Docker build was blocked by Debian package download failures. The arm64 Docker image also cannot launch the x64 Puppeteer headless shell under OrbStack. The vfr baseline decision is therefore based on the uploaded CI artifact comparison above. - I kept this validated issue batch in one PR because the fixes overlap the same CLI/runtime capture surfaces.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { ensureDOMParser } from "../utils/dom.js";
|
||||
import { parseSubComposition } from "./compositions.js";
|
||||
|
||||
describe("parseSubComposition", () => {
|
||||
beforeEach(() => {
|
||||
ensureDOMParser();
|
||||
});
|
||||
|
||||
it("reads template-wrapped sub-composition contents", () => {
|
||||
const html = `
|
||||
<template id="foo-template">
|
||||
<div data-composition-id="foo" data-width="1920" data-height="1080" data-duration="1.5">
|
||||
<div class="clip" data-start="0" data-duration="1.5"></div>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
expect(parseSubComposition(html, "foo", 1280, 720)).toEqual({
|
||||
id: "foo",
|
||||
duration: 1.5,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
elementCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts visual-only template content and estimates simple script durations", () => {
|
||||
const html = `
|
||||
<template id="foo-template">
|
||||
<div data-composition-id="foo" data-width="1920" data-height="1080">
|
||||
<div class="foo-bg" style="position:absolute;inset:0;background:#f00;"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo(".foo-bg", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines["foo"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
expect(parseSubComposition(html, "foo", 1280, 720)).toEqual({
|
||||
id: "foo",
|
||||
duration: 0.5,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
elementCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,31 @@ interface CompositionInfo {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
|
||||
const NON_RENDERED_TAGS = new Set(["script", "style", "link", "meta", "template"]);
|
||||
|
||||
function countRenderableDescendants(root: Element): number {
|
||||
return Array.from(root.querySelectorAll("*")).filter(
|
||||
(el) => !NON_RENDERED_TAGS.has(el.tagName.toLowerCase()),
|
||||
).length;
|
||||
}
|
||||
|
||||
function estimateDurationFromScripts(root: ParentNode): number {
|
||||
let duration = 0;
|
||||
for (const script of Array.from(root.querySelectorAll("script"))) {
|
||||
const content = script.textContent ?? "";
|
||||
const durationPattern = /\bduration\s*:\s*(\d+(?:\.\d+)?)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = durationPattern.exec(content)) !== null) {
|
||||
const value = Number.parseFloat(match[1] ?? "");
|
||||
if (Number.isFinite(value) && value > duration) {
|
||||
duration = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
export function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
@@ -81,7 +105,7 @@ function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
|
||||
return compositions;
|
||||
}
|
||||
|
||||
function parseSubComposition(
|
||||
export function parseSubComposition(
|
||||
html: string,
|
||||
fallbackId: string,
|
||||
fallbackWidth: number,
|
||||
@@ -89,20 +113,23 @@ function parseSubComposition(
|
||||
): CompositionInfo {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const template = doc.querySelector("template");
|
||||
const searchDocument = template?.content ?? doc;
|
||||
|
||||
// Sub-compositions may use <template> wrappers or direct divs
|
||||
const compDiv =
|
||||
doc.querySelector("[data-composition-id]") ??
|
||||
doc.querySelector("template [data-composition-id]");
|
||||
const compDiv = searchDocument.querySelector("[data-composition-id]");
|
||||
|
||||
const id = compDiv?.getAttribute("data-composition-id") ?? fallbackId;
|
||||
const width = parseInt(compDiv?.getAttribute("data-width") ?? String(fallbackWidth), 10);
|
||||
const height = parseInt(compDiv?.getAttribute("data-height") ?? String(fallbackHeight), 10);
|
||||
|
||||
// Count timed elements inside the sub-composition
|
||||
const searchRoot = compDiv ?? doc;
|
||||
const searchRoot = compDiv ?? searchDocument;
|
||||
const timedChildren = searchRoot.querySelectorAll("[data-start], .clip, .caption-group");
|
||||
let elementCount = timedChildren.length;
|
||||
if (elementCount === 0 && compDiv) {
|
||||
elementCount = countRenderableDescendants(compDiv);
|
||||
}
|
||||
|
||||
// Parse duration from the composition's own data-duration attribute
|
||||
let duration = 0;
|
||||
@@ -133,6 +160,9 @@ function parseSubComposition(
|
||||
}
|
||||
});
|
||||
}
|
||||
if (duration <= 0) {
|
||||
duration = estimateDurationFromScripts(searchRoot);
|
||||
}
|
||||
|
||||
return { id, duration, width, height, elementCount };
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ window.__contrastAudit = async function (imgBase64, time) {
|
||||
if (parseFloat(cs.opacity) <= 0.01) continue;
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) continue;
|
||||
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
|
||||
|
||||
var fg = parseColor(cs.color);
|
||||
if (fg[3] <= 0.01) continue;
|
||||
@@ -103,6 +104,7 @@ window.__contrastAudit = async function (imgBase64, time) {
|
||||
var y0 = Math.max(0, Math.floor(rect.y) - 4);
|
||||
var y1 = Math.min(h - 1, Math.ceil(rect.y + rect.height) + 4);
|
||||
var sample = function (sx, sy) {
|
||||
if (sx < 0 || sx >= w || sy < 0 || sy >= h) return;
|
||||
var idx = (sy * w + sx) * 4;
|
||||
rr.push(px[idx]);
|
||||
gg.push(px[idx + 1]);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
buildLayoutSampleTimes,
|
||||
@@ -130,57 +130,6 @@ async function bundleProjectHtml(projectDir: string): Promise<string> {
|
||||
return html;
|
||||
}
|
||||
|
||||
async function serveProject(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
): Promise<{
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const { getMimeType } = await import("@hyperframes/core/studio-api");
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/" || url === "/index.html") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
|
||||
const rel = relative(projectDir, filePath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
|
||||
res.end(readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolvePort, rejectPort) => {
|
||||
server.on("error", rejectPort);
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
const resolvedPort = typeof addr === "object" && addr ? addr.port : 0;
|
||||
if (!resolvedPort) rejectPort(new Error("Failed to bind local layout audit server"));
|
||||
else resolvePort(resolvedPort);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose) => {
|
||||
server.close(() => resolveClose());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function alignViewportToComposition(
|
||||
page: import("puppeteer-core").Page,
|
||||
url: string,
|
||||
@@ -206,7 +155,11 @@ async function runLayoutAudit(
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const html = await bundleProjectHtml(projectDir);
|
||||
const server = await serveProject(projectDir, html);
|
||||
const server = await serveStaticProjectHtml(
|
||||
projectDir,
|
||||
html,
|
||||
"Failed to bind local layout audit server",
|
||||
);
|
||||
let chromeBrowser: import("puppeteer-core").Browser | undefined;
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync } from "node:f
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute } from "node:path";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -110,42 +112,7 @@ async function captureSnapshots(
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Start minimal file server
|
||||
const { createServer } = await import("node:http");
|
||||
const { getMimeType } = await import("@hyperframes/core/studio-api");
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/" || url === "/index.html") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
|
||||
const rel = relative(projectDir, filePath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
|
||||
res.end(readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolvePort, rejectPort) => {
|
||||
server.on("error", rejectPort); // register before listen to catch sync bind errors
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
const p = typeof addr === "object" && addr ? addr.port : 0;
|
||||
if (!p) rejectPort(new Error("Failed to bind local HTTP server"));
|
||||
else resolvePort(p);
|
||||
});
|
||||
});
|
||||
const server = await serveStaticProjectHtml(projectDir, html);
|
||||
|
||||
const savedPaths: string[] = [];
|
||||
|
||||
@@ -168,9 +135,9 @@ async function captureSnapshots(
|
||||
|
||||
try {
|
||||
const page = await chromeBrowser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
await page.setViewport(resolveCompositionViewportFromHtml(html));
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/`, {
|
||||
await page.goto(server.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -414,7 +381,7 @@ async function captureSnapshots(
|
||||
await chromeBrowser.close();
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
return savedPaths;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
|
||||
@@ -172,7 +173,7 @@ async function validateInBrowser(
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
await page.setViewport(resolveCompositionViewportFromHtml(html));
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* providing a CLI-specific adapter for single-project, in-process rendering.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type ResolvedProject,
|
||||
type RenderJobState,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip";
|
||||
import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip";
|
||||
|
||||
// ── Path resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -258,25 +260,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
}, opts.seekTime);
|
||||
// Let the seek render settle.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
||||
let clip: ScreenshotClip | undefined;
|
||||
if (opts.selector) {
|
||||
clip = await page.evaluate((selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!(el instanceof HTMLElement)) return undefined;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 4 || rect.height < 4) return undefined;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, rect.left - pad);
|
||||
const y = Math.max(0, rect.top - pad);
|
||||
const maxWidth = window.innerWidth - x;
|
||||
const maxHeight = window.innerHeight - y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
||||
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
||||
};
|
||||
}, opts.selector);
|
||||
clip = await page.evaluate(getElementScreenshotClip, opts.selector);
|
||||
}
|
||||
const screenshot = (await page.screenshot(
|
||||
opts.format === "png"
|
||||
@@ -360,23 +346,16 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
});
|
||||
|
||||
// Studio SPA static files
|
||||
app.get("/assets/*", (c) => {
|
||||
const serveStudioStaticFile = (c: Context) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1));
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
||||
const content = readFileSync(filePath);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/icons/*", (c) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1));
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
||||
const content = readFileSync(filePath);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
||||
});
|
||||
});
|
||||
};
|
||||
app.get("/assets/*", serveStudioStaticFile);
|
||||
app.get("/icons/*", serveStudioStaticFile);
|
||||
|
||||
// SPA fallback
|
||||
app.get("*", (c) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCompositionViewportFromHtml } from "./compositionViewport.js";
|
||||
|
||||
describe("resolveCompositionViewportFromHtml", () => {
|
||||
it("uses root composition dimensions", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="portrait" data-width="1080" data-height="1920"></div>
|
||||
</body></html>`;
|
||||
|
||||
expect(resolveCompositionViewportFromHtml(html)).toEqual({ width: 1080, height: 1920 });
|
||||
});
|
||||
|
||||
it("falls back to landscape defaults when dimensions are missing", () => {
|
||||
expect(resolveCompositionViewportFromHtml("<html><body></body></html>")).toEqual({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ensureDOMParser } from "./dom.js";
|
||||
|
||||
const DEFAULT_VIEWPORT = { width: 1920, height: 1080 } as const;
|
||||
const MAX_VIEWPORT_DIMENSION = 4096;
|
||||
|
||||
function parseViewportDimension(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Math.min(parsed, MAX_VIEWPORT_DIMENSION);
|
||||
}
|
||||
|
||||
export function resolveCompositionViewportFromHtml(html: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
ensureDOMParser();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const root = doc.querySelector("[data-composition-id][data-width][data-height]");
|
||||
const width = parseViewportDimension(root?.getAttribute("data-width") ?? null);
|
||||
const height = parseViewportDimension(root?.getAttribute("data-height") ?? null);
|
||||
return {
|
||||
width: width ?? DEFAULT_VIEWPORT.width,
|
||||
height: height ?? DEFAULT_VIEWPORT.height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import { getMimeType } from "@hyperframes/core/studio-api";
|
||||
|
||||
export interface StaticProjectServer {
|
||||
url: string;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function serveStaticProjectHtml(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
bindErrorMessage = "Failed to bind local HTTP server",
|
||||
): Promise<StaticProjectServer> {
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/" || url === "/index.html") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
|
||||
const rel = relative(projectDir, filePath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
|
||||
res.end(readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolvePort, rejectPort) => {
|
||||
server.on("error", rejectPort);
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
const resolvedPort = typeof addr === "object" && addr ? addr.port : 0;
|
||||
if (!resolvedPort) rejectPort(new Error(bindErrorMessage));
|
||||
else resolvePort(resolvedPort);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
port,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose) => {
|
||||
server.close(() => resolveClose());
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -30,10 +30,18 @@
|
||||
"types": "./src/compiler/index.ts"
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js",
|
||||
"./runtime/lottie-readiness": {
|
||||
"import": "./src/runtime/adapters/lottieReadiness.ts",
|
||||
"types": "./src/runtime/adapters/lottieReadiness.ts"
|
||||
},
|
||||
"./studio-api": {
|
||||
"import": "./src/studio-api/index.ts",
|
||||
"types": "./src/studio-api/index.ts"
|
||||
},
|
||||
"./studio-api/screenshot-clip": {
|
||||
"import": "./src/studio-api/helpers/screenshotClip.ts",
|
||||
"types": "./src/studio-api/helpers/screenshotClip.ts"
|
||||
},
|
||||
"./text": {
|
||||
"import": "./src/text/index.ts",
|
||||
"types": "./src/text/index.ts"
|
||||
@@ -61,10 +69,18 @@
|
||||
"types": "./dist/compiler/index.d.ts"
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js",
|
||||
"./runtime/lottie-readiness": {
|
||||
"import": "./dist/runtime/adapters/lottieReadiness.js",
|
||||
"types": "./dist/runtime/adapters/lottieReadiness.d.ts"
|
||||
},
|
||||
"./studio-api": {
|
||||
"import": "./dist/studio-api/index.js",
|
||||
"types": "./dist/studio-api/index.d.ts"
|
||||
},
|
||||
"./studio-api/screenshot-clip": {
|
||||
"import": "./dist/studio-api/helpers/screenshotClip.js",
|
||||
"types": "./dist/studio-api/helpers/screenshotClip.d.ts"
|
||||
},
|
||||
"./text": {
|
||||
"import": "./dist/text/index.js",
|
||||
"types": "./dist/text/index.d.ts"
|
||||
|
||||
@@ -17,6 +17,31 @@ function makeTempProject(files: Record<string, string>): string {
|
||||
}
|
||||
|
||||
describe("bundleToSingleHtml", () => {
|
||||
it("does not merge author scripts into the runtime bootstrap placeholder", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180">
|
||||
<canvas id="scene"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
const canvas = document.getElementById("scene");
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.main = { duration: () => 1, seek() {}, pause() {} };
|
||||
</script>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
const runtimeBlock = bundled.match(
|
||||
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i,
|
||||
)?.[0];
|
||||
|
||||
expect(runtimeBlock).toBeDefined();
|
||||
expect(runtimeBlock).not.toContain("getElementById");
|
||||
expect(bundled).toContain('document.getElementById("scene")');
|
||||
});
|
||||
|
||||
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join, resolve, isAbsolute, sep } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { transformSync } from "esbuild";
|
||||
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
import {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
|
||||
/**
|
||||
* Parse an HTML string into a document. Fragments (without a full document
|
||||
* structure) are wrapped in `<!DOCTYPE html><html><head></head><body>…</body></html>`
|
||||
* so that linkedom places the content inside `document.body`.
|
||||
*/
|
||||
function parseHTMLContent(html: string): Document {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
||||
return parseHTML(html).document;
|
||||
}
|
||||
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
||||
}
|
||||
|
||||
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
|
||||
function safePath(projectDir: string, relativePath: string): string | null {
|
||||
const resolved = resolve(projectDir, relativePath);
|
||||
@@ -28,40 +19,8 @@ function safePath(projectDir: string, relativePath: string): string | null {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
|
||||
const DEFAULT_RUNTIME_SCRIPT_URL = "";
|
||||
|
||||
function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
const runtimeSrcMarkers = [
|
||||
"hyperframe.runtime.iife.js",
|
||||
"hyperframe-runtime.modular-runtime.inline.js",
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
];
|
||||
const runtimeInlineMarkers = [
|
||||
"__hyperframeRuntimeBootstrapped",
|
||||
"__hyperframeRuntime",
|
||||
"__hyperframeRuntimeTeardown",
|
||||
"window.__player =",
|
||||
"window.__playerReady",
|
||||
"window.__renderReady",
|
||||
];
|
||||
|
||||
const shouldStrip = (block: string): boolean => {
|
||||
const lowered = block.toLowerCase();
|
||||
for (const marker of runtimeSrcMarkers) {
|
||||
if (lowered.includes(marker.toLowerCase())) return true;
|
||||
}
|
||||
for (const marker of runtimeInlineMarkers) {
|
||||
if (block.includes(marker)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
|
||||
}
|
||||
|
||||
function getRuntimeScriptUrl(): string {
|
||||
const configured = (process.env.HYPERFRAME_RUNTIME_URL || "").trim();
|
||||
return configured || DEFAULT_RUNTIME_SCRIPT_URL;
|
||||
@@ -76,6 +35,11 @@ function injectInterceptor(html: string): string {
|
||||
if (sanitized.includes("</head>")) {
|
||||
return sanitized.replace("</head>", `${tag}\n</head>`);
|
||||
}
|
||||
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
|
||||
if (htmlOpenMatch?.index != null) {
|
||||
const insertPos = htmlOpenMatch.index + htmlOpenMatch[0].length;
|
||||
return `${sanitized.slice(0, insertPos)}<head>${tag}</head>${sanitized.slice(insertPos)}`;
|
||||
}
|
||||
const doctypeIdx = sanitized.toLowerCase().indexOf("<!doctype");
|
||||
if (doctypeIdx >= 0) {
|
||||
const insertPos = sanitized.indexOf(">", doctypeIdx) + 1;
|
||||
@@ -299,8 +263,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
|
||||
}
|
||||
|
||||
const bodyInlineScripts = [...document.querySelectorAll("body script")].filter((el) => {
|
||||
const src = (el.getAttribute("src") || "").trim();
|
||||
if (src) return false;
|
||||
if (el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR) || el.hasAttribute("src")) return false;
|
||||
const type = (el.getAttribute("type") || "").trim().toLowerCase();
|
||||
return !type || type === "text/javascript" || type === "application/javascript";
|
||||
});
|
||||
@@ -362,7 +325,7 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
|
||||
const withInterceptor = injectInterceptor(compiled);
|
||||
const { document } = parseHTML(withInterceptor);
|
||||
const document = parseHTMLContent(withInterceptor);
|
||||
|
||||
// Inline local CSS
|
||||
const localCssChunks: string[] = [];
|
||||
|
||||
@@ -20,4 +20,14 @@ describe("compileHtml", () => {
|
||||
expect(compiled).toContain('data-duration="3.125"');
|
||||
expect(compiled).toContain('data-end="3.125"');
|
||||
});
|
||||
|
||||
it("preserves explicit media durations when probe precision differs slightly", async () => {
|
||||
const html =
|
||||
'<audio id="click" src="click.mp3" data-start="0" data-duration="1.044898" data-end="1.044898">';
|
||||
|
||||
const compiled = await compileHtml(html, "/project", async () => 1);
|
||||
|
||||
expect(compiled).toContain('data-duration="1.044898"');
|
||||
expect(compiled).toContain('data-end="1.044898"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
shouldClampMediaDuration,
|
||||
type ResolvedDuration,
|
||||
} from "./timingCompiler";
|
||||
|
||||
@@ -76,7 +77,7 @@ export async function compileHtml(
|
||||
if (fileDuration <= 0) continue;
|
||||
|
||||
const maxDuration = fileDuration - el.mediaStart;
|
||||
if (maxDuration > 0 && el.duration > maxDuration) {
|
||||
if (maxDuration > 0 && shouldClampMediaDuration(el.duration, maxDuration)) {
|
||||
clampList.push({ id: el.id, duration: maxDuration });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
injectScriptsAtHeadStart,
|
||||
injectScriptsIntoHtml,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument.js";
|
||||
|
||||
describe("htmlDocument helpers", () => {
|
||||
it("wraps fragments before parsing", () => {
|
||||
const doc = parseHTMLContent("<template><span>hello</span></template>");
|
||||
expect(doc.body.querySelector("template")?.innerHTML).toContain("<span>hello</span>");
|
||||
});
|
||||
|
||||
it("strips every known embedded HyperFrames runtime marker", () => {
|
||||
const html = `
|
||||
<script src="hyperframe.runtime.iife.js"></script>
|
||||
<script src="hyperframes-runtime.modular.inline.js"></script >
|
||||
<script src="hyperframe-runtime.modular-runtime.inline.js"></script>
|
||||
<script data-hyperframes-preview-runtime="1"></script>
|
||||
<script>window.__playerReady = true;</script >
|
||||
<script>window.authored = true;</script>`;
|
||||
|
||||
const stripped = stripEmbeddedRuntimeScripts(html);
|
||||
|
||||
expect(stripped).not.toContain("hyperframe.runtime.iife.js");
|
||||
expect(stripped).not.toContain("hyperframes-runtime.modular.inline.js");
|
||||
expect(stripped).not.toContain("hyperframe-runtime.modular-runtime.inline.js");
|
||||
expect(stripped).not.toContain("data-hyperframes-preview-runtime");
|
||||
expect(stripped).not.toContain("window.__playerReady");
|
||||
expect(stripped).toContain("window.authored = true");
|
||||
});
|
||||
|
||||
it("does not treat non-script tags as scripts when stripping runtimes", () => {
|
||||
const html = "<scripture>window.__playerReady = true;</scripture>";
|
||||
|
||||
expect(stripEmbeddedRuntimeScripts(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("injects head and body scripts without replacement-token interpolation", () => {
|
||||
const html = "<html><head></head><body></body></html>";
|
||||
const injected = injectScriptsIntoHtml(html, ["window.x = '$&';"], ["window.y = '$&';"]);
|
||||
|
||||
expect(injected).toContain("<script>window.x = '$&';</script>\n</head>");
|
||||
expect(injected).toContain("<script>window.y = '$&';</script>\n</body>");
|
||||
});
|
||||
|
||||
it("injects early head scripts before authored head scripts", () => {
|
||||
const html = '<html><head><script id="authored"></script></head><body></body></html>';
|
||||
const injected = injectScriptsAtHeadStart(html, ["window.early = true;"]);
|
||||
|
||||
expect(injected.indexOf("window.early = true")).toBeLessThan(injected.indexOf('id="authored"'));
|
||||
});
|
||||
|
||||
it("escapes inline scripts so authored script text cannot break out of the wrapper tag", () => {
|
||||
const html = "<html><head></head><body></body></html>";
|
||||
const injected = injectScriptsIntoHtml(
|
||||
html,
|
||||
['window.payload = "</script ><script>window.pwned = true;</script>";'],
|
||||
["window.comment = '<!-- kept as script text';"],
|
||||
);
|
||||
|
||||
expect(injected).toContain("<\\/script ><script>window.pwned = true;<\\/script>");
|
||||
expect(injected).toContain("<\\!-- kept as script text");
|
||||
expect(injected).not.toContain("</script ><script>window.pwned = true;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
|
||||
|
||||
const RUNTIME_SRC_MARKERS = [
|
||||
"hyperframe.runtime.iife.js",
|
||||
"hyperframes-runtime.modular.inline.js",
|
||||
"hyperframe-runtime.modular-runtime.inline.js",
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
];
|
||||
|
||||
const RUNTIME_INLINE_MARKERS = [
|
||||
"__hyperframeRuntimeBootstrapped",
|
||||
"__hyperframeRuntime",
|
||||
"__hyperframeRuntimeTeardown",
|
||||
"window.__player =",
|
||||
"window.__playerReady",
|
||||
"window.__renderReady",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a full HTML document or wrap a fragment so linkedom consistently puts
|
||||
* fragment content under document.body.
|
||||
*/
|
||||
export function parseHTMLContent(html: string): Document {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
||||
return parseHTML(html).document;
|
||||
}
|
||||
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
||||
}
|
||||
|
||||
export function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const loweredHtml = html.toLowerCase();
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < html.length) {
|
||||
const scriptStart = findScriptStart(loweredHtml, cursor);
|
||||
if (scriptStart === -1) {
|
||||
output += html.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
output += html.slice(cursor, scriptStart);
|
||||
const startTagEnd = findTagEnd(html, scriptStart + 1);
|
||||
if (startTagEnd === -1) {
|
||||
output += html.slice(scriptStart);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeTagEnd = findScriptCloseTagEnd(loweredHtml, startTagEnd + 1);
|
||||
const scriptEnd = closeTagEnd === -1 ? html.length : closeTagEnd;
|
||||
const block = html.slice(scriptStart, scriptEnd);
|
||||
if (!shouldStripRuntimeScriptBlock(block)) {
|
||||
output += block;
|
||||
}
|
||||
cursor = scriptEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function findScriptStart(loweredHtml: string, from: number): number {
|
||||
let index = loweredHtml.indexOf("<script", from);
|
||||
while (index !== -1) {
|
||||
const next = loweredHtml[index + "<script".length] ?? "";
|
||||
if (isTagBoundary(next)) return index;
|
||||
index = loweredHtml.indexOf("<script", index + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTagEnd(html: string, from: number): number {
|
||||
let quote: string | undefined;
|
||||
for (let index = from; index < html.length; index += 1) {
|
||||
const char = html[index];
|
||||
if (quote) {
|
||||
if (char === quote) quote = undefined;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === ">") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
|
||||
let index = loweredHtml.indexOf("</script", from);
|
||||
while (index !== -1) {
|
||||
const closeTagEnd = findScriptCloseTagBoundary(loweredHtml, index + "</script".length);
|
||||
if (closeTagEnd !== -1) return closeTagEnd;
|
||||
index = loweredHtml.indexOf("</script", index + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findScriptCloseTagBoundary(loweredHtml: string, from: number): number {
|
||||
let cursor = from;
|
||||
while (cursor < loweredHtml.length && isHtmlWhitespace(loweredHtml[cursor] ?? "")) {
|
||||
cursor += 1;
|
||||
}
|
||||
return loweredHtml[cursor] === ">" ? cursor + 1 : -1;
|
||||
}
|
||||
|
||||
function shouldStripRuntimeScriptBlock(block: string): boolean {
|
||||
const lowered = block.toLowerCase();
|
||||
for (const marker of RUNTIME_SRC_MARKERS) {
|
||||
if (lowered.includes(marker.toLowerCase())) return true;
|
||||
}
|
||||
for (const marker of RUNTIME_INLINE_MARKERS) {
|
||||
if (block.includes(marker)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTagBoundary(char: string): boolean {
|
||||
return char === "" || char === ">" || char === "/" || isHtmlWhitespace(char);
|
||||
}
|
||||
|
||||
function isHtmlWhitespace(char: string): boolean {
|
||||
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
|
||||
}
|
||||
|
||||
function escapeInlineScriptSource(source: string): string {
|
||||
return escapeCaseInsensitiveToken(
|
||||
escapeCaseInsensitiveToken(source, "</script", "<\\/script"),
|
||||
"<!--",
|
||||
"<\\!--",
|
||||
);
|
||||
}
|
||||
|
||||
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
|
||||
const loweredSource = source.toLowerCase();
|
||||
const loweredToken = token.toLowerCase();
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < source.length) {
|
||||
const tokenStart = loweredSource.indexOf(loweredToken, cursor);
|
||||
if (tokenStart === -1) {
|
||||
output += source.slice(cursor);
|
||||
break;
|
||||
}
|
||||
output += source.slice(cursor, tokenStart) + replacement;
|
||||
cursor = tokenStart + token.length;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function inlineScriptTags(scripts: readonly string[]): string {
|
||||
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
|
||||
}
|
||||
|
||||
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
|
||||
if (scripts.length === 0) return html;
|
||||
const headTags = inlineScriptTags(scripts);
|
||||
if (html.includes("<head")) {
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
|
||||
}
|
||||
if (html.includes("<body")) {
|
||||
return html.replace("<body", () => `${headTags}\n<body`);
|
||||
}
|
||||
return `${headTags}\n${html}`;
|
||||
}
|
||||
|
||||
export function injectScriptsIntoHtml(
|
||||
html: string,
|
||||
headScripts: readonly string[],
|
||||
bodyScripts: readonly string[],
|
||||
stripEmbeddedRuntime = true,
|
||||
): string {
|
||||
if (stripEmbeddedRuntime) {
|
||||
html = stripEmbeddedRuntimeScripts(html);
|
||||
}
|
||||
|
||||
if (headScripts.length > 0) {
|
||||
const headTags = inlineScriptTags(headScripts);
|
||||
if (html.includes("</head>")) {
|
||||
// Function replacement avoids `$&` interpolation in runtime source.
|
||||
html = html.replace("</head>", () => `${headTags}\n</head>`);
|
||||
} else if (html.includes("<body")) {
|
||||
html = html.replace("<body", () => `${headTags}\n<body`);
|
||||
} else {
|
||||
html = `${headTags}\n${html}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyScripts.length > 0) {
|
||||
const bodyTags = inlineScriptTags(bodyScripts);
|
||||
if (html.includes("</body>")) {
|
||||
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
||||
} else {
|
||||
html = `${html}\n${bodyTags}`;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
shouldClampMediaDuration,
|
||||
type UnresolvedElement,
|
||||
type ResolvedDuration,
|
||||
type ResolvedMediaElement,
|
||||
@@ -16,6 +17,14 @@ export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
// HTML bundler (Node.js — requires fs, linkedom, esbuild)
|
||||
export { bundleToSingleHtml, type BundleOptions } from "./htmlBundler";
|
||||
|
||||
export {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
injectScriptsAtHeadStart,
|
||||
injectScriptsIntoHtml,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
|
||||
// Static guard
|
||||
export {
|
||||
validateHyperframeHtmlContract,
|
||||
|
||||
@@ -25,6 +25,14 @@ describe("compileTimingAttrs", () => {
|
||||
expect(unresolved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks muted videos as visual-only audio sources", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3" muted playsinline>';
|
||||
const { html: compiled } = compileTimingAttrs(html);
|
||||
|
||||
expect(compiled).toContain('data-has-audio="false"');
|
||||
expect(compiled).not.toContain('data-has-audio="true"');
|
||||
});
|
||||
|
||||
it("marks video as unresolved when data-duration and data-end are missing", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="1">';
|
||||
const { unresolved } = compileTimingAttrs(html);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Guarantees every timed element gets:
|
||||
* - id on media elements when missing
|
||||
* - data-end (computed from data-start + data-duration when possible)
|
||||
* - data-has-audio="true" on <video> elements
|
||||
* - data-has-audio on <video> elements (false for muted visual-only videos)
|
||||
*
|
||||
* For elements without data-duration (e.g. videos relying on source duration),
|
||||
* this compiler identifies them as "unresolved" so the caller can provide
|
||||
@@ -48,6 +48,13 @@ export interface CompilationResult {
|
||||
unresolved: UnresolvedElement[];
|
||||
}
|
||||
|
||||
// ffprobe precision can differ slightly across local and CI media stacks.
|
||||
const MEDIA_DURATION_CLAMP_EPSILON_SECONDS = 0.05;
|
||||
|
||||
export function shouldClampMediaDuration(declaredDuration: number, maxDuration: number): boolean {
|
||||
return declaredDuration > maxDuration + MEDIA_DURATION_CLAMP_EPSILON_SECONDS;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getAttr(tag: string, attr: string): string | null {
|
||||
@@ -101,9 +108,11 @@ function compileTag(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add data-has-audio="true" to <video> elements
|
||||
// 2. Add data-has-audio to <video> elements. Muted videos are visual-only by
|
||||
// contract; audible media should be represented by either an unmuted video
|
||||
// with data-has-audio="true" or a separate <audio> element.
|
||||
if (isVideo && !hasAttr(result, "data-has-audio")) {
|
||||
result = injectAttr(result, "data-has-audio", "true");
|
||||
result = injectAttr(result, "data-has-audio", hasAttr(result, "muted") ? "false" : "true");
|
||||
}
|
||||
|
||||
return { tag: result, unresolved };
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("@hyperframes/core public API exports", () => {
|
||||
expect(typeof core.injectDurations).toBe("function");
|
||||
expect(typeof core.extractResolvedMedia).toBe("function");
|
||||
expect(typeof core.clampDurations).toBe("function");
|
||||
expect(typeof core.shouldClampMediaDuration).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export {
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
shouldClampMediaDuration,
|
||||
} from "./compiler/timingCompiler";
|
||||
|
||||
// Lint
|
||||
|
||||
@@ -630,4 +630,18 @@ describe("composition rules", () => {
|
||||
expect(gsapFinding).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("root_composition_missing_data_start", () => {
|
||||
it("does not warn for template-wrapped sub-composition files", () => {
|
||||
const html = `
|
||||
<template id="foo-template">
|
||||
<div data-composition-id="foo" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="1"></div>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "root_composition_missing_data_start");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,8 +303,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// root_composition_missing_data_start
|
||||
({ rootTag }) => {
|
||||
({ rootTag, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (options.isSubComposition) return findings;
|
||||
if (!rootTag) return findings;
|
||||
const compId = readAttr(rootTag.raw, "data-composition-id");
|
||||
if (!compId) return findings;
|
||||
|
||||
@@ -582,6 +582,27 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not error on repeat: -1 inside JavaScript comments", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// avoid repeat:-1 anywhere in user code
|
||||
/*
|
||||
This rule should still allow comments mentioning repeat: -1.
|
||||
*/
|
||||
tl.to("#spinner", { rotation: 360, duration: 0.8, repeat: 4, ease: "none" }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT report overlapping_gsap_tweens when an object-target tween is interleaved (regression)", () => {
|
||||
// Regression: a non-DOM-targeting tween like `tl.to({ _: 0 }, …)` (used to
|
||||
// anchor timeline duration) was matched by the regex but skipped by the
|
||||
|
||||
@@ -27,6 +27,70 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
||||
|
||||
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
||||
|
||||
function stripJsComments(source: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
let escaped = false;
|
||||
|
||||
while (i < source.length) {
|
||||
const ch = source[i] ?? "";
|
||||
const next = source[i + 1] ?? "";
|
||||
|
||||
if (quote) {
|
||||
out += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === quote) {
|
||||
quote = null;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "/") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
|
||||
out += " ";
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "*") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
while (i < source.length) {
|
||||
const blockCh = source[i] ?? "";
|
||||
const blockNext = source[i + 1] ?? "";
|
||||
if (blockCh === "*" && blockNext === "/") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function countClassUsage(tags: OpenTag[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const tag of tags) {
|
||||
@@ -685,7 +749,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const content = stripJsComments(script.content);
|
||||
// Match repeat: -1 in GSAP tweens or timeline configs
|
||||
const pattern = /repeat\s*:\s*-1(?!\d)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
export { isLottieAnimationLoaded } from "./lottieReadiness";
|
||||
|
||||
/**
|
||||
* Lottie adapter for HyperFrames
|
||||
@@ -187,24 +188,3 @@ interface LottieWindow extends Window {
|
||||
/** Compositions register their Lottie animation instances here for the adapter to seek. */
|
||||
__hfLottie?: Array<LottieWebAnimation | DotLottiePlayer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a registered Lottie animation has finished loading its JSON source.
|
||||
*
|
||||
* Handles both supported player shapes:
|
||||
* - `lottie-web` exposes a boolean `isLoaded` property on `AnimationItem`.
|
||||
* - `@dotlottie/player-component` doesn't have `isLoaded`; we infer readiness
|
||||
* from `totalFrames > 0` since that value is only populated once the
|
||||
* manifest/animation JSON has been parsed.
|
||||
*
|
||||
* Exported so consumers (e.g. the studio "Loading assets…" overlay) share a
|
||||
* single source of truth instead of re-implementing the duck-typing per call
|
||||
* site.
|
||||
*/
|
||||
export function isLottieAnimationLoaded(anim: unknown): boolean {
|
||||
if (typeof anim !== "object" || anim === null) return true; // unknown shape — don't block
|
||||
const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
|
||||
if (maybe.isLoaded === true) return true;
|
||||
if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLottieAnimationLoaded } from "./lottieReadiness.js";
|
||||
|
||||
describe("isLottieAnimationLoaded", () => {
|
||||
it("does not block on unknown shapes", () => {
|
||||
expect(isLottieAnimationLoaded(null)).toBe(true);
|
||||
expect(isLottieAnimationLoaded("not-an-animation")).toBe(true);
|
||||
});
|
||||
|
||||
it("supports lottie-web isLoaded", () => {
|
||||
expect(isLottieAnimationLoaded({ isLoaded: true })).toBe(true);
|
||||
expect(isLottieAnimationLoaded({ isLoaded: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("supports dotLottie totalFrames readiness", () => {
|
||||
expect(isLottieAnimationLoaded({ totalFrames: 12 })).toBe(true);
|
||||
expect(isLottieAnimationLoaded({ totalFrames: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Whether a registered Lottie animation has finished loading its JSON source.
|
||||
*
|
||||
* Handles both supported player shapes:
|
||||
* - `lottie-web` exposes a boolean `isLoaded` property on `AnimationItem`.
|
||||
* - `@dotlottie/player-component` doesn't have `isLoaded`; readiness is
|
||||
* inferred from `totalFrames > 0` after manifest/animation JSON parsing.
|
||||
*/
|
||||
export function isLottieAnimationLoaded(anim: unknown): boolean {
|
||||
if (typeof anim !== "object" || anim === null) return true;
|
||||
const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
|
||||
if (maybe.isLoaded === true) return true;
|
||||
if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1336,8 +1336,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Composition hosts must respect both the authored clip window in the parent
|
||||
// composition and the child composition's own live timeline duration.
|
||||
// Composition hosts must respect both the authored parent clip window
|
||||
// and the child composition's own live timeline duration.
|
||||
if (duration != null && duration > 0 && liveDuration != null) {
|
||||
duration = Math.min(duration, liveDuration);
|
||||
} else if ((duration == null || duration <= 0) && liveDuration != null) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface ScreenshotClip {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function getElementScreenshotClip(selector: string): ScreenshotClip | undefined {
|
||||
const el = document.querySelector(selector);
|
||||
if (!(el instanceof HTMLElement)) return undefined;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 4 || rect.height < 4) return undefined;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, rect.left - pad);
|
||||
const y = Math.max(0, rect.top - pad);
|
||||
const maxWidth = window.innerWidth - x;
|
||||
const maxHeight = window.innerHeight - y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
||||
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
||||
};
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } fr
|
||||
export { isSafePath, walkDir } from "./helpers/safePath.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
|
||||
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
|
||||
|
||||
@@ -13,32 +13,33 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
const CLEANUP_INTERVAL_MS = 60_000;
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (
|
||||
const cleanupEnabled = () =>
|
||||
typeof process !== "undefined" &&
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
!process.argv.includes("build")
|
||||
) {
|
||||
cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, job] of renderJobs) {
|
||||
if (
|
||||
(job.status === "complete" || job.status === "failed") &&
|
||||
now - job.createdAt > TTL_MS
|
||||
) {
|
||||
renderJobs.delete(key);
|
||||
}
|
||||
!process.argv.includes("build");
|
||||
|
||||
const cleanupFinishedJobs = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, job] of renderJobs) {
|
||||
if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
|
||||
renderJobs.delete(key);
|
||||
}
|
||||
// Self-cleanup when no jobs remain
|
||||
if (renderJobs.size === 0 && cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
// Prevent the timer from keeping the process alive
|
||||
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
||||
}
|
||||
if (renderJobs.size === 0 && cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCleanupTimer = () => {
|
||||
if (cleanupTimer || !cleanupEnabled()) return;
|
||||
cleanupTimer = setInterval(cleanupFinishedJobs, CLEANUP_INTERVAL_MS);
|
||||
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ensureCleanupTimer();
|
||||
|
||||
// Start a render
|
||||
api.post("/projects/:id/render", async (c) => {
|
||||
@@ -78,27 +79,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
|
||||
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
|
||||
|
||||
// Restart cleanup timer if needed
|
||||
if (!cleanupTimer && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, job] of renderJobs) {
|
||||
if (
|
||||
(job.status === "complete" || job.status === "failed") &&
|
||||
now - job.createdAt > TTL_MS
|
||||
) {
|
||||
renderJobs.delete(key);
|
||||
}
|
||||
}
|
||||
if (renderJobs.size === 0 && cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
ensureCleanupTimer();
|
||||
|
||||
return c.json({ jobId, status: "rendering" });
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import { injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -34,74 +35,6 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".otf": "font/otf",
|
||||
};
|
||||
|
||||
function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
const runtimeSrcMarkers = [
|
||||
"hyperframe.runtime.iife.js",
|
||||
"hyperframes-runtime.modular.inline.js",
|
||||
"data-hyperframes-preview-runtime",
|
||||
];
|
||||
const runtimeInlineMarkers = [
|
||||
"__hyperframeRuntimeBootstrapped",
|
||||
"__hyperframeRuntime",
|
||||
"__hyperframeRuntimeTeardown",
|
||||
"window.__player =",
|
||||
"window.__playerReady",
|
||||
"window.__renderReady",
|
||||
];
|
||||
|
||||
const shouldStrip = (block: string): boolean => {
|
||||
const lowered = block.toLowerCase();
|
||||
for (const marker of runtimeSrcMarkers) {
|
||||
if (lowered.includes(marker.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const marker of runtimeInlineMarkers) {
|
||||
if (block.includes(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
|
||||
}
|
||||
|
||||
function injectScriptsIntoHtml(
|
||||
html: string,
|
||||
headScripts: string[],
|
||||
bodyScripts: string[],
|
||||
stripEmbedded: boolean,
|
||||
): string {
|
||||
if (stripEmbedded) {
|
||||
html = stripEmbeddedRuntimeScripts(html);
|
||||
}
|
||||
|
||||
if (headScripts.length > 0) {
|
||||
const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
|
||||
if (html.includes("</head>")) {
|
||||
html = html.replace("</head>", () => `${headTags}\n</head>`);
|
||||
} else if (html.includes("<body")) {
|
||||
html = html.replace("<body", () => `${headTags}\n<body`);
|
||||
} else {
|
||||
html = headTags + "\n" + html;
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyScripts.length > 0) {
|
||||
const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
|
||||
if (html.includes("</body>")) {
|
||||
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
||||
} else {
|
||||
html = html + "\n" + bodyTags;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export interface FileServerOptions {
|
||||
projectDir: string;
|
||||
compiledDir?: string;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
function parseHTMLContent(html: string): Document {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
||||
return parseHTML(html).document;
|
||||
}
|
||||
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
||||
}
|
||||
import { parseHTMLContent } from "@hyperframes/core/compiler";
|
||||
|
||||
function getSingleMeaningfulChild(container: Element): Element | null {
|
||||
let child: Element | null = null;
|
||||
|
||||
@@ -1,35 +1 @@
|
||||
export const MEDIA_VISUAL_STYLE_PROPERTIES = [
|
||||
"width",
|
||||
"height",
|
||||
"top",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"inset",
|
||||
"object-fit",
|
||||
"object-position",
|
||||
"z-index",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"filter",
|
||||
"mix-blend-mode",
|
||||
"backdrop-filter",
|
||||
"border-radius",
|
||||
"overflow",
|
||||
"clip-path",
|
||||
"mask",
|
||||
"mask-image",
|
||||
"mask-size",
|
||||
"mask-position",
|
||||
"mask-repeat",
|
||||
"transform",
|
||||
"transform-origin",
|
||||
"box-sizing",
|
||||
] as const;
|
||||
|
||||
export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
|
||||
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
||||
const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
|
||||
const frameIndex = Math.floor(safeTime * safeFps + 1e-9);
|
||||
return frameIndex / safeFps;
|
||||
}
|
||||
export { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "@hyperframes/core";
|
||||
|
||||
@@ -12,8 +12,11 @@ import { serve } from "@hono/node-server";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { join, extname, resolve, sep } from "node:path";
|
||||
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
||||
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
||||
|
||||
export { injectScriptsAtHeadStart, injectScriptsIntoHtml };
|
||||
|
||||
type PathModuleLike = {
|
||||
resolve: (...segments: string[]) => string;
|
||||
sep: string;
|
||||
@@ -407,88 +410,6 @@ const HF_BRIDGE_SCRIPT = `(function() {
|
||||
}, 50);
|
||||
})();`;
|
||||
|
||||
function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
const runtimeSrcMarkers = [
|
||||
"hyperframe.runtime.iife.js",
|
||||
"hyperframe-runtime.modular-runtime.inline.js",
|
||||
"data-hyperframes-preview-runtime",
|
||||
];
|
||||
const runtimeInlineMarkers = [
|
||||
"__hyperframeRuntimeBootstrapped",
|
||||
"__hyperframeRuntime",
|
||||
"__hyperframeRuntimeTeardown",
|
||||
"window.__player =",
|
||||
"window.__playerReady",
|
||||
"window.__renderReady",
|
||||
];
|
||||
|
||||
const shouldStrip = (block: string): boolean => {
|
||||
const lowered = block.toLowerCase();
|
||||
for (const marker of runtimeSrcMarkers) {
|
||||
if (lowered.includes(marker.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const marker of runtimeInlineMarkers) {
|
||||
if (block.includes(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
|
||||
}
|
||||
|
||||
export function injectScriptsIntoHtml(
|
||||
html: string,
|
||||
headScripts: string[],
|
||||
bodyScripts: string[],
|
||||
stripEmbedded: boolean,
|
||||
): string {
|
||||
if (stripEmbedded) {
|
||||
html = stripEmbeddedRuntimeScripts(html);
|
||||
}
|
||||
|
||||
if (headScripts.length > 0) {
|
||||
const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
|
||||
if (html.includes("</head>")) {
|
||||
// Use function replacement to avoid $& interpolation in runtime source
|
||||
html = html.replace("</head>", () => `${headTags}\n</head>`);
|
||||
} else if (html.includes("<body")) {
|
||||
html = html.replace("<body", () => `${headTags}\n<body`);
|
||||
} else {
|
||||
html = headTags + "\n" + html;
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyScripts.length > 0) {
|
||||
const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
|
||||
if (html.includes("</body>")) {
|
||||
// Use function replacement to avoid $& interpolation in runtime source
|
||||
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
||||
} else {
|
||||
html = html + "\n" + bodyTags;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function injectScriptsAtHeadStart(html: string, scripts: string[]): string {
|
||||
if (scripts.length === 0) return html;
|
||||
const headTags = scripts.map((src) => `<script>${src}</script>`).join("\n");
|
||||
if (html.includes("<head")) {
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
|
||||
}
|
||||
if (html.includes("<body")) {
|
||||
return html.replace("<body", () => `${headTags}\n<body`);
|
||||
}
|
||||
return headTags + "\n" + html;
|
||||
}
|
||||
|
||||
export interface FileServerOptions {
|
||||
projectDir: string;
|
||||
compiledDir?: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
shouldClampMediaDuration,
|
||||
type ResolvedDuration,
|
||||
type UnresolvedElement,
|
||||
rewriteAssetPaths,
|
||||
@@ -236,7 +237,7 @@ async function compileHtmlFile(
|
||||
);
|
||||
const clampList: ResolvedDuration[] = [];
|
||||
for (const r of clampResults) {
|
||||
if (r.maxDuration > 0 && r.duration > r.maxDuration) {
|
||||
if (r.maxDuration > 0 && shouldClampMediaDuration(r.duration, r.maxDuration)) {
|
||||
clampList.push({ id: r.id, duration: r.maxDuration });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Producer-local timing compiler helpers.
|
||||
* Keep this aligned with core timing compiler behavior.
|
||||
*/
|
||||
|
||||
export interface UnresolvedElement {
|
||||
id: string;
|
||||
tagName: string;
|
||||
src?: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
duration?: number;
|
||||
mediaStart: number;
|
||||
compositionSrc?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedDuration {
|
||||
id: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface ResolvedMediaElement {
|
||||
id: string;
|
||||
tagName: string;
|
||||
src?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
mediaStart: number;
|
||||
}
|
||||
|
||||
export interface CompilationResult {
|
||||
html: string;
|
||||
unresolved: UnresolvedElement[];
|
||||
}
|
||||
|
||||
function getAttr(tag: string, attr: string): string | null {
|
||||
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
|
||||
return match ? (match[1] ?? null) : null;
|
||||
}
|
||||
|
||||
function hasAttr(tag: string, attr: string): boolean {
|
||||
return new RegExp(`${attr}=["']`).test(tag);
|
||||
}
|
||||
|
||||
function injectAttr(tag: string, attr: string, value: string): string {
|
||||
return tag.replace(/>$/, ` ${attr}="${value}">`);
|
||||
}
|
||||
|
||||
function compileTag(
|
||||
tag: string,
|
||||
isVideo: boolean,
|
||||
): { tag: string; unresolved: UnresolvedElement | null } {
|
||||
let result = tag;
|
||||
let unresolved: UnresolvedElement | null = null;
|
||||
const id = getAttr(result, "id");
|
||||
const startStr = getAttr(result, "data-start");
|
||||
const start = startStr !== null ? parseFloat(startStr) : 0;
|
||||
const mediaStartStr = getAttr(result, "data-media-start");
|
||||
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
|
||||
|
||||
if (!hasAttr(result, "data-end")) {
|
||||
const durationStr = getAttr(result, "data-duration");
|
||||
if (durationStr !== null) {
|
||||
result = injectAttr(result, "data-end", String(start + parseFloat(durationStr)));
|
||||
} else if (id) {
|
||||
unresolved = {
|
||||
id,
|
||||
tagName: isVideo ? "video" : "audio",
|
||||
src: getAttr(result, "src") ?? undefined,
|
||||
start,
|
||||
mediaStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo && !hasAttr(result, "data-has-audio")) {
|
||||
result = injectAttr(result, "data-has-audio", "true");
|
||||
}
|
||||
|
||||
return { tag: result, unresolved };
|
||||
}
|
||||
|
||||
export function compileTimingAttrs(html: string): CompilationResult {
|
||||
const unresolved: UnresolvedElement[] = [];
|
||||
|
||||
html = html.replace(/<video[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, true);
|
||||
if (u) {
|
||||
unresolved.push(u);
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
html = html.replace(/<audio[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, false);
|
||||
if (u) {
|
||||
unresolved.push(u);
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
|
||||
if (!hasAttr(match, "data-start")) {
|
||||
return match;
|
||||
}
|
||||
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) {
|
||||
return match;
|
||||
}
|
||||
const id = getAttr(match, "id");
|
||||
if (id) {
|
||||
const startStr = getAttr(match, "data-start");
|
||||
const compositionSrc = getAttr(match, "data-composition-src");
|
||||
unresolved.push({
|
||||
id,
|
||||
tagName: "div",
|
||||
start: startStr ? parseFloat(startStr) : 0,
|
||||
mediaStart: 0,
|
||||
compositionSrc: compositionSrc ?? undefined,
|
||||
});
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return { html, unresolved };
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of resolutions) {
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
let result = tag;
|
||||
if (!hasAttr(result, "data-duration")) {
|
||||
result = injectAttr(result, "data-duration", String(duration));
|
||||
}
|
||||
if (!hasAttr(result, "data-end")) {
|
||||
const startStr = getAttr(result, "data-start");
|
||||
const start = startStr ? parseFloat(startStr) : 0;
|
||||
result = injectAttr(result, "data-end", String(start + duration));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract video/audio elements that already have data-duration set.
|
||||
* Used by callers to validate declared durations against actual source durations.
|
||||
*/
|
||||
export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
|
||||
const resolved: ResolvedMediaElement[] = [];
|
||||
|
||||
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mediaRegex.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const id = getAttr(tag, "id");
|
||||
const durationStr = getAttr(tag, "data-duration");
|
||||
if (!id || durationStr === null) continue;
|
||||
|
||||
const duration = parseFloat(durationStr);
|
||||
if (!Number.isFinite(duration) || duration <= 0) continue;
|
||||
|
||||
const isVideo = /^<video/i.test(tag);
|
||||
const startStr = getAttr(tag, "data-start");
|
||||
const mediaStartStr = getAttr(tag, "data-media-start");
|
||||
|
||||
resolved.push({
|
||||
id,
|
||||
tagName: isVideo ? "video" : "audio",
|
||||
src: getAttr(tag, "src") ?? undefined,
|
||||
start: startStr !== null ? parseFloat(startStr) : 0,
|
||||
duration,
|
||||
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp existing data-duration and data-end on media elements.
|
||||
* For each resolution, replaces the declared duration with the clamped value
|
||||
* and recomputes data-end accordingly.
|
||||
*/
|
||||
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of clamps) {
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
// Replace data-duration value
|
||||
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
|
||||
|
||||
// Recompute data-end from data-start + clamped duration
|
||||
const startStr = getAttr(tag, "data-start");
|
||||
const start = startStr ? parseFloat(startStr) : 0;
|
||||
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
|
||||
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -93,14 +93,14 @@
|
||||
<body>
|
||||
<div id="main" data-composition-id="hdr-hlg-regression" data-start="0" data-duration="5" data-width="1920" data-height="1080">
|
||||
<!-- Window A · Static baseline HLG · 0.0–2.5s -->
|
||||
<video id="wa-video" class="clip hdr-video" data-start="0" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="2.5" data-has-audio="true"></video>
|
||||
<video id="wa-video" class="clip hdr-video" data-start="0" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="2.5" data-has-audio="false"></video>
|
||||
<div class="label label-tl clip" data-start="0" data-duration="2.5">
|
||||
A · HLG baseline + DOM overlay
|
||||
</div>
|
||||
|
||||
<!-- Window B · Wrapper opacity fade on HLG · 2.5–5.0s -->
|
||||
<div id="window-b-wrapper">
|
||||
<video id="wb-video" class="clip hdr-video" data-start="2.5" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="5" data-has-audio="true"></video>
|
||||
<video id="wb-video" class="clip hdr-video" data-start="2.5" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="5" data-has-audio="false"></video>
|
||||
</div>
|
||||
<div class="label label-tl clip" data-start="2.5" data-duration="2.5">
|
||||
B · HLG wrapper opacity fade
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<audio
|
||||
id="click-sfx"
|
||||
data-start="0"
|
||||
data-duration="1.044898"
|
||||
data-track-index="4"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/853dda23_70261e8b676444bfbfe147e34803851e.mp3"
|
||||
></audio>
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
<!-- Main A-roll and Stats (3s - End) -->
|
||||
<div id="main-layer" data-composition-id="main" data-start="0" data-duration="16.7" data-width="1920" data-height="1080" data-track-index="5" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5">
|
||||
<!-- A-roll Video -->
|
||||
<video id="aroll" src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/70648f53_abe84fb9991841ae8ba82ee21ba6d37e.mp4" data-start="0" data-duration="16.043" data-track-index="1" muted data-end="16.043" data-has-audio="true"></video>
|
||||
<video id="aroll" src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/70648f53_abe84fb9991841ae8ba82ee21ba6d37e.mp4" data-start="0" data-duration="16.043" data-track-index="1" muted data-end="16.043" data-has-audio="false"></video>
|
||||
|
||||
<!-- Statistics Overlay -->
|
||||
<div class="stats-container">
|
||||
|
||||
@@ -215,42 +215,49 @@
|
||||
id="sfx-bounce-0"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/4ea1246d_d3e9e87e46be4104817dcd75d0adcef0.mp3"
|
||||
data-start="0"
|
||||
data-duration="1.044898"
|
||||
data-track-index="5"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-whoosh-1"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
|
||||
data-start="1.839"
|
||||
data-duration="1.044898"
|
||||
data-track-index="6"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-pop-1"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
|
||||
data-start="2.039"
|
||||
data-duration="1.044898"
|
||||
data-track-index="7"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-whoosh-2"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
|
||||
data-start="4.659"
|
||||
data-duration="1.044898"
|
||||
data-track-index="6"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-pop-2"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
|
||||
data-start="4.859"
|
||||
data-duration="1.044898"
|
||||
data-track-index="7"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-whoosh-3"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
|
||||
data-start="8.88"
|
||||
data-duration="1.044898"
|
||||
data-track-index="6"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-pop-3"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
|
||||
data-start="9.08"
|
||||
data-duration="1.044898"
|
||||
data-track-index="7"
|
||||
></audio>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" data-composition-id="vfr-screen-recording" data-start="0" data-duration="3" data-width="480" data-height="332">
|
||||
<video id="clip" class="clip" data-start="0" data-duration="3" data-media-start="1" data-track-index="0" src="clip.mp4" muted playsinline data-end="3" data-has-audio="true"></video>
|
||||
<video id="clip" class="clip" data-start="0" data-duration="3" data-media-start="1" data-track-index="0" src="clip.mp4" muted playsinline data-end="3" data-has-audio="false"></video>
|
||||
|
||||
<div id="label">VFR</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
||||
// at module load, which throws under SSR. Defer the import to the mount effect
|
||||
@@ -15,30 +16,6 @@ interface HyperframesPlayerElement extends HTMLElement {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness check for a Lottie animation instance. Duck-types both supported
|
||||
* player shapes:
|
||||
*
|
||||
* - `lottie-web` exposes a boolean `isLoaded` on `AnimationItem`.
|
||||
* - `@dotlottie/player-component` doesn't; we infer readiness from
|
||||
* `totalFrames > 0` since that value is only populated once the animation
|
||||
* JSON has been parsed.
|
||||
*
|
||||
* Kept in sync with the runtime adapter's own checks in
|
||||
* `@hyperframes/core/runtime/adapters/lottie.ts` — that module would be a
|
||||
* more canonical home for the helper, but importing from the core package's
|
||||
* root index pulls Node-only submodules (path, url) into this browser bundle
|
||||
* and breaks Vite. If the helper grows, split a browser-safe submodule
|
||||
* export in core and switch this to import it.
|
||||
*/
|
||||
function isLottieAnimationReady(anim: unknown): boolean {
|
||||
if (typeof anim !== "object" || anim === null) return true;
|
||||
const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
|
||||
if (maybe.isLoaded === true) return true;
|
||||
if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
||||
// to play through without buffering, and every registered Lottie animation has
|
||||
// finished loading.
|
||||
@@ -62,7 +39,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
||||
const lotties = win.__hfLottie;
|
||||
if (lotties?.length) {
|
||||
for (const anim of lotties) {
|
||||
if (!isLottieAnimationReady(anim)) return true;
|
||||
if (!isLottieAnimationLoaded(anim)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
||||
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
|
||||
const THUMBNAIL_CACHE_VERSION = "v2";
|
||||
|
||||
interface ScreenshotClip {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ── Vite adapter for the shared studio API ───────────────────────────────────
|
||||
|
||||
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
||||
@@ -287,7 +294,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
}, opts.seekTime);
|
||||
await page.evaluate("document.fonts?.ready");
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
||||
let clip: ScreenshotClip | undefined;
|
||||
if (opts.selector) {
|
||||
clip = await page.evaluate((selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
|
||||
@@ -156,7 +156,9 @@ async function annotateFrame(pngBuf, elements) {
|
||||
|
||||
const measured = [];
|
||||
for (const el of elements) {
|
||||
if (isBBoxOutsideFrame(el.bbox, width, height)) continue;
|
||||
const bg = sampleRingMedian(raw, width, height, channels, el.bbox);
|
||||
if (!bg) continue;
|
||||
const fg = compositeOver(el.fg, bg); // flatten any alpha against measured bg
|
||||
const ratio = wcagRatio(fg, bg);
|
||||
const large = isLargeText(el.fontSize, el.fontWeight);
|
||||
@@ -167,6 +169,8 @@ async function annotateFrame(pngBuf, elements) {
|
||||
el.wcagAAA = large ? ratio >= 4.5 : ratio >= 7;
|
||||
measured.push(el);
|
||||
}
|
||||
elements.length = 0;
|
||||
elements.push(...measured);
|
||||
|
||||
// Draw boxes + ratio labels as an SVG overlay (sharp composite).
|
||||
const svg = buildOverlaySVG(measured, width, height);
|
||||
@@ -186,6 +190,7 @@ function sampleRingMedian(raw, width, height, channels, bbox) {
|
||||
const y0 = Math.max(0, Math.floor(bbox.y) - 4);
|
||||
const y1 = Math.min(height - 1, Math.ceil(bbox.y + bbox.h) + 4);
|
||||
const pushPixel = (x, y) => {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||||
const i = (y * width + x) * channels;
|
||||
r.push(raw[i]);
|
||||
g.push(raw[i + 1]);
|
||||
@@ -199,9 +204,14 @@ function sampleRingMedian(raw, width, height, channels, bbox) {
|
||||
pushPixel(x0, y);
|
||||
pushPixel(x1, y);
|
||||
}
|
||||
if (r.length === 0) return null;
|
||||
return [median(r), median(g), median(b), 1];
|
||||
}
|
||||
|
||||
function isBBoxOutsideFrame(bbox, width, height) {
|
||||
return bbox.x + bbox.w <= 0 || bbox.y + bbox.h <= 0 || bbox.x >= width || bbox.y >= height;
|
||||
}
|
||||
|
||||
function median(arr) {
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return s[Math.floor(s.length / 2)];
|
||||
|
||||
Reference in New Issue
Block a user