From 36bcededf47af437b74964bcfca512351c7eaee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 18:38:00 -0400 Subject: [PATCH] feat: add layout audit command --- docs/packages/cli.mdx | 35 ++ packages/cli/scripts/build-copy.mjs | 6 + packages/cli/src/cli.ts | 1 + .../cli/src/commands/layout-audit.browser.js | 329 ++++++++++++++ packages/cli/src/commands/layout.ts | 414 ++++++++++++++++++ packages/cli/src/help.ts | 2 + packages/cli/src/utils/layoutAudit.test.ts | 86 ++++ packages/cli/src/utils/layoutAudit.ts | 163 +++++++ skills/hyperframes-cli/SKILL.md | 28 +- skills/hyperframes/SKILL.md | 15 + 10 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/layout-audit.browser.js create mode 100644 packages/cli/src/commands/layout.ts create mode 100644 packages/cli/src/utils/layoutAudit.test.ts create mode 100644 packages/cli/src/utils/layoutAudit.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 07575b3e2..3940d6b4f 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -19,6 +19,7 @@ npx hyperframes - Preview compositions with live hot reload (`preview`) - Render compositions to MP4 locally or in Docker (`render`) - Lint compositions for structural issues (`lint`) +- Audit rendered layout for text overflow and clipped containers (`layout`) - Capture key frames as PNG screenshots (`snapshot`) - Check your environment for missing dependencies (`doctor`) @@ -445,6 +446,40 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + ### `layout` + + Audit rendered layout across the composition timeline: + + ```bash + npx hyperframes layout [dir] + npx hyperframes layout [dir] --json + npx hyperframes layout [dir] --samples 15 + npx hyperframes layout [dir] --at 1.5,4,7.25 + ``` + + ``` + ◆ Auditing layout for my-project (9 timeline samples) + + ✗ text_box_overflow t=3.25s #headline inside .bubble overflowed right 18px — "Quarterly plan" + Fix: Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container. + + ◇ 1 error(s), 0 warning(s) + ``` + + `layout` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a timestamp, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. + + | Flag | Description | + |------|-------------| + | `--json` | Output agent-readable findings with `samples`, `issues`, bounding boxes, and summary counts | + | `--samples` | Number of midpoint samples across the composition duration (default: 9) | + | `--at` | Comma-separated timestamps in seconds for explicit hero-frame checks | + | `--tolerance` | Allowed pixel overflow before reporting an issue (default: 2) | + | `--timeout` | Ms to wait for runtime initialization (default: 5000) | + | `--max-issues` | Maximum findings to print or return (default: 80) | + | `--strict` | Exit non-zero on warnings as well as errors | + + Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. + ### `snapshot` Capture key frames from a composition as PNG screenshots — verify visual output without a full render: diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 15bd91161..9132c7095 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -58,6 +58,7 @@ async function main() { for (const sub of ["studio", "docs", "templates", "skills", "docker"]) { mkdirSync(join(DIST, sub), { recursive: true }); } + mkdirSync(join(DIST, "commands"), { recursive: true }); const studioDist = resolve(CLI_ROOT, "..", "studio", "dist"); await waitForStudioDist(studioDist); @@ -76,6 +77,11 @@ async function main() { cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render")); } + const layoutAuditScript = join(CLI_ROOT, "src", "commands", "layout-audit.browser.js"); + if (existsSync(layoutAuditScript)) { + cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js")); + } + copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs")); console.log("[build-copy] done"); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83528dbb0..ad84f7cba 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,7 @@ const subCommands = { publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), + layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), benchmark: () => import("./commands/benchmark.js").then((m) => m.default), diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js new file mode 100644 index 000000000..00bdc4ccc --- /dev/null +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -0,0 +1,329 @@ +(function () { + const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]); + + function toRect(rect) { + return { + left: round(rect.left), + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + width: round(rect.width), + height: round(rect.height), + }; + } + + function round(value) { + return Math.round(value * 100) / 100; + } + + function overflowFor(subject, container, tolerance) { + const overflow = {}; + if (subject.left < container.left - tolerance) + overflow.left = round(container.left - subject.left); + if (subject.right > container.right + tolerance) + overflow.right = round(subject.right - container.right); + if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top); + if (subject.bottom > container.bottom + tolerance) + overflow.bottom = round(subject.bottom - container.bottom); + return Object.keys(overflow).length > 0 ? overflow : null; + } + + function escapeCss(value) { + if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); + return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + + function selectorFor(element) { + if (element.id) return `#${escapeCss(element.id)}`; + const dataName = + element.getAttribute("data-layout-name") || + element.getAttribute("data-composition-id") || + element.getAttribute("data-start"); + if (dataName) { + const attr = element.hasAttribute("data-layout-name") + ? "data-layout-name" + : element.hasAttribute("data-composition-id") + ? "data-composition-id" + : "data-start"; + return `${element.tagName.toLowerCase()}[${attr}="${escapeCss(dataName)}"]`; + } + const classes = Array.from(element.classList).slice(0, 2); + if (classes.length > 0) { + return `${element.tagName.toLowerCase()}.${classes.map(escapeCss).join(".")}`; + } + const parent = element.parentElement; + if (!parent) return element.tagName.toLowerCase(); + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + + function hasIgnoreFlag(element) { + return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); + } + + function hasAllowOverflowFlag(element) { + return !!element.closest("[data-layout-allow-overflow]"); + } + + function opacityChain(element) { + let opacity = 1; + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed)) opacity *= parsed; + } + return opacity; + } + + function isVisibleElement(element) { + if (IGNORE_TAGS.has(element.tagName)) return false; + if (hasIgnoreFlag(element)) return false; + const style = getComputedStyle(element); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.visibility === "collapse" + ) { + return false; + } + if (opacityChain(element) < 0.2) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0.5 && rect.height > 0.5; + } + + function textContentFor(element) { + return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element) { + const text = textContentFor(element); + if (!text) return false; + for (const child of Array.from(element.children)) { + if (isVisibleElement(child) && textContentFor(child)) return false; + } + return true; + } + + function textRectFor(element) { + const range = document.createRange(); + range.selectNodeContents(element); + const rects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ); + range.detach(); + if (rects.length === 0) return null; + + const union = rects.reduce( + (acc, rect) => ({ + left: Math.min(acc.left, rect.left), + top: Math.min(acc.top, rect.top), + right: Math.max(acc.right, rect.right), + bottom: Math.max(acc.bottom, rect.bottom), + }), + { + left: Number.POSITIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + }, + ); + + return toRect({ + ...union, + width: union.right - union.left, + height: union.bottom - union.top, + }); + } + + function parsePx(value) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + function hasPaint(style) { + const backgroundColor = style.backgroundColor || ""; + const hasBackground = + backgroundColor !== "" && + backgroundColor !== "transparent" && + !backgroundColor.endsWith(", 0)") && + backgroundColor !== "rgba(0, 0, 0, 0)"; + const hasImage = style.backgroundImage && style.backgroundImage !== "none"; + const hasBorder = + parsePx(style.borderTopWidth) + + parsePx(style.borderRightWidth) + + parsePx(style.borderBottomWidth) + + parsePx(style.borderLeftWidth) > + 0; + const hasRadius = + parsePx(style.borderTopLeftRadius) + + parsePx(style.borderTopRightRadius) + + parsePx(style.borderBottomRightRadius) + + parsePx(style.borderBottomLeftRadius) > + 0; + return hasBackground || hasImage || hasBorder || hasRadius; + } + + function clipsOverflow(style) { + return [style.overflowX, style.overflowY, style.overflow].some( + (value) => value && value !== "visible" && value !== "clip visible", + ); + } + + function isConstraintCandidate(element, root) { + if (element === root) return true; + const style = getComputedStyle(element); + if (clipsOverflow(style)) return true; + if (element.hasAttribute("data-layout-boundary")) return true; + if (!hasPaint(style)) return false; + const rect = element.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + const rootArea = rootRect.width * rootRect.height; + const area = rect.width * rect.height; + return area > 0 && area < rootArea * 0.95; + } + + function nearestConstraint(element, root) { + for ( + let current = element; + current && current !== document.body; + current = current.parentElement + ) { + if (!isVisibleElement(current)) continue; + if (isConstraintCandidate(current, root)) return current; + if (current === root) return current; + } + return root; + } + + function clippedTextIssue(element, time, tolerance) { + const style = getComputedStyle(element); + if (!clipsOverflow(style)) return null; + const overflowX = element.scrollWidth - element.clientWidth; + const overflowY = element.scrollHeight - element.clientHeight; + if (overflowX <= tolerance && overflowY <= tolerance) return null; + const overflow = {}; + if (overflowX > tolerance) overflow.right = round(overflowX); + if (overflowY > tolerance) overflow.bottom = round(overflowY); + const selector = selectorFor(element); + const text = textContentFor(element); + return { + code: "clipped_text", + severity: "error", + time, + selector, + text, + message: "Text content is clipped by its own box.", + rect: toRect(element.getBoundingClientRect()), + overflow, + fixHint: + "Increase the element width/height, reduce font-size, loosen letter-spacing, or use fitTextFontSize for dynamic copy.", + }; + } + + function textOverflowIssues(element, root, rootRect, time, tolerance) { + const textRect = textRectFor(element); + if (!textRect) return []; + const text = textContentFor(element); + const selector = selectorFor(element); + const issues = []; + + const container = nearestConstraint(element, root); + const containerRect = toRect(container.getBoundingClientRect()); + const containerOverflow = overflowFor(textRect, containerRect, tolerance); + if (containerOverflow && !hasAllowOverflowFlag(element)) { + issues.push({ + code: "text_box_overflow", + severity: "error", + time, + selector, + containerSelector: selectorFor(container), + text, + message: "Text extends outside its nearest visual/container box.", + rect: textRect, + containerRect, + overflow: containerOverflow, + fixHint: + "Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container.", + }); + } + + const canvasOverflow = overflowFor(textRect, rootRect, tolerance); + if (canvasOverflow && !hasAllowOverflowFlag(element)) { + issues.push({ + code: "canvas_overflow", + severity: "warning", + time, + selector, + containerSelector: selectorFor(root), + text, + message: "Text extends outside the composition canvas.", + rect: textRect, + containerRect: rootRect, + overflow: canvasOverflow, + fixHint: + "Move the text inward, reduce its size, or mark intentional off-canvas animation with data-layout-allow-overflow.", + }); + } + + return issues; + } + + function containerOverflowIssues(root, time, tolerance) { + const issues = []; + const containers = Array.from(root.querySelectorAll("*")).filter((element) => { + if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false; + const style = getComputedStyle(element); + return clipsOverflow(style) || element.hasAttribute("data-layout-boundary"); + }); + + for (const container of containers) { + const containerRect = toRect(container.getBoundingClientRect()); + for (const child of Array.from(container.children)) { + if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue; + const childRect = toRect(child.getBoundingClientRect()); + const overflow = overflowFor(childRect, containerRect, tolerance); + if (!overflow) continue; + issues.push({ + code: "container_overflow", + severity: "warning", + time, + selector: selectorFor(child), + containerSelector: selectorFor(container), + message: "Element extends outside a clipping layout container.", + rect: childRect, + containerRect, + overflow, + fixHint: + "Resize/reposition the child or container, or mark intentional overflow with data-layout-allow-overflow.", + }); + } + } + + return issues; + } + + window.__hyperframesLayoutAudit = function auditLayout(options) { + const time = options && typeof options.time === "number" ? options.time : 0; + const tolerance = + options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = toRect(root.getBoundingClientRect()); + const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); + const issues = []; + + for (const element of elements) { + if (!hasOwnTextCandidate(element)) continue; + const clipped = clippedTextIssue(element, time, tolerance); + if (clipped) issues.push(clipped); + issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance)); + } + + issues.push(...containerOverflowIssues(root, time, tolerance)); + return issues; + }; +})(); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts new file mode 100644 index 000000000..31da96e61 --- /dev/null +++ b/packages/cli/src/commands/layout.ts @@ -0,0 +1,414 @@ +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 { fileURLToPath } from "node:url"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { + buildLayoutSampleTimes, + dedupeLayoutIssues, + formatLayoutIssue, + summarizeLayoutIssues, + type LayoutIssue, +} from "../utils/layoutAudit.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SEEK_SETTLE_MS = 120; + +export const examples: Example[] = [ + ["Audit layout across the current composition", "hyperframes layout"], + ["Audit a specific project", "hyperframes layout ./my-video"], + ["Output agent-readable JSON", "hyperframes layout --json"], + ["Use explicit hero-frame timestamps", "hyperframes layout --at 1.5,4.0,7.25"], +]; + +interface LayoutAuditResult { + duration: number; + samples: number[]; + issues: LayoutIssue[]; +} + +async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + return page.evaluate(() => { + const win = window as unknown as { + __hf?: { duration?: number }; + __player?: { duration?: number | (() => number) }; + __timelines?: Record number) }>; + }; + if (typeof win.__hf?.duration === "number" && win.__hf.duration > 0) return win.__hf.duration; + const playerDuration = win.__player?.duration; + if (typeof playerDuration === "function") return playerDuration(); + if (typeof playerDuration === "number" && playerDuration > 0) return playerDuration; + + const root = document.querySelector("[data-composition-id][data-duration]"); + const attrDuration = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0; + if (attrDuration > 0) return attrDuration; + + const timelines = win.__timelines; + if (timelines) { + for (const timeline of Object.values(timelines)) { + const duration = timeline.duration; + if (typeof duration === "function") return duration(); + if (typeof duration === "number" && duration > 0) return duration; + } + } + + return 0; + }); +} + +async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { + await page.evaluate((t: number) => { + const win = window as unknown as { + __hf?: { seek?: (time: number) => void }; + __player?: { seek?: (time: number) => void }; + __timelines?: Record void; seek?: (time: number) => void }>; + }; + if (typeof win.__hf?.seek === "function") { + win.__hf.seek(t); + return; + } + if (typeof win.__player?.seek === "function") { + win.__player.seek(t); + return; + } + const timelines = win.__timelines; + if (timelines) { + for (const timeline of Object.values(timelines)) { + if (typeof timeline.pause === "function") timeline.pause(); + if (typeof timeline.seek === "function") timeline.seek(t); + } + } + }, time); + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); +} + +async function bundleProjectHtml(projectDir: string): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + let html = await bundleToSingleHtml(projectDir); + + const runtimePath = resolve( + __dirname, + "..", + "..", + "..", + "core", + "dist", + "hyperframe.runtime.iife.js", + ); + if (existsSync(runtimePath)) { + const runtimeSource = readFileSync(runtimePath, "utf-8"); + html = html.replace( + /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, + () => ``, + ); + } + + return html; +} + +async function serveProject( + projectDir: string, + html: string, +): Promise<{ + url: string; + close: () => Promise; +}> { + 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((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((resolveClose) => { + server.close(() => resolveClose()); + }), + }; +} + +async function alignViewportToComposition( + page: import("puppeteer-core").Page, + url: string, +): Promise { + const size = await page.evaluate(() => { + const root = document.querySelector("[data-composition-id][data-width][data-height]"); + const width = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0; + const height = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0; + return { + width: Number.isFinite(width) && width > 0 ? Math.min(width, 4096) : 1920, + height: Number.isFinite(height) && height > 0 ? Math.min(height, 4096) : 1080, + }; + }); + + await page.setViewport(size); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); +} + +async function runLayoutAudit( + projectDir: string, + opts: { samples: number; at?: number[]; timeout: number; tolerance: number; maxIssues: number }, +): Promise { + const { ensureBrowser } = await import("../browser/manager.js"); + const puppeteer = await import("puppeteer-core"); + const html = await bundleProjectHtml(projectDir); + const server = await serveProject(projectDir, html); + let chromeBrowser: import("puppeteer-core").Browser | undefined; + + try { + const browser = await ensureBrowser(); + chromeBrowser = await puppeteer.default.launch({ + headless: true, + executablePath: browser.executablePath, + args: [ + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--enable-webgl", + "--use-gl=angle", + "--use-angle=swiftshader", + ], + }); + + const page = await chromeBrowser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 }); + await alignViewportToComposition(page, server.url); + await page + .waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, { + timeout: opts.timeout, + }) + .catch(() => {}); + await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); + + const duration = await getCompositionDuration(page); + const samples = buildLayoutSampleTimes({ duration, samples: opts.samples, at: opts.at }); + if (samples.length === 0) return { duration, samples, issues: [] }; + + await page.addScriptTag({ content: loadLayoutAuditScript() }); + + const issues: LayoutIssue[] = []; + for (const time of samples) { + await seekTo(page, time); + const sampleIssues = await page.evaluate( + (auditOptions: { time: number; tolerance: number }) => { + const win = window as unknown as { + __hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[]; + }; + return win.__hyperframesLayoutAudit?.(auditOptions) ?? []; + }, + { time, tolerance: opts.tolerance }, + ); + issues.push(...(sampleIssues as LayoutIssue[])); + if (issues.length >= opts.maxIssues * 2) break; + } + + return { + duration, + samples, + issues: dedupeLayoutIssues(issues).slice(0, opts.maxIssues), + }; + } finally { + await chromeBrowser?.close().catch(() => {}); + await server.close(); + } +} + +function loadLayoutAuditScript(): string { + const candidates = [ + join(__dirname, "layout-audit.browser.js"), + join(__dirname, "commands", "layout-audit.browser.js"), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) return readFileSync(candidate, "utf-8"); + } + + throw new Error("Missing layout audit browser script"); +} + +function parseAt(value: unknown): number[] | undefined { + if (!value) return undefined; + const times = String(value) + .split(",") + .map((entry) => parseFloat(entry.trim())) + .filter((time) => Number.isFinite(time) && time >= 0); + return times.length > 0 ? times : undefined; +} + +export default defineCommand({ + meta: { + name: "layout", + description: "Audit rendered composition layout for text and container overflow", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for runtime to initialize (default: 5000)", + default: "5000", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return (default: 80)", + default: "80", + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); + const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); + const timeout = Math.max(500, parseInt(args.timeout as string, 10) || 5000); + const maxIssues = Math.max(1, parseInt(args["max-issues"] as string, 10) || 80); + const at = parseAt(args.at); + const strict = !!args.strict; + + if (!args.json) { + const sampleLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`; + console.log( + `${c.accent("◆")} Auditing layout for ${c.accent(project.name)} (${sampleLabel})`, + ); + } + + try { + const result = await runLayoutAudit(project.dir, { + samples, + at, + timeout, + tolerance, + maxIssues, + }); + const summary = summarizeLayoutIssues(result.issues); + const ok = summary.errorCount === 0 && (!strict || summary.warningCount === 0); + + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + duration: result.duration, + samples: result.samples, + tolerance, + strict, + ...summary, + ok, + issues: result.issues, + }), + null, + 2, + ), + ); + process.exit(ok ? 0 : 1); + } + + if (result.samples.length === 0) { + console.log(); + console.log( + `${c.error("✗")} Could not determine composition duration — no layout samples run`, + ); + process.exit(1); + } + + console.log(); + if (result.issues.length === 0) { + console.log(`${c.success("◇")} 0 layout issues across ${result.samples.length} sample(s)`); + return; + } + + for (const issue of result.issues) { + const icon = issue.severity === "error" ? c.error("✗") : c.warn("⚠"); + const formatted = formatLayoutIssue(issue).replace(/\n/g, "\n "); + console.log(` ${icon} ${c.dim(formatted)}`); + } + + console.log(); + const parts = [`${summary.errorCount} error(s)`, `${summary.warningCount} warning(s)`]; + const suffix = + result.issues.length >= maxIssues ? c.dim(`, truncated at ${maxIssues} issue(s)`) : ""; + console.log(`${ok ? c.success("◇") : c.error("◇")} ${parts.join(", ")}${suffix}`); + + process.exit(ok ? 0 : 1); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + issueCount: 0, + }), + null, + 2, + ), + ); + process.exit(1); + } + console.error(`${c.error("✗")} Layout audit failed: ${message}`); + process.exit(1); + } + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 0f5630c72..8805b28a8 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -32,6 +32,7 @@ const GROUPS: Group[] = [ title: "Project", commands: [ ["lint", "Validate a composition for common mistakes"], + ["layout", "Audit rendered text and container layout across the timeline"], ["snapshot", "Capture key frames as PNG screenshots for visual verification"], ["info", "Print project metadata"], ["compositions", "List all compositions in a project"], @@ -77,6 +78,7 @@ const ROOT_EXAMPLES: Example[] = [ ["Render to MP4", "hyperframes render -o out.mp4"], ["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"], ["Validate your composition", "hyperframes lint"], + ["Audit timeline layout", "hyperframes layout"], ["Check system dependencies", "hyperframes doctor"], ]; diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts new file mode 100644 index 000000000..e4d298c29 --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + buildLayoutSampleTimes, + computeOverflow, + summarizeLayoutIssues, + formatLayoutIssue, + type LayoutIssue, +} from "./layoutAudit.js"; + +describe("layoutAudit helpers", () => { + it("samples the whole duration using stable midpoint timestamps", () => { + expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]); + }); + + it("prefers explicit timestamps and keeps them inside the composition duration", () => { + expect(buildLayoutSampleTimes({ duration: 10, samples: 5, at: [0, 2.5, 12, -1, NaN] })).toEqual( + [0, 2.5], + ); + }); + + it("computes per-side overflow beyond a tolerance", () => { + const overflow = computeOverflow( + { left: 88, top: 102, right: 231, bottom: 181, width: 143, height: 79 }, + { left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 }, + 2, + ); + + expect(overflow).toEqual({ left: 12, right: 11 }); + }); + + it("returns no overflow when the subject only exceeds the box within tolerance", () => { + const overflow = computeOverflow( + { left: 99, top: 100, right: 221, bottom: 180, width: 122, height: 80 }, + { left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 }, + 2, + ); + + expect(overflow).toBeNull(); + }); + + it("summarizes errors and warnings separately", () => { + const issues: LayoutIssue[] = [ + issue("text_box_overflow", "error"), + issue("canvas_overflow", "warning"), + issue("clipped_text", "error"), + ]; + + expect(summarizeLayoutIssues(issues)).toEqual({ + ok: false, + errorCount: 2, + warningCount: 1, + issueCount: 3, + }); + }); + + it("formats issues with timestamp, selector, container, and fix hint", () => { + const formatted = formatLayoutIssue({ + ...issue("text_box_overflow", "error"), + time: 3.25, + selector: "#headline", + containerSelector: ".bubble", + text: "Quarterly plan", + overflow: { right: 18, bottom: 7 }, + fixHint: "Increase container padding or reduce font-size.", + }); + + expect(formatted).toContain("t=3.25s"); + expect(formatted).toContain("#headline"); + expect(formatted).toContain("inside .bubble"); + expect(formatted).toContain("right 18px, bottom 7px"); + expect(formatted).toContain("Fix: Increase container padding"); + }); +}); + +function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue { + return { + code, + severity, + time: 1, + selector: ".label", + message: "Layout issue", + rect: { left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20 }, + overflow: { right: 8 }, + fixHint: "Adjust layout.", + }; +} diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts new file mode 100644 index 000000000..e637be871 --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.ts @@ -0,0 +1,163 @@ +export interface LayoutRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export type LayoutOverflow = Partial>; + +export type LayoutIssueCode = + | "text_box_overflow" + | "clipped_text" + | "canvas_overflow" + | "container_overflow"; + +export interface LayoutIssue { + code: LayoutIssueCode; + severity: "error" | "warning"; + time: number; + selector: string; + containerSelector?: string; + text?: string; + message: string; + rect: LayoutRect; + containerRect?: LayoutRect; + overflow?: LayoutOverflow; + fixHint?: string; +} + +export interface LayoutSummary { + ok: boolean; + errorCount: number; + warningCount: number; + issueCount: number; +} + +export interface LayoutSampleOptions { + duration: number; + samples: number; + at?: number[]; +} + +export function buildLayoutSampleTimes({ duration, samples, at }: LayoutSampleOptions): number[] { + if (at?.length) { + return uniqueSortedTimes( + at.filter( + (time) => Number.isFinite(time) && time >= 0 && (duration <= 0 || time <= duration), + ), + ); + } + + if (!Number.isFinite(duration) || duration <= 0 || samples <= 0) return []; + + const count = Math.max(1, Math.floor(samples)); + return Array.from({ length: count }, (_, index) => roundTime(((index + 0.5) / count) * duration)); +} + +export function computeOverflow( + subject: LayoutRect, + container: LayoutRect, + tolerance: number, +): LayoutOverflow | null { + const overflow: LayoutOverflow = {}; + + if (subject.left < container.left - tolerance) { + overflow.left = roundPx(container.left - subject.left); + } + if (subject.right > container.right + tolerance) { + overflow.right = roundPx(subject.right - container.right); + } + if (subject.top < container.top - tolerance) { + overflow.top = roundPx(container.top - subject.top); + } + if (subject.bottom > container.bottom + tolerance) { + overflow.bottom = roundPx(subject.bottom - container.bottom); + } + + return Object.keys(overflow).length > 0 ? overflow : null; +} + +export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary { + const errorCount = issues.filter((issue) => issue.severity === "error").length; + const warningCount = issues.filter((issue) => issue.severity === "warning").length; + + return { + ok: errorCount === 0, + errorCount, + warningCount, + issueCount: issues.length, + }; +} + +export function formatLayoutIssue(issue: LayoutIssue): string { + const parts = [ + `t=${formatNumber(issue.time)}s`, + issue.code, + issue.selector, + issue.containerSelector ? `inside ${issue.containerSelector}` : "", + issue.overflow ? `overflowed ${formatOverflow(issue.overflow)}` : "", + issue.text ? quoteText(issue.text) : "", + ].filter(Boolean); + + const line = `${parts.join(" ")} — ${issue.message}`; + return issue.fixHint ? `${line}\n Fix: ${issue.fixHint}` : line; +} + +export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { + const seen = new Set(); + const result: LayoutIssue[] = []; + + for (const issue of issues) { + const key = [ + issue.code, + issue.severity, + issue.time.toFixed(3), + issue.selector, + issue.containerSelector ?? "", + issue.text ?? "", + issue.overflow ? formatOverflow(issue.overflow) : "", + ].join("|"); + if (seen.has(key)) continue; + seen.add(key); + result.push(issue); + } + + return result; +} + +function uniqueSortedTimes(times: number[]): number[] { + const rounded = times.map(roundTime); + return [...new Set(rounded)].sort((a, b) => a - b); +} + +function formatOverflow(overflow: LayoutOverflow): string { + return (["left", "right", "top", "bottom"] as const) + .flatMap((side) => { + const value = overflow[side]; + return value == null ? [] : `${side} ${formatNumber(value)}px`; + }) + .join(", "); +} + +function quoteText(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + const truncated = normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; + return `"${truncated}"`; +} + +function formatNumber(value: number): string { + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} + +function roundTime(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function roundPx(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index ba14cfe64..947382399 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hyperframes-cli -description: HyperFrames CLI tool — hyperframes init, lint, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting or validating compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. +description: HyperFrames CLI tool — hyperframes init, lint, layout, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, layout-checking compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. --- # HyperFrames CLI @@ -12,10 +12,11 @@ Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg. 1. **Scaffold** — `npx hyperframes init my-video` 2. **Write** — author HTML composition (see the `hyperframes` skill) 3. **Lint** — `npx hyperframes lint` -4. **Preview** — `npx hyperframes preview` -5. **Render** — `npx hyperframes render` +4. **Layout audit** — `npx hyperframes layout` +5. **Preview** — `npx hyperframes preview` +6. **Render** — `npx hyperframes render` -Lint before preview — catches missing `data-composition-id`, overlapping tracks, unregistered timelines. +Lint and layout-audit before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `layout` opens the rendered composition in headless Chrome, seeks through the timeline, and reports text spilling out of bubbles/containers or off the canvas. ## Scaffolding @@ -42,6 +43,25 @@ npx hyperframes lint --json # machine-readable Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). +## Layout Audit + +```bash +npx hyperframes layout # audit rendered layout over the timeline +npx hyperframes layout ./my-project # specific project +npx hyperframes layout --json # agent-readable findings +npx hyperframes layout --samples 15 # denser timeline sweep +npx hyperframes layout --at 1.5,4,7.25 # explicit hero-frame timestamps +``` + +Use this after `lint` and `validate`, especially for compositions with speech bubbles, cards, captions, or tight typography. It reports: + +- Text extending outside the nearest visual container or bubble +- Text clipped by its own fixed-width/fixed-height box +- Text extending outside the composition canvas +- Children escaping clipping containers + +Errors should be fixed before rendering. Warnings are surfaced for agent review; add `--strict` to fail on warnings too. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + ## Previewing ```bash diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 5584493f3..0c5e4ec6e 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -277,11 +277,26 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl ## Output Checklist - [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass +- [ ] `npx hyperframes layout` passes, or every reported overflow is intentionally marked - [ ] Contrast warnings addressed (see Quality Checks below) +- [ ] Layout issues addressed (see Quality Checks below) - [ ] Animation choreography verified (see Quality Checks below) ## Quality Checks +### Layout + +`hyperframes layout` runs the composition in headless Chrome, seeks through the timeline, and maps layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`: + +```bash +npx hyperframes layout +npx hyperframes layout --json +``` + +Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy. + +Use `--samples 15` for dense videos and `--at 1.5,4,7.25` for specific hero frames. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + ### Contrast `hyperframes validate` runs a WCAG contrast audit by default. It seeks to 5 timestamps, screenshots the page, samples background pixels behind every text element, and computes contrast ratios. Failures appear as warnings: