mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user