@bycrux/editor 1.1.0 → 1.2.1

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__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  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/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  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 +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
@@ -14,7 +14,7 @@ import {
14
14
  type MontajWindow,
15
15
  } from './audio-context'
16
16
  import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
17
- import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
17
+ import { audioEnd, effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
18
18
 
19
19
  // Typed extension for video elements that cache their GainNode
20
20
  interface MontajVideoElement extends HTMLVideoElement {
@@ -122,6 +122,7 @@ export function useVideoPlayback(
122
122
  currentTime: number,
123
123
  onTimeUpdate: (t: number) => void,
124
124
  fileUrl: (path: string) => string,
125
+ muted = false,
125
126
  ) {
126
127
  // Double-buffer video elements for seamless clip transitions
127
128
  const video0Ref = useRef<HTMLVideoElement>(null)
@@ -137,6 +138,20 @@ export function useVideoPlayback(
137
138
  const loopOffsetRef = useRef(0)
138
139
  const rafRef = useRef<number | null>(null)
139
140
  const rafLastMs = useRef<number | null>(null)
141
+ // Second-rider master mute (PreviewPlayer's `muted` prop). Read through a
142
+ // ref — not the `muted` param directly — so every gain-setting site below,
143
+ // including the useCallback closures whose own dependency arrays this
144
+ // deliberately leaves untouched (`tickGap`, `handleTimeUpdate`,
145
+ // `syncAudioTracks`), always sees the current value without forcing a
146
+ // reload/re-wire of anything. Covers BOTH GainNode families this hook owns:
147
+ // the video-slot slots (`videoGainRef`, via `applyClipVolume`) and every
148
+ // background audio-track lane (`gainNodesMap`) — `<video muted>`/
149
+ // `el.muted` alone would silence neither, since both are routed through
150
+ // `MediaElementSource → GainNode → ctx.destination` (see `audio-context.ts`
151
+ // and `ensureVideoGain` below): once wired, the element's own mute/volume
152
+ // have no audible effect.
153
+ const mutedRef = useRef(muted)
154
+ useEffect(() => { mutedRef.current = muted }, [muted])
140
155
  // rAF clock for VIDEO projects — drives clip-boundary detection at ~60Hz
141
156
  // instead of the <video> element's coarse `timeupdate` event (~4Hz). See the
142
157
  // effect below for why.
@@ -309,31 +324,60 @@ export function useVideoPlayback(
309
324
  function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
310
325
  const slot = activeSlotRef.current
311
326
  const gain = getVideoGain(slot)
312
- if (gain) gain.gain.value = clipGain(videoTrack, clip)
327
+ if (gain) gain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, clip)
313
328
  }
314
329
 
315
330
  // Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification).
316
331
  // `videoTrack` is a dep in its own right: pulling the TRACK's fader while the
317
- // clips themselves are untouched has to reach the live gain node too.
332
+ // clips themselves are untouched has to reach the live gain node too. `muted`
333
+ // is a dep for the same reason: an external mute toggle on an already-loaded
334
+ // slot must reach the live node immediately, not wait for the next natural
335
+ // clip switch (which is the only other place `mutedRef` gets re-read).
318
336
  useEffect(() => {
319
337
  const idx = activeIdxRef.current
320
338
  const clip = clips[idx]
321
339
  if (!clip) return
322
340
  applyClipVolume(clip)
323
- }, [clips, videoTrack, activeSlot])
341
+ }, [clips, videoTrack, activeSlot, muted])
324
342
 
325
- // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
343
+ // maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
326
344
  // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
327
345
  // down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
328
346
  // to it). onTimeUpdate is the stable clock.set identity.
347
+ //
348
+ // Spans EVERY enabled track, track 0 INCLUDED — not `overlayTracks`
349
+ // (`slice(1)`). This clock only ever runs for canvas projects, where track 0
350
+ // holds content rather than the primary footage: the background images, and
351
+ // on an agent-authored project frequently the overlays themselves (an
352
+ // animations-workflow project is often ONE track of nothing but overlays).
353
+ // Reading `overlayTracks` here collapsed the ceiling to 0 for exactly those,
354
+ // so the very first tick clamped to 0 and immediately called
355
+ // `setIsPlaying(false)` — space appeared to do nothing at all.
356
+ // `OverlayItemsLayer` already draws track 0 in canvas mode; this keeps the
357
+ // clock's ceiling and what's on screen in agreement. Mirrored in the engine
358
+ // path's `transportEndFor` (engine/scheduler.ts) — change both together.
359
+ //
360
+ // Audio stays OUT of the ceiling whenever anything VISUAL sets one: the
361
+ // canvas/video divergence over the audio tail is documented in timeline-core's
362
+ // `durations.js` and is deliberate.
363
+ //
364
+ // It cannot stay out when nothing visual sets one at all, though. An
365
+ // audio-only timeline — an animations-workflow project whose music is wired
366
+ // before any overlay exists — left this at 0, so the first tick clamped to 0
367
+ // and immediately called `setIsPlaying(false)`: play/space did nothing
368
+ // whatsoever, with no feedback saying why. The audio end is the last-resort
369
+ // ceiling for exactly that case and changes nothing for any project that has
370
+ // visual content. Mirrored in `transportEndFor` (engine/scheduler.ts) — change
371
+ // both together.
329
372
  const canvasMaxEndRef = useRef(0)
330
373
  useEffect(() => {
331
374
  const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
332
- canvasMaxEndRef.current = Math.max(
333
- overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
375
+ const visualCeiling = Math.max(
376
+ enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
334
377
  captionEnd,
335
378
  )
336
- }, [overlayTracks, project.captions])
379
+ canvasMaxEndRef.current = visualCeiling > 0 ? visualCeiling : audioEnd(project)
380
+ }, [project])
337
381
 
338
382
  useEffect(() => {
339
383
  if (!isCanvasProject) return
@@ -416,7 +460,7 @@ export function useVideoPlayback(
416
460
  const ctx = getSharedAudioContext()
417
461
  const source = ctx.createMediaElementSource(el)
418
462
  const gain = ctx.createGain()
419
- gain.gain.value = track.volume ?? 1
463
+ gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
420
464
  source.connect(gain)
421
465
  gain.connect(ctx.destination)
422
466
  gains.set(track.id, gain)
@@ -427,18 +471,21 @@ export function useVideoPlayback(
427
471
  }
428
472
  // Volume is controlled via GainNode, not el.volume
429
473
  const gain = gains.get(track.id)
430
- if (gain) gain.gain.value = track.volume ?? 1
474
+ if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
431
475
  }
432
476
  // Keyed on identity string — only fires when tracks are added/removed/src changes
433
477
  }, [audioTrackIdentity])
434
478
 
435
- // Update volume in-place on every render via GainNode — cheap, no element churn
479
+ // Update volume in-place on every render via GainNode — cheap, no element churn.
480
+ // `muted` is a dep (not just read via `mutedRef`) so an external mute toggle
481
+ // reaches every lane immediately rather than waiting for the next track-set
482
+ // change or `syncAudioTracks` tick.
436
483
  useEffect(() => {
437
484
  for (const track of unmutedAudioTracks) {
438
485
  const gain = gainNodesMap.current.get(track.id)
439
- if (gain) gain.gain.value = track.volume ?? 1
486
+ if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
440
487
  }
441
- }, [unmutedAudioTracks])
488
+ }, [unmutedAudioTracks, muted])
442
489
 
443
490
  // Cleanup on unmount only. The shared AudioContext (window.__montajSharedCtx)
444
491
  // is intentionally NOT closed — it's window-scoped and reused across remounts
@@ -489,7 +536,7 @@ export function useVideoPlayback(
489
536
 
490
537
  // `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
491
538
  const gain = gainNodesMap.current.get(track.id)
492
- if (gain) gain.gain.value = win.gain
539
+ if (gain) gain.gain.value = mutedRef.current ? 0 : win.gain
493
540
  }
494
541
  }, [])
495
542
 
@@ -625,7 +672,7 @@ export function useVideoPlayback(
625
672
  const src = fileUrlRef.current(playbackSrcFor(nc))
626
673
  if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
627
674
  const gain = ensureVideoGain(ns)
628
- if (gain) gain.gain.value = clipGain(videoTrack, nc)
675
+ if (gain) gain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, nc)
629
676
  playSoon(nv)
630
677
  }
631
678
  void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
@@ -743,7 +790,7 @@ export function useVideoPlayback(
743
790
  inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
744
791
  const inactiveSlot = (1 - slot) as 0 | 1
745
792
  const nextGain = ensureVideoGain(inactiveSlot)
746
- if (nextGain) nextGain.gain.value = clipGain(videoTrack, clips[nextIdx])
793
+ if (nextGain) nextGain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, clips[nextIdx])
747
794
  }
748
795
  }
749
796
 
@@ -798,7 +845,7 @@ export function useVideoPlayback(
798
845
  nextVideo.currentTime = effectiveInPoint(next)
799
846
  }
800
847
  const nextGain = ensureVideoGain(nextSlot)
801
- if (nextGain) nextGain.gain.value = clipGain(videoTrack, next)
848
+ if (nextGain) nextGain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, next)
802
849
  playSoon(nextVideo)
803
850
  }
804
851
 
@@ -18,6 +18,7 @@ import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
18
18
  import { keyframeUnionTimes } from './canvas/keyframe-strip'
19
19
  import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
20
20
  import { deleteSelection, toggleSelection } from './multiSelectOps'
21
+ import { removeMarkers } from './markers'
21
22
  import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, trackItems } from './timeline-model'
22
23
  import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
23
24
  import type { KeyframeSelection } from './canvas/pointer-machine'
@@ -836,6 +837,11 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
836
837
  updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
837
838
  }
838
839
  }
840
+ // Markers live at project.markers, outside tracks/audio, so `deleteSelection`
841
+ // cannot see them — same reason captions need their own strip above. Folded
842
+ // into the SAME commit so a mixed clip + caption + marker delete is one
843
+ // undo entry.
844
+ updated = removeMarkers(updated, new Set(selectedIds))
839
845
  if (rippleMode) updated = collapseGaps(updated)
840
846
  // Deleting a clip out of a magnetic audio lane leaves a gap exactly
841
847
  // like a trim or a move would — close it the same way, on release.
@@ -360,6 +360,22 @@ describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
360
360
  expect(onProjectChange).not.toHaveBeenCalled()
361
361
  })
362
362
 
363
+ it('Delete removes a selected marker, in the same commit as everything else', () => {
364
+ // Markers live outside tracks/audio, so deleteSelection cannot see them —
365
+ // the same reason captions needed their own strip in this action.
366
+ const clock = createPlaybackClock(0)
367
+ const onProjectChange = vi.fn()
368
+ const project = { ...makeProject(), markers: [{ id: 'm1', t: 5, label: '1' }] }
369
+ const { container } = render(
370
+ <Timeline project={project} clock={clock} selectedIds={['m1']} onProjectChange={onProjectChange} />,
371
+ )
372
+ focusTimelineRoot(container)
373
+ fireEvent.keyDown(document.body, { key: 'Delete' })
374
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
375
+ const updated = onProjectChange.mock.calls[0][0] as Project
376
+ expect(updated.markers).toBeUndefined() // last one gone → key dropped
377
+ })
378
+
363
379
  it('exposes zoomFit through actionsRef for a host-level palette', () => {
364
380
  const clock = createPlaybackClock(1)
365
381
  const actionsRef: { current: TimelineActions | null } = { current: null }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { addMarker, moveMarker, removeMarkers, renameMarker, nextMarkerLabel } from '../markers'
3
+ import type { EditorProject, Marker } from '../../../schema'
4
+
5
+ function proj(markers?: Marker[]): EditorProject {
6
+ return {
7
+ id: 'p1', status: 'draft', settings: { resolution: [1920, 1080], fps: 30 },
8
+ tracks: [{ id: 'trk-0', items: [] }],
9
+ ...(markers ? { markers } : {}),
10
+ } as EditorProject
11
+ }
12
+ const mk = (id: string, t: number, label: string): Marker => ({ id, t, label })
13
+
14
+ describe('nextMarkerLabel', () => {
15
+ it('starts at 1 on a project with no markers', () => {
16
+ expect(nextMarkerLabel([])).toBe('1')
17
+ })
18
+
19
+ it('continues past the highest NUMERIC label, ignoring renamed ones', () => {
20
+ // A renamed marker must not stall the counter, and must not be parsed as 0.
21
+ expect(nextMarkerLabel([mk('a', 1, '1'), mk('b', 2, 'cut this'), mk('c', 3, '7')])).toBe('8')
22
+ })
23
+
24
+ it('does not reuse a number after a delete', () => {
25
+ // Counting markers instead of reading the max would hand out '2' twice here.
26
+ expect(nextMarkerLabel([mk('a', 1, '1'), mk('c', 3, '3')])).toBe('4')
27
+ })
28
+ })
29
+
30
+ describe('addMarker', () => {
31
+ it('creates the markers array on a project that has none', () => {
32
+ const out = addMarker(proj(), 4.25)
33
+ expect(out.markers).toHaveLength(1)
34
+ expect(out.markers![0]).toMatchObject({ t: 4.25, label: '1' })
35
+ expect(typeof out.markers![0].id).toBe('string')
36
+ expect(out.markers![0].id.length).toBeGreaterThan(0)
37
+ })
38
+
39
+ it('keeps markers sorted by time whatever order they are added in', () => {
40
+ let p = addMarker(proj(), 10)
41
+ p = addMarker(p, 2)
42
+ p = addMarker(p, 6)
43
+ expect(p.markers!.map(m => m.t)).toEqual([2, 6, 10])
44
+ // Labels record creation order, not position.
45
+ expect(p.markers!.map(m => m.label)).toEqual(['2', '3', '1'])
46
+ })
47
+
48
+ it('returns the SAME reference when a marker already sits within half a frame', () => {
49
+ // Key repeat: holding M must not spray a pile of stacked markers.
50
+ const p = addMarker(proj(), 5)
51
+ const again = addMarker(p, 5 + (1 / 30) * 0.4, 30)
52
+ expect(again).toBe(p)
53
+ })
54
+
55
+ it('does allow a second marker just outside the half-frame window', () => {
56
+ const p = addMarker(proj(), 5)
57
+ const again = addMarker(p, 5 + (1 / 30) * 0.6, 30)
58
+ expect(again).not.toBe(p)
59
+ expect(again.markers).toHaveLength(2)
60
+ })
61
+
62
+ it('clamps a negative time to 0', () => {
63
+ expect(addMarker(proj(), -3).markers![0].t).toBe(0)
64
+ })
65
+
66
+ it('never mutates the project it was given', () => {
67
+ const p = proj([mk('a', 1, '1')])
68
+ const before = JSON.stringify(p)
69
+ addMarker(p, 9)
70
+ expect(JSON.stringify(p)).toBe(before)
71
+ })
72
+ })
73
+
74
+ describe('moveMarker', () => {
75
+ it('retimes and re-sorts', () => {
76
+ const p = proj([mk('a', 1, '1'), mk('b', 2, '2'), mk('c', 3, '3')])
77
+ const out = moveMarker(p, 'a', 2.5)
78
+ expect(out.markers!.map(m => m.id)).toEqual(['b', 'a', 'c'])
79
+ expect(out.markers!.find(m => m.id === 'a')!.t).toBe(2.5)
80
+ })
81
+
82
+ it('clamps to 0 and returns the same reference for an unknown id or an unchanged time', () => {
83
+ const p = proj([mk('a', 1, '1')])
84
+ expect(moveMarker(p, 'a', -5).markers![0].t).toBe(0)
85
+ expect(moveMarker(p, 'nope', 4)).toBe(p)
86
+ expect(moveMarker(p, 'a', 1)).toBe(p)
87
+ })
88
+ })
89
+
90
+ describe('renameMarker', () => {
91
+ it('replaces the label', () => {
92
+ const out = renameMarker(proj([mk('a', 1, '1')]), 'a', 'intro')
93
+ expect(out.markers![0].label).toBe('intro')
94
+ })
95
+
96
+ it('trims, and rejects an empty label by returning the same reference', () => {
97
+ const p = proj([mk('a', 1, '1')])
98
+ expect(renameMarker(p, 'a', ' hi ').markers![0].label).toBe('hi')
99
+ expect(renameMarker(p, 'a', ' ')).toBe(p)
100
+ expect(renameMarker(p, 'a', '1')).toBe(p) // unchanged label, no commit
101
+ })
102
+ })
103
+
104
+ describe('removeMarkers', () => {
105
+ it('drops every id in the set and leaves the rest', () => {
106
+ const p = proj([mk('a', 1, '1'), mk('b', 2, '2'), mk('c', 3, '3')])
107
+ expect(removeMarkers(p, new Set(['a', 'c'])).markers!.map(m => m.id)).toEqual(['b'])
108
+ })
109
+
110
+ it('drops the markers key entirely when the last one goes', () => {
111
+ // Keeps a marker-less project byte-identical to one that never had markers.
112
+ const out = removeMarkers(proj([mk('a', 1, '1')]), new Set(['a']))
113
+ expect(out).not.toHaveProperty('markers')
114
+ })
115
+
116
+ it('returns the same reference when nothing matched', () => {
117
+ const p = proj([mk('a', 1, '1')])
118
+ expect(removeMarkers(p, new Set(['zzz']))).toBe(p)
119
+ })
120
+
121
+ it('returns the same reference when the project has no markers at all', () => {
122
+ const p = proj()
123
+ expect(removeMarkers(p, new Set(['a']))).toBe(p)
124
+ })
125
+ })
@@ -36,13 +36,14 @@
36
36
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
37
37
  import type { GetFilmstripArgs, GetWaveformPeaksArgs, FilmstripIndex, PeaksData, PendingDrop, Project, FootageDropPayload, ResolveFilePath, TimelineDropPlacement } from '../../../types'
38
38
  import { FOOTAGE_DND_MIME } from '../../../types'
39
- import type { KeyframeProp } from '../../../schema'
39
+ import type { KeyframeProp, Marker } from '../../../schema'
40
40
  import type { PlaybackClock } from '../../playback-clock'
41
41
  import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../timeline-model'
42
42
  import { placeDroppedClip, resolveDropPoint } from '../placement'
43
- import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
43
+ import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, MARKER_FLAG_WIDTH_PX, MARKER_LABEL_GAP_PX, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
44
44
  import { hitTest, isEdgeHit, type Point, type SurfaceRect } from './hit-test'
45
45
  import { keyframeUnionTimes } from './keyframe-strip'
46
+ import { renameMarker } from '../markers'
46
47
  import {
47
48
  createPointerMachine,
48
49
  type KeyframeSelection,
@@ -67,6 +68,7 @@ import {
67
68
  reclampForDuration,
68
69
  scaleContextToDpr,
69
70
  syncCanvasBackingStore,
71
+ timeToX,
70
72
  useViewportValue,
71
73
  wheelIntent,
72
74
  withSurfaceWidth,
@@ -183,6 +185,53 @@ export interface TimelineCanvasProps {
183
185
  pendingDrops?: readonly PendingDrop[]
184
186
  }
185
187
 
188
+ /**
189
+ * The marker rename box, isolated into its own component for the same reason
190
+ * `CanvasZoomBadge` (bottom of this file) is: it owns the viewport
191
+ * subscription itself, so a zoom or scroll while the box is open moves it
192
+ * without making `TimelineCanvas`'s own render body subscribe to the
193
+ * viewport — which would re-render the whole surface on every wheel-zoom
194
+ * tick and pan frame, the exact thing this file's header doc says never
195
+ * happens. The subscription only exists for as long as this component is
196
+ * mounted, i.e. only while a rename is actually open.
197
+ */
198
+ function MarkerRenameBox({
199
+ marker,
200
+ store,
201
+ top,
202
+ onCommit,
203
+ onCancel,
204
+ }: {
205
+ marker: Marker
206
+ store: ViewportStore
207
+ top: number
208
+ onCommit: (value: string) => void
209
+ onCancel: () => void
210
+ }) {
211
+ const viewport = useViewportValue(store)
212
+ return (
213
+ <input
214
+ aria-label="Rename marker"
215
+ autoFocus
216
+ defaultValue={marker.label}
217
+ className="absolute z-10 h-4 rounded-sm border border-sky-400 bg-slate-900 px-1 text-[11px] text-slate-50 outline-none"
218
+ style={{
219
+ left: Math.round(timeToX(marker.t, viewport)) + MARKER_FLAG_WIDTH_PX + MARKER_LABEL_GAP_PX,
220
+ top,
221
+ width: 120,
222
+ }}
223
+ onKeyDown={e => {
224
+ // Stop Escape/Enter reaching the document keymap: this box owns
225
+ // them while it is open.
226
+ e.stopPropagation()
227
+ if (e.key === 'Enter') onCommit((e.target as HTMLInputElement).value)
228
+ if (e.key === 'Escape') onCancel()
229
+ }}
230
+ onBlur={e => onCommit(e.target.value)}
231
+ />
232
+ )
233
+ }
234
+
186
235
  /**
187
236
  * Which video row a drop at surface-y `y` landed on, as an index into the
188
237
  * NORMALIZED track order `placeDroppedClip` measures in — or `-1` for "no
@@ -418,6 +467,13 @@ export default function TimelineCanvas({
418
467
  const [paneFill, setPaneFill] = useState(0)
419
468
  const surfaceHeight = Math.max(layout.height, VISUAL_ROW_RENDER_HEIGHT_PX, paneFill)
420
469
 
470
+ // Which marker's rename box is open, or null for none. The box itself is a
471
+ // real DOM `<input>` (rendered below, as a third child of the wrapper div)
472
+ // rather than a canvas-drawn fake — `editCaption` has no equivalent here
473
+ // because it routes to the transcript sidebar instead, so this is the one
474
+ // inline text editor this canvas hosts directly.
475
+ const [editingMarkerId, setEditingMarkerId] = useState<string | null>(null)
476
+
421
477
  // Latest draw inputs, readable from the imperative paint without making the
422
478
  // paint a dependency of every effect (the ref-to-latest pattern
423
479
  // `useTimelineZoom` uses for its wheel handler).
@@ -785,6 +841,7 @@ export default function TimelineCanvas({
785
841
  case 'commit': p.onOverlayEdit?.(effect.project); break
786
842
  case 'inspect': (effect.target === 'visual' ? p.onInspectClip : p.onInspectAudio)?.(effect.id); break
787
843
  case 'editCaption': p.onEditCaption?.(effect.id); break
844
+ case 'editMarker': setEditingMarkerId(effect.id); break
788
845
  // Cursor is written straight to the node: an affordance that changes on
789
846
  // every hover must not cost a React render.
790
847
  case 'cursor': if (containerRef.current) containerRef.current.style.cursor = effect.cursor; break
@@ -888,7 +945,7 @@ export default function TimelineCanvas({
888
945
  // precedence here, same as it does in the machine's own hit-test — a
889
946
  // trim handle must not light up underneath a diamond that would win the
890
947
  // actual press.
891
- const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
948
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds, markers: p.project.markers })
892
949
  if (!isEdgeHit(hit) || hit.itemId === undefined || hit.edge === undefined) return null
893
950
  return p.selectedIds.includes(hit.itemId) ? { itemId: hit.itemId, edge: hit.edge } : null
894
951
  }
@@ -959,12 +1016,14 @@ export default function TimelineCanvas({
959
1016
  //
960
1017
  // Standard NLE behaviour: drag an item/handle past the visible edge and the
961
1018
  // view pans to follow, rather than trapping the gesture at whatever was on
962
- // screen when the drag started. Only gestures where "the pointer is
963
- // captured and following makes sense" qualify — every `dragging` state
964
- // EXCEPT `scrub` (the ruler already owns the playhead directly; panning
965
- // underneath it while it drags would fight the seek instead of extending
966
- // it). Marquee selection is included: dragging the box out past the edge to
967
- // catch items further along the timeline is the same affordance.
1019
+ // screen when the drag started. Every `dragging` state qualifies, including
1020
+ // `scrub` — dragging the playhead to the edge should extend the visible
1021
+ // range the same way dragging a clip does, rather than capping the seek at
1022
+ // whatever was on screen when the scrub started. `applyScrub` resolves an
1023
+ // absolute time from the screen point each call, so re-feeding the same
1024
+ // point after a pan naturally advances the seek. Marquee selection is
1025
+ // included too: dragging the box out past the edge to catch items further
1026
+ // along the timeline is the same affordance.
968
1027
 
969
1028
  function dispatchPointerMove(point: Point, modifiers: Modifiers) {
970
1029
  runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers, ctx: buildContext() }))
@@ -986,7 +1045,7 @@ export default function TimelineCanvas({
986
1045
  edgeScrollFrameRef.current = null
987
1046
 
988
1047
  const state = machine.state
989
- if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
1048
+ if (state.kind !== 'dragging') { stopEdgeAutoScroll(); return }
990
1049
  const drag = lastDragPointRef.current
991
1050
  const rect = gestureRectRef.current
992
1051
  if (!drag || !rect || rect.width <= 0) { stopEdgeAutoScroll(); return }
@@ -1031,7 +1090,7 @@ export default function TimelineCanvas({
1031
1090
  * that leaves the zone is caught on the loop's own next tick). */
1032
1091
  function updateEdgeAutoScroll() {
1033
1092
  const state = machine.state
1034
- if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
1093
+ if (state.kind !== 'dragging') { stopEdgeAutoScroll(); return }
1035
1094
  const drag = lastDragPointRef.current
1036
1095
  const rect = gestureRectRef.current
1037
1096
  if (!drag || !rect || rect.width <= 0 || !inEdgeZone(drag.point.x, rect.width)) return
@@ -1137,7 +1196,7 @@ export default function TimelineCanvas({
1137
1196
  if (!p.onFadeCurveMenu && !p.onKeyframeMenu) return
1138
1197
  const point = surfacePoint(e)
1139
1198
  if (!point) return
1140
- const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
1199
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds, markers: p.project.markers })
1141
1200
  if (p.onFadeCurveMenu && hit.kind === 'audio-fade' && hit.itemId !== undefined && hit.side) {
1142
1201
  e.preventDefault()
1143
1202
  p.onFadeCurveMenu({ trackId: hit.itemId, side: hit.side, x: e.clientX, y: e.clientY })
@@ -1368,6 +1427,36 @@ export default function TimelineCanvas({
1368
1427
  }
1369
1428
  }, [])
1370
1429
 
1430
+ // ── Marker rename box ──
1431
+ //
1432
+ // Unlike the imperative paint (which reads `store.get()` fresh per frame),
1433
+ // the box's `left` is a React-owned DOM style, so it needs a subscription to
1434
+ // follow a zoom or scroll that happens while it's open. That subscription
1435
+ // lives on `MarkerRenameBox` itself, below, rather than here — the same
1436
+ // reason `CanvasZoomBadge` is its own component: TimelineCanvas must not
1437
+ // subscribe to the viewport in its own render body, or every wheel-zoom
1438
+ // tick and pan frame re-renders this whole surface, which is exactly what
1439
+ // this file's header doc promises never happens.
1440
+ const editingMarker = editingMarkerId
1441
+ ? (project.markers ?? []).find(m => m.id === editingMarkerId) ?? null
1442
+ : null
1443
+
1444
+ const commitRename = (value: string) => {
1445
+ // The id is captured and null-checked here because this same function is
1446
+ // also the input's blur handler, and it must be safe to call when there
1447
+ // is nothing being edited. Escape does not route through here at all —
1448
+ // its handler clears `editingMarkerId` directly (see `MarkerRenameBox`
1449
+ // below) — so the null check is for blur-with-nothing-open, not for
1450
+ // "Escape already cleared it before blur could commit."
1451
+ const id = editingMarkerId
1452
+ setEditingMarkerId(null)
1453
+ if (!id) return
1454
+ const next = renameMarker(project, id, value)
1455
+ if (next === project) return // blank or unchanged — no undo entry
1456
+ onProjectChange?.(next)
1457
+ onOverlayEdit?.(next) // one commit, one undo step
1458
+ }
1459
+
1371
1460
  return (
1372
1461
  <div
1373
1462
  ref={containerRef}
@@ -1399,6 +1488,15 @@ export default function TimelineCanvas({
1399
1488
  >
1400
1489
  <canvas ref={contentCanvasRef} className="absolute inset-0 w-full h-full" />
1401
1490
  <canvas ref={overlayCanvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />
1491
+ {editingMarker && (
1492
+ <MarkerRenameBox
1493
+ marker={editingMarker}
1494
+ store={store}
1495
+ top={layout.markers?.y ?? 0}
1496
+ onCommit={commitRename}
1497
+ onCancel={() => setEditingMarkerId(null)}
1498
+ />
1499
+ )}
1402
1500
  </div>
1403
1501
  )
1404
1502
  }
@@ -8,9 +8,10 @@
8
8
  * The ramp/clamp MATH is `edgeScrollDelta` in viewport.ts, covered exhaustively
9
9
  * as pure data in viewport.test.ts. What can only be shown with a mounted
10
10
  * component is here: that a real drag actually starts the loop, that panning
11
- * re-feeds the pointer machine so the dragged item keeps tracking, that it
12
- * clamps and stops at the legal scroll range, and that it stands down when the
13
- * pointer leaves the zone, the drag ends, or the gesture is a ruler scrub.
11
+ * re-feeds the pointer machine so the dragged item (or, for a ruler scrub,
12
+ * the playhead) keeps tracking, that it clamps and stops at the legal scroll
13
+ * range, and that it stands down when the pointer leaves the zone or the
14
+ * drag ends.
14
15
  *
15
16
  * jsdom's `performance.now()` is NOT tied to Vitest's fake timers (verified:
16
17
  * advancing the fake clock by 16ms moves it by a fraction of a millisecond of
@@ -306,7 +307,7 @@ describe('TimelineCanvas — edge auto-scroll', () => {
306
307
  }
307
308
  })
308
309
 
309
- it('does not auto-scroll for a ruler scrub, even with the pointer held at the edge', () => {
310
+ it('auto-scrolls for a ruler scrub held at the edge, same as any other drag', () => {
310
311
  const perf = stubPerfNow()
311
312
  try {
312
313
  const { surface, store, clock } = mount()
@@ -316,13 +317,43 @@ describe('TimelineCanvas — edge auto-scroll', () => {
316
317
  expect(clock.get()).toBeCloseTo(9.9)
317
318
  act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
318
319
 
320
+ act(() => { vi.advanceTimersByTime(20) }) // seed
319
321
  perf.advance(1000)
320
- act(() => { vi.advanceTimersByTime(200) })
322
+ act(() => { vi.advanceTimersByTime(20) }) // one real pan
323
+
324
+ // The view panned to follow the scrub, same as it would for a clip
325
+ // drag, and the playhead kept tracking the held screen point.
326
+ expect(store.get().scrollSeconds).toBeGreaterThan(0)
327
+ expect(clock.get()).toBeGreaterThan(9.9)
328
+
329
+ act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
330
+
331
+ const pannedTo = store.get().scrollSeconds
321
332
  perf.advance(5000)
322
333
  act(() => { vi.advanceTimersByTime(200) })
334
+ expect(store.get().scrollSeconds).toBe(pannedTo)
335
+ } finally {
336
+ perf.restore()
337
+ }
338
+ })
323
339
 
324
- // The playhead moved (that's what a scrub does); the VIEWPORT did not.
325
- expect(store.get().scrollSeconds).toBe(0)
340
+ it('scrub auto-scroll clamps at the rightmost legal scroll and stops panning', () => {
341
+ const perf = stubPerfNow()
342
+ try {
343
+ const { surface, store } = mount()
344
+ act(() => { surface.dispatchEvent(mouse('mousedown', 990, RULER_Y)) })
345
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
346
+
347
+ // x=990 pans at ≈0.393s/tick at this scale (see the equivalent
348
+ // non-scrub clamp test above) — 140 ticks clears RIGHTMOST_SCROLL
349
+ // (52.5s) and then some, to prove it holds there rather than merely
350
+ // arriving at it.
351
+ for (let i = 0; i < 140; i++) {
352
+ perf.advance(1000)
353
+ act(() => { vi.advanceTimersByTime(20) })
354
+ }
355
+
356
+ expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
326
357
 
327
358
  act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
328
359
  } finally {