Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/cli/src/commands/compositions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
42 changes: 36 additions & 6 deletions packages/cli/src/commands/compositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -81,28 +105,31 @@ function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
return compositions;
}

function parseSubComposition(
export function parseSubComposition(
html: string,
fallbackId: string,
fallbackWidth: number,
fallbackHeight: number,
): 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;
Expand Down Expand Up @@ -133,6 +160,9 @@ function parseSubComposition(
}
});
}
if (duration <= 0) {
duration = estimateDurationFromScripts(searchRoot);
}

return { id, duration, width, height, elementCount };
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down
61 changes: 7 additions & 54 deletions packages/cli/src/commands/layout.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
45 changes: 6 additions & 39 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[] = [];

Expand All @@ -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,
});
Expand Down Expand Up @@ -414,7 +381,7 @@ async function captureSnapshots(
await chromeBrowser.close();
}
} finally {
server.close();
await server.close();
}

return savedPaths;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
39 changes: 9 additions & 30 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading