@bycrux/editor 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -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
|
+
})
|
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
resolveAudioWindow,
|
|
7
7
|
computeAutoCrossfade,
|
|
8
8
|
computeDerivedTiming,
|
|
9
|
+
computeVisualCrossfade,
|
|
9
10
|
groupAudioLanes,
|
|
10
11
|
mapTrackItems,
|
|
12
|
+
normalizeAudioTracks,
|
|
11
13
|
normalizeTrackOrder,
|
|
12
14
|
normalizeTracks,
|
|
13
15
|
trackItems,
|
|
16
|
+
updateAudioTrack,
|
|
14
17
|
} from '../timeline-model'
|
|
15
18
|
|
|
16
19
|
function track(overrides: Partial<AudioTrack> = {}): AudioTrack {
|
|
@@ -165,6 +168,36 @@ describe('computeAutoCrossfade', () => {
|
|
|
165
168
|
expect(next!.audio!.tracks.map(t => t.id)).toEqual(['solo', 'a', 'b'])
|
|
166
169
|
expect(next!.audio!.tracks.find(t => t.id === 'solo')!.fadeOut).toBeUndefined()
|
|
167
170
|
})
|
|
171
|
+
|
|
172
|
+
// Regression test for the sharpest edge of the id-less-audio-track defect.
|
|
173
|
+
// `trackMap = new Map(updated.map(t => [t.id, t]))` keys on `t.id`; with two
|
|
174
|
+
// id-less tracks, both keys are the same `undefined`, so the map collapses
|
|
175
|
+
// to ONE entry (the last one processed) and `audioTracks.map(t =>
|
|
176
|
+
// trackMap.get(t.id) ?? t)` hands that SAME entry back for every original
|
|
177
|
+
// track — every track in the project becomes a byte-identical copy of it,
|
|
178
|
+
// `src` included. This runs unconditionally on any project with two
|
|
179
|
+
// overlapping audio tracks, no user edit required, which is why it's the
|
|
180
|
+
// sharpest edge: it doesn't wait for a gesture like `updateAudioTrack` does.
|
|
181
|
+
it('does not collapse two overlapping id-less audio tracks into copies of each other', () => {
|
|
182
|
+
const p = project({
|
|
183
|
+
audio: {
|
|
184
|
+
tracks: [
|
|
185
|
+
{ src: 'music-a.mp3', start: 0, end: 3 } as AudioTrack,
|
|
186
|
+
{ src: 'music-b.mp3', start: 2, end: 5 } as AudioTrack,
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
const next = computeAutoCrossfade(p)
|
|
191
|
+
expect(next).not.toBeNull()
|
|
192
|
+
const tracks = next!.audio!.tracks
|
|
193
|
+
expect(tracks).toHaveLength(2)
|
|
194
|
+
expect(new Set(tracks.map(t => t.id)).size).toBe(2)
|
|
195
|
+
// Each track keeps its OWN src — neither becomes a copy of the other.
|
|
196
|
+
expect(tracks.find(t => t.start === 0)!.src).toBe('music-a.mp3')
|
|
197
|
+
expect(tracks.find(t => t.start === 2)!.src).toBe('music-b.mp3')
|
|
198
|
+
expect(tracks.find(t => t.start === 0)!.fadeOut).toBe(1)
|
|
199
|
+
expect(tracks.find(t => t.start === 2)!.fadeIn).toBe(1)
|
|
200
|
+
})
|
|
168
201
|
})
|
|
169
202
|
|
|
170
203
|
describe('groupAudioLanes', () => {
|
|
@@ -574,3 +607,277 @@ describe('mapTrackItems', () => {
|
|
|
574
607
|
expect(mapTrackItems({ id: 'p1', tracks: 'nope' } as Proj, items => items)).toEqual([])
|
|
575
608
|
})
|
|
576
609
|
})
|
|
610
|
+
|
|
611
|
+
// ── Audio track id policy ────────────────────────────────────────────────
|
|
612
|
+
//
|
|
613
|
+
// Same load-bearing properties as `normalizeTracks` above: normalization
|
|
614
|
+
// never mutates its input, is idempotent, and returns the SAME OBJECT when
|
|
615
|
+
// every audio track already has a usable id (the lazy on-open migration
|
|
616
|
+
// reads that identity as "no write needed").
|
|
617
|
+
|
|
618
|
+
describe('normalizeAudioTracks', () => {
|
|
619
|
+
/** Deliberately loose, like the function itself. */
|
|
620
|
+
type Proj = { id: string; audio?: unknown }
|
|
621
|
+
interface NormTrack { id: string; src: string; [k: string]: unknown }
|
|
622
|
+
|
|
623
|
+
const raw = (id: unknown, src: string, extra: Record<string, unknown> = {}) =>
|
|
624
|
+
(id === undefined ? { src, ...extra } : { id, src, ...extra })
|
|
625
|
+
|
|
626
|
+
/** The normalized audio tracks, typed. */
|
|
627
|
+
const tracksOf = (p: Proj): NormTrack[] => (normalizeAudioTracks(p).audio as { tracks: NormTrack[] }).tracks
|
|
628
|
+
|
|
629
|
+
it('fills in distinct ids for tracks with no id at all', () => {
|
|
630
|
+
const out = tracksOf({ id: 'p1', audio: { tracks: [raw(undefined, 'a.mp3'), raw(undefined, 'b.mp3')] } })
|
|
631
|
+
expect(out.map(t => t.id)).toEqual(['aud-0', 'aud-1'])
|
|
632
|
+
expect(new Set(out.map(t => t.id)).size).toBe(2)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('fills in a missing, empty, or non-string id', () => {
|
|
636
|
+
const out = tracksOf({
|
|
637
|
+
id: 'p1',
|
|
638
|
+
audio: { tracks: [raw(undefined, 'a.mp3'), raw('', 'b.mp3'), raw(7, 'c.mp3')] },
|
|
639
|
+
})
|
|
640
|
+
expect(out.map(t => t.id)).toEqual(['aud-0', 'aud-1', 'aud-2'])
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('lets the first holder of a duplicate id keep it', () => {
|
|
644
|
+
const out = tracksOf({
|
|
645
|
+
id: 'p1',
|
|
646
|
+
audio: { tracks: [raw('dup', 'a.mp3'), raw('dup', 'b.mp3')] },
|
|
647
|
+
})
|
|
648
|
+
expect(out.map(t => t.id)).toEqual(['dup', 'aud-1'])
|
|
649
|
+
// The first holder keeps ITS OWN src, not the second track's.
|
|
650
|
+
expect(out[0].src).toBe('a.mp3')
|
|
651
|
+
expect(out[1].src).toBe('b.mp3')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('steps a generated id aside when an explicit id already claims the name', () => {
|
|
655
|
+
const out = tracksOf({
|
|
656
|
+
id: 'p1',
|
|
657
|
+
audio: { tracks: [raw(undefined, 'a.mp3'), raw('aud-0', 'b.mp3')] },
|
|
658
|
+
})
|
|
659
|
+
expect(out.map(t => t.id)).toEqual(['aud-0-2', 'aud-0'])
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('keeps generated ids unique under repeated collision', () => {
|
|
663
|
+
const out = tracksOf({
|
|
664
|
+
id: 'p1',
|
|
665
|
+
audio: { tracks: [raw(undefined, 'a.mp3'), raw('aud-0', 'b.mp3'), raw('aud-0-2', 'c.mp3')] },
|
|
666
|
+
})
|
|
667
|
+
expect(out.map(t => t.id)).toEqual(['aud-0-3', 'aud-0', 'aud-0-2'])
|
|
668
|
+
expect(new Set(out.map(t => t.id)).size).toBe(3)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('preserves existing valid ids (e.g. init.py-style `vo-01`) untouched', () => {
|
|
672
|
+
const p: Proj = { id: 'p1', audio: { tracks: [raw('vo-01', 'voice.wav'), raw('music-bed', 'bed.mp3')] } }
|
|
673
|
+
expect(normalizeAudioTracks(p)).toBe(p)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('returns the SAME OBJECT when every track already has a usable id', () => {
|
|
677
|
+
const p: Proj = { id: 'p1', audio: { tracks: [raw('aud-0', 'a.mp3'), raw('aud-1', 'b.mp3')] } }
|
|
678
|
+
expect(normalizeAudioTracks(p)).toBe(p)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('is idempotent and converges, so a second pass needs no write', () => {
|
|
682
|
+
const once = normalizeAudioTracks({ id: 'p1', audio: { tracks: [raw(undefined, 'a.mp3'), raw(undefined, 'b.mp3')] } })
|
|
683
|
+
const twice = normalizeAudioTracks(once)
|
|
684
|
+
expect(twice).toBe(once)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('carries volume / muted / fadeIn / ducking and unknown keys through untouched', () => {
|
|
688
|
+
const out = tracksOf({
|
|
689
|
+
id: 'p1',
|
|
690
|
+
audio: {
|
|
691
|
+
tracks: [raw(undefined, 'a.mp3', {
|
|
692
|
+
volume: 0.15, muted: true, fadeIn: 1.5, ducking: { enabled: true, depth: -12 }, somethingNew: { a: 1 },
|
|
693
|
+
})],
|
|
694
|
+
},
|
|
695
|
+
})
|
|
696
|
+
expect(out[0]).toEqual({
|
|
697
|
+
id: 'aud-0', src: 'a.mp3', volume: 0.15, muted: true, fadeIn: 1.5,
|
|
698
|
+
ducking: { enabled: true, depth: -12 }, somethingNew: { a: 1 },
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('does not mutate its input', () => {
|
|
703
|
+
const p: Proj = { id: 'p1', audio: { tracks: [raw(undefined, 'a.mp3')] } }
|
|
704
|
+
const before = structuredClone(p)
|
|
705
|
+
tracksOf(p)
|
|
706
|
+
expect(p).toEqual(before)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('is tolerant of a project with no audio, or a malformed audio/tracks, and never throws', () => {
|
|
710
|
+
const missing: Proj = { id: 'p1' }
|
|
711
|
+
expect(normalizeAudioTracks(missing)).toBe(missing)
|
|
712
|
+
expect('audio' in normalizeAudioTracks(missing)).toBe(false)
|
|
713
|
+
|
|
714
|
+
for (const audio of [null, 'nope', 7, []]) {
|
|
715
|
+
const p: Proj = { id: 'p1', audio }
|
|
716
|
+
expect(() => normalizeAudioTracks(p)).not.toThrow()
|
|
717
|
+
expect(normalizeAudioTracks(p)).toBe(p)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
for (const tracks of [undefined, null, 'nope', {}]) {
|
|
721
|
+
const p: Proj = { id: 'p1', audio: { tracks } }
|
|
722
|
+
expect(() => normalizeAudioTracks(p)).not.toThrow()
|
|
723
|
+
expect(normalizeAudioTracks(p)).toBe(p)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const empty: Proj = { id: 'p1', audio: { tracks: [] } }
|
|
727
|
+
expect(normalizeAudioTracks(empty)).toBe(empty)
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
describe('updateAudioTrack', () => {
|
|
732
|
+
it('patches only the matching track when ids are already real', () => {
|
|
733
|
+
const p = project({
|
|
734
|
+
audio: { tracks: [track({ id: 'a', src: 'a.mp3', volume: 1 }), track({ id: 'b', src: 'b.mp3', volume: 1 })] },
|
|
735
|
+
})
|
|
736
|
+
const out = updateAudioTrack(p, 'a', { volume: 0.4 })
|
|
737
|
+
expect(out.audio?.tracks.find(t => t.id === 'a')?.volume).toBe(0.4)
|
|
738
|
+
expect(out.audio?.tracks.find(t => t.id === 'b')).toEqual(track({ id: 'b', src: 'b.mp3', volume: 1 }))
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
// Regression test for the actual defect: `docs/schemas/project.md` never
|
|
742
|
+
// required `id` on an audio track, but every editor mutation (this
|
|
743
|
+
// function included) used to key a track by `id` with `===`. Two id-less
|
|
744
|
+
// tracks both read `id` as the same `undefined`, so `t.id === trackId`
|
|
745
|
+
// matched BOTH of them at once — dragging one fade handle on a 2-track
|
|
746
|
+
// music bed fanned the edit out and clobbered the sibling. Reproduced here
|
|
747
|
+
// exactly as the live app hits it: the ids passed in are the ones
|
|
748
|
+
// `normalizeAudioTracks` (VideoEditor's on-open backfill) would hand out —
|
|
749
|
+
// `aud-0`/`aud-1` — but `updateAudioTrack` itself is called with the
|
|
750
|
+
// ORIGINAL, still id-less project, proving it is safe even when a caller
|
|
751
|
+
// hasn't normalized first.
|
|
752
|
+
it('edits exactly one id-less audio track, leaving its sibling byte-for-byte untouched', () => {
|
|
753
|
+
const original: Project = project({
|
|
754
|
+
audio: {
|
|
755
|
+
tracks: [
|
|
756
|
+
{ src: 'music-a.mp3', start: 0, end: 10, volume: 1 } as AudioTrack,
|
|
757
|
+
{ src: 'music-b.mp3', start: 10, end: 20, volume: 1 } as AudioTrack,
|
|
758
|
+
],
|
|
759
|
+
},
|
|
760
|
+
})
|
|
761
|
+
const untouchedSibling = original.audio!.tracks[1]
|
|
762
|
+
const targetId = normalizeAudioTracks(original).audio!.tracks[0].id
|
|
763
|
+
|
|
764
|
+
const out = updateAudioTrack(original, targetId, { volume: 0.4 })
|
|
765
|
+
|
|
766
|
+
expect(out.audio?.tracks).toHaveLength(2)
|
|
767
|
+
expect(out.audio?.tracks[0].volume).toBe(0.4)
|
|
768
|
+
expect(out.audio?.tracks[0].src).toBe('music-a.mp3')
|
|
769
|
+
// The defect this guards against: the sibling's `src` (and every other
|
|
770
|
+
// field) must be exactly what it started as — not silently fanned into
|
|
771
|
+
// the edited track's values.
|
|
772
|
+
expect(out.audio?.tracks[1]).toEqual({ ...untouchedSibling, id: out.audio?.tracks[1].id })
|
|
773
|
+
expect(out.audio?.tracks[1].src).toBe('music-b.mp3')
|
|
774
|
+
expect(out.audio?.tracks[1].volume).toBe(1)
|
|
775
|
+
})
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
// ── Visual crossfade (overlays) ──────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
/** An overlay item. `extra` carries whatever the case under test needs —
|
|
781
|
+
* `opaque`, a hand-authored `keyframes` array — without a helper per shape. */
|
|
782
|
+
const ov = (id: string, start: number, end: number, extra: Record<string, unknown> = {}) =>
|
|
783
|
+
({ id, type: 'overlay' as const, src: 'Card.jsx', start, end, ...extra })
|
|
784
|
+
|
|
785
|
+
/** Two-track project in the OBJECT form: `tracks[0]` is the (empty) primary
|
|
786
|
+
* footage row `computeVisualCrossfade` skips wholesale, `tracks[1]` is the
|
|
787
|
+
* overlay row under test. */
|
|
788
|
+
const proj = (items: unknown[]) => ({
|
|
789
|
+
version: '0.2',
|
|
790
|
+
tracks: [{ id: 'trk-0', items: [] }, { id: 'trk-1', items }],
|
|
791
|
+
}) as never
|
|
792
|
+
|
|
793
|
+
const opacityTrack = (p: Project, trackIdx: number, itemIdx: number) =>
|
|
794
|
+
(p as unknown as { tracks: { items: { keyframes?: { prop: string; points: unknown[] }[] }[] }[] })
|
|
795
|
+
.tracks[trackIdx].items[itemIdx].keyframes?.find(k => k.prop === 'opacity')
|
|
796
|
+
|
|
797
|
+
describe('computeVisualCrossfade', () => {
|
|
798
|
+
it('writes complementary opacity curves onto a transparent overlapping pair', () => {
|
|
799
|
+
const out = computeVisualCrossfade(proj([ov('a', 0, 4), ov('b', 3, 8)]))!
|
|
800
|
+
expect(out).not.toBeNull()
|
|
801
|
+
// 'a' fades 1 -> 0 over its LAST second (item-relative t = 3 .. 4)
|
|
802
|
+
expect(opacityTrack(out, 1, 0)!.points).toEqual([
|
|
803
|
+
{ t: 3, value: 1 }, { t: 4, value: 0 },
|
|
804
|
+
])
|
|
805
|
+
// 'b' fades 0 -> 1 over its FIRST second (item-relative t = 0 .. 1)
|
|
806
|
+
expect(opacityTrack(out, 1, 1)!.points).toEqual([
|
|
807
|
+
{ t: 0, value: 0 }, { t: 1, value: 1 },
|
|
808
|
+
])
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it('holds the outgoing side when it is opaque', () => {
|
|
812
|
+
const out = computeVisualCrossfade(
|
|
813
|
+
proj([ov('a', 0, 4, { opaque: true }), ov('b', 3, 8, { opaque: true })]),
|
|
814
|
+
)!
|
|
815
|
+
expect(opacityTrack(out, 1, 0)).toBeUndefined() // 'a' holds — no track written
|
|
816
|
+
expect(opacityTrack(out, 1, 1)!.points).toEqual([
|
|
817
|
+
{ t: 0, value: 0 }, { t: 1, value: 1 },
|
|
818
|
+
])
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('is idempotent — a converged project reports no change', () => {
|
|
822
|
+
const once = computeVisualCrossfade(proj([ov('a', 0, 4), ov('b', 3, 8)]))!
|
|
823
|
+
expect(computeVisualCrossfade(once as never)).toBeNull()
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('returns null when nothing overlaps', () => {
|
|
827
|
+
expect(computeVisualCrossfade(proj([ov('a', 0, 4), ov('b', 4, 8)]))).toBeNull()
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('never touches an item whose opacity was keyframed by hand', () => {
|
|
831
|
+
const authored = ov('b', 3, 8, {
|
|
832
|
+
keyframes: [{ prop: 'opacity', points: [{ t: 0, value: 0.5 }] }],
|
|
833
|
+
})
|
|
834
|
+
expect(computeVisualCrossfade(proj([ov('a', 0, 4), authored]))).toBeNull()
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('leaves clips alone — a video pair gets no keyframes', () => {
|
|
838
|
+
const clip = (id: string, s: number, e: number) =>
|
|
839
|
+
({ id, type: 'video' as const, src: 'a.mov', start: s, end: e, inPoint: 0, outPoint: e - s })
|
|
840
|
+
expect(computeVisualCrossfade(proj([clip('a', 0, 4), clip('b', 3, 8)]))).toBeNull()
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('preserves a non-opacity keyframe track already on the item', () => {
|
|
844
|
+
const moving = ov('b', 3, 8, {
|
|
845
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: -20 }, { t: 1, value: 0 }] }],
|
|
846
|
+
})
|
|
847
|
+
const out = computeVisualCrossfade(proj([ov('a', 0, 4), moving]))!
|
|
848
|
+
const kf = (out as unknown as { tracks: { items: { keyframes: { prop: string }[] }[] }[] })
|
|
849
|
+
.tracks[1].items[1].keyframes
|
|
850
|
+
expect(kf.map(k => k.prop).sort()).toEqual(['offsetX', 'opacity'])
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('scales the derived curve by each side\'s own base opacity', () => {
|
|
854
|
+
// Both sides authored at 0.5 opacity (not via a keyframe track — that
|
|
855
|
+
// would be "hand-authored" and skip derivation entirely per the test
|
|
856
|
+
// above). The derived fade must ride on top of that base level, not
|
|
857
|
+
// silently replace it with a full 1<->0 fade for the pair's whole
|
|
858
|
+
// pre/post-overlap life.
|
|
859
|
+
const out = computeVisualCrossfade(
|
|
860
|
+
proj([ov('a', 0, 4, { opacity: 0.5 }), ov('b', 3, 8, { opacity: 0.5 })]),
|
|
861
|
+
)!
|
|
862
|
+
expect(out).not.toBeNull()
|
|
863
|
+
// 'a' fades 0.5 -> 0 over its LAST second
|
|
864
|
+
expect(opacityTrack(out, 1, 0)!.points).toEqual([
|
|
865
|
+
{ t: 3, value: 0.5 }, { t: 4, value: 0 },
|
|
866
|
+
])
|
|
867
|
+
// 'b' fades 0 -> 0.5 over its FIRST second
|
|
868
|
+
expect(opacityTrack(out, 1, 1)!.points).toEqual([
|
|
869
|
+
{ t: 0, value: 0 }, { t: 1, value: 0.5 },
|
|
870
|
+
])
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it('removes a derived curve when the overlap is removed', () => {
|
|
874
|
+
const faded = computeVisualCrossfade(proj([ov('a', 0, 4), ov('b', 3, 8)]))!
|
|
875
|
+
const pulled = structuredClone(faded) as unknown as
|
|
876
|
+
{ tracks: { items: { start: number; end: number }[] }[] }
|
|
877
|
+
pulled.tracks[1].items[1].start = 4
|
|
878
|
+
const out = computeVisualCrossfade(pulled as never)!
|
|
879
|
+
expect(out).not.toBeNull()
|
|
880
|
+
expect(opacityTrack(out, 1, 0)).toBeUndefined()
|
|
881
|
+
expect(opacityTrack(out, 1, 1)).toBeUndefined()
|
|
882
|
+
})
|
|
883
|
+
})
|
|
@@ -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
|
}
|