@bycrux/editor 1.1.0 → 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__/scheduler.test.ts +21 -1
- package/src/engine/scheduler.ts +14 -4
- package/src/index.ts +4 -0
- package/src/schema.ts +21 -0
- package/src/video/VideoEditor.tsx +79 -3
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -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/cuts.ts +30 -2
- 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 +6 -0
- 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/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 +100 -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 +83 -4
- package/src/video/timeline/markers.ts +109 -0
|
@@ -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
|
|
|
@@ -3472,3 +3472,102 @@ describe('caption lanes — hole collapse at commit', () => {
|
|
|
3472
3472
|
expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 1, top: 2 })
|
|
3473
3473
|
})
|
|
3474
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
|
+
})
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* design.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
|
|
18
|
+
import type { AudioTrack, CaptionSegment, Marker, VisualItem } from '../../../schema'
|
|
19
19
|
import type { Project } from '../../../types'
|
|
20
20
|
import { groupCaptionLanes } from '../../captionLanes'
|
|
21
21
|
import {
|
|
@@ -305,6 +305,14 @@ export const TIMELINE_COLORS = {
|
|
|
305
305
|
rulerTick: 'rgba(148,163,184,0.55)',
|
|
306
306
|
rulerTickMinor: 'rgba(148,163,184,0.25)',
|
|
307
307
|
rulerText: 'rgba(148,163,184,0.9)',
|
|
308
|
+
/** A marker's flag stem, in the strip above the ruler. Sky-400 — every
|
|
309
|
+
* saturated hue below it is spoken for (red is playback, yellow the
|
|
310
|
+
* preview axis, cyan the snap guide, amber a keyframe, emerald audio), and
|
|
311
|
+
* a marker is none of those: it is a bookmark, not a state of the edit. */
|
|
312
|
+
markerFlag: '#38bdf8',
|
|
313
|
+
/** The one marker in the current selection. White, the same selection
|
|
314
|
+
* vocabulary `clipSelectedOutline`/`handleFill` already use. */
|
|
315
|
+
markerFlagSelected: '#f8fafc',
|
|
308
316
|
/** The marquee (rubber-band) selection box. Same white as the selection
|
|
309
317
|
* vocabulary, since what it is doing IS selecting. */
|
|
310
318
|
marqueeFill: 'rgba(255,255,255,0.08)',
|
|
@@ -462,6 +470,13 @@ export const LIGHT_TIMELINE_COLORS: TimelineColors = {
|
|
|
462
470
|
rulerTick: 'rgba(71,85,105,0.55)',
|
|
463
471
|
rulerTickMinor: 'rgba(71,85,105,0.3)',
|
|
464
472
|
rulerText: 'rgba(51,65,85,0.85)',
|
|
473
|
+
/** Still sky, stepped from sky-400 to sky-600 for the same reason
|
|
474
|
+
* `snapGuide` steps cyan-400 to cyan-600: the lighter tone doesn't clear a
|
|
475
|
+
* light row. */
|
|
476
|
+
markerFlag: '#0284c7',
|
|
477
|
+
/** The selected marker. Near-black, following `clipSelectedOutline` into
|
|
478
|
+
* the same "white becomes near-black here" rule. */
|
|
479
|
+
markerFlagSelected: '#0f172a',
|
|
465
480
|
/** The marquee. Selection vocabulary again, so it follows the outline and
|
|
466
481
|
* the handles into near-black — at the dark set's own alphas, which were
|
|
467
482
|
* already tuned to "a wash you can see through and a border you can't miss". */
|
|
@@ -503,6 +518,35 @@ export const RULER_STEPS_SECONDS = [
|
|
|
503
518
|
60, 120, 300, 600, 900, 1800, 3600,
|
|
504
519
|
] as const
|
|
505
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The marker strip: a thin band ABOVE the ruler.
|
|
523
|
+
*
|
|
524
|
+
* Markers do NOT share the ruler's 18px. Two reasons, and the second is the
|
|
525
|
+
* load-bearing one:
|
|
526
|
+
*
|
|
527
|
+
* 1. The ruler already spends that band on tick labels. A marker name would
|
|
528
|
+
* overprint them at any useful zoom.
|
|
529
|
+
* 2. `hitTest` returns `'ruler'` for the WHOLE ruler band before it tests
|
|
530
|
+
* anything else, and the pointer machine scrubs on pointerDown the instant
|
|
531
|
+
* it sees that — no drag threshold, no deferral. A marker sharing the band
|
|
532
|
+
* would have to fight that branch for precedence. Its own band means the
|
|
533
|
+
* ruler's behaviour is untouched and a marker hit is simply a different
|
|
534
|
+
* rectangle.
|
|
535
|
+
*
|
|
536
|
+
* Present ONLY when the project has markers, the same discipline
|
|
537
|
+
* `TimelineLayout.captions` follows: a marker-less project lays out exactly as
|
|
538
|
+
* it did before this existed, so no existing layout expectation moves.
|
|
539
|
+
*/
|
|
540
|
+
export const MARKER_STRIP_HEIGHT_PX = 16
|
|
541
|
+
/** Width of a marker's flag stem, and the gap before its label. */
|
|
542
|
+
export const MARKER_FLAG_WIDTH_PX = 2
|
|
543
|
+
export const MARKER_LABEL_GAP_PX = 4
|
|
544
|
+
/** Baseline for a marker label, measured from the top of the strip. */
|
|
545
|
+
export const MARKER_LABEL_BASELINE_PX = 11
|
|
546
|
+
/** Longest label drawn, in characters. Past this the name is clipped with an
|
|
547
|
+
* ellipsis — a marker is a flag, not a notes field. */
|
|
548
|
+
export const MARKER_LABEL_MAX_CHARS = 24
|
|
549
|
+
|
|
506
550
|
export const ROW_RADIUS_PX = 4 // Tailwind `rounded`
|
|
507
551
|
/** Horizontal inset per side between a clip's time span and its drawn body —
|
|
508
552
|
* two touching clips therefore show twice this as a dark gutter. */
|
|
@@ -673,10 +717,14 @@ export interface CaptionRowLayout {
|
|
|
673
717
|
}
|
|
674
718
|
|
|
675
719
|
export interface TimelineLayout {
|
|
676
|
-
/** The time ruler strip
|
|
677
|
-
*
|
|
678
|
-
*
|
|
720
|
+
/** The time ruler strip. Always present; carried in the layout rather than
|
|
721
|
+
* assumed so hit-testing and painting read the same rectangle, the same
|
|
722
|
+
* contract the rows have. Sits at y=0 when the project has no markers, and
|
|
723
|
+
* below the marker strip (see `MARKER_STRIP_HEIGHT_PX`) when it does. */
|
|
679
724
|
ruler: { y: number; height: number }
|
|
725
|
+
/** The marker strip above the ruler. Absent — not a zero-height rect — when
|
|
726
|
+
* the project has no markers; see `MARKER_STRIP_HEIGHT_PX`. */
|
|
727
|
+
markers?: { y: number; height: number }
|
|
680
728
|
/** Visual rows in DRAW order (top of the surface first). */
|
|
681
729
|
rows: VisualRowLayout[]
|
|
682
730
|
lanes: AudioLaneLayout[]
|
|
@@ -717,9 +765,13 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
|
|
|
717
765
|
// legacy-shaped project has no settings, so every row is enabled.
|
|
718
766
|
const trackSettings = normalizeTracks(project).tracks ?? []
|
|
719
767
|
const rows: VisualRowLayout[] = []
|
|
720
|
-
// The
|
|
721
|
-
|
|
722
|
-
|
|
768
|
+
// The marker strip owns the very top when there is anything to put in it;
|
|
769
|
+
// the ruler starts below it, and every row below that.
|
|
770
|
+
const hasMarkers = (project.markers?.length ?? 0) > 0
|
|
771
|
+
const markers = hasMarkers ? { y: 0, height: MARKER_STRIP_HEIGHT_PX } : undefined
|
|
772
|
+
const stripHeight = hasMarkers ? MARKER_STRIP_HEIGHT_PX : 0
|
|
773
|
+
const ruler = { y: stripHeight, height: RULER_HEIGHT_PX }
|
|
774
|
+
let y = stripHeight + RULER_HEIGHT_PX + ROW_GAP_PX
|
|
723
775
|
|
|
724
776
|
// Only stored when there is something to show — an empty `segments` (or no
|
|
725
777
|
// `project.captions` at all) means no bands at all, not an empty array, so a
|
|
@@ -811,6 +863,7 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
|
|
|
811
863
|
})
|
|
812
864
|
|
|
813
865
|
const layout: TimelineLayout = { ruler, rows, lanes, height: Math.max(0, y - ROW_GAP_PX) }
|
|
866
|
+
if (markers) layout.markers = markers
|
|
814
867
|
if (captions.length > 0) layout.captions = captions
|
|
815
868
|
return layout
|
|
816
869
|
}
|
|
@@ -1752,6 +1805,47 @@ export function drawRuler(
|
|
|
1752
1805
|
ctx.restore()
|
|
1753
1806
|
}
|
|
1754
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* The operator's markers, as small flags along the top strip.
|
|
1810
|
+
*
|
|
1811
|
+
* Content-layer, not overlay: markers are project data that changes on an edit,
|
|
1812
|
+
* not per frame. A drag repaints the content layer per move exactly the way a
|
|
1813
|
+
* clip drag already does (`applyMove` emits `projectChange`, the host redraws
|
|
1814
|
+
* content) — there is no ghost, because there is no ghost for clips either.
|
|
1815
|
+
*
|
|
1816
|
+
* `markers` is expected sorted by `t` (the model guarantees it), so this walks
|
|
1817
|
+
* in draw order and a later marker's label overprints an earlier one's when
|
|
1818
|
+
* they crowd. That is the correct z-order: the one further right is the one
|
|
1819
|
+
* whose flag you can still see.
|
|
1820
|
+
*/
|
|
1821
|
+
export function drawMarkers(
|
|
1822
|
+
ctx: DrawContext,
|
|
1823
|
+
markers: readonly Marker[],
|
|
1824
|
+
viewport: Viewport,
|
|
1825
|
+
rect: { y: number; height: number },
|
|
1826
|
+
surfaceWidth: number,
|
|
1827
|
+
selectedIds: readonly string[],
|
|
1828
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1829
|
+
): void {
|
|
1830
|
+
if (markers.length === 0) return
|
|
1831
|
+
ctx.save()
|
|
1832
|
+
ctx.font = LABEL_FONT
|
|
1833
|
+
ctx.textBaseline = 'alphabetic'
|
|
1834
|
+
for (const m of markers) {
|
|
1835
|
+
const x = Math.round(timeToX(m.t, viewport)) + 0.5
|
|
1836
|
+
if (x < 0 || x > surfaceWidth) continue
|
|
1837
|
+
const selected = selectedIds.includes(m.id)
|
|
1838
|
+
ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.markerFlag
|
|
1839
|
+
ctx.fillRect(x, rect.y + 2, MARKER_FLAG_WIDTH_PX, rect.height - 4)
|
|
1840
|
+
ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.rulerText
|
|
1841
|
+
const label = m.label.length > MARKER_LABEL_MAX_CHARS
|
|
1842
|
+
? `${m.label.slice(0, MARKER_LABEL_MAX_CHARS - 1)}…`
|
|
1843
|
+
: m.label
|
|
1844
|
+
ctx.fillText(label, x + MARKER_FLAG_WIDTH_PX + MARKER_LABEL_GAP_PX, rect.y + MARKER_LABEL_BASELINE_PX)
|
|
1845
|
+
}
|
|
1846
|
+
ctx.restore()
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1755
1849
|
// ── Pending-drop ghost ───────────────────────────────────────────────────
|
|
1756
1850
|
|
|
1757
1851
|
/** Corner radius of a ghost band. The same 4px a clip uses — the band stands
|
|
@@ -1886,6 +1980,9 @@ export function drawTimelineContent(ctx: DrawContext, scene: TimelineScene): Dra
|
|
|
1886
1980
|
|
|
1887
1981
|
ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
|
|
1888
1982
|
|
|
1983
|
+
if (layout.markers) {
|
|
1984
|
+
drawMarkers(ctx, scene.project.markers ?? [], viewport, layout.markers, surfaceWidth, selectedIds, themePalette)
|
|
1985
|
+
}
|
|
1889
1986
|
drawRuler(ctx, viewport, layout.ruler, surfaceWidth, themePalette)
|
|
1890
1987
|
|
|
1891
1988
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* which is why captions hit-test at the audio tolerance.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
|
|
25
|
+
import type { AudioTrack, CaptionSegment, Marker, VisualItem } from '../../../schema'
|
|
26
26
|
import { canKeyframe, isKeyframed } from '../../keyframeOps'
|
|
27
27
|
import { AUDIO_ITEM_INSET_PX, type TimelineLayout } from './draw'
|
|
28
28
|
import { KEYFRAME_HIT_HALF_WIDTH_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
|
|
@@ -58,6 +58,10 @@ export const FADE_GRIP_HALF_WIDTH_PX = 5
|
|
|
58
58
|
* this. */
|
|
59
59
|
export const FADE_GRIP_ZONE_HEIGHT_PX = 10
|
|
60
60
|
|
|
61
|
+
/** How far right of a marker's flag the label claims as a target. Wide enough
|
|
62
|
+
* that the LABEL is clickable, not just the 2px stem. */
|
|
63
|
+
export const MARKER_HIT_WIDTH_PX = 72
|
|
64
|
+
|
|
61
65
|
export interface HitTestOptions {
|
|
62
66
|
visualEdgeTolerancePx?: number
|
|
63
67
|
audioEdgeTolerancePx?: number
|
|
@@ -68,9 +72,18 @@ export interface HitTestOptions {
|
|
|
68
72
|
* is also what every caller that predates T3.3 gets — this option is
|
|
69
73
|
* purely additive. */
|
|
70
74
|
selectedIds?: readonly string[]
|
|
75
|
+
/** The project's markers, threaded the same way `selectedIds` is: the layout
|
|
76
|
+
* carries the strip's RECTANGLE but not what sits in it, so the flags have
|
|
77
|
+
* to arrive here to be hit-testable. Absent means no marker hits are
|
|
78
|
+
* possible, which is what every caller that predates this gets. */
|
|
79
|
+
markers?: readonly Marker[]
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
export type HitKind =
|
|
83
|
+
/** On a marker's flag or its label, in the strip ABOVE the ruler. Only ever
|
|
84
|
+
* produced when the project HAS markers (the strip is absent otherwise) and
|
|
85
|
+
* the caller passed them through `HitTestOptions.markers`. */
|
|
86
|
+
| 'marker'
|
|
74
87
|
/** The time ruler strip along the top. Scrubbing lives here, and only here:
|
|
75
88
|
* the track area's empty space belongs to the marquee now. */
|
|
76
89
|
| 'ruler'
|
|
@@ -111,6 +124,10 @@ export interface HitResult {
|
|
|
111
124
|
* point outside the rows is still a point in time. */
|
|
112
125
|
t: number
|
|
113
126
|
itemId?: string
|
|
127
|
+
/** The hit marker's id, on a `'marker'` hit. A field of its own rather than a
|
|
128
|
+
* reuse of `itemId`: a marker is not an item, and every caller that reads
|
|
129
|
+
* `itemId` means "a thing on a track" by it. */
|
|
130
|
+
markerId?: string
|
|
114
131
|
/** Which trim handle, on the two `*-edge` kinds. `in` is the left/start
|
|
115
132
|
* handle, `out` the right/end one — the vocabulary `cuts.ts` uses for source
|
|
116
133
|
* windows, rather than the DOM hook's `start`/`end`. */
|
|
@@ -144,6 +161,10 @@ export interface HitResult {
|
|
|
144
161
|
* press time by the pointer machine and used as the gesture's origin. */
|
|
145
162
|
item?: VisualItem
|
|
146
163
|
track?: AudioTrack
|
|
164
|
+
/** The hit marker itself, for the same reason `item`/`track` are here: the
|
|
165
|
+
* pointer machine captures it at press time and reads its `t` for the whole
|
|
166
|
+
* gesture rather than re-scanning `project.markers` per move. */
|
|
167
|
+
marker?: Marker
|
|
147
168
|
/** The hit caption segment, for the same reason `item`/`track` are here: the
|
|
148
169
|
* pointer machine captures it at press time and reads its `start`/`end` for
|
|
149
170
|
* the whole gesture, rather than re-scanning `project.captions` per move. */
|
|
@@ -307,6 +328,51 @@ export function hitTest(
|
|
|
307
328
|
const visualTolerance = opts.visualEdgeTolerancePx ?? VISUAL_EDGE_TOLERANCE_PX
|
|
308
329
|
const audioTolerance = opts.audioEdgeTolerancePx ?? AUDIO_EDGE_TOLERANCE_PX
|
|
309
330
|
|
|
331
|
+
// The marker strip sits ABOVE the ruler and is tested first, because the
|
|
332
|
+
// ruler's own check below claims its whole band unconditionally. Ordering is
|
|
333
|
+
// the entire mechanism here: reverse these two and every marker click scrubs.
|
|
334
|
+
//
|
|
335
|
+
// NO LOWER BOUND on the band, deliberately. The ruler's check below is
|
|
336
|
+
// written the same way, and its comment says why: a pointer that has run off
|
|
337
|
+
// the TOP of the surface is still aiming at the topmost strip. With a marker
|
|
338
|
+
// strip present that strip is the topmost one, so it inherits that duty —
|
|
339
|
+
// adding a `point.y >= strip.y` guard would drop those points through to the
|
|
340
|
+
// ruler and scrub instead.
|
|
341
|
+
const strip = layout.markers
|
|
342
|
+
if (strip && point.y < strip.y + strip.height) {
|
|
343
|
+
const markers = opts.markers ?? []
|
|
344
|
+
// Right-to-left, matching draw order: a later marker paints OVER an earlier
|
|
345
|
+
// one, so it must also win the click.
|
|
346
|
+
for (let i = markers.length - 1; i >= 0; i--) {
|
|
347
|
+
const m = markers[i]
|
|
348
|
+
const x = timeToX(m.t, viewport)
|
|
349
|
+
// Cull exactly where `drawMarkers` culls, so the hit region cannot
|
|
350
|
+
// outlive the picture. A marker scrolled off the LEFT is not drawn at
|
|
351
|
+
// all — label included — but its claim reaches MARKER_HIT_WIDTH_PX to
|
|
352
|
+
// the RIGHT of the flag, so without this a click on apparently-empty
|
|
353
|
+
// strip near the left edge grabs a marker nobody can see, and can then
|
|
354
|
+
// drag it.
|
|
355
|
+
//
|
|
356
|
+
// The predicate is copied from the painter (`Math.round(x) + 0.5 < 0`)
|
|
357
|
+
// but the hit region is measured from the TRUE `x`. That asymmetry is
|
|
358
|
+
// deliberate: the painter's `+ 0.5` is a half-pixel crispness offset for
|
|
359
|
+
// drawing a sharp 1px stem, not a position. Folding it into the hit
|
|
360
|
+
// region shifts every marker's target half a pixel right of its own
|
|
361
|
+
// flag, so a click landing exactly on the flag misses.
|
|
362
|
+
//
|
|
363
|
+
// `drawMarkers` also culls `x > surfaceWidth`; that needs no counterpart
|
|
364
|
+
// here, since a hit requires `point.x >= x` and `point.x` never exceeds
|
|
365
|
+
// the surface.
|
|
366
|
+
if (Math.round(x) + 0.5 < 0) continue
|
|
367
|
+
if (point.x >= x && point.x <= x + MARKER_HIT_WIDTH_PX) {
|
|
368
|
+
return { kind: 'marker', t, markerId: m.id, marker: m }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Bare strip: not a marker, and deliberately NOT the ruler either — a click
|
|
372
|
+
// on empty strip should do nothing, not seek.
|
|
373
|
+
return { kind: 'background', t }
|
|
374
|
+
}
|
|
375
|
+
|
|
310
376
|
// Before the rows, and including everything above the strip: a pointer that
|
|
311
377
|
// has run off the top of the surface is still aiming at the ruler, and
|
|
312
378
|
// clamping it there keeps a scrub alive when the hand drifts upward
|
|
@@ -490,6 +556,11 @@ export function isItemHit(hit: HitResult): boolean {
|
|
|
490
556
|
|| hit.kind === 'audio-body' || hit.kind === 'audio-edge'
|
|
491
557
|
}
|
|
492
558
|
|
|
559
|
+
/** Is this a hit on a marker's flag or label in the strip? */
|
|
560
|
+
export function isMarkerHit(hit: HitResult): boolean {
|
|
561
|
+
return hit.kind === 'marker'
|
|
562
|
+
}
|
|
563
|
+
|
|
493
564
|
/** Is this a hit on a caption block (either body or edge)? */
|
|
494
565
|
export function isCaptionHit(hit: HitResult): boolean {
|
|
495
566
|
return hit.kind === 'caption-body' || hit.kind === 'caption-edge'
|