@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.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/scheduler.test.ts +56 -1
- package/src/engine/scheduler.ts +23 -5
- package/src/index.ts +36 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/schema.ts +21 -0
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +98 -4
- 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/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +64 -17
- 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 +110 -12
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- 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 +182 -8
- 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
- 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
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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.
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
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'
|
|
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'
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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('
|
|
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(
|
|
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
|
-
|
|
325
|
-
|
|
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 {
|