feat(studio): timeline collision and placement model#2195
Conversation
858368d to
6ecac1c
Compare
055fc12 to
eb1e603
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Reviewed as part of the 25-PR NLE overhaul stack. Full stack review on #2205 (the keystone). No blockers on this PR. — Miga
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: LGTM (green) — pure math, complete coverage
Elementary, but pleasing in its restraint. The placement resolver has been reduced to a set of unit-consistent primitives — resolvePlacement, clampTrackToZone, isInsertAllowedForZone, resolveZoneDropPlacement — each modest, each testable, each with its coverage certificate in the sibling file.
The pointed queries
- Unit consistency (seconds throughout).
packages/studio/src/player/components/timelineCollision.ts:127—timeRangesOverlap(aStart, aEnd, bStart, bEnd)operates in seconds;end = start + durationis the sole arithmetic; no pixel term contaminates the calculation.resolveInsertRowat :105 is the only pixel-domain input (rowFloatin track-height units) and returns a boundary integer — cleanly typed at the boundary. - Half-open at the edge.
timeRangesOverlapuses strict<on both sides (:127). Clip A ending at t=5 and clip B starting at t=5 →5 < 5is false, so they are adjacent, not overlapping. Test file at :20 pins this explicitly (touching at 2→ false). TheresolveZoneDropPlacement — shares a track for sequential (non-overlapping) clipscase at :245 verifies the invariant end-to-end. - Empty inputs.
resolvePlacementwith adesiredTrackoutsidetrackOrder(:188) short-circuits to{ track: desiredTrack, needsInsert: false }— defensive and correct.resolveInsertRowwithtrackCount === 0(:110) returns 0 (insert-at-top). Both edges named in the focus are handled. - Zone identity preserved.
clampTrackToZone(:10) andisInsertAllowedForZone(:30) form the boundary; a visual clip clamped is guaranteed to remain in the visual zone (walks up to the last visual row), an audio clip clamped is guaranteed to remain in the audio zone. Cross-tested at :256–:290. - Self-exclusion during drag.
isLaneFree(excludeKey)at :139–:147 correctly filters the dragged clip out of its own collision computation — the "placeholder-scenario" test at :121–:133 nails it.
Cross-batch coherence
Half-open convention is consistent with #2198 (overlapsInTime uses < too, with an epsilon guard); adjacent-not-overlapping is the operative contract in both.
R1 by Via
eb1e603 to
e21c151
Compare
13c0d74 to
ba49f01
Compare
e21c151 to
ed27a2e
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review — the fix has a mirror bug in the same function
The commit that closes Abhai's flagged empty-zone case caught the occupied-aim path via the idx === -1 → needsInsert: true return at timelineCollision.ts:189-193. But the same class of bug exists one branch above at :184-186:
if (isLaneFree(elements, desiredTrack, from, to)) {
return { track: desiredTrack, needsInsert: false };
}
const idx = trackOrder.indexOf(desiredTrack);
if (idx === -1) return { track: desiredTrack, needsInsert: true };The isLaneFree short-circuit fires BEFORE the idx === -1 check. When an audio clip is dropped on a visual-only timeline at a free span aimed at the sole visual track, the module returns {track: <visual>, needsInsert: false} — violating the module's own stated invariant "a clip stays in its kind-zone."
Reproduced against the shipped functions:
Case: audio on visual-only, FREE aim → {track:0, needsInsert:false} ← wrong kind
Case: audio on visual-only, OCCUPIED → {track:0, needsInsert:true} ← the fix, correct
Case: visual on audio-only, FREE aim → {track:0, needsInsert:false} ← mirror
normalizeToZones (#2199) papers over the display, but the intermediate contract is wrong: any caller that reads needsInsert to drive a new-lane animation, or reads track before normalize runs, is misled. Same class as the original bug this PR closes.
Fix: hoist the idx === -1 → needsInsert: true return above the isLaneFree short-circuit — ~5 LOC. Add a test parallel to the :310 occupied-aim test that reproduces the FREE-aim scenario.
— Rames Jusso
6e7a79c to
b970d96
Compare
ed27a2e to
7f9c812
Compare
b970d96 to
6890ed1
Compare
3916b0f to
e0ad57b
Compare
5299032 to
7694155
Compare
e0ad57b to
28f4251
Compare
7694155 to
db3b224
Compare
28f4251 to
ca5caa8
Compare
db3b224 to
c15819f
Compare
What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed).
ca5caa8 to
b910ef1
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-reviewed current head and verified the addressed feedback and required behavior. Approval is for this exact head; Graphite mergeability may still be pending.

What
new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite.
Why
the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes.
How
new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs.
Test plan
bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed).
Stack position 4/25 — studio NLE overhaul (CapCut-parity timeline + canvas). Graphite manages bases; merge from #2192 upward. Temporary
TEMP(studio-dnd)fallow entries (unwired-component windows) are all removed by #2213 (app-shell swap), whose tree is byte-identical to the fully-verified integration branch.