Files
hyperframes/packages/cli/src/commands/layout.ts
T
Rames JussoandClaude Opus 4.7 dfca302d37 fix(bundler): runtime mode opt-in, ASI-safe joinJsChunks, prune dead subs
Per @vai-bot's review on hf#641:

Important #1: dead `src=""` substitution sites
=============================================

Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.

Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.

Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
  is "inline" (matches the self-contained-bundle promise the function name
  makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
  to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
  existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
  substitution code (regex never matches the new inlined-runtime shape).

Important #2: joinJsChunks ASI hazard
======================================

The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.

Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.

Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.

New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.

Verification
============

- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:37:04 +00:00

404 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number> {
return page.evaluate(() => {
const win = window as unknown as {
__hf?: { duration?: number };
__player?: { duration?: number | (() => number) };
__timelines?: Record<string, { duration?: number | (() => 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<void> {
await page.evaluate((t: number) => {
const win = window as unknown as {
__hf?: { seek?: (time: number) => void };
__player?: { seek?: (time: number) => void };
__timelines?: Record<string, { pause?: () => 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<void>((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<void>((resolve) => setTimeout(resolve, 500)),
]);
})
.catch(() => {});
await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS));
}
async function bundleProjectHtml(projectDir: string): Promise<string> {
// `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<void> {
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<LayoutAuditResult> {
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<void>((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");