From 3cdb0228766c4d5712f64255facc5486b1a37df2 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Fri, 12 Jun 2026 11:30:45 -0700 Subject: [PATCH 1/2] 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 --- packages/cli/src/commands/inspect.ts | 4 + packages/cli/src/commands/layout.ts | 128 ++++++++++++++++----- packages/cli/src/utils/layoutAudit.test.ts | 39 +++++++ packages/cli/src/utils/layoutAudit.ts | 49 ++++++++ 4 files changed, 194 insertions(+), 26 deletions(-) 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..b9fafe557 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,16 @@ 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[]; rawIssues: LayoutIssue[]; } @@ -64,6 +71,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 +113,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 +197,13 @@ async function alignViewportToComposition( async function runLayoutAudit( projectDir: string, - opts: { samples: number; at?: number[]; timeout: number; tolerance: number }, + opts: { + samples: number; + at?: number[]; + atTransitions: boolean; + timeout: number; + tolerance: number; + }, ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); @@ -169,21 +239,18 @@ 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[] = []; + if (opts.atTransitions) { + const boundaries = await collectTweenBoundaries(page); + transitionSamples = buildTransitionSampleTimes({ duration, boundaries }); + } + const samples = mergeSampleTimes(baseSamples, transitionSamples); + if (samples.length === 0) return { duration, samples, transitionSamples, rawIssues: [] }; await page.addScriptTag({ content: loadLayoutAuditScript() }); @@ -205,6 +272,7 @@ async function runLayoutAudit( return { duration, samples, + transitionSamples, rawIssues: dedupeLayoutIssues(issues), }; } finally { @@ -253,6 +321,12 @@ 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, + }, tolerance: { type: "string", description: "Allowed pixel overflow before reporting an issue (default: 2)", @@ -286,13 +360,13 @@ 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 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,6 +376,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { const result = await runLayoutAudit(project.dir, { samples, at, + atTransitions, timeout, tolerance, }); @@ -319,6 +394,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { schemaVersion: INSPECT_SCHEMA_VERSION, duration: result.duration, samples: result.samples, + transitionSamples: atTransitions ? result.transitionSamples : undefined, tolerance, strict, collapseStatic, diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index f4fc1f30c..5fe1a0dfd 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -1,14 +1,53 @@ 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 times = buildTransitionSampleTimes({ + duration: 20, + boundaries: [11.33, 11.55, 11.35, 11.69], + }); + expect(times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 11.69]); + }); + + it("drops boundaries outside the composition and dedupes repeats", () => { + const times = buildTransitionSampleTimes({ + duration: 10, + boundaries: [2, 2, -1, 10.5, NaN, 4], + }); + expect(times).toEqual([2, 3, 4]); + }); + + it("returns an empty list without a valid duration", () => { + expect(buildTransitionSampleTimes({ duration: 0, boundaries: [1, 2] })).toEqual([]); + }); + + it("caps the result with an evenly-strided subset that keeps the extremes", () => { + const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05); + const times = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 }); + expect(times.length).toBeLessThanOrEqual(40); + expect(times[0]).toBe(0); + expect(times[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..72a505bc2 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -217,6 +217,55 @@ function uniqueSortedTimes(times: number[]): number[] { return [...new Set(rounded)].sort((a, b) => a - b); } +const DEFAULT_TRANSITION_SAMPLE_CAP = 40; + +export interface TransitionSampleOptions { + duration: number; + boundaries: number[]; + cap?: 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. Capped with an evenly-strided subset so compositions + * with hundreds of tweens don't trigger hundreds of seeks. + */ +export function buildTransitionSampleTimes({ + duration, + boundaries, + cap, +}: TransitionSampleOptions): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + 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); + const limit = Math.max(2, cap ?? DEFAULT_TRANSITION_SAMPLE_CAP); + if (merged.length <= limit) return merged; + 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); + } + return uniqueSortedTimes(strided); +} + +/** 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) => { From a497a5c04c99b0ab647206ce2c9e453657d130b3 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Fri, 12 Jun 2026 12:23:53 -0700 Subject: [PATCH 2/2] 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. --- packages/cli/src/commands/layout.ts | 35 ++++++++++++++++++++-- packages/cli/src/utils/layoutAudit.test.ts | 34 ++++++++++++++------- packages/cli/src/utils/layoutAudit.ts | 27 +++++++++++------ 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index b9fafe557..874d379ed 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -39,6 +39,7 @@ interface LayoutAuditResult { duration: number; samples: number[]; transitionSamples: number[]; + transitionSamplesDropped: number; rawIssues: LayoutIssue[]; } @@ -201,6 +202,7 @@ async function runLayoutAudit( samples: number; at?: number[]; atTransitions: boolean; + maxTransitionSamples?: number; timeout: number; tolerance: number; }, @@ -245,12 +247,21 @@ async function runLayoutAudit( const duration = await getCompositionDuration(page); const baseSamples = buildLayoutSampleTimes({ duration, samples: opts.samples, at: opts.at }); let transitionSamples: number[] = []; + let transitionSamplesDropped = 0; if (opts.atTransitions) { const boundaries = await collectTweenBoundaries(page); - transitionSamples = buildTransitionSampleTimes({ duration, boundaries }); + 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, rawIssues: [] }; + if (samples.length === 0) { + return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] }; + } await page.addScriptTag({ content: loadLayoutAuditScript() }); @@ -273,6 +284,7 @@ async function runLayoutAudit( duration, samples, transitionSamples, + transitionSamplesDropped, rawIssues: dedupeLayoutIssues(issues), }; } finally { @@ -327,6 +339,11 @@ export function createInspectCommand(commandName: "inspect" | "layout") { "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)", @@ -361,6 +378,11 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 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; @@ -377,9 +399,15 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 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; @@ -395,6 +423,9 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 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 5fe1a0dfd..f74899205 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -16,31 +16,45 @@ describe("buildTransitionSampleTimes (#1380)", () => { // 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 times = buildTransitionSampleTimes({ + const result = buildTransitionSampleTimes({ duration: 20, boundaries: [11.33, 11.55, 11.35, 11.69], }); - expect(times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 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 times = buildTransitionSampleTimes({ + const result = buildTransitionSampleTimes({ duration: 10, boundaries: [2, 2, -1, 10.5, NaN, 4], }); - expect(times).toEqual([2, 3, 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([]); + 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 the result with an evenly-strided subset that keeps the extremes", () => { + 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 times = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 }); - expect(times.length).toBeLessThanOrEqual(40); - expect(times[0]).toBe(0); - expect(times[times.length - 1]).toBeCloseTo(9.95, 3); + 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", () => { diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 72a505bc2..f02e8211b 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -217,29 +217,35 @@ function uniqueSortedTimes(times: number[]): number[] { return [...new Set(rounded)].sort((a, b) => a - b); } -const DEFAULT_TRANSITION_SAMPLE_CAP = 40; - 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. Capped with an evenly-strided subset so compositions - * with hundreds of tweens don't trigger hundreds of seeks. + * 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): number[] { - if (!Number.isFinite(duration) || duration <= 0) return []; +}: 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), ); @@ -251,14 +257,17 @@ export function buildTransitionSampleTimes({ withMidpoints.push(roundTime((current + next) / 2)); } const merged = uniqueSortedTimes(withMidpoints); - const limit = Math.max(2, cap ?? DEFAULT_TRANSITION_SAMPLE_CAP); - if (merged.length <= limit) return merged; + 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); } - return uniqueSortedTimes(strided); + const times = uniqueSortedTimes(strided); + return { times, dropped: merged.length - times.length }; } /** Merge sample-time lists into one deduplicated ascending list. */