@bycrux/editor 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/eligibility.test.ts +97 -0
  4. package/src/engine/__tests__/engine.test.ts +47 -2
  5. package/src/engine/__tests__/scheduler.test.ts +385 -4
  6. package/src/engine/__tests__/source-host.test.ts +165 -0
  7. package/src/engine/eligibility.ts +37 -1
  8. package/src/engine/index.ts +165 -29
  9. package/src/engine/scheduler.ts +345 -23
  10. package/src/index.ts +8 -0
  11. package/src/schema.ts +41 -1
  12. package/src/video/VideoEditor.tsx +106 -10
  13. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  14. package/src/video/__tests__/VideoEditor.test.tsx +169 -0
  15. package/src/video/__tests__/cuts.test.ts +84 -0
  16. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  17. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  18. package/src/video/captionStyleDefaults.ts +2 -2
  19. package/src/video/cuts.ts +30 -2
  20. package/src/video/preview/OverlayItemsLayer.tsx +85 -6
  21. package/src/video/preview/PreviewPlayer.tsx +28 -1
  22. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
  23. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
  24. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  25. package/src/video/preview/useVideoPlayback.ts +18 -3
  26. package/src/video/timeline/Timeline.tsx +49 -1
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
  30. package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
  35. package/src/video/timeline/canvas/draw.ts +104 -7
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +142 -5
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +286 -15
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import { StrictMode } from 'react'
12
12
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
13
- import { render, act, cleanup } from '@testing-library/react'
13
+ import { render, act, cleanup, screen, fireEvent } from '@testing-library/react'
14
14
  import { createPlaybackClock } from '../../../playback-clock'
15
15
  import type { Project } from '../../../../types'
16
16
  import TimelineCanvas from '../TimelineCanvas'
17
- import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, type TimelineMode } from '../draw'
17
+ import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, computeTimelineLayout, type TimelineMode } from '../draw'
18
18
  import { createViewportStore, type ViewportStore } from '../viewport'
19
19
 
20
20
  interface RecordedCall { method: string; args: unknown[] }
@@ -405,3 +405,107 @@ describe('TimelineCanvas — theme mode', () => {
405
405
  expect(overlay).toHaveLength(0)
406
406
  })
407
407
  })
408
+
409
+ // ── Marker rename box ────────────────────────────────────────────────────
410
+ //
411
+ // The gesture DECISION (double-click on a marker → `editMarker` effect) is
412
+ // covered as pure data in pointer-machine.test.ts. What only a mounted
413
+ // component can show is the box itself: that it mounts, that it prefills,
414
+ // and that Enter/Escape commit or cancel through the same
415
+ // onProjectChange/onOverlayEdit pair every other discrete edit here uses.
416
+ describe('marker rename box', () => {
417
+ const markerProject = {
418
+ id: 'p-marker',
419
+ tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8 }]],
420
+ markers: [{ id: 'm1', t: 2, label: '1' }],
421
+ } as unknown as Project
422
+
423
+ // Derived from the layout itself, not hardcoded, so a strip-height change
424
+ // elsewhere can't quietly desync this from where the flag actually paints.
425
+ const MARKER_STRIP = computeTimelineLayout(markerProject).markers!
426
+ const MARKER_STRIP_Y = Math.round(MARKER_STRIP.y + MARKER_STRIP.height / 2)
427
+
428
+ function renderMarkerCanvas(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
429
+ const store = createViewportStore()
430
+ const clock = createPlaybackClock()
431
+ const utils = render(
432
+ <TimelineCanvas
433
+ project={markerProject}
434
+ clock={clock}
435
+ store={store}
436
+ totalDuration={20}
437
+ fps={30}
438
+ selectedIds={NO_SELECTION}
439
+ {...overrides}
440
+ />,
441
+ )
442
+ act(() => { vi.advanceTimersByTime(32) })
443
+ // Pin the scale so the marker's on-screen x is deterministic: t=2s at
444
+ // 100px/s is x=200.
445
+ act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
446
+ act(() => { vi.advanceTimersByTime(32) })
447
+ return { ...utils, store, clock }
448
+ }
449
+
450
+ /** Dispatches a real `dblclick` at marker m1's surface coordinates, exactly
451
+ * the way an operator's browser event would arrive — not by poking state. */
452
+ function doubleClickAtMarker(container: HTMLElement) {
453
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
454
+ act(() => {
455
+ surface.dispatchEvent(new MouseEvent('dblclick', {
456
+ clientX: 200, clientY: MARKER_STRIP_Y, bubbles: true, cancelable: true,
457
+ }))
458
+ })
459
+ }
460
+
461
+ it('is not mounted until a marker is double-clicked', () => {
462
+ renderMarkerCanvas()
463
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
464
+ })
465
+
466
+ it('mounts prefilled with the marker label after an editMarker effect', () => {
467
+ const { container } = renderMarkerCanvas()
468
+ doubleClickAtMarker(container)
469
+ // The state update lands synchronously inside `doubleClickAtMarker`'s own
470
+ // `act()`, so the box is already in the DOM — no need for `findBy`'s
471
+ // real-timer polling, which never fires under `vi.useFakeTimers()`.
472
+ const input = screen.getByLabelText('Rename marker')
473
+ expect((input as HTMLInputElement).value).toBe('1')
474
+ })
475
+
476
+ it('Enter commits the new name through onProjectChange and onOverlayEdit', () => {
477
+ const onProjectChange = vi.fn()
478
+ const onOverlayEdit = vi.fn()
479
+ const { container } = renderMarkerCanvas({ onProjectChange, onOverlayEdit })
480
+ doubleClickAtMarker(container)
481
+ const input = screen.getByLabelText('Rename marker')
482
+ fireEvent.change(input, { target: { value: 'intro' } })
483
+ fireEvent.keyDown(input, { key: 'Enter' })
484
+ expect(onProjectChange).toHaveBeenCalled()
485
+ const next = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
486
+ expect(next.markers![0].label).toBe('intro')
487
+ expect(onOverlayEdit).toHaveBeenCalled() // one undo entry
488
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
489
+ })
490
+
491
+ it('Escape cancels and commits nothing', () => {
492
+ const onProjectChange = vi.fn()
493
+ const { container } = renderMarkerCanvas({ onProjectChange })
494
+ doubleClickAtMarker(container)
495
+ const input = screen.getByLabelText('Rename marker')
496
+ fireEvent.change(input, { target: { value: 'nope' } })
497
+ fireEvent.keyDown(input, { key: 'Escape' })
498
+ expect(onProjectChange).not.toHaveBeenCalled()
499
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
500
+ })
501
+
502
+ it('is a real <input>, so the document keymap ignores typing in it', () => {
503
+ // useKeymap's isTypingTarget guards INPUT/TEXTAREA/contentEditable — this
504
+ // pins that the box is a real input in the document, not a canvas-drawn
505
+ // fake.
506
+ const { container } = renderMarkerCanvas()
507
+ doubleClickAtMarker(container)
508
+ const input = screen.getByLabelText('Rename marker')
509
+ expect(input.tagName).toBe('INPUT')
510
+ })
511
+ })
@@ -26,8 +26,10 @@ import {
26
26
  LIGHT_TRACK_PALETTE,
27
27
  timelinePalette,
28
28
  drawCursorLine,
29
+ drawMarkers,
29
30
  drawMarquee,
30
31
  drawRuler,
32
+ MARKER_STRIP_HEIGHT_PX,
31
33
  type TimelinePalette,
32
34
  CLIP_HANDLE_WIDTH_PX,
33
35
  FADE_GRIP_SIZE_PX,
@@ -2196,3 +2198,74 @@ describe('drawTimelineContent / drawTimelineOverlay — mode', () => {
2196
2198
  expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
2197
2199
  })
2198
2200
  })
2201
+
2202
+ describe('marker strip layout', () => {
2203
+ it('is absent, and the ruler still starts at 0, when the project has no markers', () => {
2204
+ // A marker-less project must lay out byte-identically to before the feature.
2205
+ const layout = computeTimelineLayout(project({ tracks: [{ id: 'trk-0', items: [] }] }))
2206
+ expect(layout.markers).toBeUndefined()
2207
+ expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
2208
+ })
2209
+
2210
+ it('claims the top strip and pushes the ruler down once markers exist', () => {
2211
+ const layout = computeTimelineLayout(project({
2212
+ tracks: [{ id: 'trk-0', items: [] }],
2213
+ markers: [{ id: 'a', t: 1, label: '1' }],
2214
+ }))
2215
+ expect(layout.markers).toEqual({ y: 0, height: MARKER_STRIP_HEIGHT_PX })
2216
+ expect(layout.ruler).toEqual({ y: MARKER_STRIP_HEIGHT_PX, height: RULER_HEIGHT_PX })
2217
+ })
2218
+
2219
+ it('pushes every row down by exactly the strip height', () => {
2220
+ const withOut = computeTimelineLayout(project({ tracks: [{ id: 'trk-0', items: [clip()] }] }))
2221
+ const withIn = computeTimelineLayout(project({
2222
+ tracks: [{ id: 'trk-0', items: [clip()] }],
2223
+ markers: [{ id: 'a', t: 1, label: '1' }],
2224
+ }))
2225
+ expect(withIn.rows[0].y - withOut.rows[0].y).toBe(MARKER_STRIP_HEIGHT_PX)
2226
+ expect(withIn.height - withOut.height).toBe(MARKER_STRIP_HEIGHT_PX)
2227
+ })
2228
+ })
2229
+
2230
+ describe('drawMarkers', () => {
2231
+ const strip = { y: 0, height: MARKER_STRIP_HEIGHT_PX }
2232
+
2233
+ it('draws nothing at all for an empty list', () => {
2234
+ const r = recordingContext()
2235
+ drawMarkers(r.ctx, [], viewport(), strip, 800, [])
2236
+ expect(r.calls).toHaveLength(0) // not even a background fill
2237
+ })
2238
+
2239
+ it('draws a flag and its label at the marker time', () => {
2240
+ const r = recordingContext()
2241
+ const vp = viewport({ pxPerSecond: 10, scrollSeconds: 0 })
2242
+ drawMarkers(r.ctx, [{ id: 'a', t: 5, label: 'intro' }], vp, strip, 800, [])
2243
+ const text = r.of('fillText')
2244
+ expect(text).toHaveLength(1)
2245
+ expect(text[0].args[0]).toBe('intro')
2246
+ // Label sits just right of the flag, which stands at timeToX(5) = 50.
2247
+ expect(text[0].args[1]).toBeGreaterThanOrEqual(50)
2248
+ expect(r.of('fillRect').length).toBeGreaterThan(0)
2249
+ })
2250
+
2251
+ it('skips a marker scrolled out of view', () => {
2252
+ const r = recordingContext()
2253
+ drawMarkers(r.ctx, [{ id: 'a', t: 500, label: '1' }], viewport({ pxPerSecond: 10, scrollSeconds: 0 }), strip, 800, [])
2254
+ expect(r.of('fillText')).toHaveLength(0)
2255
+ })
2256
+
2257
+ it('paints a selected marker in the selected colour', () => {
2258
+ const base = recordingContext()
2259
+ drawMarkers(base.ctx, [{ id: 'a', t: 5, label: '1' }], viewport({ pxPerSecond: 10 }), strip, 800, [])
2260
+ const sel = recordingContext()
2261
+ drawMarkers(sel.ctx, [{ id: 'a', t: 5, label: '1' }], viewport({ pxPerSecond: 10 }), strip, 800, ['a'])
2262
+ const fills = (r: typeof base) => r.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
2263
+ expect(fills(sel)).not.toEqual(fills(base))
2264
+ })
2265
+
2266
+ it('truncates a long label instead of letting it run across the strip', () => {
2267
+ const r = recordingContext()
2268
+ drawMarkers(r.ctx, [{ id: 'a', t: 0, label: 'a'.repeat(400) }], viewport({ pxPerSecond: 10 }), strip, 800, [])
2269
+ expect((r.of('fillText')[0].args[0] as string).length).toBeLessThan(400)
2270
+ })
2271
+ })
@@ -18,6 +18,7 @@ import { AUDIO_ITEM_INSET_PX, RULER_HEIGHT_PX, computeTimelineLayout } from '../
18
18
  import {
19
19
  AUDIO_EDGE_TOLERANCE_PX,
20
20
  FADE_GRIP_ZONE_HEIGHT_PX,
21
+ MARKER_HIT_WIDTH_PX,
21
22
  VISUAL_EDGE_TOLERANCE_PX,
22
23
  hitTest,
23
24
  isCaptionHit,
@@ -474,6 +475,81 @@ describe('hitTest — outside the rows', () => {
474
475
  })
475
476
  })
476
477
 
478
+ describe('marker hit region', () => {
479
+ // A marker-bearing twin of the module fixture. Local, because adding markers
480
+ // to the shared `project` would shift every other probe's Y by the strip
481
+ // height and rewrite this whole file's expectations.
482
+ const markers = [{ id: 'm1', t: 5, label: 'intro' }]
483
+ const markerProject = { ...project, markers } as unknown as Project
484
+ const markerLayout = () => computeTimelineLayout(markerProject)
485
+ // x === t * 100 at this viewport, so the flag stands at 500.
486
+ const FLAG_X = 500
487
+
488
+ it('hits the marker when the pointer is on its flag in the strip', () => {
489
+ const hit = hitTest({ x: FLAG_X, y: 8 }, markerLayout(), VIEWPORT, { markers })
490
+ expect(hit.kind).toBe('marker')
491
+ expect(hit.markerId).toBe('m1')
492
+ })
493
+
494
+ it('claims the whole label box, not just the flag stem', () => {
495
+ // The label is what the operator aims at — a 2px stem is not a target.
496
+ const hit = hitTest({ x: FLAG_X + 40, y: 8 }, markerLayout(), VIEWPORT, { markers })
497
+ expect(hit.kind).toBe('marker')
498
+ })
499
+
500
+ it('does not claim strip for a marker scrolled off the LEFT edge', () => {
501
+ // `drawMarkers` culls a marker whose flag has scrolled left of x=0 — the
502
+ // whole marker, label included. Without the same cull here, the hit region
503
+ // outlives the picture: the marker is invisible but its 72px claim still
504
+ // covers the left of the strip, so a click on apparently-empty strip
505
+ // selects (and can then DRAG) a marker nobody can see. Painting and hitting
506
+ // have to cull on the same rule or one of them is lying.
507
+ const scrolled = { ...VIEWPORT, scrollSeconds: 5.05 } // marker t=5 → x = -5
508
+ const hit = hitTest({ x: 10, y: 8 }, markerLayout(), scrolled, { markers })
509
+ expect(hit.kind).toBe('background')
510
+ })
511
+
512
+ it('still claims a marker whose flag is just INSIDE the left edge', () => {
513
+ // The boundary the cull sits on, from the visible side — proves the fix
514
+ // culls off-screen markers rather than simply narrowing the hit region.
515
+ const scrolled = { ...VIEWPORT, scrollSeconds: 4.99 } // marker t=5 → x = +1
516
+ const hit = hitTest({ x: 10, y: 8 }, markerLayout(), scrolled, { markers })
517
+ expect(hit.kind).toBe('marker')
518
+ expect(hit.markerId).toBe('m1')
519
+ })
520
+
521
+ it('stops claiming past the label box', () => {
522
+ const hit = hitTest({ x: FLAG_X + MARKER_HIT_WIDTH_PX + 10, y: 8 }, markerLayout(), VIEWPORT, { markers })
523
+ expect(hit.kind).not.toBe('marker')
524
+ })
525
+
526
+ it('is background, NOT ruler, on empty strip — a click there must not seek', () => {
527
+ const hit = hitTest({ x: 20, y: 8 }, markerLayout(), VIEWPORT, { markers })
528
+ expect(hit.kind).toBe('background')
529
+ })
530
+
531
+ it('never returns a marker for a point in the RULER band', () => {
532
+ // The ruler keeps its band and its scrub behaviour, untouched.
533
+ const layout = markerLayout()
534
+ const hit = hitTest({ x: FLAG_X, y: layout.ruler.y + 4 }, layout, VIEWPORT, { markers })
535
+ expect(hit.kind).toBe('ruler')
536
+ })
537
+
538
+ it('catches a pointer that has run off the TOP of the surface', () => {
539
+ // The ruler's own early return used to be what caught this; the strip now
540
+ // sits above it and must inherit the same duty.
541
+ const hit = hitTest({ x: FLAG_X, y: -20 }, markerLayout(), VIEWPORT, { markers })
542
+ expect(hit.kind).toBe('marker')
543
+ })
544
+
545
+ it('returns ruler in the top band when the project has no markers', () => {
546
+ // No strip exists, so y=8 is inside the ruler exactly as it was before —
547
+ // this is the guard that every existing probe in this file still holds.
548
+ const layout = computeTimelineLayout(project as unknown as Project)
549
+ expect(hitTest({ x: 50, y: 8 }, layout, VIEWPORT).kind).toBe('ruler')
550
+ })
551
+ })
552
+
477
553
  describe('hitTest — time', () => {
478
554
  it('always reports the time under x, whatever was hit', () => {
479
555
  expect(at(250, BASE_Y).t).toBeCloseTo(2.5)
@@ -38,7 +38,7 @@ import {
38
38
  } from '../pointer-machine'
39
39
  import { FADE_GRIP_ZONE_HEIGHT_PX, hitTest } from '../hit-test'
40
40
  import type { SnapConfig } from '../snap'
41
- import { xToTime, type Viewport } from '../viewport'
41
+ import { timeToX, xToTime, type Viewport } from '../viewport'
42
42
 
43
43
  const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
44
44
 
@@ -245,6 +245,14 @@ const BASE_Y = rowMidY(0) // track 0, the tall base row
245
245
  const OVERLAY_Y = rowMidY(1) // track 1, stacked above it
246
246
  const LANE_Y = Math.round(LAYOUT.lanes[0].y + LAYOUT.lanes[0].height / 2)
247
247
 
248
+ /** `rowMidY`'s twin for a one-off project whose layout isn't the shared
249
+ * `LAYOUT` above — the overlay-trim containment tests below each build
250
+ * their own small fixture. */
251
+ function rowMidYFor(ctx: PointerContext, trackIdx: number): number {
252
+ const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
253
+ return Math.round(row.y + row.height / 2)
254
+ }
255
+
248
256
  const C0_BODY = { x: 250, y: BASE_Y }
249
257
  const C0_OUT_EDGE = { x: 495, y: BASE_Y }
250
258
  const C1_BODY = { x: 750, y: BASE_Y }
@@ -940,6 +948,114 @@ describe('edge trim — ripple mode', () => {
940
948
  })
941
949
  })
942
950
 
951
+ // Two butt-joined overlays on trk-1: a 0s–4s, b 4s–8s. trk-0 carries one
952
+ // plain video clip spanning both, just so the fixture is a valid project —
953
+ // no test below touches it.
954
+ function overlapProject(): Project {
955
+ return {
956
+ id: 'p',
957
+ tracks: [
958
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8, inPoint: 0, outPoint: 8, sourceDuration: 20 }] },
959
+ {
960
+ id: 'trk-1',
961
+ items: [
962
+ { id: 'a', type: 'overlay', start: 0, end: 4 },
963
+ { id: 'b', type: 'overlay', start: 4, end: 8 },
964
+ ],
965
+ },
966
+ ],
967
+ } as unknown as Project
968
+ }
969
+
970
+ describe('edge trim on an overlay track — bounded overlap (transitions)', () => {
971
+ it("trimming an overlay's right edge past its neighbour's start creates an overlap", () => {
972
+ const ctx = makeContext({ project: overlapProject() })
973
+ const row1Y = rowMidYFor(ctx, 1)
974
+ const d = new Driver(ctx)
975
+ // a's out edge sits at t=4 (x=400); offset 5px toward `a` so the hit
976
+ // resolves to ITS edge rather than b's in edge at the same x — the same
977
+ // disambiguation `C0_OUT_EDGE`/`C1_IN_EDGE` use above.
978
+ d.down(395, row1Y)
979
+ // +1s worth of pixels (100px/s): the edge lands at t=5, one second into b.
980
+ const next = lastProjectChange(d.move(495, row1Y))
981
+ expect(visual(next, 'a')).toMatchObject({ start: 0, end: 5 })
982
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
983
+ })
984
+
985
+ it('trimming still refuses to swallow the neighbour entirely', () => {
986
+ const ctx = makeContext({ project: overlapProject() })
987
+ const row1Y = rowMidYFor(ctx, 1)
988
+ const d = new Driver(ctx)
989
+ d.down(395, row1Y)
990
+ // +5s worth of pixels would land a's edge at t=9, past b's own end (8) —
991
+ // full containment, which is not a transition.
992
+ const next = lastProjectChange(d.move(895, row1Y))
993
+ expect(visual(next, 'a').end).toBeLessThan(visual(next, 'b').end)
994
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
995
+ })
996
+
997
+ it('stops short of a three-way overlap when the next neighbour already overlaps ITS next', () => {
998
+ // a 0-4, b 4-8, c 7-10: b and c already overlap at [7, 8) before this
999
+ // drag even starts. The single-neighbour clamp (against b.end alone)
1000
+ // would let a's edge travel anywhere up to b.end (8), including into
1001
+ // b∩c — landing a, b, and c all live at the same instant, a shape the
1002
+ // editor would accept but `validate_project` rejects.
1003
+ const threeWayProject: Project = {
1004
+ id: 'p',
1005
+ tracks: [
1006
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] },
1007
+ {
1008
+ id: 'trk-1',
1009
+ items: [
1010
+ { id: 'a', type: 'overlay', start: 0, end: 4 },
1011
+ { id: 'b', type: 'overlay', start: 4, end: 8 },
1012
+ { id: 'c', type: 'overlay', start: 7, end: 10 },
1013
+ ],
1014
+ },
1015
+ ],
1016
+ } as unknown as Project
1017
+ const ctx = makeContext({ project: threeWayProject })
1018
+ const row1Y = rowMidYFor(ctx, 1)
1019
+ const d = new Driver(ctx)
1020
+ // a's out edge sits at t=4 (x=400); offset 5px toward `a` for the same
1021
+ // disambiguation the tests above use.
1022
+ d.down(395, row1Y)
1023
+ // +4s worth of pixels targets a's edge at t=8 — inside b (4-8) and past
1024
+ // c's start (7). The new guard must clamp short of c.start, not just
1025
+ // short of b.end.
1026
+ const next = lastProjectChange(d.move(795, row1Y))
1027
+ expect(visual(next, 'a').end).toBeLessThan(7)
1028
+ expect(visual(next, 'a').end).toBeGreaterThan(6.9)
1029
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
1030
+ expect(visual(next, 'c')).toMatchObject({ start: 7, end: 10 })
1031
+ })
1032
+
1033
+ it("still stops a tracks[0] trim at nothing — overlap there was never checked", () => {
1034
+ // Same shape, but the pair sits on tracks[0] instead: no containment
1035
+ // guard applies there (see the comment in `applyTrim`), so the edge
1036
+ // travels exactly as far as the drag and the magnet/floor logic alone
1037
+ // would already take it — this only guards against the new clamp
1038
+ // accidentally leaking onto trackIdx 0.
1039
+ const project: Project = {
1040
+ id: 'p',
1041
+ tracks: [{
1042
+ id: 'trk-0',
1043
+ items: [
1044
+ { id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
1045
+ { id: 'b', type: 'video', src: 'b.mp4', start: 4, end: 8, inPoint: 0, outPoint: 4, sourceDuration: 20 },
1046
+ ],
1047
+ }],
1048
+ } as unknown as Project
1049
+ const ctx = makeContext({ project })
1050
+ const row0Y = rowMidYFor(ctx, 0)
1051
+ const d = new Driver(ctx)
1052
+ d.down(395, row0Y)
1053
+ const next = lastProjectChange(d.move(895, row0Y))
1054
+ expect(visual(next, 'a').end).toBeCloseTo(9)
1055
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
1056
+ })
1057
+ })
1058
+
943
1059
  // ── Alt / Cmd trim ops ───────────────────────────────────────────────────
944
1060
 
945
1061
  describe('Alt + edge-drag — roll', () => {
@@ -3356,3 +3472,102 @@ describe('caption lanes — hole collapse at commit', () => {
3356
3472
  expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 1, top: 2 })
3357
3473
  })
3358
3474
  })
3475
+
3476
+ describe('marker drag', () => {
3477
+ const MARKERS = [{ id: 'm1', t: 5, label: '1' }]
3478
+ /** The module fixture plus markers, so the strip exists in the layout. */
3479
+ function markerCtx(over: Partial<PointerContext> = {}): PointerContext {
3480
+ return makeContext({ project: { ...baseProject(), markers: MARKERS }, ...over })
3481
+ }
3482
+ /** x of the flag, and a y inside the strip. */
3483
+ const flagX = (ctx: PointerContext) => timeToX(5, ctx.viewport)
3484
+ const STRIP_Y = 8
3485
+
3486
+ it('pressing a marker does NOT scrub — the ruler branch must not claim it', () => {
3487
+ // The whole risk of this feature: pointerDown scrubs on sight for the ruler
3488
+ // and for the playhead grab band, with no drag threshold.
3489
+ const ctx = markerCtx()
3490
+ const m = createPointerMachine()
3491
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3492
+ expect(effects.some(e => e.type === 'seek')).toBe(false)
3493
+ expect(m.state.kind).toBe('pressed')
3494
+ })
3495
+
3496
+ it('still scrubs on the ruler band itself', () => {
3497
+ const ctx = markerCtx()
3498
+ const m = createPointerMachine()
3499
+ const y = ctx.layout.ruler.y + 4
3500
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: 200, y }, modifiers: NO_MODIFIERS, ctx })
3501
+ expect(effects.some(e => e.type === 'seek')).toBe(true)
3502
+ })
3503
+
3504
+ it('a marker sitting under the playhead is still grabbable', () => {
3505
+ // grabsPlayhead is not y-gated, so without an explicit exclusion the
3506
+ // playhead swallows any marker that happens to line up with it — and a
3507
+ // marker dropped with M lands exactly on the playhead, so that is the
3508
+ // COMMONEST marker, not an edge case.
3509
+ const ctx = markerCtx({ playheadTime: 5 })
3510
+ const m = createPointerMachine()
3511
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3512
+ expect(effects.some(e => e.type === 'seek')).toBe(false)
3513
+ })
3514
+
3515
+ it('dragging past the threshold retimes the marker', () => {
3516
+ const ctx = markerCtx()
3517
+ const m = createPointerMachine()
3518
+ const x0 = flagX(ctx)
3519
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3520
+ const effects = m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3521
+ const changes = of(effects, 'projectChange')
3522
+ expect(changes).toHaveLength(1)
3523
+ const moved = changes[0].project.markers![0]
3524
+ expect(moved.t).toBeGreaterThan(5)
3525
+ expect(moved.id).toBe('m1')
3526
+ })
3527
+
3528
+ it('drags grab-relative — pressing the label does not teleport the marker to the cursor', () => {
3529
+ // The 40px press offset is the entire point of this test. The hit region is
3530
+ // a flat MARKER_HIT_WIDTH_PX (72) wide, so a press 40px right of the flag
3531
+ // lands on the LABEL — the common case, not a contrived one — and the two
3532
+ // candidate behaviours diverge there by exactly that offset. At 100px/s a
3533
+ // 100px drag is 1s: grab-relative lands the marker at 6, absolute lands it
3534
+ // at 6.4, the cursor's own time. Only one of those can pass, which is what
3535
+ // the `> 5` assertion in the test above cannot distinguish.
3536
+ const ctx = markerCtx()
3537
+ const m = createPointerMachine()
3538
+ const x0 = flagX(ctx) + 40
3539
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3540
+ const effects = m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3541
+ const changes = of(effects, 'projectChange')
3542
+ expect(changes).toHaveLength(1)
3543
+ const moved = changes[0].project.markers![0]
3544
+ expect(moved.t).toBeCloseTo(6, 6)
3545
+ expect(moved.t).not.toBeCloseTo(xToTime(x0 + 100, ctx.viewport), 6)
3546
+ })
3547
+
3548
+ it('commits once on release', () => {
3549
+ const ctx = markerCtx()
3550
+ const m = createPointerMachine()
3551
+ const x0 = flagX(ctx)
3552
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3553
+ m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3554
+ const up = m.dispatch({ type: 'pointerUp', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3555
+ expect(up.filter(e => e.type === 'commit')).toHaveLength(1)
3556
+ })
3557
+
3558
+ it('a press with no drag selects the marker instead of moving it', () => {
3559
+ const ctx = markerCtx()
3560
+ const m = createPointerMachine()
3561
+ const x0 = flagX(ctx)
3562
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3563
+ const up = m.dispatch({ type: 'pointerUp', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3564
+ expect(up).toContainEqual(expect.objectContaining({ type: 'select', id: 'm1' }))
3565
+ })
3566
+
3567
+ it('double-clicking a marker asks the host to edit it', () => {
3568
+ const ctx = markerCtx()
3569
+ const m = createPointerMachine()
3570
+ const effects = m.dispatch({ type: 'doubleClick', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3571
+ expect(effects).toContainEqual({ type: 'editMarker', id: 'm1' })
3572
+ })
3573
+ })