diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 1c79a7051..a791f593d 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -6,6 +6,10 @@ export const examples: Example[] = [ ["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"], + [ + "Also sample at tween boundaries to catch transient overlaps", + "hyperframes inspect --at-transitions", + ], ["Run the compatibility alias", "hyperframes layout --json"], ]; diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index fa27dd70b..874d379ed 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -9,10 +9,12 @@ import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, + buildTransitionSampleTimes, collapseStaticLayoutIssues, dedupeLayoutIssues, formatLayoutIssue, limitLayoutIssues, + mergeSampleTimes, summarizeLayoutIssues, type LayoutIssue, } from "../utils/layoutAudit.js"; @@ -27,11 +29,17 @@ export const examples: Example[] = [ ["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", + ], ]; interface LayoutAuditResult { duration: number; samples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; rawIssues: LayoutIssue[]; } @@ -64,6 +72,19 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } +async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise { + await page + .evaluate((ms: number) => { + 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, ms)), + ]); + }, timeoutMs) + .catch(() => {}); +} + async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { await page.evaluate((t: number) => { const win = window as unknown as { @@ -93,19 +114,63 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis 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 waitForFonts(page, 500); await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); } +/** + * 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 { + 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 }; + return Object.values(win.__timelines ?? {}).flatMap(timelineBoundaries); + }); +} + 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. @@ -133,7 +198,14 @@ async function alignViewportToComposition( async function runLayoutAudit( projectDir: string, - opts: { samples: number; at?: number[]; timeout: number; tolerance: number }, + opts: { + samples: number; + at?: number[]; + atTransitions: boolean; + maxTransitionSamples?: number; + timeout: number; + tolerance: number; + }, ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); @@ -169,21 +241,27 @@ async function runLayoutAudit( 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 waitForFonts(page, 750); 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: [] }; + 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); + if (samples.length === 0) { + return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] }; + } await page.addScriptTag({ content: loadLayoutAuditScript() }); @@ -205,6 +283,8 @@ async function runLayoutAudit( return { duration, samples, + transitionSamples, + transitionSamplesDropped, rawIssues: dedupeLayoutIssues(issues), }; } finally { @@ -253,6 +333,17 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 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)", @@ -286,13 +377,18 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 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; if (!args.json) { - const sampleLabel = at - ? `${at.length} explicit timestamp(s)` - : `${samples} timeline samples`; + const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`; + const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel; console.log( `${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`, ); @@ -302,9 +398,16 @@ export function createInspectCommand(commandName: "inspect" | "layout") { const result = await runLayoutAudit(project.dir, { samples, at, + atTransitions, + maxTransitionSamples, timeout, tolerance, }); + 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.rawIssues; @@ -319,6 +422,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") { schemaVersion: INSPECT_SCHEMA_VERSION, duration: result.duration, samples: result.samples, + transitionSamples: atTransitions ? result.transitionSamples : undefined, + transitionSamplesDropped: atTransitions + ? result.transitionSamplesDropped + : undefined, tolerance, strict, collapseStatic, diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index f4fc1f30c..f74899205 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -1,14 +1,67 @@ import { describe, expect, it } from "vitest"; import { buildLayoutSampleTimes, + buildTransitionSampleTimes, computeOverflow, collapseStaticLayoutIssues, limitLayoutIssues, + mergeSampleTimes, summarizeLayoutIssues, formatLayoutIssue, type LayoutIssue, } from "./layoutAudit.js"; +describe("buildTransitionSampleTimes (#1380)", () => { + it("samples boundaries plus the midpoint of each segment between them", () => { + // The #1380 repro: capA fades out 11.33–11.55, capB slams in 11.35–11.69. + // The collision window 11.35–11.55 only shows both captions half-visible + // away from the exact boundaries — the midpoints land inside it. + const result = buildTransitionSampleTimes({ + duration: 20, + boundaries: [11.33, 11.55, 11.35, 11.69], + }); + expect(result.times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 11.69]); + expect(result.dropped).toBe(0); + }); + + it("drops boundaries outside the composition and dedupes repeats", () => { + const result = buildTransitionSampleTimes({ + duration: 10, + boundaries: [2, 2, -1, 10.5, NaN, 4], + }); + expect(result.times).toEqual([2, 3, 4]); + expect(result.dropped).toBe(0); + }); + + it("returns an empty list without a valid duration", () => { + expect(buildTransitionSampleTimes({ duration: 0, boundaries: [1, 2] })).toEqual({ + times: [], + dropped: 0, + }); + }); + + it("samples every collected boundary when no cap is given", () => { + const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05); + const result = buildTransitionSampleTimes({ duration: 10, boundaries }); + // 200 boundaries + 199 segment midpoints, all distinct after rounding. + expect(result.times.length).toBe(399); + expect(result.dropped).toBe(0); + }); + + it("caps only on explicit request, reporting the omitted count and keeping the extremes", () => { + const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05); + const result = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 }); + expect(result.times.length).toBeLessThanOrEqual(40); + expect(result.dropped).toBe(399 - result.times.length); + expect(result.times[0]).toBe(0); + expect(result.times[result.times.length - 1]).toBeCloseTo(9.95, 3); + }); + + it("merges with even-spacing samples into one deduplicated ascending list", () => { + expect(mergeSampleTimes([1, 3, 5], [3, 2.5, 7])).toEqual([1, 2.5, 3, 5, 7]); + }); +}); + describe("layoutAudit helpers", () => { it("samples the whole duration using stable midpoint timestamps", () => { expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]); diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 237366b54..f02e8211b 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -217,6 +217,64 @@ function uniqueSortedTimes(times: number[]): number[] { return [...new Set(rounded)].sort((a, b) => a - b); } +export interface TransitionSampleOptions { + duration: number; + boundaries: number[]; + /** Optional hard limit on the returned sample count. No limit when absent. */ + cap?: number; +} + +export interface TransitionSamples { + times: number[]; + /** Sample times omitted because of `cap`. Always 0 when no cap is given. */ + dropped: number; +} + +/** + * Build sample times from tween start/end boundaries: the boundaries + * themselves plus the midpoint of every segment between consecutive + * boundaries. Boundary frames are where transient overlaps live (#1380), but + * sampling exactly at a boundary can land on an element at opacity 0 — the + * segment midpoints catch the window where both sides of a transition are + * partially visible. Every collected boundary is sampled unless the caller + * passes an explicit `cap`, in which case the result is an evenly-strided + * subset and `dropped` reports how many sample times were omitted. + */ +export function buildTransitionSampleTimes({ + duration, + boundaries, + cap, +}: TransitionSampleOptions): TransitionSamples { + if (!Number.isFinite(duration) || duration <= 0) return { times: [], dropped: 0 }; + const inRange = uniqueSortedTimes( + boundaries.filter((time) => Number.isFinite(time) && time >= 0 && time <= duration), + ); + const withMidpoints = [...inRange]; + for (let i = 0; i < inRange.length - 1; i++) { + const current = inRange[i]; + const next = inRange[i + 1]; + if (current === undefined || next === undefined) continue; + withMidpoints.push(roundTime((current + next) / 2)); + } + const merged = uniqueSortedTimes(withMidpoints); + if (cap === undefined || merged.length <= Math.max(2, cap)) { + return { times: merged, dropped: 0 }; + } + const limit = Math.max(2, cap); + const strided: number[] = []; + for (let i = 0; i < limit; i++) { + const pick = merged[Math.floor((i * (merged.length - 1)) / (limit - 1))]; + if (pick !== undefined) strided.push(pick); + } + const times = uniqueSortedTimes(strided); + return { times, dropped: merged.length - times.length }; +} + +/** Merge sample-time lists into one deduplicated ascending list. */ +export function mergeSampleTimes(...lists: number[][]): number[] { + return uniqueSortedTimes(lists.flat()); +} + function formatOverflow(overflow: LayoutOverflow): string { return (["left", "right", "top", "bottom"] as const) .flatMap((side) => {