Skip to content

Commit 6364281

Browse files
authored
feat(cli): add --at-transitions to inspect for sampling at tween boundaries (#1386)
* feat(cli): add --at-transitions to inspect for sampling at tween boundaries Even spacing samples are structurally blind to sub-second overlap windows at transition seams - a 0.2s caption collision slips between samples by construction (#1380). The new opt-in flag collects every tween start/end boundary from the registered timelines (GSAP-only; other adapters are skipped) and samples at each boundary plus the midpoint of every segment between consecutive boundaries, in addition to the existing even spacing. 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. Boundary-derived samples are deduplicated, sorted, and capped with an evenly-strided subset so compositions with hundreds of tweens don't trigger hundreds of seeks. Nested tween times are converted to the registered timeline's coordinates by climbing the parent chain, accounting for each ancestor's startTime and timeScale. The JSON output gains a transitionSamples field when the flag is on. Fixes #1380 * fix(cli): sample every transition boundary by default; cap only on explicit request Review follow-up on #1386: the silent cap of 40 contradicted the flag's promise - on a dense timeline the strided subset could skip the exact short boundary window the mode exists to catch, with no indication that samples were omitted. --at-transitions now samples every collected boundary by default. The cap only applies when the new --max-transition-samples flag is passed, and when it truncates, the omitted count is reported both as a console warning and as transitionSamplesDropped in the JSON output.
1 parent a037505 commit 6364281

4 files changed

Lines changed: 248 additions & 26 deletions

File tree

packages/cli/src/commands/inspect.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export const examples: Example[] = [
66
["Inspect a specific project", "hyperframes inspect ./my-video"],
77
["Output agent-readable JSON", "hyperframes inspect --json"],
88
["Use explicit hero-frame timestamps", "hyperframes inspect --at 1.5,4.0,7.25"],
9+
[
10+
"Also sample at tween boundaries to catch transient overlaps",
11+
"hyperframes inspect --at-transitions",
12+
],
913
["Run the compatibility alias", "hyperframes layout --json"],
1014
];
1115

packages/cli/src/commands/layout.ts

Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
99
import { withMeta } from "../utils/updateCheck.js";
1010
import {
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

3238
interface 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+
6788
async 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+
109174
async 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

134199
async 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,

packages/cli/src/utils/layoutAudit.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,67 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
buildLayoutSampleTimes,
4+
buildTransitionSampleTimes,
45
computeOverflow,
56
collapseStaticLayoutIssues,
67
limitLayoutIssues,
8+
mergeSampleTimes,
79
summarizeLayoutIssues,
810
formatLayoutIssue,
911
type LayoutIssue,
1012
} from "./layoutAudit.js";
1113

14+
describe("buildTransitionSampleTimes (#1380)", () => {
15+
it("samples boundaries plus the midpoint of each segment between them", () => {
16+
// The #1380 repro: capA fades out 11.33–11.55, capB slams in 11.35–11.69.
17+
// The collision window 11.35–11.55 only shows both captions half-visible
18+
// away from the exact boundaries — the midpoints land inside it.
19+
const result = buildTransitionSampleTimes({
20+
duration: 20,
21+
boundaries: [11.33, 11.55, 11.35, 11.69],
22+
});
23+
expect(result.times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 11.69]);
24+
expect(result.dropped).toBe(0);
25+
});
26+
27+
it("drops boundaries outside the composition and dedupes repeats", () => {
28+
const result = buildTransitionSampleTimes({
29+
duration: 10,
30+
boundaries: [2, 2, -1, 10.5, NaN, 4],
31+
});
32+
expect(result.times).toEqual([2, 3, 4]);
33+
expect(result.dropped).toBe(0);
34+
});
35+
36+
it("returns an empty list without a valid duration", () => {
37+
expect(buildTransitionSampleTimes({ duration: 0, boundaries: [1, 2] })).toEqual({
38+
times: [],
39+
dropped: 0,
40+
});
41+
});
42+
43+
it("samples every collected boundary when no cap is given", () => {
44+
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
45+
const result = buildTransitionSampleTimes({ duration: 10, boundaries });
46+
// 200 boundaries + 199 segment midpoints, all distinct after rounding.
47+
expect(result.times.length).toBe(399);
48+
expect(result.dropped).toBe(0);
49+
});
50+
51+
it("caps only on explicit request, reporting the omitted count and keeping the extremes", () => {
52+
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
53+
const result = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 });
54+
expect(result.times.length).toBeLessThanOrEqual(40);
55+
expect(result.dropped).toBe(399 - result.times.length);
56+
expect(result.times[0]).toBe(0);
57+
expect(result.times[result.times.length - 1]).toBeCloseTo(9.95, 3);
58+
});
59+
60+
it("merges with even-spacing samples into one deduplicated ascending list", () => {
61+
expect(mergeSampleTimes([1, 3, 5], [3, 2.5, 7])).toEqual([1, 2.5, 3, 5, 7]);
62+
});
63+
});
64+
1265
describe("layoutAudit helpers", () => {
1366
it("samples the whole duration using stable midpoint timestamps", () => {
1467
expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]);

0 commit comments

Comments
 (0)