@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
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
 
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
2
- import { containsTime, geometryAt, geometryFor } from '@bycrux/timeline-core'
2
+ import { containsTime, geometryAt, geometryFor, resolveAt } from '@bycrux/timeline-core'
3
3
  import { isProxyUsable, markProxyFailed } from './proxySupport'
4
4
  import type { EditorProject as Project, VisualItem } from '../../schema'
5
5
  import type { OverlayFactory } from '../../types'
@@ -8,7 +8,7 @@ import { getOverlayDesignCanvas } from '../design-canvas'
8
8
  import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
9
9
  import type { Corner, Edge, OverlayChanges } from './useDragOverlay'
10
10
  import type { useDragOverlay } from './useDragOverlay'
11
- import { enabledTrackItems } from '../timeline/timeline-model'
11
+ import { enabledTrackItems, withEnabledItemTracks } from '../timeline/timeline-model'
12
12
 
13
13
  // Mount video items this many seconds before item.start so the frame is ready.
14
14
  //
@@ -421,6 +421,62 @@ export default function OverlayItemsLayer({
421
421
  // Enabled only: a skipped track must not be draggable in the preview either.
422
422
  const interactiveTracks = isCanvasProject ? enabledTrackItems(project) : overlayTracks
423
423
 
424
+ // Split from the `scene` memo below ON PURPOSE. `withEnabledItemTracks`
425
+ // spreads the project and re-runs `normalizeTracks` plus a `.map()` over every
426
+ // track — work that depends on `project` alone. Folded into one memo keyed
427
+ // `[project, currentTime]` it re-ran on EVERY frame of playback, rebuilding an
428
+ // identical track array 30-60 times a second. Keyed on `project` it runs once
429
+ // per edit, and only the genuinely time-dependent `resolveAt` runs per frame.
430
+ const previewProject = useMemo(() => withEnabledItemTracks(project), [project])
431
+
432
+ // The resolver's Scene at this instant — the SAME `resolveAt` call the canvas
433
+ // engine's `previewResolver` makes (engine/scheduler.ts), so this layer and
434
+ // the engine read the identical `crossfade` stamp for a clip that happens to
435
+ // sit on an overlay track. `ResolvedItem.item` is the project's own object by
436
+ // reference (timeline-core's contract), so the blocks below look their
437
+ // resolved counterparts up by identity rather than recomputing anything.
438
+ const scene = useMemo(
439
+ () => resolveAt(previewProject, currentTime, { variant: 'preview' }),
440
+ [previewProject, currentTime],
441
+ )
442
+
443
+ // The transition weight for ONE clip at this instant, shared by BOTH render
444
+ // blocks below — the tracks[0] images and the interactive tracks. Both are
445
+ // fed by `enabledTrackItems(project)` (`useVideoPlayback.ts` / this file's own
446
+ // `previewProject`), so the identity lookup resolves in either.
447
+ //
448
+ // ONLY THE INCOMING SIDE RAMPS. The outgoing side keeps its own full opacity —
449
+ // factor 1, not `1-p` — and that is not a shortcut, it is what this kind of
450
+ // compositor requires:
451
+ //
452
+ // - The EXPORT is a SPLIT-AND-LERP compositor: `encode-segment.js` splits
453
+ // the canvas, composites each side down its own branch at its OWN full
454
+ // opacity, then lerps the two finished frames with
455
+ // `blend=all_expr='A+(B-A)*p'` — `(1-p)*from + p*to`.
456
+ // - THIS layer is a SEQUENTIAL source-over compositor: each item is a DOM
457
+ // node painted over the ones before it. Leaving `from` at 1 and giving
458
+ // `to` alpha p produces exactly `p*to + (1-p)*from` — the same lerp,
459
+ // reached by a different route.
460
+ //
461
+ // Feeding the symmetric `1-p` / `p` pair into a sequential stack does NOT
462
+ // reproduce the lerp; it yields `p*to + (1-p)^2*from + p(1-p)*bg`, which at
463
+ // p=0.5 shows the outgoing clip at QUARTER weight and lets the background leak
464
+ // through the middle of every transition.
465
+ //
466
+ // `createCanvasPainter.paintBlend` (engine/index.ts) is the shared reference
467
+ // for the sequential form: `globalAlpha = 1` for `from`, then `globalAlpha = p`
468
+ // for `to`. `render/sample-frame.js` — also sequential — carries the same
469
+ // factor. Do not "restore" the symmetric form; it is the obvious-looking wrong
470
+ // answer for this compositor.
471
+ //
472
+ // `crossfade` is CLIPS-ONLY and null outside an overlap
473
+ // (@bycrux/timeline-core's `crossfadesAt`), so this returns 1 — a no-op —
474
+ // everywhere a transition is not actually in play.
475
+ const crossfadeFactor = (item: VisualItem) => {
476
+ const xf = scene.items.find((r) => r.item === item)?.crossfade ?? null
477
+ return xf?.role === 'to' ? xf.p : 1
478
+ }
479
+
424
480
  return (
425
481
  <>
426
482
  {/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
@@ -438,9 +494,20 @@ export default function OverlayItemsLayer({
438
494
  // buildImageItemFilterParts, which since SP9d compiles their curves into
439
495
  // ffmpeg expressions. Leaving this branch on the static resolve would
440
496
  // put back a preview/render divergence in the one place nobody would
441
- // look for it. Opacity stays static here too — ffmpeg cannot vary it.
497
+ // look for it. The opacity CURVE stays ignored here too — ffmpeg cannot
498
+ // vary clip alpha.
499
+ //
500
+ // A TRANSITION is the one thing that does move this opacity, and these
501
+ // items need it as much as the interactive block below does:
502
+ // `crossfadesAt` stamps IMAGE clips exactly like video ones, and the
503
+ // export blends a track[0] image pair through the same
504
+ // `encode-segment.js` `blend` branch. The canvas engine skips them
505
+ // (`scheduler.ts` filters its scan to `trackIdx === 0 && kind ===
506
+ // 'video'`), so this block is the ONLY thing drawing them in non-canvas
507
+ // mode — leave it static and the preview hard-cuts a pair the export
508
+ // dissolves. Same shared factor, same reasoning: see `crossfadeFactor`.
442
509
  const gAnimated = geometryAt(item, 'image', currentTime - item.start)
443
- const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity }
510
+ const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity * crossfadeFactor(item) }
444
511
  const fit = g.fit ?? 'cover'
445
512
  const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
446
513
  const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
@@ -542,9 +609,21 @@ export default function OverlayItemsLayer({
542
609
  // unaffected — they are baked per frame in a browser, where opacity is
543
610
  // just another CSS value.
544
611
  const animated = geometryAt(item, item.type, currentTime - item.start)
545
- const g = item.type === 'overlay'
612
+ // SP9d's pin STANDS: a clip's opacity CURVE is still ignored here,
613
+ // because ffmpeg cannot animate clip alpha and preview must not
614
+ // promise a fade the export cannot produce.
615
+ //
616
+ // A TRANSITION is different, and is not an exception to that rule but
617
+ // an application of it: the export really does blend a transitioning
618
+ // pair (`encode-segment.js`'s `blend` branch), so showing the blend
619
+ // here is what keeps preview honest. The factor comes from the shared
620
+ // resolver via `crossfadeFactor` above — read its note for WHY only
621
+ // the incoming side ramps while the outgoing side stays at 1. That is
622
+ // the compositing model, not an oversight.
623
+ const factor = crossfadeFactor(item)
624
+ const g = item.type === 'overlay'
546
625
  ? animated
547
- : { ...animated, opacity: geometryFor(item, item.type).opacity }
626
+ : { ...animated, opacity: geometryFor(item, item.type).opacity * factor }
548
627
  const fit = g.fit ?? 'cover'
549
628
  const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
550
629
  const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
@@ -12,7 +12,7 @@ import OverlayItemsLayer from './OverlayItemsLayer'
12
12
  import { useVideoPlayback } from './useVideoPlayback'
13
13
  import { useEnginePlayback, type EnginePlayback } from './useEnginePlayback'
14
14
  import EngineSurface from './EngineSurface'
15
- import { evaluateEngineEligibility } from '../../engine/eligibility'
15
+ import { evaluateEngineEligibility, engineRequiredReason } from '../../engine/eligibility'
16
16
  import type { AcquiredDemux } from '../../engine'
17
17
  import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
18
18
  import { gateTimeSink, handOverToHover, useHoverScrubTime, type HoverScrub } from '../hover-scrub'
@@ -630,6 +630,33 @@ function PreviewSurface({
630
630
  internal ordering). No-ops on an absent/unrecognized platform, so
631
631
  this is unconditional. */}
632
632
  <SocialSafeZoneOverlay platform={socialPreview} />
633
+
634
+ {/* Task 10b — legacy has no compositing stage (one <video> element per
635
+ clip), so it cannot blend a clip crossfade the way render does. v4's
636
+ rule is against a preview that SILENTLY disagrees with the export;
637
+ a visible, persistent notice satisfies that without blocking
638
+ preview outright, which would make a whole project un-previewable
639
+ over one transition (and would make every background-removed
640
+ project, legacy-only in v1, never previewable at all — see
641
+ `engineRequiredReason`'s doc in eligibility.ts).
642
+
643
+ Deliberately NOT folded into `useEngineMode`/`mode` above: that hook
644
+ evaluates once per project LOAD, on purpose, so the PLAYER never
645
+ remounts mid-edit (the anti-flapping rule — see its comment). This
646
+ check is the opposite by design: cheap, synchronous, recomputed on
647
+ every render, so the banner appears the instant an operator drags
648
+ two clips into overlap and disappears the instant they pull them
649
+ apart, without ever touching which player is mounted. */}
650
+ {playback.mode === 'legacy' && engineRequiredReason(project) !== null && (
651
+ <div
652
+ className="montaj-legacy-crossfade-banner absolute inset-x-0 bottom-0 flex justify-center pointer-events-none"
653
+ style={{ zIndex: 90 }}
654
+ >
655
+ <div className="mb-2 rounded bg-black/70 px-3 py-1.5 text-center text-xs text-white/90">
656
+ Crossfades will not appear in this preview. They will render in the export.
657
+ </div>
658
+ </div>
659
+ )}
633
660
  </div>
634
661
  )
635
662
  }
@@ -361,3 +361,94 @@ describe('OverlayItemsLayer — per-axis scale', () => {
361
361
  expect(nw.style.transform).toBe('scale(0.5, 0.5) translate(-50%, -50%)')
362
362
  })
363
363
  })
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Layered-clip transition blend (Task 11). SP9d's pin — a clip's opacity
367
+ // CURVE is ignored in this layer — still holds; it is not what this section
368
+ // tests. What's new is the resolver's `crossfade` stamp: two clips on the
369
+ // SAME overlay track that overlap now blend, because the export genuinely
370
+ // blends that pair (encode-segment.js's `blend` branch, SP9d/T8). Showing the
371
+ // same blend here is an application of the SP9d rule, not an exception to it.
372
+ //
373
+ // Unlike `renderLayer` above (which renders its item from the `overlayTracks`
374
+ // prop while `project.tracks` stays empty), these tests need `project.tracks`
375
+ // itself to carry the items: the component looks its resolved crossfade stamp
376
+ // up via `resolveAt(project, ...)`, matched back to the rendered item BY
377
+ // REFERENCE — so the fixture's `project.tracks` and its `overlayTracks` prop
378
+ // must share the exact same item objects.
379
+ // ---------------------------------------------------------------------------
380
+
381
+ function renderLayeredClips(overlayItems: VisualItem[], currentTime: number) {
382
+ const project = {
383
+ id: 'p',
384
+ status: 'draft',
385
+ settings: { resolution: [1080, 1920], fps: 30 },
386
+ tracks: [[], overlayItems],
387
+ } as unknown as EditorProject
388
+
389
+ const utils = render(
390
+ <OverlayItemsLayer
391
+ project={project}
392
+ currentTime={currentTime}
393
+ isPlaying={false}
394
+ isCanvasProject={false}
395
+ overlayTracks={[overlayItems]}
396
+ tracks0NonVideo={[]}
397
+ renderScale={0.2}
398
+ selectedOverlayId={undefined}
399
+ containerRef={{ current: document.createElement('div') }}
400
+ dragState={null}
401
+ setDragState={vi.fn()}
402
+ liveOffset={null}
403
+ liveScale={null}
404
+ liveRotation={null}
405
+ snapGuides={emptySnap}
406
+ snapRotation={null}
407
+ compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
408
+ fileUrl={(pth: string) => pth}
409
+ />,
410
+ )
411
+ // Each clip's <img> sits directly inside its own wrapper div — the wrapper
412
+ // is where `opacity` actually lands (OverlayItemsLayer.tsx's `wrapperStyle`).
413
+ const styleOf = (src: string) =>
414
+ (utils.container.querySelector(`img[src="${src}"]`) as HTMLElement).parentElement!.style
415
+ return { ...utils, styleOf }
416
+ }
417
+
418
+ describe('OverlayItemsLayer — layered clip transition blend (Task 11)', () => {
419
+ it('a layered clip mid-transition renders at its blended opacity', () => {
420
+ // a: [0,4), b: [3,7) — overlap [3,4). At t=3.5 the pair is exactly
421
+ // half-through: p=0.5.
422
+ //
423
+ // ONLY THE INCOMING SIDE RAMPS. `a` (role 'from') stays at 1 and `b` (role
424
+ // 'to') gets 0.5, because this layer stacks DOM nodes with source-over: `b`
425
+ // at alpha 0.5 over an opaque `a` composites to `0.5*b + 0.5*a`, which is
426
+ // exactly the lerp `encode-segment.js` emits as `blend=all_expr='A+(B-A)*p'`.
427
+ //
428
+ // The old expectation was '0.5' / '0.5', and it was WRONG: symmetric factors
429
+ // in a sequential stack give `p*b + (1-p)^2*a + p(1-p)*bg` — `a` at quarter
430
+ // weight with the background leaking through mid-transition. It passed only
431
+ // because it was written against the same mistaken model as the code.
432
+ // `createCanvasPainter.paintBlend` (engine/index.ts) is the reference.
433
+ const a = { id: 'a', type: 'image', src: 'a.png', start: 0, end: 4 } as VisualItem
434
+ const b = { id: 'b', type: 'image', src: 'b.png', start: 3, end: 7 } as VisualItem
435
+ const { styleOf } = renderLayeredClips([a, b], 3.5)
436
+
437
+ expect(styleOf('a.png').opacity).toBe('1')
438
+ expect(styleOf('b.png').opacity).toBe('0.5')
439
+ })
440
+
441
+ it('a layered clip outside a transition still reads its STATIC opacity', () => {
442
+ // A lone clip on its track has no partner to pair with, so the resolver
443
+ // stamps `crossfade: null` — this is the SP9d regression guard: the
444
+ // opacity keyframe curve must stay ignored, and the wrapper must show the
445
+ // STATIC `opacity` field (0.4), not a sampled curve value.
446
+ const item = {
447
+ id: 'a', type: 'image', src: 'a.png', start: 0, end: 10, opacity: 0.4,
448
+ keyframes: [{ prop: 'opacity', points: [{ t: 0, value: 0 }, { t: 10, value: 1 }] }],
449
+ } as VisualItem
450
+ const { styleOf } = renderLayeredClips([item], 1)
451
+
452
+ expect(styleOf('a.png').opacity).toBe('0.4')
453
+ })
454
+ })
@@ -15,7 +15,7 @@
15
15
  * here is which surface mounts, not what it paints.
16
16
  */
17
17
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
- import { render, waitFor } from '@testing-library/react'
18
+ import { render, screen, waitFor } from '@testing-library/react'
19
19
  import PreviewPlayer from '../PreviewPlayer'
20
20
  import { createPlaybackClock } from '../../playback-clock'
21
21
  import { __setEngineCapabilityForTests } from '../../../engine/eligibility'
@@ -48,6 +48,27 @@ function makeProject(clipOverrides: Record<string, unknown> = {}): Project {
48
48
  } as unknown as Project
49
49
  }
50
50
 
51
+ /**
52
+ * Two overlapping track-0 video clips — c1 starts (3) before c0 ends (5) and
53
+ * does not contain it, a real `transitionPairs()` pair per
54
+ * `engineRequiredReason`'s test in eligibility.test.ts. `withProxy` controls
55
+ * whether the pair is also engine-shape-eligible, so the same fixture serves
56
+ * both the "still building proxies" legacy case and the "engine running"
57
+ * case.
58
+ */
59
+ function makeCrossfadeProject(withProxy: boolean): Project {
60
+ const proxy = withProxy ? { proxySrc: 'proxy.mp4' } : {}
61
+ return {
62
+ id: withProxy ? 'p-crossfade-proxy' : 'p-crossfade-no-proxy',
63
+ status: 'draft',
64
+ settings: { resolution: [1080, 1920], fps: 30 },
65
+ tracks: [[
66
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, ...proxy },
67
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 3, end: 8, ...proxy },
68
+ ]],
69
+ } as unknown as Project
70
+ }
71
+
51
72
  function renderPreview(project: Project, engine?: { enabled: boolean }) {
52
73
  return render(
53
74
  <PreviewPlayer
@@ -137,3 +158,30 @@ describe('PreviewPlayer engine branch', () => {
137
158
  expect(root.querySelectorAll('svg')).toHaveLength(1)
138
159
  })
139
160
  })
161
+
162
+ describe('PreviewPlayer legacy crossfade banner (Task 10b)', () => {
163
+ it('warns that crossfades are missing when a crossfading project falls back to legacy', async () => {
164
+ // No proxySrc => shape-ineligible => legacy player, which cannot blend.
165
+ renderPreview(makeCrossfadeProject(false))
166
+ await screen.findByText(/crossfades will not appear/i)
167
+ })
168
+
169
+ it('shows no such warning when the engine is running', async () => {
170
+ __setEngineCapabilityForTests(true)
171
+ renderPreview(makeCrossfadeProject(true), { enabled: true })
172
+
173
+ // Wait for the engine to actually take over (canvas mounted, legacy
174
+ // <video> slots gone) before asserting the banner's absence — otherwise
175
+ // the assertion could pass trivially on the pre-decision render.
176
+ await waitFor(() => expect(document.querySelector('canvas')).not.toBeNull())
177
+ expect(screen.queryByText(/crossfades will not appear/i)).toBeNull()
178
+ })
179
+
180
+ it('shows no such warning on a legacy project that has no crossfade', () => {
181
+ // A single track-0 clip: engineRequiredReason is trivially null
182
+ // (transitionPairs needs 2+ items), and the flag is absent so this is
183
+ // legacy synchronously, same as the "flag absent" test above.
184
+ renderPreview(makeProject())
185
+ expect(screen.queryByText(/crossfades will not appear/i)).toBeNull()
186
+ })
187
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The canvas rAF clock's CEILING, driven through the real effect.
3
+ *
4
+ * `canvasMaxEndRef` is the only thing standing between "space plays" and "space
5
+ * does nothing" on a project with no track-0 video. It used to read
6
+ * `overlayTracks` (`tracks.slice(1)`), which silently skips track 0 — and track
7
+ * 0 in canvas mode is not the primary footage track, it is content: the
8
+ * background images, and on an agent-authored project frequently the overlays
9
+ * themselves. A project that is ONE track of nothing but overlays (what the
10
+ * animations workflow emits) therefore got a ceiling of 0, and the first tick
11
+ * clamped the playhead to 0 and called `setIsPlaying(false)` in the same frame.
12
+ *
13
+ * These drive the hook's own rAF effect rather than asserting on a helper: the
14
+ * ceiling lives in a ref that nothing exports, so the only honest way to pin it
15
+ * is to run the clock and watch where time goes. Each case fails on the
16
+ * pre-fix `overlayTracks` read.
17
+ */
18
+ import { describe, it, expect, vi, afterEach } from 'vitest'
19
+ import { act, renderHook } from '@testing-library/react'
20
+ import { useVideoPlayback } from '../useVideoPlayback'
21
+ import type { EditorProject, VisualItem } from '../../../schema'
22
+
23
+ const overlay = (id: string, start: number, end: number): VisualItem =>
24
+ ({ id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }) as VisualItem
25
+
26
+ const image = (id: string, start: number, end: number): VisualItem =>
27
+ ({ id, type: 'image', src: `/img/${id}.png`, start, end }) as VisualItem
28
+
29
+ /** The last emission. Not `.at(-1)` — this package targets ES2020. */
30
+ const last = (xs: number[]): number | undefined => xs[xs.length - 1]
31
+
32
+ function projectOf(...tracks: VisualItem[][]): EditorProject {
33
+ return {
34
+ id: 'canvas-clock',
35
+ status: 'draft',
36
+ settings: { resolution: [1920, 1080] },
37
+ tracks: tracks.map((items, i) => ({ id: `trk-${i}`, items })),
38
+ } as EditorProject
39
+ }
40
+
41
+ /**
42
+ * Mount the hook on a canvas project and run the clock for one second of wall
43
+ * time, returning every playhead value it emitted.
44
+ *
45
+ * Two ticks, because the first only anchors `rafLastMs` — it has no `dt` yet
46
+ * and emits nothing. The second is the first that can move the playhead, and
47
+ * the one the collapsed ceiling used to kill.
48
+ */
49
+ function runClock(project: EditorProject) {
50
+ const times: number[] = []
51
+ const scheduled: FrameRequestCallback[] = []
52
+ vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
53
+ scheduled.push(cb)
54
+ return scheduled.length
55
+ })
56
+
57
+ const view = renderHook(() => useVideoPlayback(project, 0, (t) => times.push(t), (p) => p))
58
+ expect(view.result.current.isCanvasProject).toBe(true)
59
+
60
+ scheduled.length = 0
61
+ act(() => { view.result.current.setIsPlaying(true) })
62
+ expect(scheduled.length).toBeGreaterThan(0)
63
+
64
+ act(() => { scheduled[0](0) }) // anchor only
65
+ act(() => { scheduled[scheduled.length - 1](1000) }) // +1s of wall clock
66
+
67
+ return { times, isPlaying: view.result.current.isPlaying }
68
+ }
69
+
70
+ describe('useVideoPlayback — the canvas clock counts track 0', () => {
71
+ afterEach(() => { vi.restoreAllMocks() })
72
+
73
+ it('advances on a single track holding nothing but overlays', () => {
74
+ // The Daubert-demo shape: one track, 14 overlays, no captions, no video.
75
+ const { times, isPlaying } = runClock(projectOf([overlay('o1', 0, 5), overlay('o2', 5, 12)]))
76
+ expect(last(times)).toBeCloseTo(1, 6)
77
+ expect(isPlaying).toBe(true)
78
+ })
79
+
80
+ it('advances on a track-0 image that outlasts every overlay track', () => {
81
+ // Same defect, other content kind — the background image was invisible to
82
+ // the ceiling, so a slideshow stopped at its last overlay.
83
+ //
84
+ // The overlay track has to end INSIDE the second this clock runs (0.4 < 1),
85
+ // or `overlayTracks` alone already carries the playhead to 1 and the case
86
+ // passes on the broken code without ever reading track 0.
87
+ const { times } = runClock(projectOf([image('bg', 0, 20)], [overlay('o', 0, 0.4)]))
88
+ expect(last(times)).toBeCloseTo(1, 6)
89
+ })
90
+
91
+ it('still stops at the ceiling rather than running past the content', () => {
92
+ // The clamp itself is not what was wrong, and must survive: one second of
93
+ // wall clock against a half-second project lands on the end, not past it,
94
+ // and playback stops there.
95
+ const { times, isPlaying } = runClock(projectOf([overlay('o', 0, 0.5)]))
96
+ expect(last(times)).toBeCloseTo(0.5, 6)
97
+ expect(isPlaying).toBe(false)
98
+ })
99
+ })
@@ -322,18 +322,33 @@ export function useVideoPlayback(
322
322
  applyClipVolume(clip)
323
323
  }, [clips, videoTrack, activeSlot])
324
324
 
325
- // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
325
+ // maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
326
326
  // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
327
327
  // down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
328
328
  // to it). onTimeUpdate is the stable clock.set identity.
329
+ //
330
+ // Spans EVERY enabled track, track 0 INCLUDED — not `overlayTracks`
331
+ // (`slice(1)`). This clock only ever runs for canvas projects, where track 0
332
+ // holds content rather than the primary footage: the background images, and
333
+ // on an agent-authored project frequently the overlays themselves (an
334
+ // animations-workflow project is often ONE track of nothing but overlays).
335
+ // Reading `overlayTracks` here collapsed the ceiling to 0 for exactly those,
336
+ // so the very first tick clamped to 0 and immediately called
337
+ // `setIsPlaying(false)` — space appeared to do nothing at all.
338
+ // `OverlayItemsLayer` already draws track 0 in canvas mode; this keeps the
339
+ // clock's ceiling and what's on screen in agreement. Mirrored in the engine
340
+ // path's `transportEndFor` (engine/scheduler.ts) — change both together.
341
+ //
342
+ // Audio stays OUT, unchanged: the canvas/video divergence over the audio tail
343
+ // is documented in timeline-core's `durations.js` and is not this fix.
329
344
  const canvasMaxEndRef = useRef(0)
330
345
  useEffect(() => {
331
346
  const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
332
347
  canvasMaxEndRef.current = Math.max(
333
- overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
348
+ enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
334
349
  captionEnd,
335
350
  )
336
- }, [overlayTracks, project.captions])
351
+ }, [project])
337
352
 
338
353
  useEffect(() => {
339
354
  if (!isCanvasProject) return
@@ -18,7 +18,8 @@ import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
18
18
  import { keyframeUnionTimes } from './canvas/keyframe-strip'
19
19
  import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
20
20
  import { deleteSelection, toggleSelection } from './multiSelectOps'
21
- import { computeAutoCrossfade, computeDerivedTiming, trackItems } from './timeline-model'
21
+ import { removeMarkers } from './markers'
22
+ import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, trackItems } from './timeline-model'
22
23
  import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
23
24
  import type { KeyframeSelection } from './canvas/pointer-machine'
24
25
  import { timeToX, useViewportStore, useViewportValue, xToTime, type ViewportStore } from './canvas/viewport'
@@ -657,6 +658,48 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
657
658
  // re-runs on real edits (see above for why the fades belong in the key).
658
659
  }, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}:${t.fadeIn ?? ''}:${t.fadeOut ?? ''}`).join('|')])
659
660
 
661
+ // The VISUAL sibling of the pass above: two OVERLAYS overlapping on the same
662
+ // track get complementary derived `opacity` curves (`computeVisualCrossfade`
663
+ // in timeline-model.ts, so the decision logic is shared and testable away
664
+ // from React).
665
+ //
666
+ // A separate effect, deliberately NOT folded into the one above. That one's
667
+ // dependency digest is keyed on the audio FADES on purpose — the digest is
668
+ // what clears a pending timer instead of letting a stale one fire — and
669
+ // mixing a second, independently-changing key into it would re-arm the audio
670
+ // timer on every unrelated overlay edit, and the visual timer on every
671
+ // unrelated audio edit. Two passes, two digests, two timers.
672
+ //
673
+ // Same debounce, for the same reason: an overlay's span moves on every
674
+ // mousemove of a drag, and committing each one is the per-move-undo bug. A
675
+ // gesture's own commit (`commitTimelineEdit`) folds the visual fade into its
676
+ // one undo step, so mid-drag this timer is cleared and rescheduled every
677
+ // frame and never fires, and right after a gesture it no-ops because
678
+ // `computeVisualCrossfade` is idempotent. This pass is therefore only the
679
+ // catch-all for overlay timing that changes OUTSIDE a gesture — ripple-
680
+ // delete, gap-collapse — which reach `sync.mutate` directly and never pass
681
+ // through `commitTimelineEdit` at all. Commits only; a preview via
682
+ // `onProjectChange` would change the digest below and clear the timer before
683
+ // it fired, so a ripple-delete's fade would never get saved.
684
+ useEffect(() => {
685
+ if (!onOverlayEdit) return
686
+ const next = computeVisualCrossfade(project)
687
+ if (!next) return
688
+ const timer = setTimeout(() => onOverlayEdit(next), CROSSFADE_COMMIT_DELAY_MS)
689
+ return () => clearTimeout(timer)
690
+ // Keyed on every visual item's SPAN and its DERIVED opacity curve, for
691
+ // exactly the reason the audio digest above includes the fades: the
692
+ // gesture's own crossfade commit changes this digest, which re-runs the
693
+ // effect and clears the pending timer rather than letting a stale one fire.
694
+ // `origin` is what makes a curve derived, and this pass is its only writer,
695
+ // so a hand-authored curve (no `origin`) contributes nothing to the key —
696
+ // it can never be the reason this pass needs to run again.
697
+ }, [allTracks.map(items => items.map(i => {
698
+ const fade = i.keyframes?.find(k => k.prop === 'opacity')
699
+ const curve = fade?.origin ? fade.points.map(p => `${p.t},${p.value}`).join(';') : ''
700
+ return `${i.id}:${i.start}:${i.end}:${curve}`
701
+ }).join('|')).join('||')])
702
+
660
703
  // The tabIndex={0} root below — Delete/Enter's guards below check focus is
661
704
  // inside it before firing (see the useKeymap block), restoring the pre-T9
662
705
  // scoping those two bindings had (arrows/Escape were already document-level
@@ -794,6 +837,11 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
794
837
  updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
795
838
  }
796
839
  }
840
+ // Markers live at project.markers, outside tracks/audio, so `deleteSelection`
841
+ // cannot see them — same reason captions need their own strip above. Folded
842
+ // into the SAME commit so a mixed clip + caption + marker delete is one
843
+ // undo entry.
844
+ updated = removeMarkers(updated, new Set(selectedIds))
797
845
  if (rippleMode) updated = collapseGaps(updated)
798
846
  // Deleting a clip out of a magnetic audio lane leaves a gap exactly
799
847
  // like a trim or a move would — close it the same way, on release.
@@ -360,6 +360,22 @@ describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
360
360
  expect(onProjectChange).not.toHaveBeenCalled()
361
361
  })
362
362
 
363
+ it('Delete removes a selected marker, in the same commit as everything else', () => {
364
+ // Markers live outside tracks/audio, so deleteSelection cannot see them —
365
+ // the same reason captions needed their own strip in this action.
366
+ const clock = createPlaybackClock(0)
367
+ const onProjectChange = vi.fn()
368
+ const project = { ...makeProject(), markers: [{ id: 'm1', t: 5, label: '1' }] }
369
+ const { container } = render(
370
+ <Timeline project={project} clock={clock} selectedIds={['m1']} onProjectChange={onProjectChange} />,
371
+ )
372
+ focusTimelineRoot(container)
373
+ fireEvent.keyDown(document.body, { key: 'Delete' })
374
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
375
+ const updated = onProjectChange.mock.calls[0][0] as Project
376
+ expect(updated.markers).toBeUndefined() // last one gone → key dropped
377
+ })
378
+
363
379
  it('exposes zoomFit through actionsRef for a host-level palette', () => {
364
380
  const clock = createPlaybackClock(1)
365
381
  const actionsRef: { current: TimelineActions | null } = { current: null }