@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.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- 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
|
+
})
|