@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
@@ -11,10 +11,12 @@ import { getOverlayDesignCanvas } from './design-canvas'
11
11
  import { availableResolutionTiers, availableFpsTiers, currentResolutionTier, maxExportFps } from './export-limits'
12
12
  import { applyTheme, defaultMontajTheme, isLightTheme } from '../theme'
13
13
  import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
14
+ import { addMarker } from './timeline/markers'
14
15
  import { repairCaptionWords } from './captionRepair'
15
16
  import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
16
17
  import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
17
- import { computeAutoCrossfade, computeDerivedTiming, enabledTrackItems, mapTrackItems, trackItems } from './timeline/timeline-model'
18
+ import { visualDuration } from '@bycrux/timeline-core'
19
+ import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
18
20
  import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
19
21
  import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
20
22
  import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
@@ -144,6 +146,56 @@ export function backfillCaptionIds<P extends Project>(project: P): P {
144
146
  }
145
147
  }
146
148
 
149
+ /**
150
+ * Where a marker lands.
151
+ *
152
+ * With the preview axis ON, the yellow line is what the operator is looking at,
153
+ * so that is where the marker goes; with it OFF — or ON but with the pointer
154
+ * off the timeline, where `hoverScrub` reads null — the playhead is.
155
+ *
156
+ * `??`, never `||`: a hovered 0 is the start of the timeline, a perfectly
157
+ * ordinary place to mark, and `||` would silently reroute it to the playhead.
158
+ *
159
+ * Exported and tested on its own because `hoverScrub` is a private ref with no
160
+ * test seam. `handleSplit` carries the same rule inline
161
+ * (`previewAxis ? (hoverScrub.get() ?? undefined) : undefined`) and is
162
+ * deliberately NOT refactored onto this helper here: that is existing,
163
+ * currently-untested behavior and changing it is outside this plan. If you
164
+ * touch Split's rule later, unify it with this one.
165
+ */
166
+ export function markerDropTime(previewAxis: boolean, hovered: number | null, playhead: number): number {
167
+ return previewAxis ? (hovered ?? playhead) : playhead
168
+ }
169
+
170
+ /**
171
+ * How long the export dialog thinks the finished video is.
172
+ *
173
+ * This is what bounds the cover-frame picker, so it has to be the duration the
174
+ * RENDER produces — hence `visualDuration`, timeline-core's port of render.js's
175
+ * own `getTotalDurationSeconds`: the furthest `end` over every item on every
176
+ * track, audio excluded. Anything narrower offers the operator a grid that
177
+ * stops before their video does.
178
+ *
179
+ * It used to be `max(end)` over the track-0 VIDEO items alone (the same list
180
+ * that feeds `keeps`). That reads track 0 as "the footage track", which is only
181
+ * true when there IS footage: an overlay-only project — one track of nothing
182
+ * but overlays, which the animations workflow emits routinely — measured 0, so
183
+ * the picker reported "No frames to pick from" for a project made entirely of
184
+ * pickable frames. The milder version of the same bug cut the grid short
185
+ * whenever an outro overlay outlived the last clip.
186
+ *
187
+ * `keeps` is deliberately NOT widened to match. It answers a different
188
+ * question — which windows contain real footage the SDR curve thumbnails can
189
+ * sample — and an overlay-only project genuinely has none.
190
+ *
191
+ * Enabled tracks only, and via `withEnabledItemTracks` because `visualDuration`
192
+ * wants the flat array-of-arrays shape: a track the operator has switched off
193
+ * is skipped by the render too, so it must not stretch the picker.
194
+ */
195
+ export function exportDurationSec(project: { tracks?: unknown }): number {
196
+ return visualDuration(withEnabledItemTracks(project))
197
+ }
198
+
147
199
  /**
148
200
  * `<VideoEditor>` — the assembled, host-agnostic video editor.
149
201
  *
@@ -253,6 +305,18 @@ export default function VideoEditor<P extends Project = Project>({
253
305
  // `onProjectChange` and paying an extra render for it. Chained, the host only
254
306
  // ever sees the input or the finished result.
255
307
  //
308
+ // A THIRD pass, same treatment, backfills AUDIO track ids
309
+ // (`normalizeAudioTracks`). `audio.tracks[*].id` is optional on disk
310
+ // (`docs/schemas/project.md` only requires `src`) but required by the
311
+ // editor's `AudioTrack` type, and every audio mutation — `updateAudioTrack`,
312
+ // this component's own track-replace path, multi-select mute/delete — keys
313
+ // a track by `id` with `===`. Id-less tracks all read as the same
314
+ // `undefined`, so editing one fanned the edit out to every audio track at
315
+ // once. Chained onto the captions result (not `sync.project` directly) for
316
+ // the same half-normalized-frame reason as the lane pass, and keyed into the
317
+ // deps below on `sync.project.audio` so an id-less track arriving via SSE
318
+ // mid-session self-heals too, not just on first mount.
319
+ //
256
320
  // `applyExternal` — no save, no undo push: this is normalization of loaded
257
321
  // data, not a user edit, so it must not dirty the project or contend with the
258
322
  // undo stack; the ids and lanes persist naturally the next time the operator
@@ -267,9 +331,10 @@ export default function VideoEditor<P extends Project = Project>({
267
331
  const captions = captionGestureRef.current
268
332
  ? backfilled.captions
269
333
  : normalizeCaptionLanes(backfilled.captions)
270
- const normalized = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
334
+ const withCaptions = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
335
+ const normalized = normalizeAudioTracks(withCaptions)
271
336
  if (normalized !== sync.project) sync.applyExternal(normalized)
272
- }, [sync.project.id, sync.project.captions])
337
+ }, [sync.project.id, sync.project.captions, sync.project.audio])
273
338
 
274
339
  // Notify the host of every authoritative change — edits, undo/redo, and SSE
275
340
  // frames — so its non-editor chrome (title, status pill) stays in sync. Mirrors
@@ -1165,7 +1230,10 @@ function ReviewSurface<P extends Project>({
1165
1230
  keeps: renderKeeps,
1166
1231
  imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
1167
1232
  name: project.name?.trim() || undefined,
1168
- durationSec: renderKeeps.reduce((m, k) => Math.max(m, k.end), 0),
1233
+ // NOT derived from `renderKeeps` — see `exportDurationSec`. Keeps covers
1234
+ // the footage windows the curve thumbnails can sample; the cover picker
1235
+ // has to span the whole rendered video, overlay-only projects included.
1236
+ durationSec: exportDurationSec(project),
1169
1237
  aspectRatio: (() => {
1170
1238
  const r = project.settings?.resolution
1171
1239
  return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
@@ -1181,7 +1249,13 @@ function ReviewSurface<P extends Project>({
1181
1249
  set: handleExportFpsChange,
1182
1250
  },
1183
1251
  }), [
1184
- isHdrProject, renderKeeps, currentImageTone, handleImageToneChange,
1252
+ // `project.tracks` is listed for `exportDurationSec`, which reads every
1253
+ // track rather than just the footage keeps. `renderKeeps` already changes
1254
+ // identity with it, so this is belt-and-braces — but relying on that would
1255
+ // leave the duration silently stale the day keeps stops being memoized on
1256
+ // tracks. Deliberately NOT the whole `project`: this memo is kept off
1257
+ // unrelated project mutations on purpose (see `availableRes` above).
1258
+ isHdrProject, renderKeeps, project.tracks, currentImageTone, handleImageToneChange,
1185
1259
  project.name, project.settings?.resolution, project.settings?.fps,
1186
1260
  availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
1187
1261
  ])
@@ -1385,13 +1459,20 @@ function ReviewSurface<P extends Project>({
1385
1459
  */
1386
1460
  function commitTimelineEdit(p: Project) {
1387
1461
  captionGestureRef.current = false
1388
- // Fold the auto-crossfade into the SAME commit as the gesture, so an audio
1462
+ // Fold BOTH derived crossfades into the SAME commit as the gesture, so a
1389
1463
  // drag/trim that ends overlapping a neighbour lands as ONE undo step (the
1390
1464
  // move and its derived fade together) rather than the move here plus a
1391
- // separate fade commit. Idempotent — a project needing no fade comes back
1392
- // unchanged — so video moves and non-overlapping audio moves are untouched.
1393
- const faded = computeAutoCrossfade(p) ?? p
1394
- sync.mutateTransient(() => faded as P)
1465
+ // separate fade commit. Both passes are idempotent — a gesture that creates
1466
+ // no overlap gets its project back unchanged — so a move that overlaps
1467
+ // nothing is untouched by either.
1468
+ //
1469
+ // Audio first, then visual, on the audio pass's OUTPUT: the two touch
1470
+ // disjoint parts of the project (`audio.tracks` vs `tracks`), so the order
1471
+ // is not load-bearing, but chaining them means one commit carries both
1472
+ // rather than the second silently dropping the first's work.
1473
+ const withAudio = computeAutoCrossfade(p) ?? p
1474
+ const withVisual = computeVisualCrossfade(withAudio) ?? withAudio
1475
+ sync.mutateTransient(() => withVisual as P)
1395
1476
  void sync.commit()
1396
1477
  }
1397
1478
 
@@ -1601,6 +1682,13 @@ function ReviewSurface<P extends Project>({
1601
1682
  void sync.mutate(() => updated as P)
1602
1683
  }
1603
1684
 
1685
+ function handleAddMarker(at?: number) {
1686
+ const base = syncProjectRef.current
1687
+ const updated = addMarker(base, at ?? clock.get())
1688
+ if (updated === base) return // key repeat inside the half-frame window
1689
+ void sync.mutate(() => updated as P)
1690
+ }
1691
+
1604
1692
  function handleRippleToggle() {
1605
1693
  const next = !rippleMode
1606
1694
  setRippleMode(next)
@@ -1737,6 +1825,13 @@ function ReviewSurface<P extends Project>({
1737
1825
  // is selected (a no-op if the axis isn't over the item).
1738
1826
  action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
1739
1827
  },
1828
+ {
1829
+ id: 'video.add-marker',
1830
+ description: 'Drop a marker',
1831
+ keyHint: ['M'],
1832
+ matches: matchesPlainKey('m'),
1833
+ action: () => handleAddMarker(markerDropTime(previewAxis, hoverScrub.get(), clock.get())),
1834
+ },
1740
1835
  {
1741
1836
  id: 'video.undo',
1742
1837
  description: 'Undo',
@@ -1877,6 +1972,7 @@ function ReviewSurface<P extends Project>({
1877
1972
  const paletteCommands: PaletteCommand[] = [
1878
1973
  { id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
1879
1974
  { id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
1975
+ { id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
1880
1976
  ]
1881
1977
  if (primarySelectedId) {
1882
1978
  paletteCommands.push({ id: 'ripple-delete', label: 'Ripple-delete selection', keyHint: ['⇧', 'Delete'], run: () => handleRippleDelete() })
@@ -386,6 +386,70 @@ describe('VideoEditor — preview axis toggle', () => {
386
386
  })
387
387
  })
388
388
 
389
+ describe('VideoEditor — M drops a marker', () => {
390
+ it('drops one at the playhead with an auto-number label', async () => {
391
+ const adapter = makeFakeAdapter()
392
+ const onProjectChange = vi.fn()
393
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
394
+ await screen.findByLabelText('Preview axis')
395
+
396
+ await act(async () => {
397
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
398
+ })
399
+ const next = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0]
400
+ expect(next.markers).toHaveLength(1)
401
+ expect(next.markers[0]).toMatchObject({ t: 0, label: '1' }) // playhead starts at 0
402
+ })
403
+
404
+ it('does not fire while typing in a text surface', async () => {
405
+ const adapter = makeFakeAdapter()
406
+ const onProjectChange = vi.fn()
407
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
408
+ await screen.findByLabelText('Preview axis')
409
+ onProjectChange.mockClear() // drop the mount-time "notify host" call — see the Space test above
410
+
411
+ const input = document.createElement('input')
412
+ document.body.appendChild(input)
413
+ await act(async () => {
414
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', bubbles: true }))
415
+ })
416
+ expect(onProjectChange).not.toHaveBeenCalled()
417
+ input.remove()
418
+ })
419
+
420
+ it('Cmd+M is left alone — the shortcut is the bare letter', async () => {
421
+ // Cmd+M minimises the window on macOS; swallowing it would be hostile.
422
+ const adapter = makeFakeAdapter()
423
+ const onProjectChange = vi.fn()
424
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
425
+ await screen.findByLabelText('Preview axis')
426
+ onProjectChange.mockClear() // drop the mount-time "notify host" call — see the Space test above
427
+
428
+ await act(async () => {
429
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', metaKey: true }))
430
+ })
431
+ expect(onProjectChange).not.toHaveBeenCalled()
432
+ })
433
+
434
+ it('holding M does not spray markers', async () => {
435
+ // Key repeat fires keydown over and over at the same playhead; the
436
+ // half-frame guard in addMarker must make every repeat after the first a
437
+ // no-op that never reaches onProjectChange.
438
+ const adapter = makeFakeAdapter()
439
+ const onProjectChange = vi.fn()
440
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
441
+ await screen.findByLabelText('Preview axis')
442
+
443
+ for (let i = 0; i < 5; i++) {
444
+ await act(async () => {
445
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
446
+ })
447
+ }
448
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0]
449
+ expect(last.markers).toHaveLength(1)
450
+ })
451
+ })
452
+
389
453
  // ── T2 — copy / paste / duplicate / paste-attributes ────────────────────
390
454
  //
391
455
  // Persistence assertions read `adapter.saveProject`'s SECOND argument (the
@@ -12,6 +12,8 @@ import type {
12
12
  import type { Captions, VisualItem } from '../../schema'
13
13
  import type { OverlayChanges } from '../preview/useDragOverlay'
14
14
  import VideoEditor from '../VideoEditor'
15
+ import { CROSSFADE_COMMIT_DELAY_MS } from '../timeline/Timeline'
16
+ import { trackItems } from '../timeline/timeline-model'
15
17
  import { dragCanvasItem, installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
16
18
 
17
19
  // ── Fake adapter ──────────────────────────────────────────────────────────────
@@ -839,4 +841,171 @@ describe('VideoEditor — editor-package integration', () => {
839
841
 
840
842
  expect(queryByText('Compare')).toBeNull()
841
843
  })
844
+
845
+ // ── Derived OVERLAY crossfades: the wiring, not the function ─────────────
846
+ //
847
+ // `computeVisualCrossfade` (timeline-model.ts, unit-tested in
848
+ // timeline/__tests__/timeline-model.test.ts) ships INERT without two call
849
+ // sites, exactly as `computeAutoCrossfade` needs two: the gesture commit
850
+ // (`commitTimelineEdit` here in VideoEditor) and Timeline.tsx's debounced
851
+ // catch-all for overlay timing that changes without a gesture. One test each.
852
+ //
853
+ // Both assert on the ADAPTER's saveProject payload rather than on local
854
+ // `onProjectChange` state, for the reason
855
+ // VideoEditor.rippleDeleteCaptions.test.tsx records in full: only a real
856
+ // commit reaches `saveProject`, so an assertion on client state can pass on
857
+ // a transient preview that was never persisted.
858
+
859
+ /** The `opacity` keyframe track on `itemId` in a saved project, wherever the
860
+ * item lives. `undefined` when the item carries no opacity curve at all. */
861
+ function opacityTrackOf(project: Project, itemId: string) {
862
+ for (const items of trackItems(project)) {
863
+ const item = items.find((i) => i.id === itemId)
864
+ if (item) return item.keyframes?.find((k) => k.prop === 'opacity')
865
+ }
866
+ return undefined
867
+ }
868
+
869
+ /** The item itself, for the span assertions. */
870
+ function itemOf(project: Project, itemId: string): VisualItem | undefined {
871
+ for (const items of trackItems(project)) {
872
+ const item = items.find((i) => i.id === itemId)
873
+ if (item) return item
874
+ }
875
+ return undefined
876
+ }
877
+
878
+ it('a trim that creates an overlay overlap commits the fade in ONE undo step', async () => {
879
+ onTestFinished(installCanvasHarness())
880
+ // Fake timers, never advanced: Timeline.tsx's debounced catch-all cannot
881
+ // fire, so a fade in the saved project can ONLY have come from the gesture
882
+ // commit. Without this the two call sites are indistinguishable.
883
+ vi.useFakeTimers()
884
+ onTestFinished(() => { vi.useRealTimers() })
885
+
886
+ const adapter = makeFakeAdapter()
887
+ // The overlays start APART — the gesture is what creates the overlap.
888
+ const initial = makeVideoProject({
889
+ tracks: [
890
+ {
891
+ id: 'trk-0',
892
+ items: [
893
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 40 },
894
+ ],
895
+ },
896
+ {
897
+ id: 'trk-1',
898
+ items: [
899
+ { id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
900
+ { id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 5, end: 9 },
901
+ ],
902
+ },
903
+ ],
904
+ })
905
+ const { container } = render(
906
+ <VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
907
+ )
908
+ await act(async () => { await Promise.resolve() })
909
+
910
+ // Drag ov-a's OUT edge from t=4 to t=6, so it runs 1s into ov-b. A trim on
911
+ // a non-zero track is allowed past a neighbour's near boundary (a partial
912
+ // overlap is a transition) but not past its far one.
913
+ dragCanvasItem(container, initial, { id: 'ov-a' }, { fromTime: 4, toTime: 6 })
914
+ await act(async () => { await Promise.resolve() })
915
+
916
+ expect(adapter.saveCalls.length).toBe(1)
917
+ const trimmed = adapter.saveCalls[0].project
918
+ expect(itemOf(trimmed, 'ov-a')!.end).toBeCloseTo(6, 5)
919
+
920
+ // ov-a fades 1 -> 0 across the overlap, in ITS OWN item-relative seconds.
921
+ const fadeOut = opacityTrackOf(trimmed, 'ov-a')!
922
+ expect(fadeOut.origin).toBe('crossfade')
923
+ expect(fadeOut.points.length).toBe(2)
924
+ expect(fadeOut.points[0]).toEqual({ t: 5, value: 1 })
925
+ expect(fadeOut.points[1].value).toBe(0)
926
+ expect(fadeOut.points[1].t).toBeCloseTo(6, 5)
927
+
928
+ // ov-b fades 0 -> 1 across the same span, measured from ITS start (t=5).
929
+ const fadeIn = opacityTrackOf(trimmed, 'ov-b')!
930
+ expect(fadeIn.origin).toBe('crossfade')
931
+ expect(fadeIn.points[0]).toEqual({ t: 0, value: 0 })
932
+ expect(fadeIn.points[1].value).toBe(1)
933
+ expect(fadeIn.points[1].t).toBeCloseTo(1, 5)
934
+
935
+ // ONE undo takes back the trim AND its derived fade together. The audio
936
+ // version of this bug recorded dozens of entries for a single drag; a
937
+ // second undo being needed here, or either curve surviving the first, is
938
+ // the regression.
939
+ await act(async () => {
940
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
941
+ })
942
+ expect(adapter.saveCalls.length).toBe(2)
943
+ const undone = adapter.saveCalls[1].project
944
+ expect(itemOf(undone, 'ov-a')!.end).toBe(4)
945
+ expect(opacityTrackOf(undone, 'ov-a')).toBeUndefined()
946
+ expect(opacityTrackOf(undone, 'ov-b')).toBeUndefined()
947
+ })
948
+
949
+ it('a ripple-delete that creates an overlay overlap commits a fade without a gesture', async () => {
950
+ onTestFinished(installCanvasHarness())
951
+ vi.useFakeTimers()
952
+ onTestFinished(() => { vi.useRealTimers() })
953
+
954
+ const adapter = makeFakeAdapter()
955
+ // Shift+Delete on clip-A (0-2s) shifts everything starting at or after 2s
956
+ // left by 2s — across EVERY track. ov-b travels 4->2 while ov-a stays at
957
+ // 0-4, so the two overlap by 2s without any pointer ever touching them.
958
+ const initial = makeVideoProject({
959
+ tracks: [
960
+ {
961
+ id: 'trk-0',
962
+ items: [
963
+ { id: 'clip-A', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
964
+ { id: 'clip-B', type: 'video', src: 'a.mp4', start: 2, end: 6, inPoint: 2, outPoint: 6 },
965
+ ],
966
+ },
967
+ {
968
+ id: 'trk-1',
969
+ items: [
970
+ { id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
971
+ { id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 4, end: 8 },
972
+ ],
973
+ },
974
+ ],
975
+ })
976
+ const { container } = render(
977
+ <VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
978
+ )
979
+ await act(async () => { await Promise.resolve() })
980
+
981
+ selectCanvasItem(container, initial, { id: 'clip-A' })
982
+ await act(async () => {
983
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
984
+ })
985
+
986
+ // `handleRippleDelete` goes straight to `sync.mutate` — it never passes
987
+ // through `commitTimelineEdit`, so its own commit carries no fade. That is
988
+ // precisely the hole the debounced pass fills.
989
+ expect(adapter.saveCalls.length).toBe(1)
990
+ const rippled = adapter.saveCalls[0].project
991
+ expect(itemOf(rippled, 'ov-b')!.start).toBe(2)
992
+ expect(opacityTrackOf(rippled, 'ov-a')).toBeUndefined()
993
+ expect(opacityTrackOf(rippled, 'ov-b')).toBeUndefined()
994
+
995
+ await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS) })
996
+
997
+ expect(adapter.saveCalls.length).toBe(2)
998
+ const faded = adapter.saveCalls[1].project
999
+ expect(opacityTrackOf(faded, 'ov-a')!.points).toEqual([
1000
+ { t: 2, value: 1 }, { t: 4, value: 0 },
1001
+ ])
1002
+ expect(opacityTrackOf(faded, 'ov-b')!.points).toEqual([
1003
+ { t: 0, value: 0 }, { t: 2, value: 1 },
1004
+ ])
1005
+
1006
+ // Idempotent: the pass re-runs off its own commit and must find nothing,
1007
+ // or it would commit forever.
1008
+ await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS * 4) })
1009
+ expect(adapter.saveCalls.length).toBe(2)
1010
+ })
842
1011
  })
@@ -310,6 +310,90 @@ describe('splitAtTime', () => {
310
310
  expect(lw.duration + rw.duration).toBeCloseTo(10)
311
311
  })
312
312
 
313
+ // A fade belongs to an EDGE. Both halves used to inherit BOTH fades from the
314
+ // spread, so every cut manufactured a fade-out into the razor and a fade-in
315
+ // out of it — on a voiceover with the usual trailing fade, each cut left a
316
+ // fragment ramping to silence at the cut that the operator had to zero by
317
+ // hand.
318
+ it('does not manufacture a fade-out at the cut on the left half', () => {
319
+ const p = makeProject({
320
+ tracks: vtracks([]),
321
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeOut: 0.4 }] },
322
+ })
323
+ const [left, right] = splitAtTime(p, 40, null).audio!.tracks
324
+ expect(left.fadeOut).toBeUndefined() // the cut edge — no fade was asked for
325
+ expect(right.fadeOut).toBe(0.4) // still the original END, keeps its fade
326
+ })
327
+
328
+ it('does not manufacture a fade-in at the cut on the right half', () => {
329
+ const p = makeProject({
330
+ tracks: vtracks([]),
331
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeIn: 0.3 }] },
332
+ })
333
+ const [left, right] = splitAtTime(p, 40, null).audio!.tracks
334
+ expect(left.fadeIn).toBe(0.3) // still the original START, keeps its fade
335
+ expect(right.fadeIn).toBeUndefined() // the cut edge
336
+ })
337
+
338
+ it('drops the curve along with the fade it described', () => {
339
+ // A `fadeOutCurve` on a half with no fade-out is dead data, and the next
340
+ // hand-drawn fade there should start from the default shape.
341
+ const p = makeProject({
342
+ tracks: vtracks([]),
343
+ audio: { tracks: [{
344
+ id: 'au', src: 'a.mp3', start: 0, end: 100,
345
+ fadeIn: 0.3, fadeInCurve: 'linear', fadeOut: 0.4, fadeOutCurve: 'log',
346
+ }] },
347
+ })
348
+ const [left, right] = splitAtTime(p, 40, null).audio!.tracks
349
+ expect(left.fadeInCurve).toBe('linear')
350
+ expect(left.fadeOutCurve).toBeUndefined()
351
+ expect(right.fadeOutCurve).toBe('log')
352
+ expect(right.fadeInCurve).toBeUndefined()
353
+ })
354
+
355
+ it('splitting twice never reintroduces a fade at either cut', () => {
356
+ // The shape that surfaced this: successive razors down one voiceover. The
357
+ // middle fragment owns neither original edge, so it must carry no fade at
358
+ // all — before the fix it carried both.
359
+ const p = makeProject({
360
+ tracks: vtracks([]),
361
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeIn: 0.3, fadeOut: 0.4 }] },
362
+ })
363
+ const once = splitAtTime(p, 40, null)
364
+ const twice = splitAtTime(once, 70, once.audio!.tracks[1].id)
365
+ const [a, b, c] = twice.audio!.tracks
366
+ expect([a.fadeIn, a.fadeOut]).toEqual([0.3, undefined])
367
+ expect([b.fadeIn, b.fadeOut]).toEqual([undefined, undefined])
368
+ expect([c.fadeIn, c.fadeOut]).toEqual([undefined, 0.4])
369
+ })
370
+
371
+ it('clamps a kept fade that no longer fits its own fragment', () => {
372
+ // A 3s fade-out is fine on the whole 20s track; the fragment it lands on is
373
+ // 1s long. Nothing downstream re-clamps — the renderer just anchors
374
+ // `st = end - fadeOut`, which would start the fade before this fragment's
375
+ // audio begins.
376
+ const p = makeProject({
377
+ tracks: vtracks([]),
378
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 20, fadeIn: 3, fadeOut: 3 }] },
379
+ })
380
+ const [left, right] = splitAtTime(p, 19, null).audio!.tracks
381
+ expect(right.fadeOut).toBe(1) // clamped to the 1s fragment
382
+ expect(left.fadeIn).toBe(3) // still fits the 19s fragment, untouched
383
+ })
384
+
385
+ it('leaves a track with no fades alone', () => {
386
+ const p = makeProject({
387
+ tracks: vtracks([]),
388
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10 }] },
389
+ })
390
+ const [left, right] = splitAtTime(p, 4, null).audio!.tracks
391
+ for (const half of [left, right]) {
392
+ expect(half).not.toHaveProperty('fadeIn')
393
+ expect(half).not.toHaveProperty('fadeOut')
394
+ }
395
+ })
396
+
313
397
  it('carries an existing trim window across a split (both halves stay within source)', () => {
314
398
  // A pre-trimmed track: source window [2,12] mapped onto timeline [0,10].
315
399
  const p = makeProject({
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The export dialog's duration — what the cover-frame picker samples across.
3
+ *
4
+ * This used to be derived from the track-0 VIDEO items alone, which is a safe
5
+ * reading only when track 0 holds footage. An overlay-only project (the
6
+ * animations workflow emits exactly that: one track of nothing but overlays)
7
+ * therefore reported 0, and the picker had no frames to offer — on a project
8
+ * that renders perfectly well and is entirely made of pickable frames.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import { exportDurationSec } from '../VideoEditor'
12
+ import type { EditorProject, VisualItem } from '../../schema'
13
+
14
+ const video = (id: string, start: number, end: number): VisualItem =>
15
+ ({ id, type: 'video', src: `/${id}.mp4`, start, end }) as VisualItem
16
+ const overlay = (id: string, start: number, end: number): VisualItem =>
17
+ ({ id, type: 'overlay', src: `/${id}.jsx`, start, end }) as VisualItem
18
+
19
+ function proj(tracks: { items: VisualItem[]; enabled?: boolean }[]): EditorProject {
20
+ return {
21
+ id: 'p', status: 'draft', settings: { resolution: [1920, 1080], fps: 30 },
22
+ tracks: tracks.map((t, i) => ({ id: `trk-${i}`, ...t })),
23
+ } as EditorProject
24
+ }
25
+
26
+ describe('exportDurationSec', () => {
27
+ it('spans an overlay-only project, which used to report 0', () => {
28
+ // The Daubert-demo shape: one track, overlays only, no video anywhere.
29
+ expect(exportDurationSec(proj([{ items: [overlay('o1', 0, 5), overlay('o2', 5, 12)] }]))).toBe(12)
30
+ })
31
+
32
+ it('reaches an overlay that outlives the last video clip', () => {
33
+ // An outro card after the final clip is ordinary, and its frames are just
34
+ // as pickable as the footage before it — the render encodes to 14, so the
35
+ // picker has to offer the whole 14.
36
+ expect(exportDurationSec(proj([
37
+ { items: [video('v', 0, 8)] },
38
+ { items: [overlay('outro', 8, 14)] },
39
+ ]))).toBe(14)
40
+ })
41
+
42
+ it('still matches the video end when video is the longest thing', () => {
43
+ // The common case must not move.
44
+ expect(exportDurationSec(proj([
45
+ { items: [video('a', 0, 4), video('b', 4, 9)] },
46
+ { items: [overlay('o', 0, 3)] },
47
+ ]))).toBe(9)
48
+ })
49
+
50
+ it('ignores a disabled track, which the render also skips', () => {
51
+ expect(exportDurationSec(proj([
52
+ { items: [video('v', 0, 6)] },
53
+ { items: [overlay('o', 0, 30)], enabled: false },
54
+ ]))).toBe(6)
55
+ })
56
+
57
+ it('is 0 for a project with nothing on any track', () => {
58
+ expect(exportDurationSec(proj([{ items: [] }]))).toBe(0)
59
+ })
60
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { markerDropTime } from '../VideoEditor'
3
+
4
+ describe('markerDropTime', () => {
5
+ it('uses the playhead when the preview axis is off', () => {
6
+ expect(markerDropTime(false, 12, 4)).toBe(4)
7
+ })
8
+
9
+ it('uses the hovered time when the axis is on', () => {
10
+ // The yellow line is what the operator is looking at, so that is where the
11
+ // marker goes.
12
+ expect(markerDropTime(true, 12, 4)).toBe(12)
13
+ })
14
+
15
+ it('falls back to the playhead when the axis is on but nothing is hovered', () => {
16
+ // Axis on with the pointer off the timeline: hoverScrub reads null.
17
+ expect(markerDropTime(true, null, 4)).toBe(4)
18
+ })
19
+
20
+ it('treats a hovered 0 as a real position, not as absent', () => {
21
+ // The bug a `||` instead of `??` would introduce: the very start of the
22
+ // timeline is a perfectly ordinary place to mark.
23
+ expect(markerDropTime(true, 0, 4)).toBe(0)
24
+ })
25
+ })
@@ -22,7 +22,7 @@
22
22
  * highlight-box.jsx, outline.jsx
23
23
  * (all under montaj_assets/render/templates/captions/).
24
24
  */
25
- import type { Captions } from '../schema'
25
+ import type { Captions, CaptionTextTransform } from '../schema'
26
26
 
27
27
  type Style = Captions['style']
28
28
 
@@ -95,6 +95,6 @@ export const CAPTION_STYLE_TEXT_ALIGN: Record<Style, string> = {
95
95
  * `renderSegment` call site); the no-timestamps fallback branch renders the
96
96
  * segment text as-is, never uppercased. Every other style has no default in
97
97
  * any branch. */
98
- export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, string>> = {
98
+ export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, CaptionTextTransform>> = {
99
99
  outline: 'uppercase',
100
100
  }