Skip to content

feat(studio): timeline collision and placement model#2195

Merged
miguel-heygen merged 1 commit into
mainfrom
studio-dnd/pr04-timeline-collision
Jul 12, 2026
Merged

feat(studio): timeline collision and placement model#2195
miguel-heygen merged 1 commit into
mainfrom
studio-dnd/pr04-timeline-collision

Conversation

@ukimsanov

@ukimsanov ukimsanov commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

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.

ukimsanov commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

This was referenced Jul 11, 2026
@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from 858368d to 6ecac1c Compare July 11, 2026 00:55
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch 2 times, most recently from 055fc12 to eb1e603 Compare July 11, 2026 01:15

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as part of the 25-PR NLE overhaul stack. Full stack review on #2205 (the keystone). No blockers on this PR. — Miga

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:127timeRangesOverlap(aStart, aEnd, bStart, bEnd) operates in seconds; end = start + duration is the sole arithmetic; no pixel term contaminates the calculation. resolveInsertRow at :105 is the only pixel-domain input (rowFloat in track-height units) and returns a boundary integer — cleanly typed at the boundary.
  • Half-open at the edge. timeRangesOverlap uses strict < on both sides (:127). Clip A ending at t=5 and clip B starting at t=5 → 5 < 5 is false, so they are adjacent, not overlapping. Test file at :20 pins this explicitly (touching at 2 → false). The resolveZoneDropPlacement — shares a track for sequential (non-overlapping) clips case at :245 verifies the invariant end-to-end.
  • Empty inputs. resolvePlacement with a desiredTrack outside trackOrder (:188) short-circuits to { track: desiredTrack, needsInsert: false } — defensive and correct. resolveInsertRow with trackCount === 0 (:110) returns 0 (insert-at-top). Both edges named in the focus are handled.
  • Zone identity preserved. clampTrackToZone (:10) and isInsertAllowedForZone (: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

@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from eb1e603 to e21c151 Compare July 11, 2026 02:56
@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from 13c0d74 to ba49f01 Compare July 11, 2026 02:56
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from e21c151 to ed27a2e Compare July 11, 2026 08:18
@ukimsanov ukimsanov marked this pull request as ready for review July 11, 2026 09:06

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from 6e7a79c to b970d96 Compare July 11, 2026 10:49
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from ed27a2e to 7f9c812 Compare July 11, 2026 10:49
@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from b970d96 to 6890ed1 Compare July 11, 2026 12:51
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch 2 times, most recently from 3916b0f to e0ad57b Compare July 11, 2026 21:08
@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from 5299032 to 7694155 Compare July 11, 2026 22:38
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from e0ad57b to 28f4251 Compare July 11, 2026 22:38
@miguel-heygen miguel-heygen force-pushed the studio-dnd/pr03-studio-server-files-route branch from 7694155 to db3b224 Compare July 12, 2026 00:34
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from 28f4251 to ca5caa8 Compare July 12, 2026 01:02
@ukimsanov ukimsanov force-pushed the studio-dnd/pr03-studio-server-files-route branch from db3b224 to c15819f Compare July 12, 2026 01:05
Base automatically changed from studio-dnd/pr03-studio-server-files-route to main July 12, 2026 01:21
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).
@ukimsanov ukimsanov force-pushed the studio-dnd/pr04-timeline-collision branch from ca5caa8 to b910ef1 Compare July 12, 2026 01:22

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head and verified the addressed feedback and required behavior. Approval is for this exact head; Graphite mergeability may still be pending.

@miguel-heygen miguel-heygen merged commit 7f21d5d into main Jul 12, 2026
41 of 42 checks passed
@miguel-heygen miguel-heygen deleted the studio-dnd/pr04-timeline-collision branch July 12, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants