@bycrux/editor 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@ import type {
12
12
  import type { Captions, VisualItem } from '../../schema'
13
13
  import type { OverlayChanges } from '../preview/useDragOverlay'
14
14
  import VideoEditor from '../VideoEditor'
15
+ import { CROSSFADE_COMMIT_DELAY_MS } from '../timeline/Timeline'
16
+ import { trackItems } from '../timeline/timeline-model'
15
17
  import { dragCanvasItem, installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
16
18
 
17
19
  // ── Fake adapter ──────────────────────────────────────────────────────────────
@@ -839,4 +841,171 @@ describe('VideoEditor — editor-package integration', () => {
839
841
 
840
842
  expect(queryByText('Compare')).toBeNull()
841
843
  })
844
+
845
+ // ── Derived OVERLAY crossfades: the wiring, not the function ─────────────
846
+ //
847
+ // `computeVisualCrossfade` (timeline-model.ts, unit-tested in
848
+ // timeline/__tests__/timeline-model.test.ts) ships INERT without two call
849
+ // sites, exactly as `computeAutoCrossfade` needs two: the gesture commit
850
+ // (`commitTimelineEdit` here in VideoEditor) and Timeline.tsx's debounced
851
+ // catch-all for overlay timing that changes without a gesture. One test each.
852
+ //
853
+ // Both assert on the ADAPTER's saveProject payload rather than on local
854
+ // `onProjectChange` state, for the reason
855
+ // VideoEditor.rippleDeleteCaptions.test.tsx records in full: only a real
856
+ // commit reaches `saveProject`, so an assertion on client state can pass on
857
+ // a transient preview that was never persisted.
858
+
859
+ /** The `opacity` keyframe track on `itemId` in a saved project, wherever the
860
+ * item lives. `undefined` when the item carries no opacity curve at all. */
861
+ function opacityTrackOf(project: Project, itemId: string) {
862
+ for (const items of trackItems(project)) {
863
+ const item = items.find((i) => i.id === itemId)
864
+ if (item) return item.keyframes?.find((k) => k.prop === 'opacity')
865
+ }
866
+ return undefined
867
+ }
868
+
869
+ /** The item itself, for the span assertions. */
870
+ function itemOf(project: Project, itemId: string): VisualItem | undefined {
871
+ for (const items of trackItems(project)) {
872
+ const item = items.find((i) => i.id === itemId)
873
+ if (item) return item
874
+ }
875
+ return undefined
876
+ }
877
+
878
+ it('a trim that creates an overlay overlap commits the fade in ONE undo step', async () => {
879
+ onTestFinished(installCanvasHarness())
880
+ // Fake timers, never advanced: Timeline.tsx's debounced catch-all cannot
881
+ // fire, so a fade in the saved project can ONLY have come from the gesture
882
+ // commit. Without this the two call sites are indistinguishable.
883
+ vi.useFakeTimers()
884
+ onTestFinished(() => { vi.useRealTimers() })
885
+
886
+ const adapter = makeFakeAdapter()
887
+ // The overlays start APART — the gesture is what creates the overlap.
888
+ const initial = makeVideoProject({
889
+ tracks: [
890
+ {
891
+ id: 'trk-0',
892
+ items: [
893
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 40 },
894
+ ],
895
+ },
896
+ {
897
+ id: 'trk-1',
898
+ items: [
899
+ { id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
900
+ { id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 5, end: 9 },
901
+ ],
902
+ },
903
+ ],
904
+ })
905
+ const { container } = render(
906
+ <VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
907
+ )
908
+ await act(async () => { await Promise.resolve() })
909
+
910
+ // Drag ov-a's OUT edge from t=4 to t=6, so it runs 1s into ov-b. A trim on
911
+ // a non-zero track is allowed past a neighbour's near boundary (a partial
912
+ // overlap is a transition) but not past its far one.
913
+ dragCanvasItem(container, initial, { id: 'ov-a' }, { fromTime: 4, toTime: 6 })
914
+ await act(async () => { await Promise.resolve() })
915
+
916
+ expect(adapter.saveCalls.length).toBe(1)
917
+ const trimmed = adapter.saveCalls[0].project
918
+ expect(itemOf(trimmed, 'ov-a')!.end).toBeCloseTo(6, 5)
919
+
920
+ // ov-a fades 1 -> 0 across the overlap, in ITS OWN item-relative seconds.
921
+ const fadeOut = opacityTrackOf(trimmed, 'ov-a')!
922
+ expect(fadeOut.origin).toBe('crossfade')
923
+ expect(fadeOut.points.length).toBe(2)
924
+ expect(fadeOut.points[0]).toEqual({ t: 5, value: 1 })
925
+ expect(fadeOut.points[1].value).toBe(0)
926
+ expect(fadeOut.points[1].t).toBeCloseTo(6, 5)
927
+
928
+ // ov-b fades 0 -> 1 across the same span, measured from ITS start (t=5).
929
+ const fadeIn = opacityTrackOf(trimmed, 'ov-b')!
930
+ expect(fadeIn.origin).toBe('crossfade')
931
+ expect(fadeIn.points[0]).toEqual({ t: 0, value: 0 })
932
+ expect(fadeIn.points[1].value).toBe(1)
933
+ expect(fadeIn.points[1].t).toBeCloseTo(1, 5)
934
+
935
+ // ONE undo takes back the trim AND its derived fade together. The audio
936
+ // version of this bug recorded dozens of entries for a single drag; a
937
+ // second undo being needed here, or either curve surviving the first, is
938
+ // the regression.
939
+ await act(async () => {
940
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
941
+ })
942
+ expect(adapter.saveCalls.length).toBe(2)
943
+ const undone = adapter.saveCalls[1].project
944
+ expect(itemOf(undone, 'ov-a')!.end).toBe(4)
945
+ expect(opacityTrackOf(undone, 'ov-a')).toBeUndefined()
946
+ expect(opacityTrackOf(undone, 'ov-b')).toBeUndefined()
947
+ })
948
+
949
+ it('a ripple-delete that creates an overlay overlap commits a fade without a gesture', async () => {
950
+ onTestFinished(installCanvasHarness())
951
+ vi.useFakeTimers()
952
+ onTestFinished(() => { vi.useRealTimers() })
953
+
954
+ const adapter = makeFakeAdapter()
955
+ // Shift+Delete on clip-A (0-2s) shifts everything starting at or after 2s
956
+ // left by 2s — across EVERY track. ov-b travels 4->2 while ov-a stays at
957
+ // 0-4, so the two overlap by 2s without any pointer ever touching them.
958
+ const initial = makeVideoProject({
959
+ tracks: [
960
+ {
961
+ id: 'trk-0',
962
+ items: [
963
+ { id: 'clip-A', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
964
+ { id: 'clip-B', type: 'video', src: 'a.mp4', start: 2, end: 6, inPoint: 2, outPoint: 6 },
965
+ ],
966
+ },
967
+ {
968
+ id: 'trk-1',
969
+ items: [
970
+ { id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
971
+ { id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 4, end: 8 },
972
+ ],
973
+ },
974
+ ],
975
+ })
976
+ const { container } = render(
977
+ <VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
978
+ )
979
+ await act(async () => { await Promise.resolve() })
980
+
981
+ selectCanvasItem(container, initial, { id: 'clip-A' })
982
+ await act(async () => {
983
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
984
+ })
985
+
986
+ // `handleRippleDelete` goes straight to `sync.mutate` — it never passes
987
+ // through `commitTimelineEdit`, so its own commit carries no fade. That is
988
+ // precisely the hole the debounced pass fills.
989
+ expect(adapter.saveCalls.length).toBe(1)
990
+ const rippled = adapter.saveCalls[0].project
991
+ expect(itemOf(rippled, 'ov-b')!.start).toBe(2)
992
+ expect(opacityTrackOf(rippled, 'ov-a')).toBeUndefined()
993
+ expect(opacityTrackOf(rippled, 'ov-b')).toBeUndefined()
994
+
995
+ await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS) })
996
+
997
+ expect(adapter.saveCalls.length).toBe(2)
998
+ const faded = adapter.saveCalls[1].project
999
+ expect(opacityTrackOf(faded, 'ov-a')!.points).toEqual([
1000
+ { t: 2, value: 1 }, { t: 4, value: 0 },
1001
+ ])
1002
+ expect(opacityTrackOf(faded, 'ov-b')!.points).toEqual([
1003
+ { t: 0, value: 0 }, { t: 2, value: 1 },
1004
+ ])
1005
+
1006
+ // Idempotent: the pass re-runs off its own commit and must find nothing,
1007
+ // or it would commit forever.
1008
+ await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS * 4) })
1009
+ expect(adapter.saveCalls.length).toBe(2)
1010
+ })
842
1011
  })
@@ -22,7 +22,7 @@
22
22
  * highlight-box.jsx, outline.jsx
23
23
  * (all under montaj_assets/render/templates/captions/).
24
24
  */
25
- import type { Captions } from '../schema'
25
+ import type { Captions, CaptionTextTransform } from '../schema'
26
26
 
27
27
  type Style = Captions['style']
28
28
 
@@ -95,6 +95,6 @@ export const CAPTION_STYLE_TEXT_ALIGN: Record<Style, string> = {
95
95
  * `renderSegment` call site); the no-timestamps fallback branch renders the
96
96
  * segment text as-is, never uppercased. Every other style has no default in
97
97
  * any branch. */
98
- export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, string>> = {
98
+ export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, CaptionTextTransform>> = {
99
99
  outline: 'uppercase',
100
100
  }
@@ -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
+ })
@@ -18,7 +18,7 @@ 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 { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, trackItems } from './timeline-model'
22
22
  import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
23
23
  import type { KeyframeSelection } from './canvas/pointer-machine'
24
24
  import { timeToX, useViewportStore, useViewportValue, xToTime, type ViewportStore } from './canvas/viewport'
@@ -657,6 +657,48 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
657
657
  // re-runs on real edits (see above for why the fades belong in the key).
658
658
  }, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}:${t.fadeIn ?? ''}:${t.fadeOut ?? ''}`).join('|')])
659
659
 
660
+ // The VISUAL sibling of the pass above: two OVERLAYS overlapping on the same
661
+ // track get complementary derived `opacity` curves (`computeVisualCrossfade`
662
+ // in timeline-model.ts, so the decision logic is shared and testable away
663
+ // from React).
664
+ //
665
+ // A separate effect, deliberately NOT folded into the one above. That one's
666
+ // dependency digest is keyed on the audio FADES on purpose — the digest is
667
+ // what clears a pending timer instead of letting a stale one fire — and
668
+ // mixing a second, independently-changing key into it would re-arm the audio
669
+ // timer on every unrelated overlay edit, and the visual timer on every
670
+ // unrelated audio edit. Two passes, two digests, two timers.
671
+ //
672
+ // Same debounce, for the same reason: an overlay's span moves on every
673
+ // mousemove of a drag, and committing each one is the per-move-undo bug. A
674
+ // gesture's own commit (`commitTimelineEdit`) folds the visual fade into its
675
+ // one undo step, so mid-drag this timer is cleared and rescheduled every
676
+ // frame and never fires, and right after a gesture it no-ops because
677
+ // `computeVisualCrossfade` is idempotent. This pass is therefore only the
678
+ // catch-all for overlay timing that changes OUTSIDE a gesture — ripple-
679
+ // delete, gap-collapse — which reach `sync.mutate` directly and never pass
680
+ // through `commitTimelineEdit` at all. Commits only; a preview via
681
+ // `onProjectChange` would change the digest below and clear the timer before
682
+ // it fired, so a ripple-delete's fade would never get saved.
683
+ useEffect(() => {
684
+ if (!onOverlayEdit) return
685
+ const next = computeVisualCrossfade(project)
686
+ if (!next) return
687
+ const timer = setTimeout(() => onOverlayEdit(next), CROSSFADE_COMMIT_DELAY_MS)
688
+ return () => clearTimeout(timer)
689
+ // Keyed on every visual item's SPAN and its DERIVED opacity curve, for
690
+ // exactly the reason the audio digest above includes the fades: the
691
+ // gesture's own crossfade commit changes this digest, which re-runs the
692
+ // effect and clears the pending timer rather than letting a stale one fire.
693
+ // `origin` is what makes a curve derived, and this pass is its only writer,
694
+ // so a hand-authored curve (no `origin`) contributes nothing to the key —
695
+ // it can never be the reason this pass needs to run again.
696
+ }, [allTracks.map(items => items.map(i => {
697
+ const fade = i.keyframes?.find(k => k.prop === 'opacity')
698
+ const curve = fade?.origin ? fade.points.map(p => `${p.t},${p.value}`).join(';') : ''
699
+ return `${i.id}:${i.start}:${i.end}:${curve}`
700
+ }).join('|')).join('||')])
701
+
660
702
  // The tabIndex={0} root below — Delete/Enter's guards below check focus is
661
703
  // inside it before firing (see the useKeymap block), restoring the pre-T9
662
704
  // scoping those two bindings had (arrows/Escape were already document-level