diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 07575b3e2..779fce12a 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`) +- Inspect rendered visual layout for text overflow and clipped containers (`inspect`) - Capture key frames as PNG screenshots (`snapshot`) - Check your environment for missing dependencies (`doctor`) @@ -445,6 +446,47 @@ 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. + ### `inspect` + + Inspect rendered visual layout across the composition timeline: + + ```bash + npx hyperframes inspect [dir] + npx hyperframes inspect [dir] --json + npx hyperframes inspect [dir] --samples 15 + npx hyperframes inspect [dir] --at 1.5,4,7.25 + ``` + + ``` + ◆ Inspecting layout for my-project (9 timeline samples) + + ✗ text_box_overflow t=3.25s #headline inside .bubble overflowed right 18px — "Quarterly plan" + Fix: Text is 418px x 42px inside 400px x 120px and overflows by up to 18px; widen the container to at least ~418px, or allow wrapping with max-width/fitTextFontSize. + + ◇ 1 error(s), 0 warning(s), 0 info(s) + ``` + + `inspect` 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 schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. + + | Flag | Description | + |------|-------------| + | `--json` | Output agent-readable findings with `schemaVersion`, `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) | + | `--collapse-static` | Collapse repeated static issues across samples (default: true) | + | `--max-issues` | Maximum findings to print or return after static collapse (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. + + `layout` remains available as a compatibility alias for the same visual inspection pass: + + ```bash + npx hyperframes layout [dir] --json + ``` + ### `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..632674312 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,8 @@ 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), + inspect: () => import("./commands/inspect.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/inspect.ts b/packages/cli/src/commands/inspect.ts new file mode 100644 index 000000000..1c79a7051 --- /dev/null +++ b/packages/cli/src/commands/inspect.ts @@ -0,0 +1,12 @@ +import type { Example } from "./_examples.js"; +import { createInspectCommand } from "./layout.js"; + +export const examples: Example[] = [ + ["Inspect visual layout across the current composition", "hyperframes inspect"], + ["Inspect a specific project", "hyperframes inspect ./my-video"], + ["Output agent-readable JSON", "hyperframes inspect --json"], + ["Use explicit hero-frame timestamps", "hyperframes inspect --at 1.5,4.0,7.25"], + ["Run the compatibility alias", "hyperframes layout --json"], +]; + +export default createInspectCommand("inspect"); 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..aebcfdfac --- /dev/null +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -0,0 +1,423 @@ +(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 rectFromOrigin(left, top, width, height) { + return { + left: round(left), + top: round(top), + right: round(left + width), + bottom: round(top + height), + width: round(width), + height: round(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 escapeAttr(value) { + return value.replace(/\\/g, "\\\\").replace(/"/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"; + const attrSelector = `[${attr}="${escapeAttr(dataName)}"]`; + if (document.querySelectorAll(attrSelector).length === 1) return attrSelector; + return `${element.tagName.toLowerCase()}${attrSelector}`; + } + 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 hasMeaningfulBoxStyle(style) { + return ( + parsePx(style.paddingTop) + + parsePx(style.paddingRight) + + parsePx(style.paddingBottom) + + parsePx(style.paddingLeft) + + parsePx(style.borderTopWidth) + + parsePx(style.borderRightWidth) + + parsePx(style.borderBottomWidth) + + parsePx(style.borderLeftWidth) + + parsePx(style.borderTopLeftRadius) + + parsePx(style.borderTopRightRadius) + + parsePx(style.borderBottomRightRadius) + + parsePx(style.borderBottomLeftRadius) > + 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 rootRectFor(root) { + const measured = toRect(root.getBoundingClientRect()); + const authoredWidth = Number.parseFloat(root.getAttribute("data-width") || ""); + const authoredHeight = Number.parseFloat(root.getAttribute("data-height") || ""); + const hasAuthoredSize = + Number.isFinite(authoredWidth) && + authoredWidth > 0 && + Number.isFinite(authoredHeight) && + authoredHeight > 0; + + if (!hasAuthoredSize) return measured; + if (measured.width > 0.5 && measured.height > 0.5) return measured; + return rectFromOrigin(measured.left, measured.top, authoredWidth, authoredHeight); + } + + function isConstraintCandidate(element, root, rootRect) { + 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; + if (!hasMeaningfulBoxStyle(style)) return false; + const rect = element.getBoundingClientRect(); + const rootArea = rootRect.width * rootRect.height; + const area = rect.width * rect.height; + return area > 0 && area < rootArea * 0.95; + } + + function nearestConstraint(element, root, rootRect) { + for ( + let current = element; + current && current !== document.body; + current = current.parentElement + ) { + if (!isVisibleElement(current)) continue; + if (isConstraintCandidate(current, root, rootRect)) return current; + if (current === root) return current; + } + return root; + } + + function formatPx(value) { + return `${Math.round(value)}px`; + } + + function maxOverflow(overflow) { + return Math.max(...Object.values(overflow).filter((value) => typeof value === "number")); + } + + function textOverflowFixHint(textRect, containerRect, overflow, fontSize, targetName) { + const horizontalOverflow = (overflow.left || 0) + (overflow.right || 0); + const verticalOverflow = (overflow.top || 0) + (overflow.bottom || 0); + const neededWidth = containerRect.width + horizontalOverflow; + const neededHeight = containerRect.height + verticalOverflow; + const widthRatio = containerRect.width > 0 ? containerRect.width / textRect.width : 0; + const heightRatio = containerRect.height > 0 ? containerRect.height / textRect.height : 0; + const limitingRatio = Math.min( + widthRatio > 0 ? widthRatio : Number.POSITIVE_INFINITY, + heightRatio > 0 ? heightRatio : Number.POSITIVE_INFINITY, + ); + const shrinkPercent = + Number.isFinite(limitingRatio) && limitingRatio < 1 + ? Math.ceil((1 - limitingRatio) * 100) + : 0; + const targetFont = + shrinkPercent > 0 && Number.isFinite(fontSize) && fontSize > 0 + ? ` or shrink font-size from ${formatPx(fontSize)} to ~${formatPx(fontSize * limitingRatio)}` + : ""; + const sizeTarget = + horizontalOverflow > 0 && verticalOverflow > 0 + ? `resize ${targetName} to at least ~${formatPx(neededWidth)} x ${formatPx(neededHeight)}` + : horizontalOverflow > 0 + ? `widen ${targetName} to at least ~${formatPx(neededWidth)}` + : `increase ${targetName} height to at least ~${formatPx(neededHeight)}`; + + return `Text is ${formatPx(textRect.width)} x ${formatPx(textRect.height)} inside ${formatPx(containerRect.width)} x ${formatPx(containerRect.height)} and overflows by up to ${formatPx(maxOverflow(overflow))}; ${sizeTarget}${targetFont}, or allow wrapping with max-width/fitTextFontSize.`; + } + + 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); + const rect = toRect(element.getBoundingClientRect()); + const fontSize = parsePx(style.fontSize); + return { + code: "clipped_text", + severity: "error", + time, + selector, + text, + message: "Text content is clipped by its own box.", + rect, + overflow, + fixHint: textOverflowFixHint(rect, rect, overflow, fontSize, "the text box"), + }; + } + + 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, rootRect); + const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect()); + const containerOverflow = overflowFor(textRect, containerRect, tolerance); + if (containerOverflow && !hasAllowOverflowFlag(element)) { + const style = getComputedStyle(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: textOverflowFixHint( + textRect, + containerRect, + containerOverflow, + parsePx(style.fontSize), + "the container", + ), + }); + } + + const canvasOverflow = overflowFor(textRect, rootRect, tolerance); + if (canvasOverflow && !hasAllowOverflowFlag(element)) { + issues.push({ + code: "canvas_overflow", + severity: "info", + 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 = rootRectFor(root); + 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-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts new file mode 100644 index 000000000..981c052df --- /dev/null +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8"); + +interface RectInput { + left: number; + top: number; + width: number; + height: number; +} + +describe("layout-audit.browser", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + }); + + it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { + document.body.innerHTML = ` +
+
Quarterly plan overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 0, height: 0 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + const issues = runAudit(); + const boxOverflow = issues.find((issue) => issue.code === "text_box_overflow"); + + expect(boxOverflow).toMatchObject({ + selector: "#headline", + containerSelector: "#bubble", + overflow: { right: 1155 }, + }); + expect( + issues.some( + (issue) => + issue.code === "text_box_overflow" && + issue.selector === "#headline" && + issue.containerSelector === "#root", + ), + ).toBe(false); + }); + + it("omits tag prefixes for unique data-attribute selectors", () => { + document.body.innerHTML = ` +
+
Quarterly plan overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + const issues = runAudit(); + + expect(issues[0]?.selector).toBe('[data-layout-name="headline"]'); + }); + + it("respects layout ignore and allow-overflow opt-outs", () => { + document.body.innerHTML = ` +
+
+
Quarterly plan overflow
+
+
Ignored overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + ignored: rect({ left: 600, top: 20, width: 500, height: 40 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + expect(runAudit()).toEqual([]); + }); +}); + +function installAuditScript(): void { + window.eval(script); +} + +function runAudit(): Array<{ + code: string; + selector: string; + containerSelector?: string; + overflow?: Record; +}> { + const audit = ( + window as unknown as { + __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{ + code: string; + selector: string; + containerSelector?: string; + overflow?: Record; + }>; + } + ).__hyperframesLayoutAudit; + return audit({ time: 1, tolerance: 2 }); +} + +function installGeometry(rects: Record): void { + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + const el = element as Element; + const isBubble = el.id === "bubble"; + return { + display: "block", + visibility: "visible", + opacity: "1", + overflow: "visible", + overflowX: "visible", + overflowY: "visible", + backgroundColor: isBubble ? "rgb(255, 255, 255)" : "rgba(0, 0, 0, 0)", + backgroundImage: "none", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + borderTopLeftRadius: isBubble ? "28px" : "0px", + borderTopRightRadius: isBubble ? "28px" : "0px", + borderBottomRightRadius: isBubble ? "28px" : "0px", + borderBottomLeftRadius: isBubble ? "28px" : "0px", + paddingTop: isBubble ? "16px" : "0px", + paddingRight: isBubble ? "16px" : "0px", + paddingBottom: isBubble ? "16px" : "0px", + paddingLeft: isBubble ? "16px" : "0px", + fontSize: "36px", + } as unknown as CSSStyleDeclaration; + }); + + for (const element of Array.from(document.querySelectorAll("*"))) { + const key = + element.id === "root" || element.hasAttribute("data-composition-id") + ? "root" + : element.id === "headline" || element.hasAttribute("data-layout-name") + ? "headline" + : element.id; + const rectValue = rects[key] ?? rect({ left: 0, top: 0, width: 10, height: 10 }); + vi.spyOn(element, "getBoundingClientRect").mockReturnValue(rectValue); + } + + vi.spyOn(document, "createRange").mockImplementation(() => { + let selected: Node | null = null; + return { + selectNodeContents(node: Node) { + selected = node; + }, + getClientRects() { + const element = selected as Element | null; + const textRect = element?.id === "ignored" ? rects.ignored : rects.text; + return textRect ? ([textRect] as unknown as DOMRectList) : ([] as unknown as DOMRectList); + }, + detach() {}, + } as unknown as Range; + }); +} + +function rect({ left, top, width, height }: RectInput): DOMRect { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON() { + return this; + }, + } as DOMRect; +} diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts new file mode 100644 index 000000000..92cc0c94b --- /dev/null +++ b/packages/cli/src/commands/layout.ts @@ -0,0 +1,467 @@ +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, + collapseStaticLayoutIssues, + dedupeLayoutIssues, + formatLayoutIssue, + limitLayoutIssues, + summarizeLayoutIssues, + type LayoutIssue, +} from "../utils/layoutAudit.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SEEK_SETTLE_MS = 120; +const INSPECT_SCHEMA_VERSION = 1; + +export const examples: Example[] = [ + ["Inspect visual layout across the current composition", "hyperframes layout"], + ["Inspect 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[]; + rawIssues: 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 page + .evaluate(() => { + const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; + if (!fonts?.ready) return Promise.resolve(); + return Promise.race([ + fonts.ready.then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 500)), + ]); + }) + .catch(() => {}); + 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 }, +): 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 page + .evaluate(() => { + const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; + if (!fonts?.ready) return Promise.resolve(); + return Promise.race([ + fonts.ready.then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 750)), + ]); + }) + .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, rawIssues: [] }; + + 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[])); + } + + return { + duration, + samples, + rawIssues: dedupeLayoutIssues(issues), + }; + } 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 function createInspectCommand(commandName: "inspect" | "layout") { + return defineCommand({ + meta: { + name: commandName, + description: "Inspect 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 after static collapse (default: 80)", + default: "80", + }, + "collapse-static": { + type: "boolean", + description: "Collapse repeated static issues across samples (default: true)", + default: true, + }, + 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; + const collapseStatic = args["collapse-static"] !== false; + + if (!args.json) { + const sampleLabel = at + ? `${at.length} explicit timestamp(s)` + : `${samples} timeline samples`; + console.log( + `${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`, + ); + } + + try { + const result = await runLayoutAudit(project.dir, { + samples, + at, + timeout, + tolerance, + }); + const allIssues = collapseStatic + ? collapseStaticLayoutIssues(result.rawIssues) + : result.rawIssues; + const limited = limitLayoutIssues(allIssues, maxIssues); + const summary = summarizeLayoutIssues(allIssues); + const ok = summary.errorCount === 0 && (!strict || summary.warningCount === 0); + + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + schemaVersion: INSPECT_SCHEMA_VERSION, + duration: result.duration, + samples: result.samples, + tolerance, + strict, + collapseStatic, + ...summary, + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + ok, + issues: limited.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 (limited.issues.length === 0) { + console.log( + `${c.success("◇")} 0 layout issues across ${result.samples.length} sample(s)`, + ); + return; + } + + for (const issue of limited.issues) { + const icon = + issue.severity === "error" + ? c.error("✗") + : issue.severity === "warning" + ? c.warn("⚠") + : c.dim("ℹ"); + 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)`, + `${summary.infoCount} info(s)`, + ]; + const suffix = limited.truncated ? 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({ + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }), + null, + 2, + ), + ); + process.exit(1); + } + console.error(`${c.error("✗")} Inspect failed: ${message}`); + process.exit(1); + } + }, + }); +} + +export default createInspectCommand("layout"); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 0f5630c72..01fe56742 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"], + ["inspect", "Inspect rendered visual 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"], + ["Inspect visual layout", "hyperframes inspect"], ["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..f4fc1f30c --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + buildLayoutSampleTimes, + computeOverflow, + collapseStaticLayoutIssues, + limitLayoutIssues, + 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, + infoCount: 0, + issueCount: 3, + }); + }); + + it("tracks info findings separately from warnings and errors", () => { + expect(summarizeLayoutIssues([issue("canvas_overflow", "info")])).toEqual({ + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 1, + issueCount: 1, + }); + }); + + it("collapses repeated static issues across sampled timestamps", () => { + const collapsed = collapseStaticLayoutIssues([ + { ...issue("text_box_overflow", "error"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ]); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ + time: 1, + firstSeen: 1, + lastSeen: 5, + occurrences: 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"); + }); + + it("formats collapsed issue time ranges", () => { + const formatted = formatLayoutIssue({ + ...issue("text_box_overflow", "error"), + time: 1, + firstSeen: 1, + lastSeen: 5, + occurrences: 3, + }); + + expect(formatted).toContain("t=1-5s (3 samples)"); + }); + + it("limits returned issues by severity before truncating", () => { + const limited = limitLayoutIssues( + [ + { ...issue("canvas_overflow", "info"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 2 }, + ], + 1, + ); + + expect(limited).toMatchObject({ + totalIssueCount: 2, + truncated: true, + issues: [{ code: "text_box_overflow", severity: "error" }], + }); + }); +}); + +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..237366b54 --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.ts @@ -0,0 +1,247 @@ +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 type LayoutIssueSeverity = "error" | "warning" | "info"; + +export interface LayoutIssue { + code: LayoutIssueCode; + severity: LayoutIssueSeverity; + time: number; + firstSeen?: number; + lastSeen?: number; + occurrences?: 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; + infoCount: 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; + const infoCount = issues.filter((issue) => issue.severity === "info").length; + + return { + ok: errorCount === 0, + errorCount, + warningCount, + infoCount, + issueCount: issues.length, + }; +} + +export function formatLayoutIssue(issue: LayoutIssue): string { + const timeLabel = + issue.occurrences && issue.occurrences > 1 + ? `t=${formatNumber(issue.firstSeen ?? issue.time)}-${formatNumber(issue.lastSeen ?? issue.time)}s (${issue.occurrences} samples)` + : `t=${formatNumber(issue.time)}s`; + const parts = [ + timeLabel, + 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; +} + +export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { + const groups = new Map< + string, + { + issue: LayoutIssue; + firstSeen: number; + lastSeen: number; + occurrences: number; + } + >(); + + for (const issue of issues) { + const key = staticIssueKey(issue); + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + issue, + firstSeen: issue.time, + lastSeen: issue.time, + occurrences: 1, + }); + continue; + } + + existing.firstSeen = Math.min(existing.firstSeen, issue.time); + existing.lastSeen = Math.max(existing.lastSeen, issue.time); + existing.occurrences += 1; + } + + return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({ + ...issue, + time: firstSeen, + firstSeen, + lastSeen, + occurrences, + })); +} + +export function limitLayoutIssues( + issues: LayoutIssue[], + maxIssues: number, +): { issues: LayoutIssue[]; totalIssueCount: number; truncated: boolean } { + const limit = Math.max(1, Math.floor(maxIssues)); + const sortedIssues = [...issues].sort((a, b) => { + const severityDelta = severityRank(a.severity) - severityRank(b.severity); + if (severityDelta !== 0) return severityDelta; + return a.time - b.time; + }); + return { + issues: sortedIssues.slice(0, limit), + totalIssueCount: issues.length, + truncated: issues.length > limit, + }; +} + +function severityRank(severity: LayoutIssueSeverity): number { + if (severity === "error") return 0; + if (severity === "warning") return 1; + return 2; +} + +function staticIssueKey(issue: LayoutIssue): string { + return [ + issue.code, + issue.severity, + issue.selector, + issue.containerSelector ?? "", + issue.text ?? "", + issue.overflow ? formatOverflow(issue.overflow) : "", + ].join("|"); +} + +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..5aa3c11fb 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, inspect, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, inspecting visual layout in 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. **Visual inspect** — `npx hyperframes inspect` +5. **Preview** — `npx hyperframes preview` +6. **Render** — `npx hyperframes render` -Lint before preview — catches missing `data-composition-id`, overlapping tracks, unregistered timelines. +Lint and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `inspect` 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,27 @@ 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`). +## Visual Inspect + +```bash +npx hyperframes inspect # inspect rendered layout over the timeline +npx hyperframes inspect ./my-project # specific project +npx hyperframes inspect --json # agent-readable findings +npx hyperframes inspect --samples 15 # denser timeline sweep +npx hyperframes inspect --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. Repeated static issues are collapsed by default so JSON output stays compact for LLM context windows. 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`. + +`npx hyperframes layout` remains available as a compatibility alias for the same visual inspection pass. + ## Previewing ```bash diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 5584493f3..60f816e67 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -277,11 +277,28 @@ 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 inspect` 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 +### Visual Inspect + +`hyperframes inspect` runs the composition in headless Chrome, seeks through the timeline, and maps visual layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`: + +```bash +npx hyperframes inspect +npx hyperframes inspect --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. Repeated static issues are collapsed by default to avoid flooding agent context. 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`. + +`hyperframes layout` is the compatibility alias for the same check. + ### 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: