@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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/eligibility.test.ts +97 -0
  4. package/src/engine/__tests__/engine.test.ts +47 -2
  5. package/src/engine/__tests__/scheduler.test.ts +385 -4
  6. package/src/engine/__tests__/source-host.test.ts +165 -0
  7. package/src/engine/eligibility.ts +37 -1
  8. package/src/engine/index.ts +165 -29
  9. package/src/engine/scheduler.ts +345 -23
  10. package/src/index.ts +8 -0
  11. package/src/schema.ts +41 -1
  12. package/src/video/VideoEditor.tsx +106 -10
  13. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  14. package/src/video/__tests__/VideoEditor.test.tsx +169 -0
  15. package/src/video/__tests__/cuts.test.ts +84 -0
  16. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  17. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  18. package/src/video/captionStyleDefaults.ts +2 -2
  19. package/src/video/cuts.ts +30 -2
  20. package/src/video/preview/OverlayItemsLayer.tsx +85 -6
  21. package/src/video/preview/PreviewPlayer.tsx +28 -1
  22. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
  23. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
  24. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  25. package/src/video/preview/useVideoPlayback.ts +18 -3
  26. package/src/video/timeline/Timeline.tsx +49 -1
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
  30. package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
  35. package/src/video/timeline/canvas/draw.ts +104 -7
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +142 -5
  38. package/src/video/timeline/markers.ts +109 -0
  39. 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
  }