@bycrux/editor 1.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "lint": "eslint src"
18
18
  },
19
19
  "dependencies": {
20
- "@bycrux/timeline-core": "^0.2.0",
20
+ "@bycrux/timeline-core": "^0.2.1",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -309,6 +309,7 @@ export const VIDEO_CONTROLS: ControlSection[] = [
309
309
  heading: 'Keyboard',
310
310
  entries: [
311
311
  { keys: ['S'], label: 'Split at the playhead' },
312
+ { keys: ['M'], label: 'Drop a marker at the playhead (or the preview axis)' },
312
313
  { keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
313
314
  { keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
314
315
  { keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
@@ -483,7 +483,27 @@ describe('transportEndFor', () => {
483
483
  expect(transportEndFor(p)).toBe(8)
484
484
  })
485
485
 
486
- it('uses max(overlayEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
486
+ it('counts track 0 in a canvas project — an overlay-only single track is not a zero-length transport', () => {
487
+ // The shape an animations-workflow project actually has: ONE track, holding
488
+ // nothing but overlays, and no captions. Reading the ceiling off
489
+ // `tracks.slice(1)` made this 0, so play/space started the transport and
490
+ // stopped it in the same tick — the picture never moved.
491
+ const p = project([overlay('o1', 0, 5), overlay('o2', 5, 12)])
492
+ expect(p.tracks).toHaveLength(1)
493
+ expect(transportEndFor(p)).toBe(12)
494
+ })
495
+
496
+ it('counts track 0 images in a canvas project, even past the overlay tracks', () => {
497
+ // Same defect, other content kind: a background image outlasting every
498
+ // overlay used to be invisible to the ceiling.
499
+ const p = project(
500
+ [{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 9 }],
501
+ [overlay('o', 0, 4)],
502
+ )
503
+ expect(transportEndFor(p)).toBe(9)
504
+ })
505
+
506
+ it('uses max(visualEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
487
507
  // The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
488
508
  // leave audio out. Faithful, not unified.
489
509
  const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
@@ -517,9 +517,9 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
517
517
  * Two formulas, because the legacy hook has two and they legitimately differ:
518
518
  *
519
519
  * - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
520
- * whose ceiling is `canvasMaxEndRef` = `max(overlayEnd, captionEnd)`. Note
520
+ * whose ceiling is `canvasMaxEndRef` = `max(visualEnd, captionEnd)`. Note
521
521
  * what is NOT in it: audio. A canvas project whose music outlasts its
522
- * overlays stops at the overlays today.
522
+ * visuals stops at the visuals today.
523
523
  * - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
524
524
  * audioEnd)` — captions excluded, audio included — which timeline-core
525
525
  * already ports verbatim (including its two documented faithfulness warts:
@@ -527,18 +527,28 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
527
527
  *
528
528
  * Unifying them would be a behavior change in one mode or the other, so both
529
529
  * are kept and the divergence is named here rather than smoothed over.
530
+ *
531
+ * `visualEnd` spans EVERY enabled track, track 0 included — it is deliberately
532
+ * not the video path's `overlayEnd` (`tracks.slice(1)`). In canvas mode track 0
533
+ * is a content track like any other: it carries the background images, and an
534
+ * agent-authored project can put its overlays there too (an animations-workflow
535
+ * project is frequently ONE track holding nothing but overlays). Skipping it
536
+ * made the ceiling collapse to 0 for exactly those projects, so play/space
537
+ * started the transport and stopped it in the same tick. `OverlayItemsLayer`
538
+ * has always drawn track 0 in canvas mode (`isCanvasProject ? enabledTrackItems
539
+ * : overlayTracks`); this is the transport agreeing with what is on screen.
530
540
  */
531
541
  export function transportEndFor(project: Project): number {
532
542
  const clips = track0VideoItems(project)
533
543
  if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
534
- const overlayEnd = enabledTrackItems(project).slice(1)
544
+ const visualEnd = enabledTrackItems(project)
535
545
  .flat()
536
546
  .reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
537
547
  const captionEnd = (project.captions?.segments ?? []).reduce(
538
548
  (m: number, s) => Math.max(m, s.end ?? 0),
539
549
  0,
540
550
  )
541
- return Math.max(overlayEnd, captionEnd)
551
+ return Math.max(visualEnd, captionEnd)
542
552
  }
543
553
 
544
554
  /**
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  CarouselElement,
20
20
  Slide,
21
21
  EditorProject,
22
+ Marker,
22
23
  } from './schema'
23
24
 
24
25
  // ── Contracts (adapter, theme, render, media, component props) ────────────────
@@ -103,6 +104,9 @@ export {
103
104
  // "land where you dropped it, without stomping existing footage".
104
105
  export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
105
106
  export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
107
+ // Marker model — pure mutations over `project.markers` (see markers.ts's file
108
+ // header for the "same reference when unchanged" contract they all share).
109
+ export { addMarker, moveMarker, renameMarker, removeMarkers, nextMarkerLabel } from './video/timeline/markers'
106
110
 
107
111
  // ── Image tone (HDR image color mapping) ─────────────────────────────────────
108
112
  // The picker component is exported so hosts using `onProvideImageTone` can
package/src/schema.ts CHANGED
@@ -290,6 +290,23 @@ export interface Asset {
290
290
  name?: string
291
291
  }
292
292
 
293
+ /**
294
+ * An operator's flag on the timeline — a moment worth coming back to.
295
+ *
296
+ * Markers are an EDITING and COMMUNICATION aid: they are drawn in the editor's
297
+ * marker strip and handed to the agent through the context endpoint, and the
298
+ * renderer ignores them completely. Nothing about a marker reaches the export.
299
+ *
300
+ * `label` is always present. A new marker gets an auto-number so dropping one
301
+ * never interrupts the edit to type; renaming it is a separate, deliberate act.
302
+ */
303
+ export interface Marker {
304
+ id: string
305
+ /** Timeline position in seconds. Never negative. */
306
+ t: number
307
+ label: string
308
+ }
309
+
293
310
  // ── Carousel types ─────────────────────────────────────────────────────────
294
311
  export interface ImageElement {
295
312
  id: string
@@ -385,6 +402,10 @@ export interface EditorProject {
385
402
  captions?: Captions
386
403
  audio?: { tracks: AudioTrack[] }
387
404
  assets?: Asset[]
405
+ /** Operator markers, kept sorted by `t`. Absent — not `[]` — when the
406
+ * project has none, so a marker-less project is byte-identical to one from
407
+ * before the feature existed (the same discipline `captions` follows). */
408
+ markers?: Marker[]
388
409
  carousel?: { aspect: string }
389
410
  profile?: string
390
411
  derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
@@ -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 { 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
  ])
@@ -1621,6 +1682,13 @@ function ReviewSurface<P extends Project>({
1621
1682
  void sync.mutate(() => updated as P)
1622
1683
  }
1623
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
+
1624
1692
  function handleRippleToggle() {
1625
1693
  const next = !rippleMode
1626
1694
  setRippleMode(next)
@@ -1757,6 +1825,13 @@ function ReviewSurface<P extends Project>({
1757
1825
  // is selected (a no-op if the axis isn't over the item).
1758
1826
  action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
1759
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
+ },
1760
1835
  {
1761
1836
  id: 'video.undo',
1762
1837
  description: 'Undo',
@@ -1897,6 +1972,7 @@ function ReviewSurface<P extends Project>({
1897
1972
  const paletteCommands: PaletteCommand[] = [
1898
1973
  { id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
1899
1974
  { id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
1975
+ { id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
1900
1976
  ]
1901
1977
  if (primarySelectedId) {
1902
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
@@ -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