-
Notifications
You must be signed in to change notification settings - Fork 4.2k
perf(studio): virtualize timeline marquee selection #2707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout"; | ||
| import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout"; | ||
| import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry"; | ||
| import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex"; | ||
| import type { TimelineElement } from "../store/playerStore"; | ||
|
|
||
| /** Pointer must travel at least this far (either axis) before a pointerdown on | ||
| * the empty timeline body becomes a marquee drag instead of a plain click. */ | ||
|
|
@@ -62,23 +64,22 @@ export function getMarqueeRect( | |
| /** | ||
| * A clip's rendered rect in canvas/content coordinates (the same space the | ||
| * marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row | ||
| * index within the visible track order (cumulative row top + CLIP_Y). | ||
| * index within the canonical row geometry (cumulative row top + CLIP_Y). | ||
| * Returns null when the clip's track is not currently displayed. | ||
| */ | ||
| export function getTimelineClipRect( | ||
| clip: Pick<MarqueeClipInput, "start" | "duration" | "track">, | ||
| trackOrder: number[], | ||
| rowGeometry: TimelineRowGeometry, | ||
| pps: number, | ||
| contentOrigin: number, | ||
| rowHeights: readonly number[] = [], | ||
| ): Rect | null { | ||
| const row = trackOrder.indexOf(clip.track); | ||
| const row = rowGeometry.getRowIndex(clip.track); | ||
| if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null; | ||
| return { | ||
| left: contentOrigin + clip.start * pps, | ||
| top: getTimelineRowTop(row, rowHeights) + CLIP_Y, | ||
| top: rowGeometry.getRowTop(row) + CLIP_Y, | ||
| width: Math.max(clip.duration * pps, MIN_CLIP_W), | ||
| height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2, | ||
| height: TRACK_H - CLIP_Y * 2, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 (concern) |
||
| }; | ||
| } | ||
|
|
||
|
|
@@ -90,29 +91,56 @@ export interface MarqueeSelectionResult { | |
| primaryId: string | null; | ||
| } | ||
|
|
||
| /** Narrow a marquee hit test to the intersecting logical rows and time span. */ | ||
| export function getMarqueeClipCandidates(input: { | ||
| clipIndex: TimelineClipIndex; | ||
| rowGeometry: TimelineRowGeometry; | ||
| marquee: Rect; | ||
| pps: number; | ||
| contentOrigin: number; | ||
| }): readonly TimelineElement[] { | ||
| if (!(input.pps > 0) || input.marquee.width <= 0 || input.marquee.height <= 0) return []; | ||
| const lastRow = input.rowGeometry.rowKeys.length - 1; | ||
| const first = Math.max(0, Math.floor(input.rowGeometry.getRowFromY(input.marquee.top))); | ||
| const last = Math.min( | ||
| lastRow, | ||
| Math.floor(input.rowGeometry.getRowFromY(input.marquee.top + input.marquee.height)), | ||
| ); | ||
| if (first > last) return []; | ||
| const paddingSeconds = MIN_CLIP_W / input.pps; | ||
| const start = Math.max( | ||
| 0, | ||
| (input.marquee.left - input.contentOrigin) / input.pps - paddingSeconds, | ||
| ); | ||
| const end = | ||
| (input.marquee.left + input.marquee.width - input.contentOrigin) / input.pps + paddingSeconds; | ||
| if (end <= start) return []; | ||
|
|
||
| const candidates: TimelineElement[] = []; | ||
| for (let row = first; row <= last; row += 1) { | ||
| const rowKey = input.rowGeometry.rowKeys[row]; | ||
| if (rowKey === undefined) continue; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 (concern) |
||
| candidates.push(...queryTimelineClipIndex(input.clipIndex, rowKey, { start, end })); | ||
| } | ||
| return candidates; | ||
| } | ||
|
|
||
| /** | ||
| * Live marquee selection: every clip whose rendered rect intersects the marquee. | ||
| * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId. | ||
| */ | ||
| export function computeMarqueeSelection(input: { | ||
| clips: MarqueeClipInput[]; | ||
| trackOrder: number[]; | ||
| rowGeometry: TimelineRowGeometry; | ||
| pps: number; | ||
| contentOrigin: number; | ||
| marquee: Rect; | ||
| baseSelection?: Iterable<string>; | ||
| rowHeights?: readonly number[]; | ||
| }): MarqueeSelectionResult { | ||
| const ids = new Set<string>(input.baseSelection ?? []); | ||
| let primaryId: string | null = null; | ||
| for (const clip of input.clips) { | ||
| const rect = getTimelineClipRect( | ||
| clip, | ||
| input.trackOrder, | ||
| input.pps, | ||
| input.contentOrigin, | ||
| input.rowHeights, | ||
| ); | ||
| const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin); | ||
| if (rect && rectsOverlap(rect, input.marquee)) { | ||
| ids.add(clip.id); | ||
| primaryId = clip.id; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 (concern)
onLostPointerCapture={handlePointerCancel}(washandlePointerUponmain).handlePointerCancelcallscancelActiveGesture(true, isGestureSessionCurrent())which restores pre-drag selection for an active marquee and unconditionally nulls the live range (useTimelineRangeSelection.ts:506-531). Mid-gesture capture-loss — fullscreen swap, right-click during a marquee, iOS interruption — now silently discards in-progress work instead of committing the drawn selection. The one covering test (does not clear a finalized range when capture is lost after pointerup) exercises only the post-pointerup path where the guard atuseTimelineRangeSelection.ts:539short-circuits onactivePointerIdRef.current === null— no test drives capture-loss WHILE the gesture is active. If the semantic change is intentional (safer default: don't commit an accidentally-truncated selection), worth a line in the PR body; if not,handlePointerUpis the pre-PR shape. — Rames D Jusso