import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, 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 { // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the // previous post-bundle runtime substitution is no longer needed. const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); return bundleToSingleHtml(projectDir); } 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 serveStaticProjectHtml( projectDir, html, "Failed to bind local layout audit server", ); 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");