@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook } from '@testing-library/react'
|
|
3
|
+
import { containsTime, resolveAt, sourceWindow } from '@bycrux/timeline-core'
|
|
4
|
+
import { useVideoPlayback } from '../useVideoPlayback'
|
|
5
|
+
import { engineSrcFor } from '../../../engine/scheduler'
|
|
6
|
+
import { withItemTracks } from '../../timeline/timeline-model'
|
|
7
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
8
|
+
|
|
9
|
+
// The fixture is imported by RELATIVE path, not as `@bycrux/timeline-core/fixtures/…`:
|
|
10
|
+
// the package's `exports` map declares only the `"."` entry, so subpath imports are
|
|
11
|
+
// not part of its public API and T6 does not get to widen it. Everything else in this
|
|
12
|
+
// file goes through the package's real barrel.
|
|
13
|
+
import negativeStart from '../../../../../timeline-core/fixtures/negative-start.json'
|
|
14
|
+
// SP3: T4's proxySrc-tier fixture — four tracks[0] clips spanning every
|
|
15
|
+
// combination of {proxySrc, nobg_preview_src} presence (proxy-00 neither,
|
|
16
|
+
// proxy-01 proxySrc only, proxy-10 nobg_preview_src only, proxy-11 both, where
|
|
17
|
+
// nobg wins). Its resolver golden lives at
|
|
18
|
+
// timeline-core/expected/proxy-matrix.json and is exercised end-to-end by
|
|
19
|
+
// timeline-core's own corpus.test.mjs; what's checked HERE is the same parity
|
|
20
|
+
// contract as negative-start above — that the editor hook's derived `clips`
|
|
21
|
+
// collection agrees with `resolveAt`/`sourceWindow` — now covering the new tier.
|
|
22
|
+
import proxyMatrix from '../../../../../timeline-core/fixtures/proxy-matrix.json'
|
|
23
|
+
import proxyMatrixExpected from '../../../../../timeline-core/expected/proxy-matrix.json'
|
|
24
|
+
|
|
25
|
+
// ── Corpus parity: useVideoPlayback's derived collections vs. the resolver ────
|
|
26
|
+
//
|
|
27
|
+
// `useVideoPlayback` does not expose a Scene. It exposes the editor's own
|
|
28
|
+
// PARTITION of the timeline — `clips` (tracks[0] videos, sorted by start),
|
|
29
|
+
// `tracks0NonVideo` (everything else on tracks[0]) and `overlayTracks`
|
|
30
|
+
// (tracks[1..]) — and each consumer applies the activation predicate itself.
|
|
31
|
+
// This test reassembles those three collections back into a flat, ordered scene
|
|
32
|
+
// and asserts it is exactly what `resolveAt(project, t, {variant: 'preview'})`
|
|
33
|
+
// reports: same items, same track indices, same kinds, same source windows.
|
|
34
|
+
//
|
|
35
|
+
// `negative-start.json` is the corpus's "background reel anchored before the
|
|
36
|
+
// origin" case: a tracks[0] video over [0, 6) with a tracks[1] JSX overlay over
|
|
37
|
+
// [-0.5, 5.5). It exercises the partition (a clip AND an overlay track), the
|
|
38
|
+
// half-open predicate at both ends, and a negative `start` — while keeping ONE
|
|
39
|
+
// item per track, so the same-trackIdx tie-order divergence (D7 in
|
|
40
|
+
// KNOWN-DIVERGENCES.md) can't muddy the comparison.
|
|
41
|
+
const project = negativeStart as unknown as EditorProject
|
|
42
|
+
|
|
43
|
+
// -0.25 → overlay only (before the clip starts, after the overlay does)
|
|
44
|
+
// 2.00 → both on screen
|
|
45
|
+
// 5.75 → clip only (the overlay ended at 5.5; the clip runs to 6)
|
|
46
|
+
const TIMESTAMPS = [-0.25, 2.0, 5.75]
|
|
47
|
+
|
|
48
|
+
interface FlatItem {
|
|
49
|
+
id: string
|
|
50
|
+
trackIdx: number
|
|
51
|
+
kind: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reassemble the editor's three derived collections into the resolver's flat,
|
|
56
|
+
* back-to-front scene: track 0 first, then each overlay track in order. Track
|
|
57
|
+
* indices are absolute (overlayTracks[0] is project track 1) because that is
|
|
58
|
+
* what `ResolvedItem.trackIdx` reports.
|
|
59
|
+
*/
|
|
60
|
+
function editorSceneAt(
|
|
61
|
+
derived: {
|
|
62
|
+
clips: VisualItem[]
|
|
63
|
+
tracks0NonVideo: VisualItem[]
|
|
64
|
+
overlayTracks: VisualItem[][]
|
|
65
|
+
},
|
|
66
|
+
t: number,
|
|
67
|
+
): FlatItem[] {
|
|
68
|
+
const flat: FlatItem[] = []
|
|
69
|
+
const push = (items: VisualItem[], trackIdx: number) => {
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
if (containsTime(item.start, item.end, t)) flat.push({ id: item.id, trackIdx, kind: item.type })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
push(derived.clips, 0)
|
|
75
|
+
push(derived.tracks0NonVideo, 0)
|
|
76
|
+
derived.overlayTracks.forEach((items, i) => push(items, i + 1))
|
|
77
|
+
return flat.sort((a, b) => a.trackIdx - b.trackIdx)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `proj` defaults to the negative-start fixture so every existing call site
|
|
81
|
+
// below is unchanged; the proxy-matrix block passes its own project explicitly.
|
|
82
|
+
function resolverSceneAt(t: number, proj: EditorProject = project): FlatItem[] {
|
|
83
|
+
return resolveAt(withItemTracks(proj), t, { variant: 'preview' }).items.map((r) => ({
|
|
84
|
+
id: r.item.id as string,
|
|
85
|
+
trackIdx: r.trackIdx,
|
|
86
|
+
kind: r.kind,
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Testing Library auto-cleans between tests, so the hook is mounted fresh per
|
|
91
|
+
// case at the timestamp under test rather than reused and re-rendered.
|
|
92
|
+
function derivedAt(t: number, proj: EditorProject = project) {
|
|
93
|
+
return renderHook(() => useVideoPlayback(proj, t, () => {}, (p) => p)).result.current
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('useVideoPlayback derived collections vs. resolveAt (negative-start corpus fixture)', () => {
|
|
97
|
+
it.each(TIMESTAMPS)('agrees on what is on screen at t=%s', (t) => {
|
|
98
|
+
const { clips, tracks0NonVideo, overlayTracks } = derivedAt(t)
|
|
99
|
+
expect(editorSceneAt({ clips, tracks0NonVideo, overlayTracks }, t)).toEqual(resolverSceneAt(t))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('splits the fixture the way the resolver labels it', () => {
|
|
103
|
+
const { clips, tracks0NonVideo, overlayTracks } = derivedAt(TIMESTAMPS[1])
|
|
104
|
+
expect(clips.map((c) => c.id)).toEqual(['mainClip'])
|
|
105
|
+
expect(tracks0NonVideo).toEqual([])
|
|
106
|
+
expect(overlayTracks.map((tr) => tr.map((i) => i.id))).toEqual([['bgReel']])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it.each(TIMESTAMPS)('seeks the active clip where the resolver says, at t=%s', (t) => {
|
|
110
|
+
const { clips } = derivedAt(t)
|
|
111
|
+
const scene = resolveAt(withItemTracks(project), t, { variant: 'preview' })
|
|
112
|
+
const videos = scene.items.filter((r) => r.kind === 'video')
|
|
113
|
+
// t=-0.25 is before the only clip starts — assert that, so a fixture change
|
|
114
|
+
// that silently empties this loop can't turn the case into a no-op.
|
|
115
|
+
expect(videos.length).toBe(t < 0 ? 0 : 1)
|
|
116
|
+
for (const resolved of videos) {
|
|
117
|
+
const clip = clips.find((c) => c.id === resolved.item.id)!
|
|
118
|
+
// What the hook assigns to `video.currentTime` for this clip at this
|
|
119
|
+
// instant (useVideoPlayback's scrub branch), against ResolvedItem.seek.
|
|
120
|
+
const inPoint = sourceWindow(clip, 'preview').inPoint
|
|
121
|
+
expect(Math.max(inPoint, inPoint + (t - clip.start))).toBeCloseTo(resolved.seek, 10)
|
|
122
|
+
expect(resolved.window).toEqual(sourceWindow(clip, 'preview'))
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('reports an empty scene past the end of every item (no last-clip fallback)', () => {
|
|
127
|
+
// The resolver has no fallback; PreviewPlayer's `?? clips[clips.length - 1]`
|
|
128
|
+
// is editor-side presentation and lives there, not here.
|
|
129
|
+
expect(resolverSceneAt(6.5)).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ── Corpus parity: same contract, proxy-matrix fixture (SP3) ──────────────────
|
|
134
|
+
//
|
|
135
|
+
// proxy-matrix.json is four tracks[0] clips, one per combination of
|
|
136
|
+
// {proxySrc, nobg_preview_src}: proxy-00 has neither (falls through to
|
|
137
|
+
// normalizedSrc), proxy-01 has proxySrc only, proxy-10 has nobg_preview_src
|
|
138
|
+
// only, proxy-11 has both (nobg wins — see chooseSrcRaw in
|
|
139
|
+
// timeline-core/src/source-window.js). No overlay tracks, so this fixture
|
|
140
|
+
// complements negative-start rather than replacing it: it exercises the new
|
|
141
|
+
// tier through the SAME `clips` → `sourceWindow` path production code uses,
|
|
142
|
+
// while negative-start keeps covering the partition/overlay-track side.
|
|
143
|
+
const proxyMatrixProject = proxyMatrix as unknown as EditorProject
|
|
144
|
+
|
|
145
|
+
interface ProxyMatrixGolden {
|
|
146
|
+
sourceWindow: Record<string, { preview: unknown; render: unknown }>
|
|
147
|
+
}
|
|
148
|
+
const proxyMatrixGolden = proxyMatrixExpected as unknown as ProxyMatrixGolden
|
|
149
|
+
|
|
150
|
+
// One timestamp per clip (each clip spans 2s: [0,2), [2,4), [4,6), [6,8)),
|
|
151
|
+
// matching timeline-core/expected/proxy-matrix.json's resolveAt.preview.
|
|
152
|
+
const TIMESTAMPS_PROXY = [0, 2, 4, 6]
|
|
153
|
+
|
|
154
|
+
describe('useVideoPlayback derived collections vs. resolveAt (proxy-matrix corpus fixture)', () => {
|
|
155
|
+
it.each(TIMESTAMPS_PROXY)('agrees on what is on screen at t=%s', (t) => {
|
|
156
|
+
const { clips, tracks0NonVideo, overlayTracks } = derivedAt(t, proxyMatrixProject)
|
|
157
|
+
expect(editorSceneAt({ clips, tracks0NonVideo, overlayTracks }, t)).toEqual(resolverSceneAt(t, proxyMatrixProject))
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('splits the fixture the way the resolver labels it (four clips, no overlay tracks)', () => {
|
|
161
|
+
const { clips, tracks0NonVideo, overlayTracks } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
|
|
162
|
+
expect(clips.map((c) => c.id)).toEqual(['proxy-00', 'proxy-01', 'proxy-10', 'proxy-11'])
|
|
163
|
+
expect(tracks0NonVideo).toEqual([])
|
|
164
|
+
expect(overlayTracks).toEqual([])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it.each(TIMESTAMPS_PROXY)('seeks the active clip where the resolver says, at t=%s', (t) => {
|
|
168
|
+
const { clips } = derivedAt(t, proxyMatrixProject)
|
|
169
|
+
const scene = resolveAt(withItemTracks(proxyMatrixProject), t, { variant: 'preview' })
|
|
170
|
+
const videos = scene.items.filter((r) => r.kind === 'video')
|
|
171
|
+
// Every fixture timestamp lands inside exactly one clip's [start, end) —
|
|
172
|
+
// assert that so a fixture edit that empties this loop can't go unnoticed.
|
|
173
|
+
expect(videos.length).toBe(1)
|
|
174
|
+
for (const resolved of videos) {
|
|
175
|
+
const clip = clips.find((c) => c.id === resolved.item.id)!
|
|
176
|
+
const inPoint = sourceWindow(clip, 'preview').inPoint
|
|
177
|
+
expect(Math.max(inPoint, inPoint + (t - clip.start))).toBeCloseTo(resolved.seek, 10)
|
|
178
|
+
expect(resolved.window).toEqual(sourceWindow(clip, 'preview'))
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('resolves the proxySrc/nobg_preview_src tier per clip the same way the resolver golden does', () => {
|
|
183
|
+
// Direct tier-selection check, independent of any timestamp: each clip's
|
|
184
|
+
// `sourceWindow(clip, 'preview').src` — reached through `clips`, the same
|
|
185
|
+
// collection `playbackSrcFor` consumes in useVideoPlayback.ts — must match
|
|
186
|
+
// the committed golden. This is what actually pins proxy-01 to its
|
|
187
|
+
// proxySrc and proxy-11 to nobg_preview_src (proxy over it) rather than
|
|
188
|
+
// just "some src or other".
|
|
189
|
+
const { clips } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
|
|
190
|
+
expect(clips).toHaveLength(4)
|
|
191
|
+
for (const clip of clips) {
|
|
192
|
+
expect(sourceWindow(clip, 'preview')).toEqual(proxyMatrixGolden.sourceWindow[clip.id].preview)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ── SP4 T6: the ENGINE path reads the same window ──────────────────────────
|
|
197
|
+
//
|
|
198
|
+
// The engine cannot open every src the preview chain can — the masters are
|
|
199
|
+
// 4K 10-bit HEVC and `nobg_preview_src` is VP9 WebM, while its demuxer is
|
|
200
|
+
// MP4-only and its decoder is configured for AV1 — so `engineSrcFor` narrows
|
|
201
|
+
// the chain's answer down to "the proxy, or nothing". What must NOT differ is
|
|
202
|
+
// the INPUT: both paths ask `sourceWindow(clip, 'preview')`, one shared
|
|
203
|
+
// resolver call, never a second copy of the precedence chain. This asserts
|
|
204
|
+
// that at the pure level, per clip, over the same corpus fixture:
|
|
205
|
+
//
|
|
206
|
+
// • whenever the engine accepts a clip, the src it accepts is bit-identical
|
|
207
|
+
// to the src the legacy player would load (the golden's `.src`);
|
|
208
|
+
// • whenever it declines, it declines because the SHARED window chose
|
|
209
|
+
// something other than the proxy — never because it disagreed about what
|
|
210
|
+
// the window is.
|
|
211
|
+
//
|
|
212
|
+
// proxy-11 is the case that matters: `nobg_preview_src` outranks the proxy in
|
|
213
|
+
// the chain, so the legacy player loads the alpha preview and the engine must
|
|
214
|
+
// decline rather than quietly decode the proxy (which still has the
|
|
215
|
+
// background the user just removed in it).
|
|
216
|
+
it('selects the engine-path src from the same sourceWindow results as the legacy path', () => {
|
|
217
|
+
const { clips } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
|
|
218
|
+
const verdicts: Record<string, { src: string; blocked?: string }> = {}
|
|
219
|
+
for (const clip of clips) {
|
|
220
|
+
const golden = proxyMatrixGolden.sourceWindow[clip.id].preview as { src: string }
|
|
221
|
+
const window = sourceWindow(clip, 'preview')
|
|
222
|
+
// Same input as the legacy path, and as the committed golden.
|
|
223
|
+
expect(window.src).toBe(golden.src)
|
|
224
|
+
const verdict = engineSrcFor(clip, window)
|
|
225
|
+
// Accepted ⇒ identical to what legacy would load. Declined ⇒ empty.
|
|
226
|
+
expect(verdict.src === '' || verdict.src === golden.src).toBe(true)
|
|
227
|
+
verdicts[clip.id] = verdict
|
|
228
|
+
}
|
|
229
|
+
expect(verdicts).toEqual({
|
|
230
|
+
'proxy-00': { src: '', blocked: 'no editing proxy yet' },
|
|
231
|
+
'proxy-01': { src: (proxyMatrixGolden.sourceWindow['proxy-01'].preview as { src: string }).src },
|
|
232
|
+
'proxy-10': { src: '', blocked: 'no editing proxy yet' },
|
|
233
|
+
'proxy-11': { src: '', blocked: 'a higher-precedence preview source the engine cannot decode' },
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// ── Reload effect: the clip-identity memo key must include proxySrc ───────────
|
|
239
|
+
//
|
|
240
|
+
// useVideoPlayback.ts:522-536 tracks clip identity (nobg_preview_src|proxySrc|
|
|
241
|
+
// src|inPoint|outPoint per clip) in a ref, and only reloads the active <video>
|
|
242
|
+
// element when that string changes — otherwise overlay-only edits would tear
|
|
243
|
+
// down and restart playback on every keystroke. SP3's proxySrc arrives via SSE
|
|
244
|
+
// mid-session: the proxy step finishes AFTER the project is already loaded and
|
|
245
|
+
// playing, so the field appears on a clip whose src the <video> element already
|
|
246
|
+
// has loaded. Without proxySrc in the identity string, that arrival is
|
|
247
|
+
// invisible to the effect and the preview never picks up the proxy.
|
|
248
|
+
//
|
|
249
|
+
// Fixtures can't exercise this — it's about the SAME logical clip gaining a
|
|
250
|
+
// field BETWEEN renders — so this uses renderHook's own rerender with two
|
|
251
|
+
// hand-built projects instead of a corpus fixture. And because this file is
|
|
252
|
+
// `.ts`, not `.tsx`, there's no JSX to mount a real `<video ref={...}>`
|
|
253
|
+
// through the render tree; the ref is attached directly to a detached
|
|
254
|
+
// `<video>` element after the first render instead. That's a legitimate way to
|
|
255
|
+
// drive this specific effect: it only ever reads `video0Ref.current` /
|
|
256
|
+
// `video1Ref.current`, never the DOM position of the element.
|
|
257
|
+
describe('useVideoPlayback reload effect — proxySrc identity (SP3 mid-session arrival)', () => {
|
|
258
|
+
// jsdom doesn't implement HTMLMediaElement.pause — the identity effect calls
|
|
259
|
+
// it on the inactive slot as part of a load. Stub it so the test exercises
|
|
260
|
+
// the effect without jsdom's "not implemented" console noise (same pattern
|
|
261
|
+
// as captionPositioning.test.tsx / VideoEditor.test.tsx, minus `play` and
|
|
262
|
+
// `AudioContext`: this test never calls play() or wires audio).
|
|
263
|
+
const pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
|
264
|
+
afterEach(() => { pauseSpy.mockClear() })
|
|
265
|
+
|
|
266
|
+
const baseClip = { id: 'c1', type: 'video' as const, start: 0, end: 10, inPoint: 0, outPoint: 10, src: '/corpus/orig.mp4' }
|
|
267
|
+
const projectWith = (clip: VisualItem): EditorProject => ({
|
|
268
|
+
id: 'reload-test',
|
|
269
|
+
status: 'draft',
|
|
270
|
+
settings: { resolution: [1080, 1920] },
|
|
271
|
+
tracks: [{ id: 'trk-0', items: [clip] }],
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('reloads only when proxySrc actually changes, not on every re-render', () => {
|
|
275
|
+
const { result, rerender } = renderHook(
|
|
276
|
+
({ project }: { project: EditorProject }) => useVideoPlayback(project, 0, () => {}, (p) => p),
|
|
277
|
+
{ initialProps: { project: projectWith({ ...baseClip }) } },
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const active = document.createElement('video')
|
|
281
|
+
const inactive = document.createElement('video')
|
|
282
|
+
result.current.video0Ref.current = active
|
|
283
|
+
result.current.video1Ref.current = inactive
|
|
284
|
+
|
|
285
|
+
// Step 1 — warm-up load: the initial mount ran with video0Ref still null
|
|
286
|
+
// (attached above, after render), so its effect early-returned without
|
|
287
|
+
// recording an identity. This rerender is the first one the effect can
|
|
288
|
+
// actually act on; it must load the clip's src into the active slot.
|
|
289
|
+
inactive.setAttribute('src', 'sentinel')
|
|
290
|
+
rerender({ project: projectWith({ ...baseClip }) })
|
|
291
|
+
expect(active.src).toContain('/corpus/orig.mp4')
|
|
292
|
+
expect(inactive.hasAttribute('src')).toBe(false) // cleared as part of the load
|
|
293
|
+
|
|
294
|
+
// Step 2 — control: a brand-new project/clip object with IDENTICAL fields
|
|
295
|
+
// (same src, same in/outPoint, still no proxySrc) must produce the SAME
|
|
296
|
+
// identity string and skip the reload entirely. Re-arm the sentinel and
|
|
297
|
+
// confirm it survives untouched — proving the effect body did not run.
|
|
298
|
+
inactive.setAttribute('src', 'sentinel')
|
|
299
|
+
rerender({ project: projectWith({ ...baseClip }) })
|
|
300
|
+
expect(inactive.getAttribute('src')).toBe('sentinel')
|
|
301
|
+
|
|
302
|
+
// Step 3 — the fix under test: proxySrc appears on the SAME clip (id,
|
|
303
|
+
// src, in/outPoint all unchanged) — exactly what an SSE proxy-ready event
|
|
304
|
+
// looks like against an already-loaded project. This must be recognized
|
|
305
|
+
// as an identity change: the sentinel gets cleared again (the effect body
|
|
306
|
+
// ran) and the active slot reloads onto the proxy path. Without proxySrc
|
|
307
|
+
// in the identity string this step is indistinguishable from step 2 — the
|
|
308
|
+
// sentinel would survive and `active.src` would stay on the original file.
|
|
309
|
+
rerender({ project: projectWith({ ...baseClip, proxySrc: '/corpus/proxy.mp4' }) })
|
|
310
|
+
expect(inactive.hasAttribute('src')).toBe(false)
|
|
311
|
+
expect(active.src).toContain('/corpus/proxy.mp4')
|
|
312
|
+
})
|
|
313
|
+
})
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sourceWindow } from '@bycrux/timeline-core'
|
|
2
3
|
import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
|
|
3
4
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// `effectiveInPoint` / `effectiveOutPoint` are thin wrappers over
|
|
6
|
+
// @bycrux/timeline-core's `sourceWindow(clip, 'preview')` — the single
|
|
7
|
+
// implementation shared with render's collectAllItems (render.js). When the
|
|
8
|
+
// preview loads a normalizedSrc window cache (which plays from its own 0 and
|
|
9
|
+
// covers [normalizedInPoint, +duration] of the original), the points are
|
|
10
|
+
// rebased by the cache origin. nobg_preview_src is the full source and must NOT
|
|
11
|
+
// rebase.
|
|
12
|
+
//
|
|
13
|
+
// The cases below are unchanged from before the timeline-core rebase and are
|
|
14
|
+
// kept here as the EDITOR's own guard: they pin the numbers the preview seeks
|
|
15
|
+
// to, whatever the wrappers delegate to. The equivalence block at the bottom
|
|
16
|
+
// pins the delegation itself. timeline-core/test/source-window.test.mjs owns the
|
|
17
|
+
// exhaustive matrix (both variants, degenerate items, bad-variant throws).
|
|
9
18
|
|
|
10
19
|
describe('effectiveInPoint', () => {
|
|
11
20
|
it('rebases to 0 when normalizedSrc is the chosen src (legacy: no normalizedInPoint)', () => {
|
|
@@ -42,6 +51,10 @@ describe('effectiveInPoint', () => {
|
|
|
42
51
|
effectiveInPoint({ inPoint: 6, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' }),
|
|
43
52
|
).toBeCloseTo(1, 5)
|
|
44
53
|
})
|
|
54
|
+
|
|
55
|
+
it('does NOT rebase when a full-source proxy outranks the window cache (SP3)', () => {
|
|
56
|
+
expect(effectiveInPoint({ inPoint: 6, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4', proxySrc: '/p.mp4' })).toBe(6)
|
|
57
|
+
})
|
|
45
58
|
})
|
|
46
59
|
|
|
47
60
|
describe('effectiveOutPoint', () => {
|
|
@@ -81,3 +94,23 @@ describe('effectiveOutPoint', () => {
|
|
|
81
94
|
).toBeCloseTo(15, 5)
|
|
82
95
|
})
|
|
83
96
|
})
|
|
97
|
+
|
|
98
|
+
// The wrappers must stay wrappers: if someone reintroduces a local copy of the
|
|
99
|
+
// rebase math here, this block fails the moment the two implementations drift.
|
|
100
|
+
describe('delegation to @bycrux/timeline-core', () => {
|
|
101
|
+
const CLIPS = [
|
|
102
|
+
{ inPoint: 496.92, src: '/orig.mov' },
|
|
103
|
+
{ inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' },
|
|
104
|
+
{ inPoint: 496.92, outPoint: 514.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/c.mp4' },
|
|
105
|
+
{ inPoint: 0.9157, outPoint: 16.97, normalizedInPoint: 0, normalizedSrc: '/cache/window.mp4' },
|
|
106
|
+
{ inPoint: 6, outPoint: 20, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' },
|
|
107
|
+
{ src: '/orig.mov' },
|
|
108
|
+
{},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
it.each(CLIPS)('matches sourceWindow(clip, "preview") for %j', (clip) => {
|
|
112
|
+
const w = sourceWindow(clip, 'preview')
|
|
113
|
+
expect(effectiveInPoint(clip)).toBe(w.inPoint)
|
|
114
|
+
expect(effectiveOutPoint(clip)).toBe(w.outPoint)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track-level volume/mute in the legacy <video> preview.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, deliberately:
|
|
5
|
+
*
|
|
6
|
+
* 1. `clipGain` — the fold itself, at the only place this hook turns audio
|
|
7
|
+
* settings into a number.
|
|
8
|
+
* 2. The hook, driven through its real effects and handlers, asserting that
|
|
9
|
+
* each of the four gain-setting sites actually routes through it. A pure
|
|
10
|
+
* helper that nothing calls would pass layer 1 and leave the preview as
|
|
11
|
+
* loud as it ever was.
|
|
12
|
+
*
|
|
13
|
+
* The GainNode is faked by pre-seeding `__montajGain` on the <video> elements:
|
|
14
|
+
* production caches the node there (`ensureVideoGain`) precisely so it survives
|
|
15
|
+
* remounts, and `getVideoGain` reads it without touching an AudioContext — so a
|
|
16
|
+
* seeded node is the wiring, not a stub around it. jsdom has no Web Audio at
|
|
17
|
+
* all, which is why the real one can't be used here.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
20
|
+
import { act, renderHook } from '@testing-library/react'
|
|
21
|
+
import { clipGain, useVideoPlayback } from '../useVideoPlayback'
|
|
22
|
+
import type { EditorProject, VisualItem, VisualTrack } from '../../../schema'
|
|
23
|
+
|
|
24
|
+
// ── The fold ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('clipGain', () => {
|
|
27
|
+
it('multiplies the track fader into the clip volume instead of replacing it', () => {
|
|
28
|
+
// Replacing would silently throw away per-clip work an editor already did.
|
|
29
|
+
expect(clipGain({ volume: 0.5 }, { volume: 0.8 })).toBeCloseTo(0.4, 10)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps the RATIO between two differently-set clips on the same track', () => {
|
|
33
|
+
// The property that makes "multiply" the right rule: pulling the track down
|
|
34
|
+
// must move every clip on it by the same factor, not flatten them onto one
|
|
35
|
+
// level. Two clips a 2:1 mix apart stay 2:1 apart.
|
|
36
|
+
const track: Pick<VisualTrack, 'volume'> = { volume: 0.5 }
|
|
37
|
+
const loud = clipGain(track, { volume: 0.8 })
|
|
38
|
+
const quiet = clipGain(track, { volume: 0.4 })
|
|
39
|
+
expect(loud / quiet).toBeCloseTo(2, 10)
|
|
40
|
+
expect(loud).toBeCloseTo(0.4, 10)
|
|
41
|
+
expect(quiet).toBeCloseTo(0.2, 10)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('silences a clip on a muted track whatever its own volume says', () => {
|
|
45
|
+
expect(clipGain({ muted: true }, { volume: 2 })).toBe(0)
|
|
46
|
+
expect(clipGain({ muted: true, volume: 1 }, { volume: 1 })).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps a muted clip muted on an unmuted track', () => {
|
|
50
|
+
// Mute is either/or — the track cannot un-mute a clip.
|
|
51
|
+
expect(clipGain({ volume: 1 }, { muted: true, volume: 1 })).toBe(0)
|
|
52
|
+
expect(clipGain(undefined, { muted: true })).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves the clip untouched when the track carries no settings', () => {
|
|
56
|
+
// The overwhelmingly common case: nothing writes track volume/mute by
|
|
57
|
+
// default, so every project that predates the feature must sound identical.
|
|
58
|
+
expect(clipGain({}, { volume: 0.8 })).toBeCloseTo(0.8, 10)
|
|
59
|
+
expect(clipGain({}, {})).toBe(1)
|
|
60
|
+
expect(clipGain(undefined, { volume: 0.8 })).toBeCloseTo(0.8, 10)
|
|
61
|
+
expect(clipGain(undefined, {})).toBe(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('carries amplification through both halves (gain above 1.0 is legal)', () => {
|
|
65
|
+
// GainNode, not video.volume — values > 1 amplify, and the fold must not
|
|
66
|
+
// clamp them on the way through.
|
|
67
|
+
expect(clipGain({ volume: 2 }, { volume: 1.5 })).toBeCloseTo(3, 10)
|
|
68
|
+
expect(clipGain({ volume: 0.5 }, { volume: 2 })).toBeCloseTo(1, 10)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ── The four gain-setting sites ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
interface FakeGain {
|
|
75
|
+
gain: { value: number }
|
|
76
|
+
/**
|
|
77
|
+
* Every value assigned to `gain.value`, in order.
|
|
78
|
+
*
|
|
79
|
+
* The transition sites (a cut, the gap clock) hand the incoming clip to the
|
|
80
|
+
* OTHER slot and then make that slot active, which re-runs the load-time
|
|
81
|
+
* effect over the same node. Asserting the final value alone would therefore
|
|
82
|
+
* pass even if the transition site itself wrote an unfolded number and the
|
|
83
|
+
* effect quietly corrected it a tick later. Asserting on every write pins the
|
|
84
|
+
* site under test.
|
|
85
|
+
*/
|
|
86
|
+
writes: number[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every distinct value written to a node, rounded past float noise. */
|
|
90
|
+
function distinct(node: FakeGain): number[] {
|
|
91
|
+
return [...new Set(node.writes.map((v) => Math.round(v * 1e6) / 1e6))]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A <video> whose `currentTime` is a real read/write property (jsdom's is not). */
|
|
95
|
+
function fakeVideo(): HTMLVideoElement {
|
|
96
|
+
const el = document.createElement('video')
|
|
97
|
+
let t = 0
|
|
98
|
+
Object.defineProperty(el, 'currentTime', {
|
|
99
|
+
get: () => t,
|
|
100
|
+
set: (v: number) => { t = v },
|
|
101
|
+
configurable: true,
|
|
102
|
+
})
|
|
103
|
+
return el
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Seed the cached GainNode the hook would otherwise build on first play. */
|
|
107
|
+
function attachGain(el: HTMLVideoElement): FakeGain {
|
|
108
|
+
const writes: number[] = []
|
|
109
|
+
let value = Number.NaN
|
|
110
|
+
const node: FakeGain = {
|
|
111
|
+
gain: {
|
|
112
|
+
get value() { return value },
|
|
113
|
+
set value(next: number) { value = next; writes.push(next) },
|
|
114
|
+
},
|
|
115
|
+
writes,
|
|
116
|
+
}
|
|
117
|
+
;(el as unknown as { __montajGain: FakeGain }).__montajGain = node
|
|
118
|
+
return node
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const clip = (over: Partial<VisualItem>): VisualItem =>
|
|
122
|
+
({ id: 'a', type: 'video', src: '/a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, ...over }) as VisualItem
|
|
123
|
+
|
|
124
|
+
function projectWith(items: VisualItem[], track: Partial<VisualTrack> = {}): EditorProject {
|
|
125
|
+
return {
|
|
126
|
+
id: 'track-audio',
|
|
127
|
+
status: 'draft',
|
|
128
|
+
settings: { resolution: [1080, 1920] },
|
|
129
|
+
tracks: [{ id: 'trk-0', items, ...track }],
|
|
130
|
+
} as EditorProject
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Mount the hook, hand it two wired <video> slots, and let the load effect run.
|
|
135
|
+
*
|
|
136
|
+
* The mount render sees both refs still null (they are assigned after it), so
|
|
137
|
+
* nothing has touched a slot yet; the rerender with a fresh project object is
|
|
138
|
+
* the first pass whose effects can load the active slot and set its gain.
|
|
139
|
+
*/
|
|
140
|
+
function mount(project: EditorProject) {
|
|
141
|
+
const harness = renderHook(
|
|
142
|
+
({ p }: { p: EditorProject }) => useVideoPlayback(p, 0, () => {}, (path) => path),
|
|
143
|
+
{ initialProps: { p: project } },
|
|
144
|
+
)
|
|
145
|
+
const v0 = fakeVideo()
|
|
146
|
+
const v1 = fakeVideo()
|
|
147
|
+
const g0 = attachGain(v0)
|
|
148
|
+
const g1 = attachGain(v1)
|
|
149
|
+
harness.result.current.video0Ref.current = v0
|
|
150
|
+
harness.result.current.video1Ref.current = v1
|
|
151
|
+
harness.rerender({ p: { ...project } })
|
|
152
|
+
return { ...harness, v0, v1, g0, g1 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe('useVideoPlayback — the track fold reaches the GainNodes', () => {
|
|
156
|
+
// jsdom implements neither play nor pause; the load, boundary and gap paths
|
|
157
|
+
// all call them. Same stubbing pattern as useVideoPlayback.corpus.test.ts.
|
|
158
|
+
let playSpy: ReturnType<typeof vi.spyOn>
|
|
159
|
+
let pauseSpy: ReturnType<typeof vi.spyOn>
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
|
|
162
|
+
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
|
163
|
+
})
|
|
164
|
+
afterEach(() => {
|
|
165
|
+
playSpy.mockRestore()
|
|
166
|
+
pauseSpy.mockRestore()
|
|
167
|
+
vi.restoreAllMocks()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('folds the track fader into the active clip on load', () => {
|
|
171
|
+
const { g0 } = mount(projectWith([clip({ volume: 0.8 })], { volume: 0.5 }))
|
|
172
|
+
expect(g0.gain.value).toBeCloseTo(0.4, 10)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('leaves the clip alone when the track carries no settings', () => {
|
|
176
|
+
const { g0 } = mount(projectWith([clip({ volume: 0.8 })]))
|
|
177
|
+
expect(g0.gain.value).toBeCloseTo(0.8, 10)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('silences the active clip when the TRACK is muted', () => {
|
|
181
|
+
const { g0 } = mount(projectWith([clip({ volume: 2 })], { muted: true }))
|
|
182
|
+
expect(g0.gain.value).toBe(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps a clip muted on an unmuted track muted', () => {
|
|
186
|
+
const { g0 } = mount(projectWith([clip({ muted: true, volume: 1 })], { volume: 1 }))
|
|
187
|
+
expect(g0.gain.value).toBe(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('re-applies the gain when only the TRACK volume changes', () => {
|
|
191
|
+
// Moving the track fader edits no clip, so this is the only thing that can
|
|
192
|
+
// make the change audible on a slot that is already loaded and playing.
|
|
193
|
+
const items = [clip({ volume: 0.8 })]
|
|
194
|
+
const { rerender, g0 } = mount(projectWith(items, { volume: 0.5 }))
|
|
195
|
+
expect(g0.gain.value).toBeCloseTo(0.4, 10)
|
|
196
|
+
|
|
197
|
+
// Same item objects, new track volume — the load effect's identity string
|
|
198
|
+
// is unchanged, so nothing reloads; only the gain moves.
|
|
199
|
+
act(() => { rerender({ p: projectWith(items, { volume: 0.25 }) }) })
|
|
200
|
+
expect(g0.gain.value).toBeCloseTo(0.2, 10)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('preserves the mix between two clips when the track is pulled down (preload site)', () => {
|
|
204
|
+
// Clip A is the active slot's gain; clip B's is set by the next-clip
|
|
205
|
+
// preload inside handleTimeUpdate. Both go through the fold, so the 2:1
|
|
206
|
+
// ratio the editor set survives the track fader.
|
|
207
|
+
const a = clip({ id: 'a', volume: 0.8, start: 0, end: 5 })
|
|
208
|
+
const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 5, end: 10 })
|
|
209
|
+
const { result, v0, g0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
|
|
210
|
+
|
|
211
|
+
v0.currentTime = 1 // mid-clip: preload runs, the boundary branch does not
|
|
212
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
213
|
+
|
|
214
|
+
expect(distinct(g0)).toEqual([0.4])
|
|
215
|
+
expect(distinct(g1)).toEqual([0.2])
|
|
216
|
+
expect(g0.gain.value / g1.gain.value).toBeCloseTo(2, 10)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('folds the track into the incoming clip at a contiguous cut', () => {
|
|
220
|
+
const a = clip({ id: 'a', volume: 0.8, start: 0, end: 5, outPoint: 5 })
|
|
221
|
+
const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 5, end: 10 })
|
|
222
|
+
const { result, v0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
|
|
223
|
+
|
|
224
|
+
// First pass preloads slot 1 (asserted above); forget those writes so what
|
|
225
|
+
// the switch site itself sets is what this case measures.
|
|
226
|
+
v0.currentTime = 1
|
|
227
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
228
|
+
g1.writes.length = 0
|
|
229
|
+
|
|
230
|
+
v0.currentTime = 5 // at outPoint, and b starts exactly where a ends
|
|
231
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
232
|
+
|
|
233
|
+
expect(distinct(g1)).toEqual([0.2])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('folds the track into the clip the gap clock lands on', () => {
|
|
237
|
+
const a = clip({ id: 'a', volume: 0.8, start: 0, end: 2, outPoint: 2 })
|
|
238
|
+
const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 3, end: 5 })
|
|
239
|
+
|
|
240
|
+
// Drive the gap rAF by hand: collect every scheduled callback, then jump
|
|
241
|
+
// the wall clock past the gap so the first tick lands on b's start. The
|
|
242
|
+
// gap branch also sets isPlaying, which starts the boundary-polling pump —
|
|
243
|
+
// hence a queue rather than a single slot, and `[0]`, which is the gap
|
|
244
|
+
// tick the handler scheduled synchronously.
|
|
245
|
+
const scheduled: FrameRequestCallback[] = []
|
|
246
|
+
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
|
247
|
+
scheduled.push(cb)
|
|
248
|
+
return scheduled.length
|
|
249
|
+
})
|
|
250
|
+
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
251
|
+
|
|
252
|
+
const { result, v0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
|
|
253
|
+
scheduled.length = 0
|
|
254
|
+
v0.currentTime = 2 // at a's outPoint, and b starts 1s later — a gap, not a cut
|
|
255
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
256
|
+
expect(scheduled.length).toBeGreaterThan(0)
|
|
257
|
+
|
|
258
|
+
g1.writes.length = 0 // forget what the preload wrote; the gap site is under test
|
|
259
|
+
nowSpy.mockReturnValue(10_000)
|
|
260
|
+
act(() => { scheduled[0](10_000) })
|
|
261
|
+
|
|
262
|
+
expect(distinct(g1)).toEqual([0.2])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('silences the incoming clip too when the track is muted', () => {
|
|
266
|
+
// The transition sites have their own gain assignment; a track mute that
|
|
267
|
+
// only held for the clip already on screen would leak audio at every cut.
|
|
268
|
+
const a = clip({ id: 'a', volume: 1, start: 0, end: 5, outPoint: 5 })
|
|
269
|
+
const b = clip({ id: 'b', src: '/b.mp4', volume: 1, start: 5, end: 10 })
|
|
270
|
+
const { result, v0, g0, g1 } = mount(projectWith([a, b], { muted: true }))
|
|
271
|
+
|
|
272
|
+
v0.currentTime = 1
|
|
273
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
274
|
+
|
|
275
|
+
expect(distinct(g0)).toEqual([0])
|
|
276
|
+
expect(distinct(g1)).toEqual([0])
|
|
277
|
+
})
|
|
278
|
+
})
|