Files
hyperframes/packages/studio/src/player/components/timelineLayout.ts
T
Vance IngallsandClaude Sonnet 5 a11883e6d1 feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 02:12:22 -07:00

601 lines
23 KiB
TypeScript

import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { ZoomMode } from "../store/playerStore";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const LABEL_COL_W = 232;
export const TRACK_H = 48;
export const LANE_H = 28;
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export interface TimelineBeatEntry {
readonly index: number;
readonly time: number;
readonly strength: number | undefined;
}
function findFirstTimeAtOrAfter(times: readonly number[], target: number): number {
let low = 0;
let high = times.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1;
else high = mid;
}
return low;
}
/** Slice sorted beat data without allocating entries outside the render window. */
export function getTimelineBeatEntries(
beatTimes: readonly number[] | undefined,
beatStrengths: readonly number[] | undefined,
range: TimelineTimeRange | undefined,
pinnedIndexes: ReadonlySet<number> = new Set(),
): readonly TimelineBeatEntry[] {
if (!beatTimes?.length) return [];
const start = range?.start ?? Number.NEGATIVE_INFINITY;
const end = range?.end ?? Number.POSITIVE_INFINITY;
const selected = new Set<number>();
for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) {
const time = beatTimes[index];
if (time === undefined || time >= end) break;
selected.add(index);
}
for (const index of pinnedIndexes) {
if (index >= 0 && index < beatTimes.length) selected.add(index);
}
return [...selected]
.sort((left, right) => left - right)
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
}
export function getTimelineLaneTop(laneIndex: number): number {
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
/**
* Collapsed-row characterization value for the new-track INSERT band. Runtime
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
*/
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
/**
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
* offset; a track row's top is always ruler + top pad + cumulative row heights.
*
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
* (~half a track height) so the first clip isn't jammed under the ruler.
* - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
* enough to comfortably drag a clip into the void to create a new bottom lane.
*/
export const TRACKS_TOP_PAD = 50;
export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
/**
* Breathing room LEFT of t=0 (CapCut-style), inside the scroll content — the
* horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
* gutter and where the ruler's 00:00 / the clips actually start, scrolling
* WITH the content. Time↔pixel mapping: content x = GUTTER + TRACKS_LEFT_PAD
* + t·pps, and every pointer→time inverse subtracts it symmetrically. The
* lanes and the ruler realize it as a plain flow spacer between the sticky
* gutter cell and the time-mapped content div, so all content-relative math
* (clip left = t·pps, beat lines, lane-menu time) is untouched.
*/
export const TRACKS_LEFT_PAD = 48;
export interface TimelineTrackHeightClip {
clipId: string;
laneCount: number;
/** Audio automation lanes shown when expanded, reserved at their own height. */
automationLaneCount?: number;
}
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
/**
* Resolve each track's full height. Without expansion state every row is the
* legacy TRACK_H; if multiple clips in one track expand, the tallest one owns
* the shared row height.
*/
export function trackHeights(
tracks: TimelineTrackHeightInput,
expandedClipIds?: ReadonlySet<string>,
): number[] {
return tracks.map((clips) => {
let laneCount = 0;
let automationLanes = 0;
for (const clip of clips) {
if (!expandedClipIds?.has(clip.clipId)) continue;
laneCount = Math.max(laneCount, clip.laneCount);
automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
}
return (
TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
);
});
}
function validRowHeight(height: number | undefined): number {
if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
return height;
}
export interface TimelineRowGeometry {
readonly rowKeys: readonly number[];
readonly rowHeights: readonly number[];
/** Cumulative row boundaries, including the final bottom boundary. */
readonly rowOffsets: readonly number[];
readonly rowsHeight: number;
readonly canvasHeight: number;
getRowIndex(rowKey: number): number;
getRowHeight(row: number): number;
getRowTop(row: number): number;
getRowFromY(contentY: number): number;
getRowPositionFromY(contentY: number): {
rowFloat: number;
row: number;
fraction: number;
rowHeight: number;
};
}
const rowGeometryCache = new WeakMap<readonly number[], TimelineRowGeometry>();
const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]);
/** Build the immutable row snapshot shared by rendering and hit testing. */
export function createTimelineRowGeometry(
rowKeys: readonly number[],
rowHeights: readonly number[],
): TimelineRowGeometry {
const heights = Object.freeze(rowHeights.map(validRowHeight));
const keys = Object.freeze(
heights.map((_, row) => {
const key = rowKeys[row];
return key !== undefined && Number.isFinite(key) ? key : row;
}),
);
const offsets = [0];
for (const height of heights) offsets.push((offsets.at(-1) ?? 0) + height);
Object.freeze(offsets);
const rowIndexByKey = new Map(keys.map((key, row) => [key, row]));
const getRowHeight = (row: number) => validRowHeight(heights[row]);
const getRowOffset = (row: number) => {
if (heights.length === 0) return row * TRACK_H;
if (row <= 0) return row * getRowHeight(0);
if (row >= heights.length) {
return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H;
}
const wholeRow = Math.floor(row);
return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
};
const getRowFromY = (contentY: number) => {
const y = contentY - RULER_H - TRACKS_TOP_PAD;
if (heights.length === 0) return y / TRACK_H;
if (y < 0) return y / getRowHeight(0);
const rowsHeight = offsets[heights.length] ?? 0;
if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H;
// First boundary strictly greater than y. Unlike the old linear scan this
// stays logarithmic for large timelines and uses the precomputed offsets.
let low = 1;
let high = heights.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((offsets[mid] ?? 0) > y) high = mid;
else low = mid + 1;
}
const row = low - 1;
return row + (y - (offsets[row] ?? 0)) / getRowHeight(row);
};
const geometry: TimelineRowGeometry = {
rowKeys: keys,
rowHeights: heights,
rowOffsets: offsets,
rowsHeight: offsets.at(-1) ?? 0,
canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD,
getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
getRowHeight,
getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row),
getRowFromY,
getRowPositionFromY: (contentY) => {
const rowFloat = getRowFromY(contentY);
const row = Math.floor(rowFloat);
return { rowFloat, row, fraction: rowFloat - row, rowHeight: getRowHeight(row) };
},
};
const frozenGeometry = Object.freeze(geometry);
rowGeometryCache.set(heights, frozenGeometry);
return frozenGeometry;
}
/** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */
export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry {
const cached = rowGeometryCache.get(rowHeights);
if (cached) return cached;
const geometry = createTimelineRowGeometry(
rowHeights.map((_, row) => row),
rowHeights,
);
rowGeometryCache.set(rowHeights, geometry);
return geometry;
}
/** Cumulative top offsets, including the final bottom boundary. */
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
return [...getTimelineRowGeometry(rowHeights).rowOffsets];
}
export function getTimelineRowHeight(
row: number,
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
): number {
return validRowHeight(rowHeights[row]);
}
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD;
}
/**
* The y (content-space) of the top edge of track ROW index `row` (0 = first
* displayed lane). The single source of truth for row->y: the ruler height plus
* the top breathing pad plus whole track lanes above it. Every clip/ghost/
* placeholder/insertion top and every pointer-y->row inversion goes through this
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
*/
export function getTimelineRowTop(
row: number,
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
): number {
return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
}
/**
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
* space y (used for insert-row / drop-lane decisions). Locates the concrete row
* from cumulative offsets, then returns its local fractional position.
*/
export function getTimelineRowFromY(
contentY: number,
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
): number {
return getTimelineRowGeometry(rowHeights).getRowFromY(contentY);
}
export function getTimelineRowPositionFromY(
contentY: number,
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY);
}
/** Fractional insert band for the concrete row under a pointer. */
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
return CLIP_Y / validRowHeight(rowHeight);
}
/**
* While a clip drag is live, the rendered timeline extends this far past the
* ghost's end so the right-edge auto-scroll zone always has room to keep
* stepping — that's what lets a drag extend the timeline past its current
* rendered width (see Timeline.tsx displayContentWidth).
*/
export const DRAG_EXTEND_MARGIN_PX = 160;
/**
* The rendered timeline always spans at least this many seconds of ruler +
* track lanes, even when the composition is shorter — the empty space on the
* right is a real, drag/drop-enabled surface (clips can be moved into it; the
* composition grows on commit, content-driven). In fit mode the fit pps is
* derived against this floor, so a 10s comp renders as ~1/6 of the viewport
* with 60s of ruler after it.
*/
export const MIN_TIMELINE_EXTENT_S = 60;
/**
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
* duration — onto the viewport, so the composition ends at ~83% of the width
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
* drag clips past the current end without first zooming out). Applied ONLY
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
* zoom percentages stay defined relative to this fit basis (100% == fit).
*/
export const FIT_ZOOM_HEADROOM = 1.2;
/* ── Tick generation ──────────────────────────────────────────────── */
/* ── Width / duration derivation ──────────────────────────────────── */
/**
* Fit-mode pixels-per-second: fill the viewport with the composition plus
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
* seconds onto it — a short comp takes a fraction of the width and the
* remaining ruler runs to 1:00.
* Manual zoom multiplies this base, so the floor only anchors the default.
*/
export function getTimelineFitPps(
viewportWidth: number,
effectiveDuration: number,
contentOrigin: number,
): number {
const safeDuration =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
return (viewportWidth - contentOrigin - 2) / span;
}
/**
* The rendered timeline extent in px. Always covers, whichever is largest:
* the actual clip content, the visible viewport (no dead black after short
* content — CapCut-style), a live drag or resize ghost plus the auto-scroll
* margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
* RENDERED extent grows; clip positions/durations are untouched.
*/
export function getTimelineDisplayContentWidth(input: {
trackContentWidth: number;
viewportWidth: number;
contentOrigin: number;
pps: number;
dragGhostEndPx?: number;
resizeGhostEndPx?: number;
}): number {
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
return Math.max(
input.trackContentWidth,
input.viewportWidth - input.contentOrigin - 2,
input.dragGhostEndPx ?? 0,
input.resizeGhostEndPx ?? 0,
MIN_TIMELINE_EXTENT_S * safePps,
);
}
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
export function getTimelineContentXFromClient(input: {
clientX: number;
rectLeft: number;
scrollLeft: number;
contentOrigin: number;
}): number {
return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
}
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
clientWidth: number,
): boolean {
if (zoomMode === "fit") return false;
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
return scrollWidth - clientWidth > 1;
}
export function getTimelineScrollLeftForZoomTransition(
previousZoomMode: ZoomMode | null,
nextZoomMode: ZoomMode,
currentScrollLeft: number,
): number {
if (nextZoomMode === "fit") return 0;
return currentScrollLeft;
}
export function getTimelineScrollLeftForZoomAnchor(input: {
pointerX: number;
currentScrollLeft: number;
contentOrigin: number;
currentPixelsPerSecond: number;
nextPixelsPerSecond: number;
duration: number;
}): number {
const currentPps = Math.max(0, input.currentPixelsPerSecond);
const nextPps = Math.max(0, input.nextPixelsPerSecond);
if (
!Number.isFinite(input.pointerX) ||
!Number.isFinite(input.currentScrollLeft) ||
!Number.isFinite(input.duration) ||
input.duration <= 0 ||
currentPps <= 0 ||
nextPps <= 0
) {
return Math.max(0, input.currentScrollLeft);
}
const timelineX = Math.max(
0,
getTimelineContentXFromClient({
clientX: input.pointerX,
rectLeft: 0,
scrollLeft: input.currentScrollLeft,
contentOrigin: input.contentOrigin,
}),
);
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
}
/* ── Playhead / canvas ────────────────────────────────────────────── */
/**
* Width of the playhead wrapper element (== the diamond head chip's layout
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
* shifted LEFT by half this width for the line's center to land exactly on
* `contentOrigin + time * pps` — see {@link getTimelinePlayheadLeft}.
*/
export const PLAYHEAD_HEAD_W = 9;
/**
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
* sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
* on) at every zoom level. Without the half-head offset the line sat
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
*/
export function getTimelinePlayheadLeft(
time: number,
pixelsPerSecond: number,
contentOrigin: number,
): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
return contentOrigin - PLAYHEAD_HEAD_W / 2;
}
return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
}
/**
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
* clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
* `contentOrigin` px right of the viewport edge, so any pointer left of t=0
* maps to a negative offset. Callers used to bail on that instead of
* clamping, which made the last 80px of the drag to zero silently do nothing:
* the playhead stuck wherever the last in-range sample landed, and only a very
* slow drag that happened to sample inside the sliver before the origin reached
* 0. Every scrub path shares this so they cannot diverge on the edge again.
*/
export function getTimelineScrubTime(input: {
clientX: number;
viewportLeft: number;
scrollLeft: number;
contentOrigin: number;
pixelsPerSecond: number;
duration: number;
}): number {
const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
const x = getTimelineContentXFromClient({
clientX,
rectLeft: viewportLeft,
scrollLeft,
contentOrigin,
});
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
}
const PLAYBACK_FOLLOW_POSITION = 0.75;
/**
* Keep a playing timeline calm until the playhead crosses the right-side
* comfort line, then advance the viewport just enough to hold it there. A
* loop/reverse jump that leaves the playhead behind the sticky labels restores
* the matching earlier viewport instead.
*/
export function getTimelinePlaybackFollowScrollLeft(input: {
playheadX: number;
currentScrollLeft: number;
viewportWidth: number;
contentOrigin: number;
maxScrollLeft: number;
}): number {
const current = Number.isFinite(input.currentScrollLeft)
? Math.max(0, input.currentScrollLeft)
: 0;
const max = Number.isFinite(input.maxScrollLeft) ? Math.max(0, input.maxScrollLeft) : 0;
const visibleTimelineWidth = input.viewportWidth - input.contentOrigin;
if (
!Number.isFinite(input.playheadX) ||
!Number.isFinite(input.contentOrigin) ||
!Number.isFinite(visibleTimelineWidth) ||
visibleTimelineWidth <= 0 ||
max <= 0
) {
return Math.min(max, current);
}
const visibleStart = current + input.contentOrigin;
const followLine = visibleStart + visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
let next = current;
if (input.playheadX > followLine) {
next = input.playheadX - input.contentOrigin - visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
} else if (input.playheadX < visibleStart) {
next = input.playheadX - input.contentOrigin;
}
return Math.max(0, Math.min(max, next));
}
export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
// below the last lane is real scrollable surface, not a hidden buffer.
return getTimelineRowGeometry(rowHeights).canvasHeight;
}
/* ── UI helpers ───────────────────────────────────────────────────── */
export function shouldShowTimelineShortcutHint(
scrollHeight: number,
clientHeight: number,
): boolean {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
return scrollHeight - clientHeight <= 1;
}
export function shouldHandleTimelineDeleteKey(input: {
key: string;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
target?: EventTarget | null;
}): boolean {
if (input.key !== "Delete" && input.key !== "Backspace") return false;
if (input.metaKey || input.ctrlKey || input.altKey) return false;
const target =
input.target && typeof input.target === "object"
? (input.target as {
tagName?: string;
isContentEditable?: boolean;
closest?: (selector: string) => Element | null;
})
: null;
if (target) {
const tag = target.tagName?.toLowerCase() ?? "";
if (target.isContentEditable) return false;
if (["input", "textarea", "select"].includes(tag)) return false;
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
return false;
}
}
return true;
}
/* ── Asset drop ───────────────────────────────────────────────────── */
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
if (trackOrder.length === 0) return 0;
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
if (rowIndex >= trackOrder.length) {
return Math.max(...trackOrder) + 1;
}
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
}
export function resolveTimelineAssetDrop(
input: {
rectLeft: number;
rectTop: number;
scrollLeft: number;
scrollTop: number;
contentOrigin: number;
pixelsPerSecond: number;
duration: number;
clampStartToDuration?: boolean;
rowHeights?: readonly number[];
trackOrder: number[];
},
clientX: number,
clientY: number,
): { start: number; track: number } {
const x = getTimelineContentXFromClient({
clientX,
rectLeft: input.rectLeft,
scrollLeft: input.scrollLeft,
contentOrigin: input.contentOrigin,
});
const contentY = clientY - input.rectTop + input.scrollTop;
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
const start = Math.max(
0,
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
);
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
// past the last lane (getDefaultDroppedTrack then appends a new track).
const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
return {
start,
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}