@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.
- package/package.json +2 -2
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +364 -3
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +331 -19
- package/src/index.ts +4 -0
- package/src/schema.ts +20 -1
- package/src/video/CaptionSpecimen.tsx +2 -1
- package/src/video/VideoEditor.tsx +28 -8
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/timeline/Timeline.tsx +43 -1
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +116 -0
- package/src/video/timeline/canvas/pointer-machine.ts +59 -1
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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
|