@bycrux/editor 1.1.0 → 1.2.1

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__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  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/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  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 +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, cleanup } from '@testing-library/react'
3
+ import { FontFamilyPicker, FONT_OPTIONS, setFontsBaseUrl } from '../FontPicker'
4
+
5
+ // FontFamilyPicker's own `ensureGoogleFontsLoaded` is private to FontPicker.tsx
6
+ // (unrelated to, and independent from, `lib/google-fonts.ts`'s loader of the
7
+ // same name — see that file's header comment). It has no exported entry point,
8
+ // so we drive it the way the app does: mount the component, whose first-mount
9
+ // useEffect calls it.
10
+ function mountPicker() {
11
+ return render(<FontFamilyPicker value="" onChange={() => {}} />)
12
+ }
13
+
14
+ function injectedHrefs(): string[] {
15
+ return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
16
+ (l) => (l as HTMLLinkElement).href,
17
+ )
18
+ }
19
+
20
+ /** Every Google family the picker previews, as the labels a vendored family
21
+ * list would spell. Derived from FONT_OPTIONS rather than hardcoded, so
22
+ * adding a family to the picker cannot leave this test asserting a stale set. */
23
+ const PICKER_FAMILIES = FONT_OPTIONS.filter((f) => f.isGoogleFont).map((f) => f.label)
24
+
25
+ beforeEach(() => {
26
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
27
+ vi.spyOn(console, 'info').mockImplementation(() => {})
28
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ setFontsBaseUrl(undefined)
34
+ vi.restoreAllMocks()
35
+ })
36
+
37
+ describe('FontFamilyPicker google fonts base URL', () => {
38
+ // Must run before any other test in this file sets a base: the injection
39
+ // guard is keyed on the computed URL, and the default URL can only be
40
+ // observed as "not yet injected" once per module lifetime.
41
+ it('unset produces exactly the default per-family googleapis URL (byte-identical to pre-base behaviour)', () => {
42
+ mountPicker()
43
+ const hrefs = injectedHrefs()
44
+ expect(hrefs).toEqual([
45
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Roboto:wght@400;700&family=Open+Sans:wght@400;700&family=Lato:wght@400;700&family=Montserrat:wght@400;700&family=Poppins:wght@400;700&family=Raleway:wght@400;700&family=Nunito:wght@400;700&family=Work+Sans:wght@400;700&family=DM+Sans:wght@400;700&family=Rubik:wght@400;700&family=Oswald:wght@400;700&family=Bebas+Neue:wght@400;700&family=Playfair+Display:wght@400;700&family=Merriweather:wght@400;700&family=Source+Serif+4:wght@400;700&family=JetBrains+Mono:wght@400;700&family=Baloo+2:wght@400;500;600;700;800&family=Fredoka:wght@300;400;500;600;700&family=Sniglet:wght@400;800&display=swap',
46
+ ])
47
+ })
48
+
49
+ it('a base whose family list covers the picker produces <base>/fonts.css ONLY — zero egress', () => {
50
+ setFontsBaseUrl('https://example.com/fonts/editor', PICKER_FAMILIES)
51
+ mountPicker()
52
+ expect(injectedHrefs()).toEqual(['https://example.com/fonts/editor/fonts.css'])
53
+ })
54
+
55
+ it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
56
+ // Bases unique to this test so it doesn't depend on injection history left
57
+ // by the tests above (module state, not DOM state, outlives a test — the
58
+ // beforeEach above only clears document.head).
59
+ setFontsBaseUrl('https://example.com/fonts/one', PICKER_FAMILIES)
60
+ mountPicker()
61
+ expect(injectedHrefs()).toEqual(['https://example.com/fonts/one/fonts.css'])
62
+
63
+ setFontsBaseUrl('https://example.com/fonts/two', PICKER_FAMILIES)
64
+ mountPicker()
65
+ const hrefs = injectedHrefs()
66
+ expect(hrefs).toHaveLength(2)
67
+ expect(hrefs).toContain('https://example.com/fonts/two/fonts.css')
68
+ })
69
+
70
+ // The picker's families ARE the vendored set's reason for existing, so in a
71
+ // correctly configured app this never fires. It is guarded because the day a
72
+ // family is added to FONT_OPTIONS and not yet vendored, the alternative is a
73
+ // picker preview silently showing a system fallback — the exact rot a
74
+ // declared family list exists to catch.
75
+ it('a family the vendored list omits falls through to Google, alongside the vendored sheet', () => {
76
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
77
+ const partial = PICKER_FAMILIES.filter((f) => f !== 'Sniglet')
78
+ setFontsBaseUrl('https://example.com/fonts/partial', partial)
79
+ mountPicker()
80
+
81
+ const hrefs = injectedHrefs()
82
+ expect(hrefs).toHaveLength(2)
83
+ expect(hrefs[0]).toBe('https://example.com/fonts/partial/fonts.css')
84
+ expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Sniglet:wght@400;800&display=swap')
85
+ expect(warn.mock.calls[0][0]).toContain('Sniglet')
86
+ })
87
+
88
+ it('a base with no family list links nothing vendored and emits exactly the default googleapis URL', async () => {
89
+ // A fresh module instance, because the URL this produces IS the default
90
+ // URL — every picker family falls through — and the module-level dedupe
91
+ // would (correctly) suppress it after the first test above already
92
+ // injected it. Asserting against the live module would therefore pass
93
+ // vacuously, on an empty list.
94
+ vi.resetModules()
95
+ const fresh = await import('../FontPicker')
96
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
97
+
98
+ fresh.setFontsBaseUrl('https://example.com/fonts/nolist')
99
+ render(<fresh.FontFamilyPicker value="" onChange={() => {}} />)
100
+
101
+ const hrefs = injectedHrefs()
102
+ expect(hrefs).toHaveLength(1)
103
+ expect(hrefs[0]).toContain('fonts.googleapis.com')
104
+ expect(hrefs[0]).not.toContain('fonts.css')
105
+ // Every picker family is in it — nothing was withheld on the strength of
106
+ // a base we were given no family list for.
107
+ for (const label of PICKER_FAMILIES) expect(hrefs[0]).toContain(`family=${label.replace(/ /g, '+')}`)
108
+ expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
109
+
110
+ fresh.setFontsBaseUrl(undefined)
111
+ })
112
+ })
@@ -111,7 +111,7 @@ export default function VersionPanel({ versions, restoring, onRestore, onSaveVer
111
111
  <button
112
112
  onClick={handleSaveClick}
113
113
  disabled={saving || !onSaveVersion}
114
- className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-white text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
114
+ className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
115
115
  >
116
116
  <Save size={13} />
117
117
  {saving ? 'Saving…' : 'Save version'}
@@ -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, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems } from './timeline/timeline-model'
18
+ import { visualDuration } from '@bycrux/timeline-core'
19
+ import { audioEnd, 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
  *
@@ -1178,7 +1230,10 @@ function ReviewSurface<P extends Project>({
1178
1230
  keeps: renderKeeps,
1179
1231
  imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
1180
1232
  name: project.name?.trim() || undefined,
1181
- 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),
1182
1237
  aspectRatio: (() => {
1183
1238
  const r = project.settings?.resolution
1184
1239
  return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
@@ -1194,7 +1249,13 @@ function ReviewSurface<P extends Project>({
1194
1249
  set: handleExportFpsChange,
1195
1250
  },
1196
1251
  }), [
1197
- 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,
1198
1259
  project.name, project.settings?.resolution, project.settings?.fps,
1199
1260
  availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
1200
1261
  ])
@@ -1246,7 +1307,25 @@ function ReviewSurface<P extends Project>({
1246
1307
  }, [project.id, project.captions])
1247
1308
 
1248
1309
  const clips = trackItems(project)[0] ?? []
1249
- const hasContent = clips.length > 0 || (trackItems(project).slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
1310
+ // Gates the ENTIRE preview region — transport, click-to-play surface and the
1311
+ // multi-track audio elements all live inside it, so anything this misses is
1312
+ // not merely invisible, it is unplayable with no UI saying why.
1313
+ //
1314
+ // Audio counts. An audio-only timeline is the normal state of an animations
1315
+ // project between wiring music and authoring the first overlay, and gating
1316
+ // the region out there left the operator a populated timeline, a drawn
1317
+ // waveform lane, and no transport to press: space did nothing at all.
1318
+ //
1319
+ // `trackItems` is deliberately NOT sliced to `[1:]`. Track 0 is a content
1320
+ // track in a canvas project — an animations-workflow project is frequently
1321
+ // ONE track holding nothing but overlays — and `clips` only counts track-0
1322
+ // *video* items, so a track-0 overlay fell through both terms. Same blind
1323
+ // spot `transportEndFor` and `canvasMaxEndRef` were fixed for.
1324
+ const hasContent =
1325
+ clips.length > 0
1326
+ || trackItems(project).flat().length > 0
1327
+ || (project.captions?.segments?.length ?? 0) > 0
1328
+ || audioEnd(project) > 0
1250
1329
 
1251
1330
  // Preview controls row's timecode readout. `currentTime` is the same
1252
1331
  // `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
@@ -1621,6 +1700,13 @@ function ReviewSurface<P extends Project>({
1621
1700
  void sync.mutate(() => updated as P)
1622
1701
  }
1623
1702
 
1703
+ function handleAddMarker(at?: number) {
1704
+ const base = syncProjectRef.current
1705
+ const updated = addMarker(base, at ?? clock.get())
1706
+ if (updated === base) return // key repeat inside the half-frame window
1707
+ void sync.mutate(() => updated as P)
1708
+ }
1709
+
1624
1710
  function handleRippleToggle() {
1625
1711
  const next = !rippleMode
1626
1712
  setRippleMode(next)
@@ -1757,6 +1843,13 @@ function ReviewSurface<P extends Project>({
1757
1843
  // is selected (a no-op if the axis isn't over the item).
1758
1844
  action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
1759
1845
  },
1846
+ {
1847
+ id: 'video.add-marker',
1848
+ description: 'Drop a marker',
1849
+ keyHint: ['M'],
1850
+ matches: matchesPlainKey('m'),
1851
+ action: () => handleAddMarker(markerDropTime(previewAxis, hoverScrub.get(), clock.get())),
1852
+ },
1760
1853
  {
1761
1854
  id: 'video.undo',
1762
1855
  description: 'Undo',
@@ -1897,6 +1990,7 @@ function ReviewSurface<P extends Project>({
1897
1990
  const paletteCommands: PaletteCommand[] = [
1898
1991
  { id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
1899
1992
  { id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
1993
+ { id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
1900
1994
  ]
1901
1995
  if (primarySelectedId) {
1902
1996
  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
@@ -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
+ })
package/src/video/cuts.ts CHANGED
@@ -481,19 +481,47 @@ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack
481
481
  const outPoint = track.outPoint ?? track.sourceDuration ?? (track.end - track.start)
482
482
  const splitPoint = inPoint + (at - track.start)
483
483
 
484
+ // A FADE BELONGS TO AN EDGE, NOT TO THE CLIP. Spreading `...track` into both
485
+ // halves gave both fragments both fades, which is wrong on the two edges the
486
+ // split just created: the left half fades OUT into the cut and the right half
487
+ // fades IN out of it, neither of which anyone asked for. On a voiceover
488
+ // carrying the usual trailing fade-out that is very visible — every cut
489
+ // produced a fragment that ramps to silence at the razor, and the operator
490
+ // has to zero it by hand each time. Fades are added deliberately; a cut is
491
+ // not a request for one.
492
+ //
493
+ // So each half keeps only the fade whose EDGE it still owns:
494
+ // • the left half keeps the original START, so it keeps `fadeIn`;
495
+ // • the right half keeps the original END, so it keeps `fadeOut`;
496
+ // • the two new edges at the cut carry no fade at all.
497
+ // The paired curve goes with its fade — a `fadeOutCurve` describing a
498
+ // fade-out that no longer exists is dead data, and the operator's next
499
+ // hand-drawn fade should start from the default shape like any other new one.
500
+ //
501
+ // The kept fade is then clamped to its own fragment's span. A 3s fade-out on
502
+ // a 20s track is fine; split that track 1s from its end and the 3s fade no
503
+ // longer fits inside the 1s fragment it lands on. Nothing downstream
504
+ // re-clamps for us — the pointer-machine and the properties panel each clamp
505
+ // their OWN edits, and the renderer just anchors `st = end - fadeOut`, which
506
+ // for an oversized fade starts before the fragment's audio does.
507
+ const { fadeOut: _outAmt, fadeOutCurve: _outCurve, ...leftBase } = track
508
+ const { fadeIn: _inAmt, fadeInCurve: _inCurve, ...rightBase } = track
509
+
484
510
  const left: AudioTrack = {
485
- ...track,
511
+ ...leftBase,
486
512
  end: at,
487
513
  inPoint,
488
514
  outPoint: splitPoint,
489
515
  }
490
516
  const right: AudioTrack = {
491
- ...track,
517
+ ...rightBase,
492
518
  id: uniqueId(track.id),
493
519
  start: at,
494
520
  inPoint: splitPoint,
495
521
  outPoint,
496
522
  }
523
+ if (left.fadeIn !== undefined) left.fadeIn = Math.min(left.fadeIn, at - track.start)
524
+ if (right.fadeOut !== undefined) right.fadeOut = Math.min(right.fadeOut, track.end - at)
497
525
  return [left, right]
498
526
  }
499
527