mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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>
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
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");
|