@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.
- package/package.json +2 -2
- 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 +364 -3
- 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 +331 -19
- package/src/index.ts +4 -0
- package/src/schema.ts +20 -1
- package/src/video/CaptionSpecimen.tsx +2 -1
- package/src/video/VideoEditor.tsx +28 -8
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/captionStyleDefaults.ts +2 -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/timeline/Timeline.tsx +43 -1
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +116 -0
- package/src/video/timeline/canvas/pointer-machine.ts +59 -1
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -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
|
-
|
|
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.
|