@@ -9,10 +9,12 @@ import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
99import { withMeta } from "../utils/updateCheck.js" ;
1010import {
1111 buildLayoutSampleTimes ,
12+ buildTransitionSampleTimes ,
1213 collapseStaticLayoutIssues ,
1314 dedupeLayoutIssues ,
1415 formatLayoutIssue ,
1516 limitLayoutIssues ,
17+ mergeSampleTimes ,
1618 summarizeLayoutIssues ,
1719 type LayoutIssue ,
1820} from "../utils/layoutAudit.js" ;
@@ -27,11 +29,17 @@ export const examples: Example[] = [
2729 [ "Inspect a specific project" , "hyperframes layout ./my-video" ] ,
2830 [ "Output agent-readable JSON" , "hyperframes layout --json" ] ,
2931 [ "Use explicit hero-frame timestamps" , "hyperframes layout --at 1.5,4.0,7.25" ] ,
32+ [
33+ "Also sample at tween boundaries to catch transient overlaps" ,
34+ "hyperframes layout --at-transitions" ,
35+ ] ,
3036] ;
3137
3238interface LayoutAuditResult {
3339 duration : number ;
3440 samples : number [ ] ;
41+ transitionSamples : number [ ] ;
42+ transitionSamplesDropped : number ;
3543 rawIssues : LayoutIssue [ ] ;
3644}
3745
@@ -64,6 +72,19 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
6472 } ) ;
6573}
6674
75+ async function waitForFonts ( page : import ( "puppeteer-core" ) . Page , timeoutMs : number ) : Promise < void > {
76+ await page
77+ . evaluate ( ( ms : number ) => {
78+ const fonts = ( document as Document & { fonts ?: FontFaceSet } ) . fonts ;
79+ if ( ! fonts ?. ready ) return Promise . resolve ( ) ;
80+ return Promise . race ( [
81+ fonts . ready . then ( ( ) => undefined ) ,
82+ new Promise < void > ( ( resolve ) => setTimeout ( resolve , ms ) ) ,
83+ ] ) ;
84+ } , timeoutMs )
85+ . catch ( ( ) => { } ) ;
86+ }
87+
6788async function seekTo ( page : import ( "puppeteer-core" ) . Page , time : number ) : Promise < void > {
6889 await page . evaluate ( ( t : number ) => {
6990 const win = window as unknown as {
@@ -93,19 +114,63 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis
93114 requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => resolveFrame ( ) ) ) ,
94115 ) ,
95116 ) ;
96- await page
97- . evaluate ( ( ) => {
98- const fonts = ( document as Document & { fonts ?: FontFaceSet } ) . fonts ;
99- if ( ! fonts ?. ready ) return Promise . resolve ( ) ;
100- return Promise . race ( [
101- fonts . ready . then ( ( ) => undefined ) ,
102- new Promise < void > ( ( resolve ) => setTimeout ( resolve , 500 ) ) ,
103- ] ) ;
104- } )
105- . catch ( ( ) => { } ) ;
117+ await waitForFonts ( page , 500 ) ;
106118 await new Promise ( ( resolveSettle ) => setTimeout ( resolveSettle , SEEK_SETTLE_MS ) ) ;
107119}
108120
121+ /**
122+ * Collect every tween start/end boundary from the registered timelines,
123+ * expressed in the registered timeline's own time (what seekTo consumes).
124+ * GSAP-only: timelines without getChildren (Anime/Lottie/Three adapters) are
125+ * skipped. Nested tween times are converted by climbing the parent chain,
126+ * accounting for each ancestor's startTime and timeScale.
127+ */
128+ async function collectTweenBoundaries ( page : import ( "puppeteer-core" ) . Page ) : Promise < number [ ] > {
129+ return page . evaluate ( ( ) => {
130+ type AnimLike = {
131+ startTime ?: ( ) => number ;
132+ duration ?: ( ) => number ;
133+ timeScale ?: ( ) => number ;
134+ parent ?: AnimLike | null ;
135+ getChildren ?: ( nested : boolean , tweens : boolean , timelines : boolean ) => AnimLike [ ] ;
136+ } ;
137+
138+ // GSAP getters read internal state through `this`, so the method must be
139+ // invoked bound to its animation (an unbound call throws inside GSAP).
140+ const callOr = ( fn : ( ( ) => number ) | undefined , self : AnimLike , fallback : number ) : number =>
141+ typeof fn === "function" ? fn . call ( self ) : fallback ;
142+
143+ const toTimelineTime = ( root : AnimLike , anim : AnimLike , localTime : number ) : number => {
144+ let time = localTime ;
145+ let node : AnimLike | null | undefined = anim ;
146+ while ( node && node !== root ) {
147+ time = callOr ( node . startTime , node , 0 ) + time / ( callOr ( node . timeScale , node , 1 ) || 1 ) ;
148+ node = node . parent ;
149+ }
150+ return time ;
151+ } ;
152+
153+ const tweenBoundaries = ( root : AnimLike , tween : AnimLike ) : number [ ] => {
154+ if ( typeof tween . duration !== "function" ) return [ ] ;
155+ const start = toTimelineTime ( root , tween , 0 ) ;
156+ const end = toTimelineTime ( root , tween , tween . duration ( ) ) ;
157+ return [ start , end ] . filter ( ( time ) => Number . isFinite ( time ) ) ;
158+ } ;
159+
160+ const timelineBoundaries = ( timeline : AnimLike ) : number [ ] => {
161+ try {
162+ const tweens = timeline . getChildren ?.( true , true , false ) ?? [ ] ;
163+ return tweens . flatMap ( ( tween ) => tweenBoundaries ( timeline , tween ) ) ;
164+ } catch {
165+ return [ ] ;
166+ }
167+ } ;
168+
169+ const win = window as unknown as { __timelines ?: Record < string , AnimLike > } ;
170+ return Object . values ( win . __timelines ?? { } ) . flatMap ( timelineBoundaries ) ;
171+ } ) ;
172+ }
173+
109174async function bundleProjectHtml ( projectDir : string ) : Promise < string > {
110175 // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
111176 // previous post-bundle runtime substitution is no longer needed.
@@ -133,7 +198,14 @@ async function alignViewportToComposition(
133198
134199async function runLayoutAudit (
135200 projectDir : string ,
136- opts : { samples : number ; at ?: number [ ] ; timeout : number ; tolerance : number } ,
201+ opts : {
202+ samples : number ;
203+ at ?: number [ ] ;
204+ atTransitions : boolean ;
205+ maxTransitionSamples ?: number ;
206+ timeout : number ;
207+ tolerance : number ;
208+ } ,
137209) : Promise < LayoutAuditResult > {
138210 const { ensureBrowser } = await import ( "../browser/manager.js" ) ;
139211 const puppeteer = await import ( "puppeteer-core" ) ;
@@ -169,21 +241,27 @@ async function runLayoutAudit(
169241 timeout : opts . timeout ,
170242 } )
171243 . catch ( ( ) => { } ) ;
172- await page
173- . evaluate ( ( ) => {
174- const fonts = ( document as Document & { fonts ?: FontFaceSet } ) . fonts ;
175- if ( ! fonts ?. ready ) return Promise . resolve ( ) ;
176- return Promise . race ( [
177- fonts . ready . then ( ( ) => undefined ) ,
178- new Promise < void > ( ( resolve ) => setTimeout ( resolve , 750 ) ) ,
179- ] ) ;
180- } )
181- . catch ( ( ) => { } ) ;
244+ await waitForFonts ( page , 750 ) ;
182245 await new Promise ( ( resolveSettle ) => setTimeout ( resolveSettle , 250 ) ) ;
183246
184247 const duration = await getCompositionDuration ( page ) ;
185- const samples = buildLayoutSampleTimes ( { duration, samples : opts . samples , at : opts . at } ) ;
186- if ( samples . length === 0 ) return { duration, samples, rawIssues : [ ] } ;
248+ const baseSamples = buildLayoutSampleTimes ( { duration, samples : opts . samples , at : opts . at } ) ;
249+ let transitionSamples : number [ ] = [ ] ;
250+ let transitionSamplesDropped = 0 ;
251+ if ( opts . atTransitions ) {
252+ const boundaries = await collectTweenBoundaries ( page ) ;
253+ const transitions = buildTransitionSampleTimes ( {
254+ duration,
255+ boundaries,
256+ cap : opts . maxTransitionSamples ,
257+ } ) ;
258+ transitionSamples = transitions . times ;
259+ transitionSamplesDropped = transitions . dropped ;
260+ }
261+ const samples = mergeSampleTimes ( baseSamples , transitionSamples ) ;
262+ if ( samples . length === 0 ) {
263+ return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues : [ ] } ;
264+ }
187265
188266 await page . addScriptTag ( { content : loadLayoutAuditScript ( ) } ) ;
189267
@@ -205,6 +283,8 @@ async function runLayoutAudit(
205283 return {
206284 duration,
207285 samples,
286+ transitionSamples,
287+ transitionSamplesDropped,
208288 rawIssues : dedupeLayoutIssues ( issues ) ,
209289 } ;
210290 } finally {
@@ -253,6 +333,17 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
253333 type : "string" ,
254334 description : "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)" ,
255335 } ,
336+ "at-transitions" : {
337+ type : "boolean" ,
338+ description :
339+ "Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams" ,
340+ default : false ,
341+ } ,
342+ "max-transition-samples" : {
343+ type : "string" ,
344+ description :
345+ "Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)" ,
346+ } ,
256347 tolerance : {
257348 type : "string" ,
258349 description : "Allowed pixel overflow before reporting an issue (default: 2)" ,
@@ -286,13 +377,18 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
286377 const timeout = Math . max ( 500 , parseInt ( args . timeout as string , 10 ) || 5000 ) ;
287378 const maxIssues = Math . max ( 1 , parseInt ( args [ "max-issues" ] as string , 10 ) || 80 ) ;
288379 const at = parseAt ( args . at ) ;
380+ const atTransitions = ! ! args [ "at-transitions" ] ;
381+ const maxTransitionSamplesRaw = parseInt ( args [ "max-transition-samples" ] as string , 10 ) ;
382+ const maxTransitionSamples =
383+ Number . isFinite ( maxTransitionSamplesRaw ) && maxTransitionSamplesRaw > 0
384+ ? maxTransitionSamplesRaw
385+ : undefined ;
289386 const strict = ! ! args . strict ;
290387 const collapseStatic = args [ "collapse-static" ] !== false ;
291388
292389 if ( ! args . json ) {
293- const sampleLabel = at
294- ? `${ at . length } explicit timestamp(s)`
295- : `${ samples } timeline samples` ;
390+ const baseLabel = at ? `${ at . length } explicit timestamp(s)` : `${ samples } timeline samples` ;
391+ const sampleLabel = atTransitions ? `${ baseLabel } + transition boundaries` : baseLabel ;
296392 console . log (
297393 `${ c . accent ( "◆" ) } Inspecting layout for ${ c . accent ( project . name ) } (${ sampleLabel } )` ,
298394 ) ;
@@ -302,9 +398,16 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
302398 const result = await runLayoutAudit ( project . dir , {
303399 samples,
304400 at,
401+ atTransitions,
402+ maxTransitionSamples,
305403 timeout,
306404 tolerance,
307405 } ) ;
406+ if ( ! args . json && result . transitionSamplesDropped > 0 ) {
407+ console . log (
408+ `${ c . warn ( "⚠" ) } ${ result . transitionSamplesDropped } transition sample(s) omitted by --max-transition-samples; raise or drop it to sample every boundary` ,
409+ ) ;
410+ }
308411 const allIssues = collapseStatic
309412 ? collapseStaticLayoutIssues ( result . rawIssues )
310413 : result . rawIssues ;
@@ -319,6 +422,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
319422 schemaVersion : INSPECT_SCHEMA_VERSION ,
320423 duration : result . duration ,
321424 samples : result . samples ,
425+ transitionSamples : atTransitions ? result . transitionSamples : undefined ,
426+ transitionSamplesDropped : atTransitions
427+ ? result . transitionSamplesDropped
428+ : undefined ,
322429 tolerance,
323430 strict,
324431 collapseStatic,
0 commit comments