mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
Layout findings now distinguish held defects from entrance/exit transients: a dynamic issue seen at a single grid sample demotes to info, while content_overlap held across two-plus samples (or 500ms+) promotes to error, resolving the long-standing re-promotion TODO. Static compositions keep their severity. check gains a sweep_static error when a 3s+ composition shows zero geometry change across every sample (a frozen timeline makes every green verdict unreliable); skipped when the motion sidecar already reported motion_frozen. text_occluded findings carry a coveredFraction; atomic labels (short, no whitespace) flag on any cover while prose needs 15%, since partial cover changes what a short label reads as. Deprecation-test scaffolding consolidates into deprecationTestHarness; tier logic and logger tests restructured under the complexity gate without suppression markers. Detection mechanics adapted from Adam Rosler's open-sourced visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design); the elementFromPoint paint model, opt-out attributes, and single-audit architecture are unchanged.
639 lines
23 KiB
TypeScript
639 lines
23 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 { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
|
||
import {
|
||
buildLayoutSampleTimes,
|
||
buildTransitionSampleTimes,
|
||
collapseStaticLayoutIssues,
|
||
dedupeLayoutIssues,
|
||
formatLayoutIssue,
|
||
limitLayoutIssues,
|
||
mergeSampleTimes,
|
||
summarizeLayoutIssues,
|
||
type LayoutIssue,
|
||
} from "../utils/layoutAudit.js";
|
||
import {
|
||
ambiguousIssue,
|
||
collectSamplingTargets,
|
||
evaluateMotion,
|
||
type MotionFrame,
|
||
} from "../utils/motionAudit.js";
|
||
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
|
||
import {
|
||
seekCompositionTimeline,
|
||
waitForCompositionFonts,
|
||
type SeekCompositionTimelineOptions,
|
||
} from "../capture/captureCompositionFrame.js";
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = dirname(__filename);
|
||
const SEEK_SETTLE_MS = 120;
|
||
const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = {
|
||
fallbackToBridgeAndTimelines: true,
|
||
animationFrameSettle: "double",
|
||
waitForFontsMs: 500,
|
||
settleMs: SEEK_SETTLE_MS,
|
||
};
|
||
// All new envelope fields are optional (?); additive changes don't bump this.
|
||
const INSPECT_SCHEMA_VERSION = 1;
|
||
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
|
||
const MOTION_FPS = 20;
|
||
const MOTION_MAX_SAMPLES = 300;
|
||
|
||
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"],
|
||
[
|
||
"Also sample at tween boundaries to catch transient overlaps",
|
||
"hyperframes layout --at-transitions",
|
||
],
|
||
[
|
||
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
|
||
"hyperframes layout --json",
|
||
],
|
||
];
|
||
|
||
interface LayoutAuditResult {
|
||
duration: number;
|
||
samples: number[];
|
||
transitionSamples: number[];
|
||
transitionSamplesDropped: number;
|
||
rawIssues: LayoutIssue[];
|
||
motionSamples: number;
|
||
}
|
||
|
||
function buildMotionSampleTimes(duration: number): number[] {
|
||
if (!Number.isFinite(duration) || duration <= 0) return [];
|
||
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
|
||
const step = duration / (count - 1);
|
||
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
|
||
}
|
||
|
||
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
|
||
// Serialized into the page; the duration-source cascade cannot be split.
|
||
// fallow-ignore-next-line complexity
|
||
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;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Collect every tween start/end boundary from the registered timelines,
|
||
* expressed in the registered timeline's own time (what seekTo consumes).
|
||
* GSAP-only: timelines without getChildren (Anime/Lottie/Three adapters) are
|
||
* skipped. Nested tween times are converted by climbing the parent chain,
|
||
* accounting for each ancestor's startTime and timeScale.
|
||
*/
|
||
async function collectTweenBoundaries(page: import("puppeteer-core").Page): Promise<number[]> {
|
||
return page.evaluate(() => {
|
||
type AnimLike = {
|
||
startTime?: () => number;
|
||
duration?: () => number;
|
||
timeScale?: () => number;
|
||
parent?: AnimLike | null;
|
||
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => AnimLike[];
|
||
};
|
||
|
||
// GSAP getters read internal state through `this`, so the method must be
|
||
// invoked bound to its animation (an unbound call throws inside GSAP).
|
||
const callOr = (fn: (() => number) | undefined, self: AnimLike, fallback: number): number =>
|
||
typeof fn === "function" ? fn.call(self) : fallback;
|
||
|
||
const toTimelineTime = (root: AnimLike, anim: AnimLike, localTime: number): number => {
|
||
let time = localTime;
|
||
let node: AnimLike | null | undefined = anim;
|
||
while (node && node !== root) {
|
||
time = callOr(node.startTime, node, 0) + time / (callOr(node.timeScale, node, 1) || 1);
|
||
node = node.parent;
|
||
}
|
||
return time;
|
||
};
|
||
|
||
const tweenBoundaries = (root: AnimLike, tween: AnimLike): number[] => {
|
||
if (typeof tween.duration !== "function") return [];
|
||
const start = toTimelineTime(root, tween, 0);
|
||
const end = toTimelineTime(root, tween, tween.duration());
|
||
return [start, end].filter((time) => Number.isFinite(time));
|
||
};
|
||
|
||
const timelineBoundaries = (timeline: AnimLike): number[] => {
|
||
try {
|
||
const tweens = timeline.getChildren?.(true, true, false) ?? [];
|
||
return tweens.flatMap((tween) => tweenBoundaries(timeline, tween));
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const win = window as unknown as { __timelines?: Record<string, AnimLike> };
|
||
return Object.values(win.__timelines ?? {}).flatMap(timelineBoundaries);
|
||
});
|
||
}
|
||
|
||
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[];
|
||
atTransitions: boolean;
|
||
maxTransitionSamples?: number;
|
||
timeout: number;
|
||
tolerance: number;
|
||
motion?: MotionSpec;
|
||
},
|
||
): Promise<LayoutAuditResult> {
|
||
const { ensureBrowser } = await import("../browser/manager.js");
|
||
const puppeteer = await import("puppeteer-core");
|
||
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||
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: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
|
||
});
|
||
|
||
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 waitForCompositionFonts(page, 750);
|
||
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
|
||
|
||
const duration = await getCompositionDuration(page);
|
||
const baseSamples = buildLayoutSampleTimes({ duration, samples: opts.samples, at: opts.at });
|
||
let transitionSamples: number[] = [];
|
||
let transitionSamplesDropped = 0;
|
||
if (opts.atTransitions) {
|
||
const boundaries = await collectTweenBoundaries(page);
|
||
const transitions = buildTransitionSampleTimes({
|
||
duration,
|
||
boundaries,
|
||
cap: opts.maxTransitionSamples,
|
||
});
|
||
transitionSamples = transitions.times;
|
||
transitionSamplesDropped = transitions.dropped;
|
||
}
|
||
const samples = mergeSampleTimes(baseSamples, transitionSamples);
|
||
|
||
const issues = await collectLayoutIssues(page, samples, opts.tolerance);
|
||
|
||
let motionSamples = 0;
|
||
if (opts.motion) {
|
||
const motion = await runMotionPass(page, opts.motion, duration);
|
||
issues.push(...motion.issues);
|
||
motionSamples = motion.sampleCount;
|
||
}
|
||
|
||
return {
|
||
duration,
|
||
samples,
|
||
transitionSamples,
|
||
transitionSamplesDropped,
|
||
rawIssues: dedupeLayoutIssues(issues),
|
||
motionSamples,
|
||
};
|
||
} finally {
|
||
await chromeBrowser?.close().catch(() => {});
|
||
await server.close();
|
||
}
|
||
}
|
||
|
||
export function loadBrowserScript(name: string): string {
|
||
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
|
||
for (const candidate of candidates) {
|
||
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
|
||
}
|
||
throw new Error(`Missing browser script ${name}`);
|
||
}
|
||
|
||
function loadLayoutAuditScript(): string {
|
||
return loadBrowserScript("layout-audit.browser.js");
|
||
}
|
||
|
||
async function collectLayoutIssues(
|
||
page: import("puppeteer-core").Page,
|
||
samples: number[],
|
||
tolerance: number,
|
||
): Promise<LayoutIssue[]> {
|
||
if (samples.length === 0) return [];
|
||
await page.addScriptTag({ content: loadLayoutAuditScript() });
|
||
|
||
const issues: LayoutIssue[] = [];
|
||
for (const time of samples) {
|
||
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||
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 },
|
||
);
|
||
issues.push(...(sampleIssues as LayoutIssue[]));
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/** Reject selectors matching multiple elements — first-match-only sampling silently passes for siblings. */
|
||
async function findAmbiguousSelectors(
|
||
page: import("puppeteer-core").Page,
|
||
selectors: string[],
|
||
): Promise<LayoutIssue[]> {
|
||
if (selectors.length === 0) return [];
|
||
const multiMatch = await page.evaluate(
|
||
(sels: string[]) =>
|
||
sels.filter((sel) => {
|
||
try {
|
||
return document.querySelectorAll(sel).length > 1;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}),
|
||
selectors,
|
||
);
|
||
return multiMatch.map(ambiguousIssue);
|
||
}
|
||
|
||
async function collectMotionFrames(
|
||
page: import("puppeteer-core").Page,
|
||
times: number[],
|
||
selectors: string[],
|
||
livenessScopes: string[],
|
||
): Promise<MotionFrame[]> {
|
||
const frames: MotionFrame[] = [];
|
||
for (const time of times) {
|
||
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||
const sample = await page.evaluate(
|
||
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
||
const win = window as unknown as {
|
||
__hyperframesMotionSample?: (o: { selectors: string[]; livenessScopes: string[] }) => {
|
||
data: MotionFrame["data"];
|
||
liveness: Record<string, string>;
|
||
};
|
||
};
|
||
return win.__hyperframesMotionSample?.(options) ?? { data: {}, liveness: {} };
|
||
},
|
||
{ selectors, livenessScopes },
|
||
);
|
||
frames.push({ time, data: sample.data, liveness: sample.liveness });
|
||
}
|
||
return frames;
|
||
}
|
||
|
||
/**
|
||
* Motion verification (#1437): sample the asserted selectors on a dense grid
|
||
* against the same seeked timeline the renderer uses, then evaluate the spec's
|
||
* assertions in Node. Reuses the live page from the layout audit — no extra
|
||
* Chrome launch. Findings reuse the LayoutIssue shape.
|
||
*/
|
||
async function runMotionPass(
|
||
page: import("puppeteer-core").Page,
|
||
spec: MotionSpec,
|
||
duration: number,
|
||
): Promise<{ issues: LayoutIssue[]; sampleCount: number }> {
|
||
const times = buildMotionSampleTimes(spec.duration ?? duration);
|
||
if (times.length === 0) return { issues: [], sampleCount: 0 };
|
||
|
||
const { selectors, livenessScopes } = collectSamplingTargets(spec.assertions);
|
||
const ambiguous = await findAmbiguousSelectors(page, selectors);
|
||
if (ambiguous.length > 0) return { issues: ambiguous, sampleCount: 0 };
|
||
|
||
const canvas = await page.evaluate(() => ({
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
}));
|
||
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
|
||
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
|
||
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
|
||
}
|
||
|
||
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
|
||
function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
|
||
const parsed = readMotionSpec(specPath);
|
||
if (parsed.ok) return parsed.spec;
|
||
|
||
const message = `Invalid motion spec ${specPath}: ${parsed.errors.join("; ")}`;
|
||
if (json) {
|
||
console.log(
|
||
JSON.stringify(
|
||
withMeta(
|
||
{
|
||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||
ok: false,
|
||
error: message,
|
||
issues: [],
|
||
errorCount: 0,
|
||
warningCount: 0,
|
||
infoCount: 0,
|
||
issueCount: 0,
|
||
},
|
||
{ deprecated: true },
|
||
),
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
} else {
|
||
console.error(`${c.error("✗")} ${message}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
export 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/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)",
|
||
},
|
||
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)",
|
||
},
|
||
"at-transitions": {
|
||
type: "boolean",
|
||
description:
|
||
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
|
||
default: false,
|
||
},
|
||
"max-transition-samples": {
|
||
type: "string",
|
||
description:
|
||
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
|
||
},
|
||
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,
|
||
},
|
||
},
|
||
// Pre-existing command-run branching; U1 only swapped the seek internals.
|
||
// fallow-ignore-next-line complexity
|
||
async run({ args }) {
|
||
printDeprecationNotice(commandName);
|
||
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 atTransitions = !!args["at-transitions"];
|
||
const maxTransitionSamplesRaw = parseInt(args["max-transition-samples"] as string, 10);
|
||
const maxTransitionSamples =
|
||
Number.isFinite(maxTransitionSamplesRaw) && maxTransitionSamplesRaw > 0
|
||
? maxTransitionSamplesRaw
|
||
: undefined;
|
||
const strict = !!args.strict;
|
||
const collapseStatic = args["collapse-static"] !== false;
|
||
|
||
// Motion verification (#1437): an optional `*.motion.json` sidecar opts the
|
||
// composition into seeked-timeline assertion checks. Absent → layout-only.
|
||
const motionSpecPath = findMotionSpec(project.dir);
|
||
const motionSpec = motionSpecPath
|
||
? resolveMotionSpec(motionSpecPath, !!args.json)
|
||
: undefined;
|
||
|
||
if (!args.json) {
|
||
const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`;
|
||
const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel;
|
||
const motionLabel = motionSpec
|
||
? ` + motion spec (${motionSpec.assertions.length} assertion(s))`
|
||
: "";
|
||
console.log(
|
||
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel}${motionLabel})`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
const result = await runLayoutAudit(project.dir, {
|
||
samples,
|
||
at,
|
||
atTransitions,
|
||
maxTransitionSamples,
|
||
timeout,
|
||
tolerance,
|
||
motion: motionSpec,
|
||
});
|
||
if (!args.json && result.transitionSamplesDropped > 0) {
|
||
console.log(
|
||
`${c.warn("⚠")} ${result.transitionSamplesDropped} transition sample(s) omitted by --max-transition-samples; raise or drop it to sample every boundary`,
|
||
);
|
||
}
|
||
const allIssues = collapseStatic
|
||
? collapseStaticLayoutIssues(result.rawIssues, result.samples.length)
|
||
: 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,
|
||
transitionSamples: atTransitions ? result.transitionSamples : undefined,
|
||
transitionSamplesDropped: atTransitions
|
||
? result.transitionSamplesDropped
|
||
: undefined,
|
||
tolerance,
|
||
strict,
|
||
collapseStatic,
|
||
motionSpec: motionSpec ? motionSpecPath : undefined,
|
||
motionSamples: motionSpec ? result.motionSamples : undefined,
|
||
...summary,
|
||
totalIssueCount: limited.totalIssueCount,
|
||
truncated: limited.truncated,
|
||
ok,
|
||
issues: limited.issues,
|
||
},
|
||
{ deprecated: true },
|
||
),
|
||
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 = normalizeErrorMessage(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,
|
||
},
|
||
{ deprecated: true },
|
||
),
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
process.exit(1);
|
||
}
|
||
console.error(`${c.error("✗")} Inspect failed: ${message}`);
|
||
process.exit(1);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
export default createInspectCommand("layout");
|