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.
This commit is contained in:
Leonel Rivas
2026-06-13 01:33:31 -04:00
committed by GitHub
parent a037505176
commit 6364281ba0
4 changed files with 248 additions and 26 deletions
@@ -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.3311.55, capB slams in 11.3511.69.
// The collision window 11.3511.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]);
+58
View File
@@ -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) => {