@bycrux/editor 1.0.1 → 1.1.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.
@@ -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
+ })
@@ -245,6 +245,14 @@ const BASE_Y = rowMidY(0) // track 0, the tall base row
245
245
  const OVERLAY_Y = rowMidY(1) // track 1, stacked above it
246
246
  const LANE_Y = Math.round(LAYOUT.lanes[0].y + LAYOUT.lanes[0].height / 2)
247
247
 
248
+ /** `rowMidY`'s twin for a one-off project whose layout isn't the shared
249
+ * `LAYOUT` above — the overlay-trim containment tests below each build
250
+ * their own small fixture. */
251
+ function rowMidYFor(ctx: PointerContext, trackIdx: number): number {
252
+ const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
253
+ return Math.round(row.y + row.height / 2)
254
+ }
255
+
248
256
  const C0_BODY = { x: 250, y: BASE_Y }
249
257
  const C0_OUT_EDGE = { x: 495, y: BASE_Y }
250
258
  const C1_BODY = { x: 750, y: BASE_Y }
@@ -940,6 +948,114 @@ describe('edge trim — ripple mode', () => {
940
948
  })
941
949
  })
942
950
 
951
+ // Two butt-joined overlays on trk-1: a 0s–4s, b 4s–8s. trk-0 carries one
952
+ // plain video clip spanning both, just so the fixture is a valid project —
953
+ // no test below touches it.
954
+ function overlapProject(): Project {
955
+ return {
956
+ id: 'p',
957
+ tracks: [
958
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8, inPoint: 0, outPoint: 8, sourceDuration: 20 }] },
959
+ {
960
+ id: 'trk-1',
961
+ items: [
962
+ { id: 'a', type: 'overlay', start: 0, end: 4 },
963
+ { id: 'b', type: 'overlay', start: 4, end: 8 },
964
+ ],
965
+ },
966
+ ],
967
+ } as unknown as Project
968
+ }
969
+
970
+ describe('edge trim on an overlay track — bounded overlap (transitions)', () => {
971
+ it("trimming an overlay's right edge past its neighbour's start creates an overlap", () => {
972
+ const ctx = makeContext({ project: overlapProject() })
973
+ const row1Y = rowMidYFor(ctx, 1)
974
+ const d = new Driver(ctx)
975
+ // a's out edge sits at t=4 (x=400); offset 5px toward `a` so the hit
976
+ // resolves to ITS edge rather than b's in edge at the same x — the same
977
+ // disambiguation `C0_OUT_EDGE`/`C1_IN_EDGE` use above.
978
+ d.down(395, row1Y)
979
+ // +1s worth of pixels (100px/s): the edge lands at t=5, one second into b.
980
+ const next = lastProjectChange(d.move(495, row1Y))
981
+ expect(visual(next, 'a')).toMatchObject({ start: 0, end: 5 })
982
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
983
+ })
984
+
985
+ it('trimming still refuses to swallow the neighbour entirely', () => {
986
+ const ctx = makeContext({ project: overlapProject() })
987
+ const row1Y = rowMidYFor(ctx, 1)
988
+ const d = new Driver(ctx)
989
+ d.down(395, row1Y)
990
+ // +5s worth of pixels would land a's edge at t=9, past b's own end (8) —
991
+ // full containment, which is not a transition.
992
+ const next = lastProjectChange(d.move(895, row1Y))
993
+ expect(visual(next, 'a').end).toBeLessThan(visual(next, 'b').end)
994
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
995
+ })
996
+
997
+ it('stops short of a three-way overlap when the next neighbour already overlaps ITS next', () => {
998
+ // a 0-4, b 4-8, c 7-10: b and c already overlap at [7, 8) before this
999
+ // drag even starts. The single-neighbour clamp (against b.end alone)
1000
+ // would let a's edge travel anywhere up to b.end (8), including into
1001
+ // b∩c — landing a, b, and c all live at the same instant, a shape the
1002
+ // editor would accept but `validate_project` rejects.
1003
+ const threeWayProject: Project = {
1004
+ id: 'p',
1005
+ tracks: [
1006
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] },
1007
+ {
1008
+ id: 'trk-1',
1009
+ items: [
1010
+ { id: 'a', type: 'overlay', start: 0, end: 4 },
1011
+ { id: 'b', type: 'overlay', start: 4, end: 8 },
1012
+ { id: 'c', type: 'overlay', start: 7, end: 10 },
1013
+ ],
1014
+ },
1015
+ ],
1016
+ } as unknown as Project
1017
+ const ctx = makeContext({ project: threeWayProject })
1018
+ const row1Y = rowMidYFor(ctx, 1)
1019
+ const d = new Driver(ctx)
1020
+ // a's out edge sits at t=4 (x=400); offset 5px toward `a` for the same
1021
+ // disambiguation the tests above use.
1022
+ d.down(395, row1Y)
1023
+ // +4s worth of pixels targets a's edge at t=8 — inside b (4-8) and past
1024
+ // c's start (7). The new guard must clamp short of c.start, not just
1025
+ // short of b.end.
1026
+ const next = lastProjectChange(d.move(795, row1Y))
1027
+ expect(visual(next, 'a').end).toBeLessThan(7)
1028
+ expect(visual(next, 'a').end).toBeGreaterThan(6.9)
1029
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
1030
+ expect(visual(next, 'c')).toMatchObject({ start: 7, end: 10 })
1031
+ })
1032
+
1033
+ it("still stops a tracks[0] trim at nothing — overlap there was never checked", () => {
1034
+ // Same shape, but the pair sits on tracks[0] instead: no containment
1035
+ // guard applies there (see the comment in `applyTrim`), so the edge
1036
+ // travels exactly as far as the drag and the magnet/floor logic alone
1037
+ // would already take it — this only guards against the new clamp
1038
+ // accidentally leaking onto trackIdx 0.
1039
+ const project: Project = {
1040
+ id: 'p',
1041
+ tracks: [{
1042
+ id: 'trk-0',
1043
+ items: [
1044
+ { id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
1045
+ { id: 'b', type: 'video', src: 'b.mp4', start: 4, end: 8, inPoint: 0, outPoint: 4, sourceDuration: 20 },
1046
+ ],
1047
+ }],
1048
+ } as unknown as Project
1049
+ const ctx = makeContext({ project })
1050
+ const row0Y = rowMidYFor(ctx, 0)
1051
+ const d = new Driver(ctx)
1052
+ d.down(395, row0Y)
1053
+ const next = lastProjectChange(d.move(895, row0Y))
1054
+ expect(visual(next, 'a').end).toBeCloseTo(9)
1055
+ expect(visual(next, 'b')).toMatchObject({ start: 4, end: 8 })
1056
+ })
1057
+ })
1058
+
943
1059
  // ── Alt / Cmd trim ops ───────────────────────────────────────────────────
944
1060
 
945
1061
  describe('Alt + edge-drag — roll', () => {
@@ -637,6 +637,23 @@ function adjacentOnTrack(project: Project, item: VisualItem): { prev?: VisualIte
637
637
  }
638
638
  }
639
639
 
640
+ /** `adjacentOnTrack`'s twin for trim's containment guard — the nearest item
641
+ * in either direction on `item`'s own track, WHETHER OR NOT it touches.
642
+ * Roll needs a shared boundary to move (that's what it's rolling); a trim's
643
+ * containment guard needs the nearest neighbour regardless, because the
644
+ * overlap it is bounding is exactly the case where the two are no longer
645
+ * touching. */
646
+ function neighboursOnTrack(project: Project, item: VisualItem): { prev?: VisualItem; next?: VisualItem } {
647
+ const track = trackItems(project).find(t => t.some(other => other.id === item.id))
648
+ if (!track) return {}
649
+ const sorted = [...track].sort((a, b) => a.start - b.start)
650
+ const pos = sorted.findIndex(other => other.id === item.id)
651
+ return {
652
+ prev: pos > 0 ? sorted[pos - 1] : undefined,
653
+ next: pos >= 0 && pos < sorted.length - 1 ? sorted[pos + 1] : undefined,
654
+ }
655
+ }
656
+
640
657
  /** Where a guide line goes, and how hard the magnet holding it pulls. */
641
658
  export interface SnapGuide {
642
659
  time: number
@@ -854,7 +871,48 @@ function applyTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapSt
854
871
  itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
855
872
  ctx.viewport, snap, ctx.snapConfig,
856
873
  )
857
- const resized = computeResizedItem(item as Draggable, edge, snapped.time)
874
+
875
+ // Overlay tracks (anything but tracks[0]) may trim an edge PAST a
876
+ // neighbour's near boundary — see transitions.js's header: a partial
877
+ // overlap is a transition, containment is not — but not far enough to
878
+ // contain the neighbour outright, which `engine/validate.py` rejects.
879
+ // tracks[0] gets none of this: its own overlap is never checked at all
880
+ // (primary clips render in `itsoffset` order), so its trim stays exactly
881
+ // as it was — bounded only by `computeResizedItem`'s own floor below.
882
+ // Clamped on the TARGET time, before `computeResizedItem` runs, so a
883
+ // clamp that lands short of the drag still produces a consistent
884
+ // inPoint/outPoint for video items via `resizeWindowedItem`, rather than
885
+ // trimming the span after the fact and leaving the source window stale.
886
+ let targetTime = snapped.time
887
+ if ((press.hit.trackIdx ?? 0) !== 0) {
888
+ const { prev, next } = neighboursOnTrack(press.baseProject, item)
889
+ if (edge === 'end' && next) {
890
+ targetTime = Math.min(targetTime, next.end - EPSILON)
891
+ // `next` may ALREADY overlap its own next neighbour (a transition that
892
+ // predates this drag). The clamp above only keeps this item from
893
+ // containing `next` outright — it says nothing about that second
894
+ // overlap, so extending into `next` can still land inside next∩afterNext
895
+ // and put three items live at the same instant, a shape the two-item
896
+ // containment guard allows but `engine/validate.py` rejects. Clamp
897
+ // short of that second neighbour's start whenever the overlap exists.
898
+ const { next: afterNext } = neighboursOnTrack(press.baseProject, next)
899
+ if (afterNext && afterNext.start < next.end) {
900
+ targetTime = Math.min(targetTime, afterNext.start - EPSILON)
901
+ }
902
+ }
903
+ if (edge === 'start' && prev) {
904
+ targetTime = Math.max(targetTime, prev.start + EPSILON)
905
+ // Mirror of the `end`-edge guard above: `prev` may already overlap ITS
906
+ // own previous neighbour, and shrinking this item's start into `prev`
907
+ // can land inside beforePrev∩prev, making three items live at once.
908
+ const { prev: beforePrev } = neighboursOnTrack(press.baseProject, prev)
909
+ if (beforePrev && beforePrev.end > prev.start) {
910
+ targetTime = Math.max(targetTime, beforePrev.end + EPSILON)
911
+ }
912
+ }
913
+ }
914
+
915
+ const resized = computeResizedItem(item as Draggable, edge, targetTime)
858
916
 
859
917
  // Trims always rebuild from the pressed-at project, never from the running
860
918
  // preview — otherwise ripple's gap collapse would compound move by move.