@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas rAF clock's CEILING, driven through the real effect.
|
|
3
|
+
*
|
|
4
|
+
* `canvasMaxEndRef` is the only thing standing between "space plays" and "space
|
|
5
|
+
* does nothing" on a project with no track-0 video. It used to read
|
|
6
|
+
* `overlayTracks` (`tracks.slice(1)`), which silently skips track 0 — and track
|
|
7
|
+
* 0 in canvas mode is not the primary footage track, it is content: the
|
|
8
|
+
* background images, and on an agent-authored project frequently the overlays
|
|
9
|
+
* themselves. A project that is ONE track of nothing but overlays (what the
|
|
10
|
+
* animations workflow emits) therefore got a ceiling of 0, and the first tick
|
|
11
|
+
* clamped the playhead to 0 and called `setIsPlaying(false)` in the same frame.
|
|
12
|
+
*
|
|
13
|
+
* These drive the hook's own rAF effect rather than asserting on a helper: the
|
|
14
|
+
* ceiling lives in a ref that nothing exports, so the only honest way to pin it
|
|
15
|
+
* is to run the clock and watch where time goes. Each case fails on the
|
|
16
|
+
* pre-fix `overlayTracks` read.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
19
|
+
import { act, renderHook } from '@testing-library/react'
|
|
20
|
+
import { useVideoPlayback } from '../useVideoPlayback'
|
|
21
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
22
|
+
|
|
23
|
+
const overlay = (id: string, start: number, end: number): VisualItem =>
|
|
24
|
+
({ id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }) as VisualItem
|
|
25
|
+
|
|
26
|
+
const image = (id: string, start: number, end: number): VisualItem =>
|
|
27
|
+
({ id, type: 'image', src: `/img/${id}.png`, start, end }) as VisualItem
|
|
28
|
+
|
|
29
|
+
/** The last emission. Not `.at(-1)` — this package targets ES2020. */
|
|
30
|
+
const last = (xs: number[]): number | undefined => xs[xs.length - 1]
|
|
31
|
+
|
|
32
|
+
function projectOf(...tracks: VisualItem[][]): EditorProject {
|
|
33
|
+
return {
|
|
34
|
+
id: 'canvas-clock',
|
|
35
|
+
status: 'draft',
|
|
36
|
+
settings: { resolution: [1920, 1080] },
|
|
37
|
+
tracks: tracks.map((items, i) => ({ id: `trk-${i}`, items })),
|
|
38
|
+
} as EditorProject
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mount the hook on a canvas project and run the clock for one second of wall
|
|
43
|
+
* time, returning every playhead value it emitted.
|
|
44
|
+
*
|
|
45
|
+
* Two ticks, because the first only anchors `rafLastMs` — it has no `dt` yet
|
|
46
|
+
* and emits nothing. The second is the first that can move the playhead, and
|
|
47
|
+
* the one the collapsed ceiling used to kill.
|
|
48
|
+
*/
|
|
49
|
+
function runClock(project: EditorProject) {
|
|
50
|
+
const times: number[] = []
|
|
51
|
+
const scheduled: FrameRequestCallback[] = []
|
|
52
|
+
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
|
53
|
+
scheduled.push(cb)
|
|
54
|
+
return scheduled.length
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const view = renderHook(() => useVideoPlayback(project, 0, (t) => times.push(t), (p) => p))
|
|
58
|
+
expect(view.result.current.isCanvasProject).toBe(true)
|
|
59
|
+
|
|
60
|
+
scheduled.length = 0
|
|
61
|
+
act(() => { view.result.current.setIsPlaying(true) })
|
|
62
|
+
expect(scheduled.length).toBeGreaterThan(0)
|
|
63
|
+
|
|
64
|
+
act(() => { scheduled[0](0) }) // anchor only
|
|
65
|
+
act(() => { scheduled[scheduled.length - 1](1000) }) // +1s of wall clock
|
|
66
|
+
|
|
67
|
+
return { times, isPlaying: view.result.current.isPlaying }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('useVideoPlayback — the canvas clock counts track 0', () => {
|
|
71
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
72
|
+
|
|
73
|
+
it('advances on a single track holding nothing but overlays', () => {
|
|
74
|
+
// The Daubert-demo shape: one track, 14 overlays, no captions, no video.
|
|
75
|
+
const { times, isPlaying } = runClock(projectOf([overlay('o1', 0, 5), overlay('o2', 5, 12)]))
|
|
76
|
+
expect(last(times)).toBeCloseTo(1, 6)
|
|
77
|
+
expect(isPlaying).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('advances on a track-0 image that outlasts every overlay track', () => {
|
|
81
|
+
// Same defect, other content kind — the background image was invisible to
|
|
82
|
+
// the ceiling, so a slideshow stopped at its last overlay.
|
|
83
|
+
//
|
|
84
|
+
// The overlay track has to end INSIDE the second this clock runs (0.4 < 1),
|
|
85
|
+
// or `overlayTracks` alone already carries the playhead to 1 and the case
|
|
86
|
+
// passes on the broken code without ever reading track 0.
|
|
87
|
+
const { times } = runClock(projectOf([image('bg', 0, 20)], [overlay('o', 0, 0.4)]))
|
|
88
|
+
expect(last(times)).toBeCloseTo(1, 6)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('still stops at the ceiling rather than running past the content', () => {
|
|
92
|
+
// The clamp itself is not what was wrong, and must survive: one second of
|
|
93
|
+
// wall clock against a half-second project lands on the end, not past it,
|
|
94
|
+
// and playback stops there.
|
|
95
|
+
const { times, isPlaying } = runClock(projectOf([overlay('o', 0, 0.5)]))
|
|
96
|
+
expect(last(times)).toBeCloseTo(0.5, 6)
|
|
97
|
+
expect(isPlaying).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -322,18 +322,33 @@ export function useVideoPlayback(
|
|
|
322
322
|
applyClipVolume(clip)
|
|
323
323
|
}, [clips, videoTrack, activeSlot])
|
|
324
324
|
|
|
325
|
-
// maxEnd for the canvas rAF clock — the furthest
|
|
325
|
+
// maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
|
|
326
326
|
// a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
|
|
327
327
|
// down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
|
|
328
328
|
// to it). onTimeUpdate is the stable clock.set identity.
|
|
329
|
+
//
|
|
330
|
+
// Spans EVERY enabled track, track 0 INCLUDED — not `overlayTracks`
|
|
331
|
+
// (`slice(1)`). This clock only ever runs for canvas projects, where track 0
|
|
332
|
+
// holds content rather than the primary footage: the background images, and
|
|
333
|
+
// on an agent-authored project frequently the overlays themselves (an
|
|
334
|
+
// animations-workflow project is often ONE track of nothing but overlays).
|
|
335
|
+
// Reading `overlayTracks` here collapsed the ceiling to 0 for exactly those,
|
|
336
|
+
// so the very first tick clamped to 0 and immediately called
|
|
337
|
+
// `setIsPlaying(false)` — space appeared to do nothing at all.
|
|
338
|
+
// `OverlayItemsLayer` already draws track 0 in canvas mode; this keeps the
|
|
339
|
+
// clock's ceiling and what's on screen in agreement. Mirrored in the engine
|
|
340
|
+
// path's `transportEndFor` (engine/scheduler.ts) — change both together.
|
|
341
|
+
//
|
|
342
|
+
// Audio stays OUT, unchanged: the canvas/video divergence over the audio tail
|
|
343
|
+
// is documented in timeline-core's `durations.js` and is not this fix.
|
|
329
344
|
const canvasMaxEndRef = useRef(0)
|
|
330
345
|
useEffect(() => {
|
|
331
346
|
const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
|
|
332
347
|
canvasMaxEndRef.current = Math.max(
|
|
333
|
-
|
|
348
|
+
enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
334
349
|
captionEnd,
|
|
335
350
|
)
|
|
336
|
-
}, [
|
|
351
|
+
}, [project])
|
|
337
352
|
|
|
338
353
|
useEffect(() => {
|
|
339
354
|
if (!isCanvasProject) return
|
|
@@ -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
|
}
|
|
@@ -1137,7 +1194,7 @@ export default function TimelineCanvas({
|
|
|
1137
1194
|
if (!p.onFadeCurveMenu && !p.onKeyframeMenu) return
|
|
1138
1195
|
const point = surfacePoint(e)
|
|
1139
1196
|
if (!point) return
|
|
1140
|
-
const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
|
|
1197
|
+
const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds, markers: p.project.markers })
|
|
1141
1198
|
if (p.onFadeCurveMenu && hit.kind === 'audio-fade' && hit.itemId !== undefined && hit.side) {
|
|
1142
1199
|
e.preventDefault()
|
|
1143
1200
|
p.onFadeCurveMenu({ trackId: hit.itemId, side: hit.side, x: e.clientX, y: e.clientY })
|
|
@@ -1368,6 +1425,36 @@ export default function TimelineCanvas({
|
|
|
1368
1425
|
}
|
|
1369
1426
|
}, [])
|
|
1370
1427
|
|
|
1428
|
+
// ── Marker rename box ──
|
|
1429
|
+
//
|
|
1430
|
+
// Unlike the imperative paint (which reads `store.get()` fresh per frame),
|
|
1431
|
+
// the box's `left` is a React-owned DOM style, so it needs a subscription to
|
|
1432
|
+
// follow a zoom or scroll that happens while it's open. That subscription
|
|
1433
|
+
// lives on `MarkerRenameBox` itself, below, rather than here — the same
|
|
1434
|
+
// reason `CanvasZoomBadge` is its own component: TimelineCanvas must not
|
|
1435
|
+
// subscribe to the viewport in its own render body, or every wheel-zoom
|
|
1436
|
+
// tick and pan frame re-renders this whole surface, which is exactly what
|
|
1437
|
+
// this file's header doc promises never happens.
|
|
1438
|
+
const editingMarker = editingMarkerId
|
|
1439
|
+
? (project.markers ?? []).find(m => m.id === editingMarkerId) ?? null
|
|
1440
|
+
: null
|
|
1441
|
+
|
|
1442
|
+
const commitRename = (value: string) => {
|
|
1443
|
+
// The id is captured and null-checked here because this same function is
|
|
1444
|
+
// also the input's blur handler, and it must be safe to call when there
|
|
1445
|
+
// is nothing being edited. Escape does not route through here at all —
|
|
1446
|
+
// its handler clears `editingMarkerId` directly (see `MarkerRenameBox`
|
|
1447
|
+
// below) — so the null check is for blur-with-nothing-open, not for
|
|
1448
|
+
// "Escape already cleared it before blur could commit."
|
|
1449
|
+
const id = editingMarkerId
|
|
1450
|
+
setEditingMarkerId(null)
|
|
1451
|
+
if (!id) return
|
|
1452
|
+
const next = renameMarker(project, id, value)
|
|
1453
|
+
if (next === project) return // blank or unchanged — no undo entry
|
|
1454
|
+
onProjectChange?.(next)
|
|
1455
|
+
onOverlayEdit?.(next) // one commit, one undo step
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1371
1458
|
return (
|
|
1372
1459
|
<div
|
|
1373
1460
|
ref={containerRef}
|
|
@@ -1399,6 +1486,15 @@ export default function TimelineCanvas({
|
|
|
1399
1486
|
>
|
|
1400
1487
|
<canvas ref={contentCanvasRef} className="absolute inset-0 w-full h-full" />
|
|
1401
1488
|
<canvas ref={overlayCanvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />
|
|
1489
|
+
{editingMarker && (
|
|
1490
|
+
<MarkerRenameBox
|
|
1491
|
+
marker={editingMarker}
|
|
1492
|
+
store={store}
|
|
1493
|
+
top={layout.markers?.y ?? 0}
|
|
1494
|
+
onCommit={commitRename}
|
|
1495
|
+
onCancel={() => setEditingMarkerId(null)}
|
|
1496
|
+
/>
|
|
1497
|
+
)}
|
|
1402
1498
|
</div>
|
|
1403
1499
|
)
|
|
1404
1500
|
}
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { StrictMode } from 'react'
|
|
12
12
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
13
|
-
import { render, act, cleanup } from '@testing-library/react'
|
|
13
|
+
import { render, act, cleanup, screen, fireEvent } from '@testing-library/react'
|
|
14
14
|
import { createPlaybackClock } from '../../../playback-clock'
|
|
15
15
|
import type { Project } from '../../../../types'
|
|
16
16
|
import TimelineCanvas from '../TimelineCanvas'
|
|
17
|
-
import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, type TimelineMode } from '../draw'
|
|
17
|
+
import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, computeTimelineLayout, type TimelineMode } from '../draw'
|
|
18
18
|
import { createViewportStore, type ViewportStore } from '../viewport'
|
|
19
19
|
|
|
20
20
|
interface RecordedCall { method: string; args: unknown[] }
|
|
@@ -405,3 +405,107 @@ describe('TimelineCanvas — theme mode', () => {
|
|
|
405
405
|
expect(overlay).toHaveLength(0)
|
|
406
406
|
})
|
|
407
407
|
})
|
|
408
|
+
|
|
409
|
+
// ── Marker rename box ────────────────────────────────────────────────────
|
|
410
|
+
//
|
|
411
|
+
// The gesture DECISION (double-click on a marker → `editMarker` effect) is
|
|
412
|
+
// covered as pure data in pointer-machine.test.ts. What only a mounted
|
|
413
|
+
// component can show is the box itself: that it mounts, that it prefills,
|
|
414
|
+
// and that Enter/Escape commit or cancel through the same
|
|
415
|
+
// onProjectChange/onOverlayEdit pair every other discrete edit here uses.
|
|
416
|
+
describe('marker rename box', () => {
|
|
417
|
+
const markerProject = {
|
|
418
|
+
id: 'p-marker',
|
|
419
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8 }]],
|
|
420
|
+
markers: [{ id: 'm1', t: 2, label: '1' }],
|
|
421
|
+
} as unknown as Project
|
|
422
|
+
|
|
423
|
+
// Derived from the layout itself, not hardcoded, so a strip-height change
|
|
424
|
+
// elsewhere can't quietly desync this from where the flag actually paints.
|
|
425
|
+
const MARKER_STRIP = computeTimelineLayout(markerProject).markers!
|
|
426
|
+
const MARKER_STRIP_Y = Math.round(MARKER_STRIP.y + MARKER_STRIP.height / 2)
|
|
427
|
+
|
|
428
|
+
function renderMarkerCanvas(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
|
|
429
|
+
const store = createViewportStore()
|
|
430
|
+
const clock = createPlaybackClock()
|
|
431
|
+
const utils = render(
|
|
432
|
+
<TimelineCanvas
|
|
433
|
+
project={markerProject}
|
|
434
|
+
clock={clock}
|
|
435
|
+
store={store}
|
|
436
|
+
totalDuration={20}
|
|
437
|
+
fps={30}
|
|
438
|
+
selectedIds={NO_SELECTION}
|
|
439
|
+
{...overrides}
|
|
440
|
+
/>,
|
|
441
|
+
)
|
|
442
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
443
|
+
// Pin the scale so the marker's on-screen x is deterministic: t=2s at
|
|
444
|
+
// 100px/s is x=200.
|
|
445
|
+
act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
|
|
446
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
447
|
+
return { ...utils, store, clock }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Dispatches a real `dblclick` at marker m1's surface coordinates, exactly
|
|
451
|
+
* the way an operator's browser event would arrive — not by poking state. */
|
|
452
|
+
function doubleClickAtMarker(container: HTMLElement) {
|
|
453
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
454
|
+
act(() => {
|
|
455
|
+
surface.dispatchEvent(new MouseEvent('dblclick', {
|
|
456
|
+
clientX: 200, clientY: MARKER_STRIP_Y, bubbles: true, cancelable: true,
|
|
457
|
+
}))
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
it('is not mounted until a marker is double-clicked', () => {
|
|
462
|
+
renderMarkerCanvas()
|
|
463
|
+
expect(screen.queryByLabelText('Rename marker')).toBeNull()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('mounts prefilled with the marker label after an editMarker effect', () => {
|
|
467
|
+
const { container } = renderMarkerCanvas()
|
|
468
|
+
doubleClickAtMarker(container)
|
|
469
|
+
// The state update lands synchronously inside `doubleClickAtMarker`'s own
|
|
470
|
+
// `act()`, so the box is already in the DOM — no need for `findBy`'s
|
|
471
|
+
// real-timer polling, which never fires under `vi.useFakeTimers()`.
|
|
472
|
+
const input = screen.getByLabelText('Rename marker')
|
|
473
|
+
expect((input as HTMLInputElement).value).toBe('1')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('Enter commits the new name through onProjectChange and onOverlayEdit', () => {
|
|
477
|
+
const onProjectChange = vi.fn()
|
|
478
|
+
const onOverlayEdit = vi.fn()
|
|
479
|
+
const { container } = renderMarkerCanvas({ onProjectChange, onOverlayEdit })
|
|
480
|
+
doubleClickAtMarker(container)
|
|
481
|
+
const input = screen.getByLabelText('Rename marker')
|
|
482
|
+
fireEvent.change(input, { target: { value: 'intro' } })
|
|
483
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
484
|
+
expect(onProjectChange).toHaveBeenCalled()
|
|
485
|
+
const next = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
486
|
+
expect(next.markers![0].label).toBe('intro')
|
|
487
|
+
expect(onOverlayEdit).toHaveBeenCalled() // one undo entry
|
|
488
|
+
expect(screen.queryByLabelText('Rename marker')).toBeNull()
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('Escape cancels and commits nothing', () => {
|
|
492
|
+
const onProjectChange = vi.fn()
|
|
493
|
+
const { container } = renderMarkerCanvas({ onProjectChange })
|
|
494
|
+
doubleClickAtMarker(container)
|
|
495
|
+
const input = screen.getByLabelText('Rename marker')
|
|
496
|
+
fireEvent.change(input, { target: { value: 'nope' } })
|
|
497
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
498
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
499
|
+
expect(screen.queryByLabelText('Rename marker')).toBeNull()
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('is a real <input>, so the document keymap ignores typing in it', () => {
|
|
503
|
+
// useKeymap's isTypingTarget guards INPUT/TEXTAREA/contentEditable — this
|
|
504
|
+
// pins that the box is a real input in the document, not a canvas-drawn
|
|
505
|
+
// fake.
|
|
506
|
+
const { container } = renderMarkerCanvas()
|
|
507
|
+
doubleClickAtMarker(container)
|
|
508
|
+
const input = screen.getByLabelText('Rename marker')
|
|
509
|
+
expect(input.tagName).toBe('INPUT')
|
|
510
|
+
})
|
|
511
|
+
})
|