@bycrux/editor 0.11.2 → 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,228 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Switch } from '../../ui/switch'
|
|
4
|
+
import SpeedControl from './SpeedControl'
|
|
5
|
+
import VolumeControl from './VolumeControl'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Track-wide settings, opened from the rail's icon button (see TrackGutter.tsx).
|
|
9
|
+
*
|
|
10
|
+
* Portaled to `document.body`, exactly like `Tooltip.tsx` and for the same
|
|
11
|
+
* reason: the rail sits inside the timeline's `overflow-x-auto` scroll
|
|
12
|
+
* container, so a popover positioned `absolute` inside its own row would be
|
|
13
|
+
* clipped the moment it grew past the row's own bounds. Positioning is a
|
|
14
|
+
* two-pass "render once off-screen, measure, place" — same shape Tooltip uses
|
|
15
|
+
* for its horizontal clamp, extended here to both axes since this panel is
|
|
16
|
+
* tall enough to run past the bottom of the viewport on a row near the end of
|
|
17
|
+
* a long timeline.
|
|
18
|
+
*
|
|
19
|
+
* Each section is independently optional and renders only when ITS OWN
|
|
20
|
+
* value+callback pair is given — the same "no dead control" rule the rail's
|
|
21
|
+
* skip toggle already follows (a control wired to nothing is worse than no
|
|
22
|
+
* control). In practice `Timeline.tsx` wires all of them together, but the
|
|
23
|
+
* component itself doesn't assume that.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Gap in px between the trigger's edge and the popover. */
|
|
27
|
+
const OFFSET_PX = 6
|
|
28
|
+
/** Keeps the popover from hanging off any edge of the window. */
|
|
29
|
+
const VIEWPORT_MARGIN_PX = 8
|
|
30
|
+
|
|
31
|
+
interface Position {
|
|
32
|
+
left: number
|
|
33
|
+
top: number
|
|
34
|
+
/** True when there wasn't room to open downward, so the popover is anchored
|
|
35
|
+
* from its own BOTTOM edge (opens upward) instead. */
|
|
36
|
+
flipped: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrackSettingsPopoverProps {
|
|
40
|
+
/** The rail button this popover is anchored to and positioned against. */
|
|
41
|
+
anchorRef: RefObject<HTMLButtonElement | null>
|
|
42
|
+
onClose: () => void
|
|
43
|
+
/** Names WHICH row this is, e.g. "Video track" / "Overlay track" / "Audio" —
|
|
44
|
+
* shown as the popover's own heading and its `aria-label`. */
|
|
45
|
+
title: string
|
|
46
|
+
/** Volume section — give both `volume` and `onVolumeChange` to show it. */
|
|
47
|
+
volume?: number
|
|
48
|
+
/** `commit: false` on every drag tick (live preview only, mirrors
|
|
49
|
+
* CaptionListPanel's fontsize slider); `commit: true` once, on
|
|
50
|
+
* release/keyup — the same preview-then-commit split every continuous
|
|
51
|
+
* timeline control uses, so a drag never produces one undo entry per
|
|
52
|
+
* pixel. */
|
|
53
|
+
onVolumeChange?: (volume: number, commit: boolean) => void
|
|
54
|
+
/** Distinct wording from `title` on purpose — "Mute audio lane" needs to
|
|
55
|
+
* read differently from AudioTrackRow's own per-clip "Mute"/"Unmute"
|
|
56
|
+
* button, which already exists and is a different control. */
|
|
57
|
+
volumeAriaLabel?: string
|
|
58
|
+
/** Mute section — give both `muted` and `onMutedChange` to show it. A mute
|
|
59
|
+
* click is a single, already-complete gesture — no preview/commit split
|
|
60
|
+
* needed, same as the rail's existing skip toggle. */
|
|
61
|
+
muted?: boolean
|
|
62
|
+
onMutedChange?: (muted: boolean) => void
|
|
63
|
+
muteAriaLabel?: string
|
|
64
|
+
/** Present only for visual tracks — audio lanes have no skip concept. When
|
|
65
|
+
* given, `onToggle` is the SAME callback the rail's inline eye icon calls;
|
|
66
|
+
* this is a second surface for it, not a second mutation path. */
|
|
67
|
+
skip?: { skipped: boolean; onToggle: () => void; ariaLabel: string }
|
|
68
|
+
/**
|
|
69
|
+
* Speed section — give both `speed` and `onApplySpeed` to show it. Present
|
|
70
|
+
* only for visual (video) tracks; speed is a video-only concept, same as
|
|
71
|
+
* volume/mute above.
|
|
72
|
+
*
|
|
73
|
+
* Applies directly, no button: it is a track control, so touching it is the
|
|
74
|
+
* intent to set the whole track. It previews into local state during a slider
|
|
75
|
+
* drag and commits `onApplySpeed` on release (and immediately on a chip
|
|
76
|
+
* click) — the same live-preview-then-commit split the volume fader uses,
|
|
77
|
+
* just committing to every clip instead of a track-level value.
|
|
78
|
+
*/
|
|
79
|
+
speed?: number
|
|
80
|
+
/** Applies `speed` to EVERY clip on the track. Fired on slider release and on
|
|
81
|
+
* a preset-chip click (via SpeedControl's `onCommit`), not per drag tick. */
|
|
82
|
+
onApplySpeed?: (speed: number) => void
|
|
83
|
+
/** Editor theme mode — light/dark. Only affects the Mute/Skip switches'
|
|
84
|
+
* unchecked track colour. Absent -> dark, matching every existing caller. */
|
|
85
|
+
mode?: 'light' | 'dark'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function TrackSettingsPopover({
|
|
89
|
+
anchorRef,
|
|
90
|
+
onClose,
|
|
91
|
+
title,
|
|
92
|
+
volume,
|
|
93
|
+
onVolumeChange,
|
|
94
|
+
volumeAriaLabel,
|
|
95
|
+
muted,
|
|
96
|
+
onMutedChange,
|
|
97
|
+
muteAriaLabel,
|
|
98
|
+
skip,
|
|
99
|
+
speed,
|
|
100
|
+
onApplySpeed,
|
|
101
|
+
mode = 'dark',
|
|
102
|
+
}: TrackSettingsPopoverProps) {
|
|
103
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
104
|
+
const [position, setPosition] = useState<Position | null>(null)
|
|
105
|
+
|
|
106
|
+
const showVolume = volume !== undefined && !!onVolumeChange
|
|
107
|
+
const showMute = muted !== undefined && !!onMutedChange
|
|
108
|
+
const showSpeed = speed !== undefined && !!onApplySpeed
|
|
109
|
+
|
|
110
|
+
// Local slider value during a drag, seeded from the committed `speed` and
|
|
111
|
+
// reset whenever it changes under us — same shape as `liveVolume` below. The
|
|
112
|
+
// bulk apply to every clip runs on commit (slider release / chip click) via
|
|
113
|
+
// SpeedControl's `onCommit`, not on each drag tick.
|
|
114
|
+
const [localSpeed, setLocalSpeed] = useState(speed ?? 1)
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (speed !== undefined) setLocalSpeed(speed)
|
|
117
|
+
}, [speed])
|
|
118
|
+
|
|
119
|
+
// Live slider value during a drag — local state so a mid-drag frame doesn't
|
|
120
|
+
// stutter waiting on a round trip through the project. Reset whenever the
|
|
121
|
+
// committed value changes under us (e.g. undo while the popover is open).
|
|
122
|
+
// Exactly CaptionListPanel's `fontsize` pattern.
|
|
123
|
+
const [liveVolume, setLiveVolume] = useState(volume ?? 1)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (volume !== undefined) setLiveVolume(volume)
|
|
126
|
+
}, [volume])
|
|
127
|
+
|
|
128
|
+
// Measure once the popover has a real size, then place it. Anchored from
|
|
129
|
+
// the trigger's top-left by default (NOT centered — the rail sits at the
|
|
130
|
+
// left edge of the timeline pane, so centering a wider panel under a 16px
|
|
131
|
+
// icon would push its left edge off-screen as often as not). Flips to open
|
|
132
|
+
// upward when there isn't room below before the viewport's bottom edge;
|
|
133
|
+
// only ever needs a LEFTWARD horizontal clamp, since the rail is the
|
|
134
|
+
// leftmost UI element and the trigger's own left edge is always ≥ 0.
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
const anchorRect = anchorRef.current?.getBoundingClientRect()
|
|
137
|
+
const popover = popoverRef.current
|
|
138
|
+
if (!anchorRect || !popover) return
|
|
139
|
+
const { width, height } = popover.getBoundingClientRect()
|
|
140
|
+
|
|
141
|
+
let left = anchorRect.left
|
|
142
|
+
if (left + width > window.innerWidth - VIEWPORT_MARGIN_PX) {
|
|
143
|
+
left = window.innerWidth - VIEWPORT_MARGIN_PX - width
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fitsBelow = anchorRect.bottom + OFFSET_PX + height <= window.innerHeight - VIEWPORT_MARGIN_PX
|
|
147
|
+
const top = fitsBelow ? anchorRect.bottom + OFFSET_PX : anchorRect.top - OFFSET_PX
|
|
148
|
+
setPosition({ left, top, flipped: !fitsBelow })
|
|
149
|
+
}, [anchorRef])
|
|
150
|
+
|
|
151
|
+
// Close on outside click / Escape — same shape as ImageToneMenu.tsx's.
|
|
152
|
+
// "Outside" excludes both the trigger (a click on it is the button's own
|
|
153
|
+
// toggle, handled by TrackGutter) and the popover's own portaled content.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const onDown = (e: MouseEvent) => {
|
|
156
|
+
const target = e.target as Node
|
|
157
|
+
if (anchorRef.current?.contains(target)) return
|
|
158
|
+
if (popoverRef.current?.contains(target)) return
|
|
159
|
+
onClose()
|
|
160
|
+
}
|
|
161
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
162
|
+
document.addEventListener('mousedown', onDown)
|
|
163
|
+
document.addEventListener('keydown', onKey)
|
|
164
|
+
return () => {
|
|
165
|
+
document.removeEventListener('mousedown', onDown)
|
|
166
|
+
document.removeEventListener('keydown', onKey)
|
|
167
|
+
}
|
|
168
|
+
}, [anchorRef, onClose])
|
|
169
|
+
|
|
170
|
+
if (typeof document === 'undefined') return null
|
|
171
|
+
|
|
172
|
+
return createPortal(
|
|
173
|
+
<div
|
|
174
|
+
ref={popoverRef}
|
|
175
|
+
role="dialog"
|
|
176
|
+
aria-label={title}
|
|
177
|
+
className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3"
|
|
178
|
+
style={{
|
|
179
|
+
left: position?.left ?? -9999,
|
|
180
|
+
top: position?.top ?? -9999,
|
|
181
|
+
// Invisible until positioned: the first render has nothing measured
|
|
182
|
+
// to position against yet, so it paints once off-screen for the
|
|
183
|
+
// effect above to measure, then becomes visible in its real place —
|
|
184
|
+
// never a visible jump.
|
|
185
|
+
visibility: position ? 'visible' : 'hidden',
|
|
186
|
+
transform: position?.flipped ? 'translateY(-100%)' : undefined,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<p className="text-xs font-semibold text-[var(--editor-text)]/90">{title}</p>
|
|
190
|
+
|
|
191
|
+
{showVolume && (
|
|
192
|
+
<VolumeControl
|
|
193
|
+
value={liveVolume}
|
|
194
|
+
onChange={v => { setLiveVolume(v); onVolumeChange!(v, false) }}
|
|
195
|
+
onCommit={v => onVolumeChange!(v, true)}
|
|
196
|
+
label="Volume"
|
|
197
|
+
ariaLabel={volumeAriaLabel}
|
|
198
|
+
idBase="track-volume"
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{showSpeed && (
|
|
203
|
+
<SpeedControl
|
|
204
|
+
value={localSpeed}
|
|
205
|
+
onChange={setLocalSpeed}
|
|
206
|
+
onCommit={onApplySpeed}
|
|
207
|
+
label="Speed"
|
|
208
|
+
idBase="track-speed"
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{showMute && (
|
|
213
|
+
<div className="flex items-center justify-between">
|
|
214
|
+
<span className="text-[11px] text-[var(--editor-text)]/70">Mute</span>
|
|
215
|
+
<Switch checked={!!muted} onCheckedChange={onMutedChange!} aria-label={muteAriaLabel} mode={mode} />
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{skip && (
|
|
220
|
+
<div className="flex items-center justify-between">
|
|
221
|
+
<span className="text-[11px] text-[var(--editor-text)]/70">Skip</span>
|
|
222
|
+
<Switch checked={skip.skipped} onCheckedChange={() => skip.onToggle()} aria-label={skip.ariaLabel} mode={mode} />
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>,
|
|
226
|
+
document.body,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A controlled volume picker: a slider plus a row of clickable preset chips
|
|
3
|
+
* snapping it to a common level. The audio twin of `SpeedControl` — same shape,
|
|
4
|
+
* same look, so the two controls read as siblings wherever they sit together
|
|
5
|
+
* (the track-wide settings popover and the per-clip inspect modal).
|
|
6
|
+
*
|
|
7
|
+
* Controlled, not stateful: the caller owns `value` and reacts to `onChange`,
|
|
8
|
+
* exactly like the range inputs elsewhere in this rail. The two mount points
|
|
9
|
+
* wire it up differently — the track popover previews into local state and
|
|
10
|
+
* commits directly via `onCommit` (slider release and chip click); the clip
|
|
11
|
+
* modal previews live and commits on an explicit Save button (so it omits
|
|
12
|
+
* `onCommit` and just reads `onChange`).
|
|
13
|
+
*
|
|
14
|
+
* The slider itself is the shared `<Slider>` (ui/Slider.tsx): its own
|
|
15
|
+
* `onCommit` already fires on gesture-end carrying the value, so this is a
|
|
16
|
+
* direct rewiring rather than a reshaping. One behaviour change comes along
|
|
17
|
+
* for free — `Slider` only commits when the gesture actually moved the value,
|
|
18
|
+
* where the old bare `<input>` committed on every pointerup/keyup regardless.
|
|
19
|
+
* That is strictly better (no more no-op undo entries) and no test here
|
|
20
|
+
* depended on the old wart.
|
|
21
|
+
*/
|
|
22
|
+
import { Slider } from '../../ui'
|
|
23
|
+
|
|
24
|
+
/** `20·log10(v)` dB, matching the readout the volume faders used before this
|
|
25
|
+
* control existed. Escapes for U+2212 (minus) and U+221E (infinity) so the
|
|
26
|
+
* glyphs survive whatever transform sits between here and the DOM. */
|
|
27
|
+
function volumeToDb(v: number): string {
|
|
28
|
+
if (v === 0) return '−∞ dB'
|
|
29
|
+
return `${(20 * Math.log10(v)).toFixed(1)} dB`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VolumeControlProps {
|
|
33
|
+
value: number
|
|
34
|
+
onChange: (v: number) => void
|
|
35
|
+
/** Optional commit signal, fired on slider release (pointer/keyboard) and on
|
|
36
|
+
* a chip click, carrying the value to commit. Callers that commit on their
|
|
37
|
+
* own control (e.g. a Save button) omit it and just read `onChange`. */
|
|
38
|
+
onCommit?: (v: number) => void
|
|
39
|
+
min?: number
|
|
40
|
+
max?: number
|
|
41
|
+
step?: number
|
|
42
|
+
/** Levels the chips snap to, as raw multipliers (1 = unity / 0 dB). Rendered
|
|
43
|
+
* as whole-percent labels. */
|
|
44
|
+
presets?: number[]
|
|
45
|
+
label?: string
|
|
46
|
+
/** Overrides the slider's `aria-label` — used where the accessible name must
|
|
47
|
+
* differ from the visible `label` (e.g. "Mute audio lane" vs "Volume"). */
|
|
48
|
+
ariaLabel?: string
|
|
49
|
+
/** Base id for the slider input, so a caller can wire a `<label htmlFor>`
|
|
50
|
+
* to it. Optional — the control is already self-labelled via `aria-label`. */
|
|
51
|
+
idBase?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_PRESETS = [0, 0.5, 1, 1.5, 2]
|
|
55
|
+
|
|
56
|
+
export default function VolumeControl({
|
|
57
|
+
value,
|
|
58
|
+
onChange,
|
|
59
|
+
onCommit,
|
|
60
|
+
min = 0,
|
|
61
|
+
max = 2,
|
|
62
|
+
step = 0.01,
|
|
63
|
+
presets = DEFAULT_PRESETS,
|
|
64
|
+
label,
|
|
65
|
+
ariaLabel,
|
|
66
|
+
idBase,
|
|
67
|
+
}: VolumeControlProps) {
|
|
68
|
+
const sliderId = idBase ? `${idBase}-volume-slider` : undefined
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-col gap-2">
|
|
72
|
+
<div className={`flex items-center text-[11px] text-[var(--editor-text)]/70 ${label ? 'justify-between' : 'justify-end'}`}>
|
|
73
|
+
{label && <span>{label}</span>}
|
|
74
|
+
<span className="font-mono text-[10px] text-[var(--editor-text)]/50">
|
|
75
|
+
{value.toFixed(2)} ({volumeToDb(value)})
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<Slider
|
|
79
|
+
id={sliderId}
|
|
80
|
+
min={min}
|
|
81
|
+
max={max}
|
|
82
|
+
step={step}
|
|
83
|
+
value={value}
|
|
84
|
+
aria-label={ariaLabel ?? label ?? 'Volume'}
|
|
85
|
+
onChange={onChange}
|
|
86
|
+
onCommit={onCommit}
|
|
87
|
+
/>
|
|
88
|
+
{/* Quick-set chips under the slider — a click snaps straight to that
|
|
89
|
+
level rather than dragging for it. The active one is marked with
|
|
90
|
+
`aria-pressed` and the accent fill, matching SpeedControl's chips. */}
|
|
91
|
+
<div role="group" aria-label={label ? `${label} presets` : 'Volume presets'} className="flex flex-wrap gap-1">
|
|
92
|
+
{presets.map(p => {
|
|
93
|
+
const active = Math.abs(value - p) < 1e-6
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
key={p}
|
|
97
|
+
type="button"
|
|
98
|
+
aria-pressed={active}
|
|
99
|
+
onClick={() => { onChange(p); onCommit?.(p) }}
|
|
100
|
+
className={`rounded-md border px-2 py-0.5 text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] ${
|
|
101
|
+
active
|
|
102
|
+
? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
|
|
103
|
+
: 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
{Math.round(p * 100)}%
|
|
107
|
+
</button>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Clicks that land on the timeline COLUMN but on no row: the readout strip
|
|
4
|
+
* above the tracks, the gaps between rows, the space under the last one.
|
|
5
|
+
*
|
|
6
|
+
* These went dead in canvas mode and nothing noticed. `handleContainerClick`
|
|
7
|
+
* mapped x→time through the scrubber bar's rect, and canvas mode doesn't
|
|
8
|
+
* render that bar, so `scrubberRef.current` was null and every such click fell
|
|
9
|
+
* out of an early return. The visible symptom was a ~40px strip under the
|
|
10
|
+
* toolbar that looked like timeline and ignored you — including for the thing
|
|
11
|
+
* people most expect from a background click, dropping the selection.
|
|
12
|
+
*
|
|
13
|
+
* There was no failing assertion to catch that: "looks live, does nothing" is
|
|
14
|
+
* invisible to a test that doesn't ask. So these ask.
|
|
15
|
+
*
|
|
16
|
+
* jsdom lays everything out at 0×0, so the canvas surface's rect is stubbed to
|
|
17
|
+
* a real one via `installCanvasHarness` — without it every click reads as x=0
|
|
18
|
+
* in a 0-wide surface and the seek half of this can't be observed at all.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
21
|
+
import { render, cleanup, fireEvent } from '@testing-library/react'
|
|
22
|
+
import type { Project } from '../../../types'
|
|
23
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
24
|
+
import Timeline from '../Timeline'
|
|
25
|
+
import { installCanvasHarness, SURFACE_LEFT } from './_canvasSelect'
|
|
26
|
+
|
|
27
|
+
let uninstall: () => void
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
uninstall = installCanvasHarness()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cleanup()
|
|
35
|
+
uninstall()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** 10s of content. NOT a round 100px/second on the fitted surface — "fit" is
|
|
39
|
+
* computed against `computeDerivedTiming().totalDuration`, which is content
|
|
40
|
+
* duration PLUS drag headroom (`+ max(5, content * 0.2)`), so 10s of content
|
|
41
|
+
* fits as 15s of fitted timeline (≈66.7px/s here), not 100px/s. Nothing below
|
|
42
|
+
* asserts the scale, so the exact value was never load-bearing — this note is
|
|
43
|
+
* just so a reader doesn't assume a round number computing their own point. */
|
|
44
|
+
function makeProject(): Project {
|
|
45
|
+
return {
|
|
46
|
+
id: 'p1',
|
|
47
|
+
status: 'draft',
|
|
48
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
49
|
+
tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
|
|
50
|
+
} as unknown as Project
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mount(clockAt = 0) {
|
|
54
|
+
const clock = createPlaybackClock(clockAt)
|
|
55
|
+
const onSelectIds = vi.fn()
|
|
56
|
+
const utils = render(
|
|
57
|
+
<Timeline
|
|
58
|
+
project={makeProject()}
|
|
59
|
+
clock={clock}
|
|
60
|
+
selectedIds={['clip-0']}
|
|
61
|
+
onSelectIds={onSelectIds}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
// The strip is the column's own background: the row area is a child, so
|
|
65
|
+
// clicking the root element itself is exactly a click that hit no row.
|
|
66
|
+
const root = utils.container.firstElementChild as HTMLElement
|
|
67
|
+
return { clock, onSelectIds, root, ...utils }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('Timeline — background clicks in canvas mode', () => {
|
|
71
|
+
it('clears the selection', () => {
|
|
72
|
+
const { root, onSelectIds } = mount()
|
|
73
|
+
fireEvent.click(root, { clientX: 600, clientY: 10 })
|
|
74
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('seeks to the clicked time on the canvas axis', () => {
|
|
78
|
+
const { root, clock } = mount()
|
|
79
|
+
// 600 - SURFACE_LEFT = 500px into a 1000px surface showing 10s of content.
|
|
80
|
+
fireEvent.click(root, { clientX: 600, clientY: 10 })
|
|
81
|
+
expect(clock.get()).toBeGreaterThan(0)
|
|
82
|
+
expect(clock.get()).toBeLessThan(10)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not seek from over the track rail, but still clears the selection', () => {
|
|
86
|
+
const { root, clock, onSelectIds } = mount(4)
|
|
87
|
+
fireEvent.click(root, { clientX: SURFACE_LEFT - 20, clientY: 10 })
|
|
88
|
+
expect(onSelectIds).toHaveBeenCalledWith([])
|
|
89
|
+
expect(clock.get()).toBe(4) // no time axis over the rail
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('leaves a click on a button alone — the zoom and readout controls are chrome', () => {
|
|
93
|
+
const { root, clock, onSelectIds } = mount(4)
|
|
94
|
+
const button = document.createElement('button')
|
|
95
|
+
root.appendChild(button)
|
|
96
|
+
fireEvent.click(button, { clientX: 600, clientY: 10, bubbles: true })
|
|
97
|
+
expect(onSelectIds).not.toHaveBeenCalled()
|
|
98
|
+
expect(clock.get()).toBe(4)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('leaves a click on a [data-timeline-chrome] readout alone', () => {
|
|
102
|
+
const { root, clock, onSelectIds } = mount(4)
|
|
103
|
+
const readout = document.createElement('span')
|
|
104
|
+
readout.setAttribute('data-timeline-chrome', '')
|
|
105
|
+
root.appendChild(readout)
|
|
106
|
+
fireEvent.click(readout, { clientX: 600, clientY: 10, bubbles: true })
|
|
107
|
+
expect(onSelectIds).not.toHaveBeenCalled()
|
|
108
|
+
expect(clock.get()).toBe(4)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The auto-crossfade pass must land as ONE commit, on a delay — not one per
|
|
4
|
+
* change. Audio timing moves on every mousemove of a drag, and the effect used
|
|
5
|
+
* to `onOverlayEdit` (commit) synchronously on each, which recorded dozens of
|
|
6
|
+
* undo entries for a single audio move. It is now debounced; a drag's own
|
|
7
|
+
* commit folds the fade into its single undo step, and this pass is the
|
|
8
|
+
* catch-all for audio-timing changes that arrive outside a gesture.
|
|
9
|
+
*
|
|
10
|
+
* jsdom lays out at 0×0 and has no 2D canvas, so both are stubbed via
|
|
11
|
+
* `installCanvasHarness` — the canvas-mode timeline mounts a real <canvas>
|
|
12
|
+
* surface underneath Timeline. This file dispatches no pointer event and
|
|
13
|
+
* `computeAutoCrossfade` reads only audio-track start/end/lane data, so the
|
|
14
|
+
* harness's surface-inset geometry (vs. this file's old blanket one-rect-fits-
|
|
15
|
+
* all stub) changes nothing this file asserts.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
import { render, cleanup, act } from '@testing-library/react'
|
|
19
|
+
import type { Project } from '../../../types'
|
|
20
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
21
|
+
import Timeline, { CROSSFADE_COMMIT_DELAY_MS } from '../Timeline'
|
|
22
|
+
import { installCanvasHarness } from './_canvasSelect'
|
|
23
|
+
|
|
24
|
+
let uninstall: () => void
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers()
|
|
28
|
+
uninstall = installCanvasHarness()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
vi.useRealTimers()
|
|
34
|
+
uninstall()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Two audio tracks that overlap by 1s and carry NO fades — so
|
|
38
|
+
* `computeAutoCrossfade` has a real change to make (fadeOut/fadeIn = 1). */
|
|
39
|
+
function projectWithOverlap(): Project {
|
|
40
|
+
return {
|
|
41
|
+
id: 'p1',
|
|
42
|
+
status: 'draft',
|
|
43
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
44
|
+
tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
|
|
45
|
+
audio: { tracks: [
|
|
46
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 5, lane: 0 },
|
|
47
|
+
{ id: 'a1', src: 'w.mp3', start: 4, end: 9, lane: 0 },
|
|
48
|
+
] },
|
|
49
|
+
} as unknown as Project
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Same shape, but the fades are already correct — the pass must find nothing. */
|
|
53
|
+
function projectAlreadyFaded(): Project {
|
|
54
|
+
return {
|
|
55
|
+
id: 'p1',
|
|
56
|
+
status: 'draft',
|
|
57
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
58
|
+
tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
|
|
59
|
+
audio: { tracks: [
|
|
60
|
+
{ id: 'a0', src: 'v.mp3', start: 0, end: 5, lane: 0, fadeOut: 1 },
|
|
61
|
+
{ id: 'a1', src: 'w.mp3', start: 4, end: 9, lane: 0, fadeIn: 1 },
|
|
62
|
+
] },
|
|
63
|
+
} as unknown as Project
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mount(project: Project) {
|
|
67
|
+
const onOverlayEdit = vi.fn()
|
|
68
|
+
const onProjectChange = vi.fn()
|
|
69
|
+
render(
|
|
70
|
+
<Timeline
|
|
71
|
+
project={project}
|
|
72
|
+
clock={createPlaybackClock(0)}
|
|
73
|
+
selectedIds={[]}
|
|
74
|
+
onSelectIds={vi.fn()}
|
|
75
|
+
onProjectChange={onProjectChange}
|
|
76
|
+
onOverlayEdit={onOverlayEdit}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
return { onOverlayEdit, onProjectChange }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('Timeline — auto-crossfade commit is debounced', () => {
|
|
83
|
+
it('does not commit synchronously, then commits once after the delay', () => {
|
|
84
|
+
const { onOverlayEdit } = mount(projectWithOverlap())
|
|
85
|
+
// The per-change commit is exactly the audio-move-undo bug — it must not
|
|
86
|
+
// fire on mount / on the audio-timing change itself.
|
|
87
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
88
|
+
|
|
89
|
+
act(() => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS) })
|
|
90
|
+
|
|
91
|
+
expect(onOverlayEdit).toHaveBeenCalledTimes(1)
|
|
92
|
+
const committed = onOverlayEdit.mock.calls[0][0] as Project
|
|
93
|
+
const a0 = committed.audio!.tracks.find(t => t.id === 'a0')!
|
|
94
|
+
const a1 = committed.audio!.tracks.find(t => t.id === 'a1')!
|
|
95
|
+
expect(a0.fadeOut).toBe(1)
|
|
96
|
+
expect(a1.fadeIn).toBe(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('never commits when the crossfade is already applied (idempotent)', () => {
|
|
100
|
+
const { onOverlayEdit } = mount(projectAlreadyFaded())
|
|
101
|
+
act(() => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS * 4) })
|
|
102
|
+
expect(onOverlayEdit).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
})
|