@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,159 @@
|
|
|
1
|
+
import type { PlaybackClock } from './playback-clock'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* J/K/L shuttle. L plays forward, J plays backward, K stops. Repeating the same
|
|
5
|
+
* direction doubles the rate (1x -> 2x -> 4x, capped); the opposite direction
|
|
6
|
+
* resets to that direction at 1x.
|
|
7
|
+
*
|
|
8
|
+
* Two mechanisms, split by what can carry audio:
|
|
9
|
+
*
|
|
10
|
+
* - **Forward (1x/2x/4x) IS real playback, with sound.** The engine has a live
|
|
11
|
+
* transport rate (`deps.setRate`): project time advances R× and audio is
|
|
12
|
+
* pitch-corrected via a streaming time-stretch, so 2x/4x forward is audible,
|
|
13
|
+
* not just faster. A forward press ensures the engine transport is running
|
|
14
|
+
* (via `deps.play()` — the same gesture-anchored path Space uses, AudioContext
|
|
15
|
+
* resume and all) and sets the rate to the shuttle's magnitude.
|
|
16
|
+
* - **Reverse is a silent seek-loop.** The engine has no reverse rate (reversed
|
|
17
|
+
* audio is not a thing), so J can only fast-scrub the shared `PlaybackClock`:
|
|
18
|
+
* real playback is paused (muting audio) and the transport rate reset to 1x,
|
|
19
|
+
* then an rAF loop steps `clock.set(clock.get() + rate * dt)`. This touches
|
|
20
|
+
* only the clock, never the engine or <video> elements directly, so it works
|
|
21
|
+
* identically over both playback paths.
|
|
22
|
+
*/
|
|
23
|
+
export type ShuttleRate = -4 | -2 | -1 | 0 | 1 | 2 | 4
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pure rate-stepping — unit-testable without rAF. Pressing the same
|
|
27
|
+
* direction again doubles the magnitude (capped at 4x); pressing the
|
|
28
|
+
* opposite direction resets to that direction at 1x.
|
|
29
|
+
*/
|
|
30
|
+
export function nextShuttleRate(current: ShuttleRate, direction: 1 | -1): ShuttleRate {
|
|
31
|
+
const sign = Math.sign(current)
|
|
32
|
+
if (sign !== 0 && sign === direction) {
|
|
33
|
+
return (Math.min(4, Math.abs(current) * 2) * direction) as ShuttleRate
|
|
34
|
+
}
|
|
35
|
+
return direction
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ShuttleDeps {
|
|
39
|
+
clock: PlaybackClock
|
|
40
|
+
/** The seekable upper bound — the loop stops when it reaches either end. */
|
|
41
|
+
getDuration: () => number
|
|
42
|
+
/** True while playback is running OUTSIDE the shuttle's own loop. Polled
|
|
43
|
+
* every tick so a real "user hit play" transport change cancels the
|
|
44
|
+
* shuttle instead of racing it. */
|
|
45
|
+
isPlaying: () => boolean
|
|
46
|
+
/** Start real forward playback WITH audio — the engine transport (the same
|
|
47
|
+
* gesture-anchored path Space uses, AudioContext resume included). Called on a
|
|
48
|
+
* forward press; a no-op if already playing. */
|
|
49
|
+
play: () => void
|
|
50
|
+
/** Pause real playback (audio implicitly muted). Called when entering the
|
|
51
|
+
* reverse seek-loop, and by stop() when the shuttle itself started playback. */
|
|
52
|
+
pause: () => void
|
|
53
|
+
/** Set the engine's live transport rate R. A forward press sets it to the
|
|
54
|
+
* shuttle magnitude (1/2/4) for audible fast-forward; reverse and stop reset
|
|
55
|
+
* it to 1x before pausing/scrubbing so nothing plays at a stale rate. */
|
|
56
|
+
setRate: (rate: number) => void
|
|
57
|
+
/** Injectable for tests; defaults to `requestAnimationFrame`/`cancelAnimationFrame`. */
|
|
58
|
+
raf?: (cb: (ms: number) => void) => number
|
|
59
|
+
caf?: (id: number) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ShuttleController {
|
|
63
|
+
/** J or L. Forward = real playback at the transport rate; reverse = the silent
|
|
64
|
+
* seek-loop. */
|
|
65
|
+
press: (direction: 1 | -1) => void
|
|
66
|
+
/** K, or any other transport/seek change — stops the loop. */
|
|
67
|
+
stop: () => void
|
|
68
|
+
getRate: () => ShuttleRate
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createShuttleController(deps: ShuttleDeps): ShuttleController {
|
|
72
|
+
const raf = deps.raf ?? requestAnimationFrame
|
|
73
|
+
const caf = deps.caf ?? cancelAnimationFrame
|
|
74
|
+
|
|
75
|
+
let rate: ShuttleRate = 0
|
|
76
|
+
let rafId: number | null = null
|
|
77
|
+
let lastMs: number | null = null
|
|
78
|
+
// The value WE last wrote — lets the tick tell "the clock changed because
|
|
79
|
+
// we changed it" apart from "something external moved the playhead" (a
|
|
80
|
+
// Scrubber click, an arrow-key step, another editor gesture).
|
|
81
|
+
let lastWritten: number | null = null
|
|
82
|
+
// True while the shuttle itself is driving real forward playback (any of
|
|
83
|
+
// 1x/2x/4x). Tells stop() to pause it back, and tells a move to reverse to
|
|
84
|
+
// mute first.
|
|
85
|
+
let realPlaying = false
|
|
86
|
+
|
|
87
|
+
function stopLoop() {
|
|
88
|
+
if (rafId !== null) caf(rafId)
|
|
89
|
+
rafId = null
|
|
90
|
+
lastMs = null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stop() {
|
|
94
|
+
rate = 0
|
|
95
|
+
stopLoop()
|
|
96
|
+
deps.setRate(1)
|
|
97
|
+
if (realPlaying) { deps.pause(); realPlaying = false }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Only ever runs for reverse now (forward is real playback, no loop). Keeps
|
|
101
|
+
// the existing external-change/isPlaying cancellation guards so a real play or
|
|
102
|
+
// an external seek during a reverse scrub cancels the shuttle instead of
|
|
103
|
+
// racing it.
|
|
104
|
+
function tick(ms: number) {
|
|
105
|
+
if (rate === 0) { stopLoop(); return }
|
|
106
|
+
if (deps.isPlaying()) { stop(); return }
|
|
107
|
+
if (lastWritten !== null && deps.clock.get() !== lastWritten) { stop(); return }
|
|
108
|
+
|
|
109
|
+
if (lastMs !== null) {
|
|
110
|
+
const dt = (ms - lastMs) / 1000
|
|
111
|
+
const duration = deps.getDuration()
|
|
112
|
+
const next = Math.max(0, Math.min(duration, deps.clock.get() + rate * dt))
|
|
113
|
+
deps.clock.set(next)
|
|
114
|
+
lastWritten = next
|
|
115
|
+
if (next <= 0 || next >= duration) { stop(); return }
|
|
116
|
+
}
|
|
117
|
+
lastMs = ms
|
|
118
|
+
rafId = raf(tick)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startLoopIfNeeded() {
|
|
122
|
+
if (rafId !== null) return
|
|
123
|
+
lastMs = null
|
|
124
|
+
lastWritten = deps.clock.get()
|
|
125
|
+
rafId = raf(tick)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
press(direction: 1 | -1) {
|
|
130
|
+
const next = nextShuttleRate(rate, direction)
|
|
131
|
+
|
|
132
|
+
if (next > 0) {
|
|
133
|
+
// FORWARD 1x/2x/4x = REAL playback at the engine transport rate — with
|
|
134
|
+
// audio, pitch-corrected. Drop any reverse seek-loop, make sure the
|
|
135
|
+
// engine is playing (resuming the AudioContext), then set the rate.
|
|
136
|
+
// Ownership (`realPlaying`) is only claimed when the shuttle itself
|
|
137
|
+
// starts playback from idle — if playback was already running (e.g.
|
|
138
|
+
// the user pressed Space first), stop() must leave it running rather
|
|
139
|
+
// than pausing playback the shuttle never started.
|
|
140
|
+
stopLoop()
|
|
141
|
+
if (!deps.isPlaying()) { deps.play(); realPlaying = true }
|
|
142
|
+
deps.setRate(next)
|
|
143
|
+
rate = next
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// REVERSE = the silent seek-loop. Reset the transport rate and pause real
|
|
148
|
+
// playback before scrubbing, so nothing plays at a stale rate and nothing
|
|
149
|
+
// double-drives the clock while the loop fast-scrubs it.
|
|
150
|
+
deps.setRate(1)
|
|
151
|
+
if (deps.isPlaying()) deps.pause()
|
|
152
|
+
realPlaying = false
|
|
153
|
+
rate = next
|
|
154
|
+
startLoopIfNeeded()
|
|
155
|
+
},
|
|
156
|
+
stop,
|
|
157
|
+
getRate: () => rate,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The source-preview override: "show this OFF-TIMELINE clip's frame in the main
|
|
5
|
+
* preview instead of the timeline's picture", or null for "show the timeline".
|
|
6
|
+
*
|
|
7
|
+
* A sibling of `hover-scrub.ts`, and for the same structural reason: it repaints
|
|
8
|
+
* the preview on every hover move without touching the playback clock. But where
|
|
9
|
+
* hover-scrub resolves a NUMBER against the timeline, this carries a directly
|
|
10
|
+
* loadable proxy `url` and a `fraction` into it — a clip the timeline knows
|
|
11
|
+
* nothing about, driven by a host's footage bin as the pointer travels one of
|
|
12
|
+
* its cards.
|
|
13
|
+
*
|
|
14
|
+
* The seam is entirely opt-in. A host that never creates the store (Hub, LP, the
|
|
15
|
+
* classic layout) passes nothing; `useSourcePreview(undefined)` returns null and
|
|
16
|
+
* no overlay is ever mounted, so the main preview behaves exactly as before.
|
|
17
|
+
*
|
|
18
|
+
* An external store rather than React state because it is written on every
|
|
19
|
+
* mousemove: only the preview-stage overlay subscribes, so the editor shell, the
|
|
20
|
+
* timeline and the caption row don't re-render as the pointer travels a card.
|
|
21
|
+
*/
|
|
22
|
+
export interface SourcePreviewValue {
|
|
23
|
+
/** A directly-loadable proxy URL (the host resolves it before setting). */
|
|
24
|
+
url: string
|
|
25
|
+
/** Position within the source, in [0, 1]. */
|
|
26
|
+
fraction: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SourcePreviewStore {
|
|
30
|
+
get(): SourcePreviewValue | null
|
|
31
|
+
set(v: SourcePreviewValue | null): void
|
|
32
|
+
subscribe(cb: () => void): () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createSourcePreviewStore(): SourcePreviewStore {
|
|
36
|
+
let value: SourcePreviewValue | null = null
|
|
37
|
+
const subs = new Set<() => void>()
|
|
38
|
+
return {
|
|
39
|
+
get: () => value,
|
|
40
|
+
set(v) {
|
|
41
|
+
// Skip the notify when nothing actually changed — a host may re-set the
|
|
42
|
+
// same url+fraction on repeated pointer events over the same tile.
|
|
43
|
+
if (v === value) return
|
|
44
|
+
if (v && value && v.url === value.url && v.fraction === value.fraction) return
|
|
45
|
+
value = v
|
|
46
|
+
subs.forEach((cb) => cb())
|
|
47
|
+
},
|
|
48
|
+
subscribe(cb) {
|
|
49
|
+
subs.add(cb)
|
|
50
|
+
return () => { subs.delete(cb) }
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stable snapshot for an absent store, so `useSyncExternalStore` isn't handed
|
|
56
|
+
* a fresh closure per render when a host omits source-preview entirely. */
|
|
57
|
+
const NO_SUBSCRIBE = () => () => {}
|
|
58
|
+
const NO_VALUE = () => null
|
|
59
|
+
|
|
60
|
+
export function useSourcePreview(store?: SourcePreviewStore): SourcePreviewValue | null {
|
|
61
|
+
return useSyncExternalStore(
|
|
62
|
+
store?.subscribe ?? NO_SUBSCRIBE,
|
|
63
|
+
store?.get ?? NO_VALUE,
|
|
64
|
+
store?.get ?? NO_VALUE,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a user-typed timecode into seconds, for the command palette's
|
|
3
|
+
* "go to time" entry. Accepted shapes:
|
|
4
|
+
*
|
|
5
|
+
* - bare seconds: "90", "12.5"
|
|
6
|
+
* - mm:ss: "1:30", "12:03.5"
|
|
7
|
+
* - hh:mm:ss: "01:02:03", "1:02:03.25"
|
|
8
|
+
*
|
|
9
|
+
* A fractional part is only valid on the LAST component (seconds) — "1.5:30"
|
|
10
|
+
* is rejected. Minutes/seconds components (everywhere except the leading
|
|
11
|
+
* hours) must be < 60. Anything that doesn't match — empty input, letters,
|
|
12
|
+
* too many `:` groups, out-of-range minutes/seconds — returns `null`.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTimecode(input: string): number | null {
|
|
15
|
+
const trimmed = input.trim()
|
|
16
|
+
if (!/^\d+(:\d+){0,2}(\.\d+)?$/.test(trimmed)) return null
|
|
17
|
+
|
|
18
|
+
const parts = trimmed.split(':').map(Number)
|
|
19
|
+
if (parts.length === 1) return parts[0]
|
|
20
|
+
|
|
21
|
+
const seconds = parts[parts.length - 1]
|
|
22
|
+
if (seconds >= 60) return null
|
|
23
|
+
|
|
24
|
+
if (parts.length === 2) {
|
|
25
|
+
const [minutes] = parts
|
|
26
|
+
return minutes * 60 + seconds
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [hours, minutes] = parts
|
|
30
|
+
if (minutes >= 60) return null
|
|
31
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format seconds for the preview transport row's timecode readout, as
|
|
36
|
+
* `m:ss.f` (or `h:mm:ss.f` once the value reaches an hour) — one decimal
|
|
37
|
+
* place, rounded to the nearest tenth of a second.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately NOT frames (no CapCut-style `hh:mm:ss:ff`): `parseTimecode`
|
|
40
|
+
* above has no notion of frames — its trailing fractional part is seconds
|
|
41
|
+
* only — so a frames-style display would silently mis-parse if a user
|
|
42
|
+
* copied it into the command palette's "go to time" field (a trailing
|
|
43
|
+
* `:18` would be read as 18 SECONDS, not 18 frames). Emitting the same
|
|
44
|
+
* `m:ss[.f]` shape `parseTimecode` already accepts keeps the two in sync;
|
|
45
|
+
* see the round-trip tests in `__tests__/timecode.test.ts`.
|
|
46
|
+
*/
|
|
47
|
+
export function formatTimecode(sec: number): string {
|
|
48
|
+
const clamped = Number.isFinite(sec) ? Math.max(0, sec) : 0
|
|
49
|
+
const totalTenths = Math.round(clamped * 10)
|
|
50
|
+
const wholeSeconds = Math.floor(totalTenths / 10)
|
|
51
|
+
const tenths = totalTenths % 10
|
|
52
|
+
const hours = Math.floor(wholeSeconds / 3600)
|
|
53
|
+
const minutes = Math.floor((wholeSeconds % 3600) / 60)
|
|
54
|
+
const seconds = wholeSeconds % 60
|
|
55
|
+
const secondsStr = `${String(seconds).padStart(2, '0')}.${tenths}`
|
|
56
|
+
return hours > 0
|
|
57
|
+
? `${hours}:${String(minutes).padStart(2, '0')}:${secondsStr}`
|
|
58
|
+
: `${minutes}:${secondsStr}`
|
|
59
|
+
}
|
|
@@ -22,7 +22,7 @@ export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit:
|
|
|
22
22
|
suppressContentEditableWarning
|
|
23
23
|
onBlur={handleBlur}
|
|
24
24
|
onKeyDown={handleKeyDown}
|
|
25
|
-
className="cursor-text rounded px-0.5 hover:bg-
|
|
25
|
+
className="cursor-text rounded px-0.5 hover:bg-[var(--editor-text)]/5 focus:bg-[var(--editor-text)]/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
|
|
26
26
|
>
|
|
27
27
|
{seg.text}
|
|
28
28
|
</span>
|
|
@@ -1,186 +1,58 @@
|
|
|
1
|
-
import { formatTime
|
|
1
|
+
import { formatTime } from './utils'
|
|
2
2
|
import { useTimelineContext } from './TimelineContext'
|
|
3
3
|
import { usePlaybackTime } from '../playback-clock'
|
|
4
|
-
|
|
4
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The strip above the tracks: current time on the left, project duration on
|
|
8
|
+
* the right.
|
|
9
|
+
*
|
|
10
|
+
* ── Why there is no bar here any more ────────────────────────────────────
|
|
11
|
+
* This used to also render an overview BAR — a track, an elapsed fill and a
|
|
12
|
+
* draggable red handle spanning the whole project across the full width. The
|
|
13
|
+
* canvas timeline owns its own zoom and scroll, so at any zoom but "fit" that
|
|
14
|
+
* handle sat at a different x than the canvas playhead for the same instant:
|
|
15
|
+
* two red markers disagreeing about where you are. The bar came out with the
|
|
16
|
+
* DOM timeline and its per-bar affordances (drag-to-scrub with boundary
|
|
17
|
+
* snapping, the hover-time tooltip) went with it — the canvas surface has its
|
|
18
|
+
* own playhead grab band and its own preview axis for those.
|
|
19
|
+
*
|
|
20
|
+
* The readouts stay, because they are TIMES rather than positions: they read
|
|
21
|
+
* from the same clock the canvas paints from and cannot disagree with it.
|
|
22
|
+
*/
|
|
5
23
|
interface ScrubberProps {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
keyNavTime: number | null
|
|
10
|
-
onSplit?: (at: number) => void
|
|
11
|
-
onCut?: (cut: { start: number; end: number }) => void
|
|
12
|
-
cutButtonLabel: string
|
|
24
|
+
/** SP5 T9 — opens the command palette's "go to time" input. Absent →
|
|
25
|
+
* the time readout stays plain, non-interactive text. */
|
|
26
|
+
onOpenGoToTime?: () => void
|
|
13
27
|
}
|
|
14
28
|
|
|
15
|
-
export default function Scrubber({
|
|
16
|
-
|
|
17
|
-
draggingPlayhead,
|
|
18
|
-
setDraggingPlayhead,
|
|
19
|
-
keyNavTime,
|
|
20
|
-
onSplit,
|
|
21
|
-
onCut,
|
|
22
|
-
cutButtonLabel,
|
|
23
|
-
}: ScrubberProps) {
|
|
24
|
-
const { clock, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, scrubberRef, selection } = useTimelineContext()
|
|
29
|
+
export default function Scrubber({ onOpenGoToTime }: ScrubberProps) {
|
|
30
|
+
const { clock, contentDuration } = useTimelineContext()
|
|
25
31
|
const currentTime = usePlaybackTime(clock)
|
|
26
32
|
|
|
27
|
-
function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
28
|
-
e.stopPropagation()
|
|
29
|
-
if (totalDuration === 0) return
|
|
30
|
-
clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
34
|
-
if (totalDuration === 0) return
|
|
35
|
-
e.preventDefault()
|
|
36
|
-
const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
37
|
-
setMarkers(([a, b]) => {
|
|
38
|
-
if (a === null) return [t, null] // place first marker
|
|
39
|
-
if (b === null) return [a, t] // place second → selection complete
|
|
40
|
-
return [t, null] // reset: start fresh with new first marker
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
33
|
return (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
style={{ width: `${pct(currentTime, totalDuration)}%` }}
|
|
57
|
-
/>
|
|
58
|
-
|
|
59
|
-
{/* Selection range fill (both markers placed) */}
|
|
60
|
-
{selection && (
|
|
61
|
-
<div
|
|
62
|
-
className="absolute inset-y-0 bg-amber-500/25 pointer-events-none"
|
|
63
|
-
style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
|
|
64
|
-
/>
|
|
65
|
-
)}
|
|
66
|
-
|
|
67
|
-
{/* Marker A */}
|
|
68
|
-
{markers[0] !== null && (
|
|
69
|
-
<div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[0], totalDuration)}%` }}>
|
|
70
|
-
<div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
|
|
71
|
-
</div>
|
|
72
|
-
)}
|
|
73
|
-
|
|
74
|
-
{/* Marker B */}
|
|
75
|
-
{markers[1] !== null && (
|
|
76
|
-
<div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[1], totalDuration)}%` }}>
|
|
77
|
-
<div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
|
|
78
|
-
</div>
|
|
79
|
-
)}
|
|
80
|
-
|
|
81
|
-
{/* Playhead handle */}
|
|
82
|
-
<div
|
|
83
|
-
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-red-500 ring-2 ring-red-500/30 transition-transform group-hover:scale-125 ${draggingPlayhead ? 'cursor-grabbing scale-125' : 'cursor-grab'}`}
|
|
84
|
-
style={{ left: `${pct(currentTime, totalDuration)}%` }}
|
|
85
|
-
onMouseDown={(e) => {
|
|
86
|
-
e.stopPropagation()
|
|
87
|
-
if (totalDuration === 0) return
|
|
88
|
-
setDraggingPlayhead(true)
|
|
89
|
-
const boundaries = snapBoundaries
|
|
90
|
-
let snappedTo: number | null = null // which boundary we're currently locked to
|
|
91
|
-
function onMove(me: MouseEvent) {
|
|
92
|
-
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
93
|
-
if (!rect) return
|
|
94
|
-
const attractPx = 18 // cursor enters this range → snaps in
|
|
95
|
-
const releasePx = 28 // cursor must leave this range → breaks free
|
|
96
|
-
const attract = (attractPx / rect.width) * totalDuration
|
|
97
|
-
const release = (releasePx / rect.width) * totalDuration
|
|
98
|
-
const rawT = ratioFromClientX(me.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
99
|
-
// Already snapped — hold until cursor escapes release radius
|
|
100
|
-
if (snappedTo !== null) {
|
|
101
|
-
if (Math.abs(rawT - snappedTo) < release) { clock.set(snappedTo); return }
|
|
102
|
-
snappedTo = null
|
|
103
|
-
}
|
|
104
|
-
// Scan for attraction
|
|
105
|
-
for (const b of boundaries) {
|
|
106
|
-
if (Math.abs(rawT - b) < attract) { snappedTo = b; clock.set(b); return }
|
|
107
|
-
}
|
|
108
|
-
clock.set(rawT)
|
|
109
|
-
}
|
|
110
|
-
function onUp() {
|
|
111
|
-
setDraggingPlayhead(false)
|
|
112
|
-
document.removeEventListener('mousemove', onMove)
|
|
113
|
-
document.removeEventListener('mouseup', onUp)
|
|
114
|
-
}
|
|
115
|
-
document.addEventListener('mousemove', onMove)
|
|
116
|
-
document.addEventListener('mouseup', onUp)
|
|
117
|
-
}}
|
|
118
|
-
onDoubleClick={(e) => {
|
|
119
|
-
e.stopPropagation()
|
|
120
|
-
handleScrubDoubleClick(e as React.MouseEvent<HTMLDivElement>)
|
|
121
|
-
}}
|
|
122
|
-
>
|
|
123
|
-
{keyNavTime !== null && (
|
|
124
|
-
<div className="absolute -top-7 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20">
|
|
125
|
-
{formatTime(keyNavTime)}
|
|
126
|
-
</div>
|
|
127
|
-
)}
|
|
128
|
-
</div>
|
|
129
|
-
|
|
130
|
-
{/* Time tooltip */}
|
|
131
|
-
{hoverPct !== null && totalDuration > 0 && (
|
|
132
|
-
<div
|
|
133
|
-
className="absolute -top-7 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20"
|
|
134
|
-
style={{ left: `${hoverPct}%` }}
|
|
34
|
+
/* `cursor-pointer`: the row is a strip of timeline background, and
|
|
35
|
+
Timeline's container click seeks through it (see `handleContainerClick`),
|
|
36
|
+
so it should look live rather than dead. */
|
|
37
|
+
<div className="flex items-center justify-between text-[10px] font-mono text-[var(--editor-text)]/35 cursor-pointer">
|
|
38
|
+
{onOpenGoToTime ? (
|
|
39
|
+
<Tooltip label="Go to time">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
aria-label="Go to time"
|
|
43
|
+
onClick={(e) => { e.stopPropagation(); onOpenGoToTime() }}
|
|
44
|
+
className="hover:text-[var(--editor-text)]/80 transition-colors"
|
|
135
45
|
>
|
|
136
|
-
{formatTime(
|
|
137
|
-
</
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{/* Time readout + marker / selection range */}
|
|
142
|
-
<div className="flex items-center justify-between text-[10px] font-mono text-gray-600 -mt-1">
|
|
46
|
+
{formatTime(currentTime)}
|
|
47
|
+
</button>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
) : (
|
|
143
50
|
<span>{formatTime(currentTime)}</span>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
>
|
|
151
|
-
Split
|
|
152
|
-
</button>
|
|
153
|
-
)}
|
|
154
|
-
{formatTime(markers[0])} — double-click to set end
|
|
155
|
-
<button
|
|
156
|
-
className="text-gray-600 hover:text-gray-400"
|
|
157
|
-
onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
|
|
158
|
-
>✕</button>
|
|
159
|
-
</span>
|
|
160
|
-
)}
|
|
161
|
-
{selection && (
|
|
162
|
-
<span className="flex items-center gap-2 text-amber-400">
|
|
163
|
-
<button
|
|
164
|
-
className="text-gray-600 hover:text-gray-400"
|
|
165
|
-
onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
|
|
166
|
-
>✕</button>
|
|
167
|
-
{formatTime(selection.start)} – {formatTime(selection.end)}
|
|
168
|
-
{onCut && (
|
|
169
|
-
<button
|
|
170
|
-
className="px-2 py-0.5 rounded bg-red-600/80 hover:bg-red-500 text-white text-[10px] font-medium transition-colors"
|
|
171
|
-
onClick={(e) => {
|
|
172
|
-
e.stopPropagation()
|
|
173
|
-
onCut(selection)
|
|
174
|
-
setMarkers([null, null])
|
|
175
|
-
}}
|
|
176
|
-
>
|
|
177
|
-
{cutButtonLabel}
|
|
178
|
-
</button>
|
|
179
|
-
)}
|
|
180
|
-
</span>
|
|
181
|
-
)}
|
|
182
|
-
<span>{formatTime(contentDuration)}</span>
|
|
183
|
-
</div>
|
|
184
|
-
</>
|
|
51
|
+
)}
|
|
52
|
+
{/* `data-timeline-chrome`: a readout, not a place to seek to. Clicking
|
|
53
|
+
it would otherwise jump the playhead to the far right of the view,
|
|
54
|
+
since it sits at the right edge of a strip that IS a time axis. */}
|
|
55
|
+
<span data-timeline-chrome className="cursor-default">{formatTime(contentDuration)}</span>
|
|
56
|
+
</div>
|
|
185
57
|
)
|
|
186
58
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A controlled speed picker: a slider plus a row of clickable preset chips
|
|
3
|
+
* snapping it to a common value. Shared by the per-clip inspect modal (host
|
|
4
|
+
* `montaj_assets/ui` package) and the track-wide settings popover
|
|
5
|
+
* (TrackSettingsPopover.tsx) — one control, one look, wherever speed is set.
|
|
6
|
+
*
|
|
7
|
+
* Controlled, not stateful: the caller owns `value` and reacts to `onChange`,
|
|
8
|
+
* exactly like the range inputs elsewhere in this rail (volume, fontsize).
|
|
9
|
+
* That keeps the two mount points free to wire it up differently — the clip
|
|
10
|
+
* modal previews live and commits on an explicit Save button; the track popover
|
|
11
|
+
* previews into local state and commits directly via `onCommit` (on slider
|
|
12
|
+
* release and on a chip click), so touching a track control just applies, no
|
|
13
|
+
* button, the same live-preview-then-commit split the volume fader uses.
|
|
14
|
+
*
|
|
15
|
+
* The slider itself is the shared `<Slider>` (ui/Slider.tsx) — see
|
|
16
|
+
* `VolumeControl`'s twin comment for the one behaviour change it brings
|
|
17
|
+
* (commit only on an actual gesture change, not on every pointerup/keyup).
|
|
18
|
+
*/
|
|
19
|
+
import { Slider } from '../../ui'
|
|
20
|
+
|
|
21
|
+
export interface SpeedControlProps {
|
|
22
|
+
value: number
|
|
23
|
+
onChange: (v: number) => void
|
|
24
|
+
/** Optional commit signal, fired on slider release (pointer/keyboard) and on
|
|
25
|
+
* a chip click, carrying the value to commit. Callers that commit on their
|
|
26
|
+
* own control (e.g. a Save button) omit it and just read `onChange`. */
|
|
27
|
+
onCommit?: (v: number) => void
|
|
28
|
+
min?: number
|
|
29
|
+
max?: number
|
|
30
|
+
step?: number
|
|
31
|
+
presets?: number[]
|
|
32
|
+
label?: string
|
|
33
|
+
/** Base id for the slider input, so a caller can wire a `<label htmlFor>`
|
|
34
|
+
* to it. Optional — the control is already self-labelled via `aria-label`. */
|
|
35
|
+
idBase?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_PRESETS = [0.25, 0.5, 1, 2, 4]
|
|
39
|
+
|
|
40
|
+
export default function SpeedControl({
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
onCommit,
|
|
44
|
+
min = 0.25,
|
|
45
|
+
max = 4,
|
|
46
|
+
step = 0.05,
|
|
47
|
+
presets = DEFAULT_PRESETS,
|
|
48
|
+
label,
|
|
49
|
+
idBase,
|
|
50
|
+
}: SpeedControlProps) {
|
|
51
|
+
const sliderId = idBase ? `${idBase}-speed-slider` : undefined
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-col gap-2">
|
|
55
|
+
<div className={`flex items-center text-[11px] text-[var(--editor-text)]/70 ${label ? 'justify-between' : 'justify-end'}`}>
|
|
56
|
+
{label && <span>{label}</span>}
|
|
57
|
+
<span className="font-mono text-[10px] text-[var(--editor-text)]/50">{value.toFixed(2)}×</span>
|
|
58
|
+
</div>
|
|
59
|
+
<Slider
|
|
60
|
+
id={sliderId}
|
|
61
|
+
min={min}
|
|
62
|
+
max={max}
|
|
63
|
+
step={step}
|
|
64
|
+
value={value}
|
|
65
|
+
aria-label={label ?? 'Speed'}
|
|
66
|
+
onChange={onChange}
|
|
67
|
+
onCommit={onCommit}
|
|
68
|
+
/>
|
|
69
|
+
{/* Quick-set chips under the slider — a click snaps straight to that
|
|
70
|
+
value rather than dragging for it. The active one is marked with
|
|
71
|
+
`aria-pressed` and the accent fill, mirroring the toolbar's own
|
|
72
|
+
bold/italic toggle buttons (TextFormattingToolbar.tsx). */}
|
|
73
|
+
<div role="group" aria-label={label ? `${label} presets` : 'Speed presets'} className="flex flex-wrap gap-1">
|
|
74
|
+
{presets.map(p => {
|
|
75
|
+
const active = Math.abs(value - p) < 1e-6
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
key={p}
|
|
79
|
+
type="button"
|
|
80
|
+
aria-pressed={active}
|
|
81
|
+
onClick={() => { onChange(p); onCommit?.(p) }}
|
|
82
|
+
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)] ${
|
|
83
|
+
active
|
|
84
|
+
? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
|
|
85
|
+
: 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
|
|
86
|
+
}`}
|
|
87
|
+
>
|
|
88
|
+
{p}×
|
|
89
|
+
</button>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|