@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
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react'
|
|
2
2
|
import type { PlaybackClock } from '../playback-clock'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* What Timeline shares with the chrome it renders around the canvas surface.
|
|
6
|
+
*
|
|
7
|
+
* That is now one component — `Scrubber`, the time-readout strip — so this
|
|
8
|
+
* carries exactly the two things it reads and nothing else. It used to also
|
|
9
|
+
* carry the canvas viewport, the DOM scroll container's zoom/scrollRef, the
|
|
10
|
+
* scrubber bar's rect ref and the overlay-drag flag, for the rows that stayed
|
|
11
|
+
* in the DOM while the tracks moved to canvas. There are no such rows left:
|
|
12
|
+
* clips, audio lanes and captions are all painted by `TimelineCanvas`, which
|
|
13
|
+
* reads the viewport from its own external store (`canvas/viewport.ts`) rather
|
|
14
|
+
* than through React, precisely so a zoom gesture doesn't re-render anything.
|
|
15
|
+
*
|
|
16
|
+
* Positions therefore never travel through this context — only times do. That
|
|
17
|
+
* is the property worth keeping: two surfaces that agreed about the clock but
|
|
18
|
+
* computed x independently is what made the old DOM playhead drift out of
|
|
19
|
+
* alignment with the clips above it the moment you zoomed.
|
|
20
|
+
*/
|
|
4
21
|
export interface TimelineContextValue {
|
|
5
|
-
totalDuration: number
|
|
6
22
|
contentDuration: number
|
|
7
|
-
snapBoundaries: number[]
|
|
8
|
-
zoom: number
|
|
9
|
-
zoomRef: React.RefObject<number>
|
|
10
|
-
scrollRef: React.RefObject<HTMLDivElement | null>
|
|
11
|
-
scrubberRef: React.RefObject<HTMLDivElement | null>
|
|
12
|
-
overlayDraggedRef: React.MutableRefObject<boolean>
|
|
13
23
|
clock: PlaybackClock
|
|
14
|
-
markers: [number | null, number | null]
|
|
15
|
-
setMarkers: (m: [number | null, number | null] | ((prev: [number | null, number | null]) => [number | null, number | null])) => void
|
|
16
|
-
selection: { start: number; end: number } | null
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export const TimelineContext = createContext<TimelineContextValue | null>(null)
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { useRef, useState, type RefObject } from 'react'
|
|
2
|
+
import { AudioLines, Captions, Eye, EyeOff, Film, Layers, Magnet, Settings2, Volume2, VolumeX } from 'lucide-react'
|
|
3
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
4
|
+
import type { Project } from '../../types'
|
|
5
|
+
import type { VisualItem } from '../../schema'
|
|
6
|
+
import { computeTimelineLayout, timelinePalette, type TimelineLayout, type TimelineMode, type TrackPalette, type VisualRowLayout, type AudioLaneLayout } from './canvas/draw'
|
|
7
|
+
import { AUDIO_LANE_HEIGHT_PX, normalizeTracks, trackItems } from './timeline-model'
|
|
8
|
+
import TrackSettingsPopover from './TrackSettingsPopover'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The left rail naming each track, aligned row-for-row with the canvas beside
|
|
12
|
+
* it — a real column rather than a badge floating over the clips, so it never
|
|
13
|
+
* covers the first frames of whatever sits at t=0.
|
|
14
|
+
*
|
|
15
|
+
* It carries what every clip used to repeat: the type. A track of twelve video
|
|
16
|
+
* clips said "video" twelve times, which cost a label slot on every block and
|
|
17
|
+
* still didn't tell you what the TRACK was. Saying it once here frees the
|
|
18
|
+
* clip's own label for what's actually particular to it — an overlay's
|
|
19
|
+
* component and copy — and lets a video clip carry no label at all, since its
|
|
20
|
+
* filmstrip identifies it better than a word could.
|
|
21
|
+
*
|
|
22
|
+
* The rail sits OUTSIDE the canvas, so the canvas keeps x=0 ≡ the viewport's
|
|
23
|
+
* scroll position and no coordinate math changes. It shrinks the canvas'
|
|
24
|
+
* measured width instead, which the viewport picks up on its own.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Icon plus one control, with room to breathe. A written label wide enough not
|
|
28
|
+
// to truncate ("Captions", "Overlay") cost ~88px of timeline for a word you
|
|
29
|
+
// only need once; the icon carries it and the word is a hover away. But 34px —
|
|
30
|
+
// icon alone, hard against both edges — was too tight to read and left nowhere
|
|
31
|
+
// for the skip control to live.
|
|
32
|
+
// Type glyph plus ONE column of stacked controls. Side-by-side controls forced
|
|
33
|
+
// the rail to 84px and still crowded; stacking them uses the height these rows
|
|
34
|
+
// already have — the base track is 120px tall and was showing a single row of
|
|
35
|
+
// icons in the top 16px of it.
|
|
36
|
+
const GUTTER_WIDTH_PX = 46
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a visual track is *for*, from what it holds. Tracks aren't typed in the
|
|
40
|
+
* schema — a track is just an array — so this reads the items: the type shared
|
|
41
|
+
* by all of them, or the first item's type when a track mixes (rare; an overlay
|
|
42
|
+
* track that also holds a still, say). An empty track has no answer, and says
|
|
43
|
+
* so rather than guessing "video".
|
|
44
|
+
*/
|
|
45
|
+
export function visualTrackKind(items: VisualItem[]): 'video' | 'overlay' | 'image' | null {
|
|
46
|
+
if (items.length === 0) return null
|
|
47
|
+
// The first item's type, whether or not the track is homogeneous — a mixed
|
|
48
|
+
// track (an overlay track that also holds a still, say) is named for what it
|
|
49
|
+
// mostly is rather than refusing to answer.
|
|
50
|
+
return items[0].type
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const KIND_LABEL: Record<string, string> = {
|
|
54
|
+
video: 'Video',
|
|
55
|
+
overlay: 'Overlay',
|
|
56
|
+
image: 'Image',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The track's shared clip speed, for the settings popover's display value —
|
|
61
|
+
* same "read the items, not track-level settings" approach `visualTrackKind`
|
|
62
|
+
* above uses, since speed lives on each video `VisualItem`, not the track.
|
|
63
|
+
* Returns 1 when the track holds no video clips or its clips disagree, same
|
|
64
|
+
* "representative value, actual apply always writes the real thing"
|
|
65
|
+
* convention `AudioLaneRailRow` already uses for a lane's volume.
|
|
66
|
+
*/
|
|
67
|
+
function commonTrackSpeed(items: VisualItem[]): number {
|
|
68
|
+
const speeds = items.filter(i => i.type === 'video').map(i => i.speed ?? 1)
|
|
69
|
+
if (speeds.length === 0) return 1
|
|
70
|
+
return speeds.every(s => s === speeds[0]) ? speeds[0] : 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function KindIcon({ kind, className }: { kind: string | null; className?: string }) {
|
|
74
|
+
if (kind === 'overlay' || kind === 'image') return <Layers size={15} className={className} />
|
|
75
|
+
return <Film size={15} className={className} />
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RailCellProps {
|
|
79
|
+
height: number
|
|
80
|
+
/** Left edge accent, matching the hue the clips on this row are drawn in. */
|
|
81
|
+
accent?: string
|
|
82
|
+
icon: React.ReactNode
|
|
83
|
+
label: string
|
|
84
|
+
/** Optional control rendered beside the icon — the skip toggle today. */
|
|
85
|
+
action?: React.ReactNode
|
|
86
|
+
/** Dim the cell to match a skipped track's dimmed clips. */
|
|
87
|
+
dimmed?: boolean
|
|
88
|
+
/**
|
|
89
|
+
* Makes the icon itself a button that opens the track-settings popover
|
|
90
|
+
* (TrackSettingsPopover). Absent → the icon stays the plain, non-interactive
|
|
91
|
+
* label it always was — the Captions row, or a visual/audio row when no
|
|
92
|
+
* settings callback at all is wired (same "no dead button" rule the skip
|
|
93
|
+
* toggle already follows).
|
|
94
|
+
*/
|
|
95
|
+
settingsButton?: {
|
|
96
|
+
onClick: () => void
|
|
97
|
+
open: boolean
|
|
98
|
+
buttonRef: RefObject<HTMLButtonElement | null>
|
|
99
|
+
/** Names the button for screen readers — the gear alone says nothing. */
|
|
100
|
+
label: string
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One row of the rail. Content sits at the TOP rather than centred: rows vary
|
|
106
|
+
* from 40px to 120px, and a centred icon on the tall base track floats in the
|
|
107
|
+
* middle of nothing while the short rows look fine. Top-aligned, every cell
|
|
108
|
+
* reads as the same control at the same place.
|
|
109
|
+
*/
|
|
110
|
+
function RailCell({ height, accent, icon, label, action, dimmed, settingsButton }: RailCellProps) {
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className={`flex items-start gap-1 rounded overflow-hidden bg-[var(--editor-surface)] border border-[var(--editor-border)] px-1 py-0.5 select-none transition-opacity ${dimmed ? 'opacity-40' : ''}`}
|
|
114
|
+
style={{ height, borderLeft: accent ? `2px solid ${accent}` : undefined }}
|
|
115
|
+
>
|
|
116
|
+
{/* The type icon identifies the row and is NOT interactive. It used to
|
|
117
|
+
double as the settings trigger, which made it impossible to tell which
|
|
118
|
+
icons could be clicked — a decorative-looking glyph that happened to
|
|
119
|
+
open a dialog. Settings moved to the gear beside it. */}
|
|
120
|
+
<Tooltip label={label}>
|
|
121
|
+
<span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[var(--editor-text)]/50">
|
|
122
|
+
{icon}
|
|
123
|
+
</span>
|
|
124
|
+
</Tooltip>
|
|
125
|
+
{/* One COLUMN of controls, not a row: the rows are 40-120px tall and were
|
|
126
|
+
wasting all of that height on a single line of icons, which forced the
|
|
127
|
+
rail wide enough to crowd the timeline.
|
|
128
|
+
|
|
129
|
+
The gap is a single constant across every row height, so the audio
|
|
130
|
+
lane's controls and the base track's controls read as the same
|
|
131
|
+
spacing rather than "cramped" vs. "spread". Content stays top-aligned,
|
|
132
|
+
so the tall base track just has unused space below the stack —
|
|
133
|
+
harmless — instead of gap scaling to fill it. */}
|
|
134
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
135
|
+
{action}
|
|
136
|
+
{settingsButton && (
|
|
137
|
+
<Tooltip label={settingsButton.label}>
|
|
138
|
+
<button
|
|
139
|
+
ref={settingsButton.buttonRef}
|
|
140
|
+
type="button"
|
|
141
|
+
aria-label={settingsButton.label}
|
|
142
|
+
aria-haspopup="dialog"
|
|
143
|
+
aria-expanded={settingsButton.open}
|
|
144
|
+
onClick={settingsButton.onClick}
|
|
145
|
+
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
146
|
+
settingsButton.open
|
|
147
|
+
? 'text-[var(--editor-text)]/90 bg-[var(--editor-text)]/10'
|
|
148
|
+
: 'text-[var(--editor-text)]/40 hover:text-[var(--editor-text)]/90 hover:bg-[var(--editor-text)]/10'
|
|
149
|
+
}`}
|
|
150
|
+
>
|
|
151
|
+
<Settings2 size={13} />
|
|
152
|
+
</button>
|
|
153
|
+
</Tooltip>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The per-track mute switch, inline in the rail beside skip.
|
|
162
|
+
*
|
|
163
|
+
* Mute lives here rather than behind the settings gear because it is a state
|
|
164
|
+
* you flip constantly while cutting and need to SEE at a glance — the icon is
|
|
165
|
+
* both the control and the indicator. The gear is for the settings you set once
|
|
166
|
+
* and leave (volume today, speed later).
|
|
167
|
+
*
|
|
168
|
+
* Only rendered for rows that can actually carry audio: an overlay-only track
|
|
169
|
+
* has no audio to mute, so offering the control there is a lie.
|
|
170
|
+
*/
|
|
171
|
+
function MuteToggle({ muted, onToggle, trackLabel, mode }: { muted: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
|
|
172
|
+
return (
|
|
173
|
+
<Tooltip label={muted ? `Unmute ${trackLabel}` : `Mute ${trackLabel}`}>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
aria-label={`Mute ${trackLabel}`}
|
|
177
|
+
aria-pressed={muted}
|
|
178
|
+
onClick={onToggle}
|
|
179
|
+
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
180
|
+
muted
|
|
181
|
+
? (mode === 'light' ? 'text-amber-600 hover:text-amber-700' : 'text-amber-400/90 hover:text-amber-300')
|
|
182
|
+
: 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
|
|
183
|
+
}`}
|
|
184
|
+
>
|
|
185
|
+
{muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
|
|
186
|
+
</button>
|
|
187
|
+
</Tooltip>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The per-lane magnet switch — gapless mode. Modeled directly on `MuteToggle`
|
|
193
|
+
* beside it: same size, same lit-vs-dim shape, the icon doubling as both
|
|
194
|
+
* control and indicator so the row's state reads at a glance without opening
|
|
195
|
+
* the settings gear. Audio-lane only today (see `AudioLaneRailRow`); no
|
|
196
|
+
* visual-track equivalent exists yet.
|
|
197
|
+
*/
|
|
198
|
+
function MagnetToggle({ magnetic, onToggle, trackLabel, mode }: { magnetic: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
|
|
199
|
+
return (
|
|
200
|
+
<Tooltip label="Magnetic track — keep gapless">
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
aria-label={`Magnetic ${trackLabel}`}
|
|
204
|
+
aria-pressed={magnetic}
|
|
205
|
+
onClick={onToggle}
|
|
206
|
+
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
207
|
+
magnetic
|
|
208
|
+
? (mode === 'light' ? 'text-emerald-600 hover:text-emerald-700' : 'text-emerald-400/90 hover:text-emerald-300')
|
|
209
|
+
: 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
|
|
210
|
+
}`}
|
|
211
|
+
>
|
|
212
|
+
<Magnet size={13} />
|
|
213
|
+
</button>
|
|
214
|
+
</Tooltip>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The per-track skip switch. Eye/EyeOff rather than a checkbox: it reads at
|
|
219
|
+
* 14px and says "shown / not shown" without a word. */
|
|
220
|
+
function SkipToggle({ enabled, onToggle, trackLabel, mode }: { enabled: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
|
|
221
|
+
return (
|
|
222
|
+
<Tooltip label={enabled ? `Skip ${trackLabel} track` : `Un-skip ${trackLabel} track`}>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
aria-label={`Skip ${trackLabel} track`}
|
|
226
|
+
aria-pressed={!enabled}
|
|
227
|
+
onClick={onToggle}
|
|
228
|
+
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
229
|
+
enabled
|
|
230
|
+
? 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
|
|
231
|
+
: (mode === 'light' ? 'text-amber-600/90 hover:text-amber-700' : 'text-amber-400/80 hover:text-amber-300')
|
|
232
|
+
}`}
|
|
233
|
+
>
|
|
234
|
+
{enabled ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
235
|
+
</button>
|
|
236
|
+
</Tooltip>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One visual-track row: the rail cell plus, on demand, its settings popover.
|
|
242
|
+
* Pulled out of the row `.map()` in `TrackGutter` because it needs its own
|
|
243
|
+
* `useState`/`useRef` for the popover's open state and anchor — hooks can't
|
|
244
|
+
* live inside a bare map callback.
|
|
245
|
+
*/
|
|
246
|
+
function VisualTrackRailRow({
|
|
247
|
+
row,
|
|
248
|
+
palette,
|
|
249
|
+
mode,
|
|
250
|
+
label,
|
|
251
|
+
kind,
|
|
252
|
+
enabled,
|
|
253
|
+
volume,
|
|
254
|
+
muted,
|
|
255
|
+
speed,
|
|
256
|
+
onToggleTrackEnabled,
|
|
257
|
+
onSetTrackVolume,
|
|
258
|
+
onSetTrackMuted,
|
|
259
|
+
onApplySpeed,
|
|
260
|
+
}: {
|
|
261
|
+
row: VisualRowLayout
|
|
262
|
+
palette: TrackPalette
|
|
263
|
+
mode: TimelineMode
|
|
264
|
+
label: string
|
|
265
|
+
kind: string | null
|
|
266
|
+
enabled: boolean
|
|
267
|
+
volume: number
|
|
268
|
+
muted: boolean
|
|
269
|
+
speed: number
|
|
270
|
+
onToggleTrackEnabled?: (trackIdx: number, enabled: boolean) => void
|
|
271
|
+
onSetTrackVolume?: (trackIdx: number, volume: number, commit: boolean) => void
|
|
272
|
+
onSetTrackMuted?: (trackIdx: number, muted: boolean) => void
|
|
273
|
+
onApplySpeed?: (trackIdx: number, speed: number) => void
|
|
274
|
+
}) {
|
|
275
|
+
const [open, setOpen] = useState(false)
|
|
276
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
277
|
+
|
|
278
|
+
// Audio controls only where there IS audio. An overlay- or image-only track
|
|
279
|
+
// carries none — `VisualItem.volume`/`muted` are documented "video type only" —
|
|
280
|
+
// so a volume slider and a mute button on such a row are controls that cannot
|
|
281
|
+
// do anything. Read the items rather than the track's nominal kind, so a
|
|
282
|
+
// mixed track (an overlay track that also holds a clip) still gets them.
|
|
283
|
+
// Speed is the same video-only concept, so it shares this gate.
|
|
284
|
+
const hasAudio = row.items.some(i => i.type === 'video')
|
|
285
|
+
|
|
286
|
+
const hasVolumeControl = hasAudio && !!onSetTrackVolume
|
|
287
|
+
const hasSpeedControl = hasAudio && !!onApplySpeed
|
|
288
|
+
|
|
289
|
+
// A button that opens an empty popover is worse than no button.
|
|
290
|
+
const hasSettings = hasVolumeControl || hasSpeedControl
|
|
291
|
+
const trackLabel = label.toLowerCase()
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div className="absolute inset-x-0" style={{ top: row.y, height: row.height }}>
|
|
295
|
+
<RailCell
|
|
296
|
+
height={row.height}
|
|
297
|
+
accent={palette.border}
|
|
298
|
+
icon={<KindIcon kind={kind} />}
|
|
299
|
+
label={label}
|
|
300
|
+
dimmed={!enabled}
|
|
301
|
+
settingsButton={hasSettings ? { onClick: () => setOpen(o => !o), open, buttonRef, label: `${label} track settings` } : undefined}
|
|
302
|
+
action={
|
|
303
|
+
<>
|
|
304
|
+
{/* Visibility on top — it is the control you reach for most, and the
|
|
305
|
+
one whose state you scan a whole column of rows for. Mute sits
|
|
306
|
+
under it so the two state toggles stay together, with the
|
|
307
|
+
settings gear last. */}
|
|
308
|
+
{onToggleTrackEnabled && (
|
|
309
|
+
<SkipToggle
|
|
310
|
+
enabled={enabled}
|
|
311
|
+
mode={mode}
|
|
312
|
+
trackLabel={trackLabel}
|
|
313
|
+
onToggle={() => onToggleTrackEnabled(row.trackIdx, !enabled)}
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
{hasAudio && onSetTrackMuted && (
|
|
317
|
+
<MuteToggle
|
|
318
|
+
muted={muted}
|
|
319
|
+
mode={mode}
|
|
320
|
+
trackLabel={`${trackLabel} track`}
|
|
321
|
+
onToggle={() => onSetTrackMuted(row.trackIdx, !muted)}
|
|
322
|
+
/>
|
|
323
|
+
)}
|
|
324
|
+
</>
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
{open && hasSettings && (
|
|
328
|
+
<TrackSettingsPopover
|
|
329
|
+
anchorRef={buttonRef}
|
|
330
|
+
onClose={() => setOpen(false)}
|
|
331
|
+
title={`${label} track`}
|
|
332
|
+
volume={hasVolumeControl ? volume : undefined}
|
|
333
|
+
onVolumeChange={hasVolumeControl ? (v, commit) => onSetTrackVolume!(row.trackIdx, v, commit) : undefined}
|
|
334
|
+
volumeAriaLabel={`${label} volume`}
|
|
335
|
+
speed={hasSpeedControl ? speed : undefined}
|
|
336
|
+
onApplySpeed={hasSpeedControl ? s => onApplySpeed!(row.trackIdx, s) : undefined}
|
|
337
|
+
mode={mode}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* One audio-lane row — same shape as `VisualTrackRailRow` minus skip (audio
|
|
346
|
+
* lanes have no enabled/disabled concept; mute IS their skip). A lane can hold
|
|
347
|
+
* several `AudioTrack`s (see `groupAudioLanes` in timeline-model.ts), so the
|
|
348
|
+
* popover's controls apply to every track sharing the lane — Timeline.tsx's
|
|
349
|
+
* `handleSetLaneVolume`/`handleSetLaneMuted` do the fan-out via the ids this
|
|
350
|
+
* component passes up.
|
|
351
|
+
*/
|
|
352
|
+
function AudioLaneRailRow({
|
|
353
|
+
lane,
|
|
354
|
+
accent,
|
|
355
|
+
mode,
|
|
356
|
+
onSetLaneVolume,
|
|
357
|
+
onSetLaneMuted,
|
|
358
|
+
onSetLaneMagnet,
|
|
359
|
+
}: {
|
|
360
|
+
lane: AudioLaneLayout
|
|
361
|
+
/** The emerald edge, handed down rather than hardcoded here: it is the audio
|
|
362
|
+
* BAR's own border colour, and on a light ground that is a different
|
|
363
|
+
* emerald. A literal here would have gone on saying the dark one. */
|
|
364
|
+
accent: string
|
|
365
|
+
mode: TimelineMode
|
|
366
|
+
onSetLaneVolume?: (trackIds: string[], volume: number, commit: boolean) => void
|
|
367
|
+
onSetLaneMuted?: (trackIds: string[], muted: boolean) => void
|
|
368
|
+
onSetLaneMagnet?: (trackIds: string[], magnetic: boolean) => void
|
|
369
|
+
}) {
|
|
370
|
+
const [open, setOpen] = useState(false)
|
|
371
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
372
|
+
// The gear holds volume only now that mute is inline in the rail.
|
|
373
|
+
const hasSettings = !!onSetLaneVolume
|
|
374
|
+
|
|
375
|
+
// Representative displayed value when the lane's tracks disagree: the
|
|
376
|
+
// FIRST track's volume, and "every track muted" for the mute switch's
|
|
377
|
+
// checked state — a half-muted lane reads as unmuted rather than silently
|
|
378
|
+
// implying full mute. This only affects what's shown before you touch it;
|
|
379
|
+
// committing the control always applies the new value to EVERY track in
|
|
380
|
+
// the lane, per the task's own rule, regardless of what was shown.
|
|
381
|
+
const volume = lane.tracks[0]?.volume ?? 1
|
|
382
|
+
const muted = lane.tracks.length > 0 && lane.tracks.every(t => t.muted)
|
|
383
|
+
const magnetic = lane.tracks.length > 0 && lane.tracks.every(t => t.magnetic)
|
|
384
|
+
const trackIds = lane.tracks.map(t => t.id)
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div className="absolute inset-x-0" style={{ top: lane.y, height: lane.height }}>
|
|
388
|
+
<RailCell
|
|
389
|
+
height={AUDIO_LANE_HEIGHT_PX}
|
|
390
|
+
accent={accent}
|
|
391
|
+
icon={<AudioLines size={15} />}
|
|
392
|
+
label="Audio"
|
|
393
|
+
settingsButton={hasSettings ? { onClick: () => setOpen(o => !o), open, buttonRef, label: 'Audio lane settings' } : undefined}
|
|
394
|
+
action={
|
|
395
|
+
<>
|
|
396
|
+
{onSetLaneMuted && (
|
|
397
|
+
<MuteToggle
|
|
398
|
+
muted={muted}
|
|
399
|
+
mode={mode}
|
|
400
|
+
trackLabel="audio lane"
|
|
401
|
+
onToggle={() => onSetLaneMuted(trackIds, !muted)}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
{onSetLaneMagnet && (
|
|
405
|
+
<MagnetToggle
|
|
406
|
+
magnetic={magnetic}
|
|
407
|
+
mode={mode}
|
|
408
|
+
trackLabel="audio lane"
|
|
409
|
+
onToggle={() => onSetLaneMagnet(trackIds, !magnetic)}
|
|
410
|
+
/>
|
|
411
|
+
)}
|
|
412
|
+
</>
|
|
413
|
+
}
|
|
414
|
+
/>
|
|
415
|
+
{open && hasSettings && (
|
|
416
|
+
<TrackSettingsPopover
|
|
417
|
+
anchorRef={buttonRef}
|
|
418
|
+
onClose={() => setOpen(false)}
|
|
419
|
+
title="Audio"
|
|
420
|
+
volume={volume}
|
|
421
|
+
onVolumeChange={(v, commit) => onSetLaneVolume!(trackIds, v, commit)}
|
|
422
|
+
volumeAriaLabel="Audio lane volume"
|
|
423
|
+
mode={mode}
|
|
424
|
+
/>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface TrackGutterProps {
|
|
431
|
+
project: Project
|
|
432
|
+
/** Gates the caption rail cells, aligned with the canvas' own caption bands
|
|
433
|
+
* (`layout.captions`). Has no effect when the layout carries no bands at all
|
|
434
|
+
* (a project with no caption segments) — there is nothing to align a cell
|
|
435
|
+
* with. Defaults true so a host that hasn't wired the flag still sees the
|
|
436
|
+
* cells whenever the layout has any. */
|
|
437
|
+
showCaptionRow?: boolean
|
|
438
|
+
/** Layout to align against. Defaults to computing it from `project` — pass the
|
|
439
|
+
* caller's own so the two can't drift. */
|
|
440
|
+
layout?: TimelineLayout
|
|
441
|
+
/** Toggle a visual track's `enabled`. Absent → no skip controls (a host that
|
|
442
|
+
* hasn't wired the edit channel shows the rail read-only rather than
|
|
443
|
+
* offering a button that does nothing). */
|
|
444
|
+
onToggleTrackEnabled?: (trackIdx: number, enabled: boolean) => void
|
|
445
|
+
/** Visual-track volume, from the settings popover. `commit: false` on every
|
|
446
|
+
* drag tick (live preview only), `commit: true` once on release/keyup —
|
|
447
|
+
* mirrors `onToggleTrackEnabled`'s "absent ⇒ no control" convention. */
|
|
448
|
+
onSetTrackVolume?: (trackIdx: number, volume: number, commit: boolean) => void
|
|
449
|
+
/** Visual-track mute, from the settings popover. One call per click — mute
|
|
450
|
+
* is a discrete toggle, not a continuous gesture. */
|
|
451
|
+
onSetTrackMuted?: (trackIdx: number, muted: boolean) => void
|
|
452
|
+
/** Applies a speed to EVERY video clip on the track, from the settings
|
|
453
|
+
* popover's Speed control — committed on slider release / chip click (no
|
|
454
|
+
* button), a bulk edit with no `commit` flag (mirrors `onSetTrackMuted`).
|
|
455
|
+
* The DISPLAYED value isn't a prop — it's read off the track's own clips,
|
|
456
|
+
* same as `kind` above (see `commonTrackSpeed`). */
|
|
457
|
+
onApplySpeed?: (trackIdx: number, speed: number) => void
|
|
458
|
+
/** Audio-lane volume, from the settings popover — applies to every
|
|
459
|
+
* `AudioTrack` sharing the lane, hence the id array rather than one id. */
|
|
460
|
+
onSetLaneVolume?: (trackIds: string[], volume: number, commit: boolean) => void
|
|
461
|
+
/** Audio-lane mute, from the settings popover — same fan-out as volume. */
|
|
462
|
+
onSetLaneMuted?: (trackIds: string[], muted: boolean) => void
|
|
463
|
+
/** Audio-lane magnet (gapless mode), from the rail's inline toggle — same
|
|
464
|
+
* fan-out as mute/volume, and the same "absent ⇒ no control" convention. */
|
|
465
|
+
onSetLaneMagnet?: (trackIds: string[], magnetic: boolean) => void
|
|
466
|
+
/** Which ground the rail's non-token colours are picked for. The cell chrome
|
|
467
|
+
* itself is `--editor-*` driven and flips on its own; the LEFT-EDGE ACCENTS
|
|
468
|
+
* are not — they are the canvas' own track/caption/audio hues, and they have
|
|
469
|
+
* to be resolved from the same palette the canvas paints with or the rail
|
|
470
|
+
* and the clips beside it stop matching. Threaded from Timeline (never
|
|
471
|
+
* looked up here) for exactly that reason. Defaults to `'dark'`. */
|
|
472
|
+
mode?: TimelineMode
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export default function TrackGutter({
|
|
476
|
+
project,
|
|
477
|
+
showCaptionRow = true,
|
|
478
|
+
layout,
|
|
479
|
+
mode = 'dark',
|
|
480
|
+
onToggleTrackEnabled,
|
|
481
|
+
onSetTrackVolume,
|
|
482
|
+
onSetTrackMuted,
|
|
483
|
+
onApplySpeed,
|
|
484
|
+
onSetLaneVolume,
|
|
485
|
+
onSetLaneMuted,
|
|
486
|
+
onSetLaneMagnet,
|
|
487
|
+
}: TrackGutterProps) {
|
|
488
|
+
const resolved = layout ?? computeTimelineLayout(project)
|
|
489
|
+
const palette = timelinePalette(mode)
|
|
490
|
+
const tracks = trackItems(project)
|
|
491
|
+
// Read `enabled`/`volume`/`muted` off the normalized tracks — `trackItems`
|
|
492
|
+
// gives items only, and a legacy-shaped project has no settings at all
|
|
493
|
+
// (every track enabled, unity volume, unmuted).
|
|
494
|
+
const trackSettings = normalizeTracks(project).tracks ?? []
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div className="flex shrink-0 flex-col" style={{ width: GUTTER_WIDTH_PX }}>
|
|
498
|
+
{/* Absolute positioning mirrors the canvas' own layout pass, so a row's
|
|
499
|
+
label sits at exactly the y its clips do — including the base track's
|
|
500
|
+
extra height. */}
|
|
501
|
+
<div className="relative" style={{ height: resolved.height }}>
|
|
502
|
+
{resolved.rows.map(row => {
|
|
503
|
+
const kind = visualTrackKind(tracks[row.trackIdx] ?? [])
|
|
504
|
+
const trackPalette = palette.tracks[row.trackIdx % palette.tracks.length]
|
|
505
|
+
const label = kind ? KIND_LABEL[kind] ?? kind : 'Track'
|
|
506
|
+
const settings = trackSettings[row.trackIdx]
|
|
507
|
+
const enabled = settings?.enabled !== false
|
|
508
|
+
return (
|
|
509
|
+
<VisualTrackRailRow
|
|
510
|
+
key={`row-${row.trackIdx}`}
|
|
511
|
+
row={row}
|
|
512
|
+
palette={trackPalette}
|
|
513
|
+
mode={mode}
|
|
514
|
+
label={label}
|
|
515
|
+
kind={kind}
|
|
516
|
+
enabled={enabled}
|
|
517
|
+
volume={settings?.volume ?? 1}
|
|
518
|
+
muted={settings?.muted ?? false}
|
|
519
|
+
speed={commonTrackSpeed(tracks[row.trackIdx] ?? [])}
|
|
520
|
+
onToggleTrackEnabled={onToggleTrackEnabled}
|
|
521
|
+
onSetTrackVolume={onSetTrackVolume}
|
|
522
|
+
onSetTrackMuted={onSetTrackMuted}
|
|
523
|
+
onApplySpeed={onApplySpeed}
|
|
524
|
+
/>
|
|
525
|
+
)
|
|
526
|
+
})}
|
|
527
|
+
{resolved.lanes.map(lane => (
|
|
528
|
+
<AudioLaneRailRow
|
|
529
|
+
key={`lane-${lane.laneIndex}`}
|
|
530
|
+
lane={lane}
|
|
531
|
+
accent={palette.colors.audioBorder}
|
|
532
|
+
mode={mode}
|
|
533
|
+
onSetLaneVolume={onSetLaneVolume}
|
|
534
|
+
onSetLaneMuted={onSetLaneMuted}
|
|
535
|
+
onSetLaneMagnet={onSetLaneMagnet}
|
|
536
|
+
/>
|
|
537
|
+
))}
|
|
538
|
+
{/* Same absolute-positioned wrapper every other row uses, keyed off
|
|
539
|
+
each band's own `y` — gutter and canvas read the identical
|
|
540
|
+
rectangles and can never drift apart. Gated on `resolved.captions`
|
|
541
|
+
FIRST: a caption-less project's layout carries no bands at all (see
|
|
542
|
+
TimelineLayout.captions), so `showCaptionRow` defaulting true must
|
|
543
|
+
not conjure cells with nothing to align to.
|
|
544
|
+
One band ⇒ the bare "Captions" label, unchanged from before N
|
|
545
|
+
lanes existed. Several bands ⇒ "Captions 1"…"Captions N" (N =
|
|
546
|
+
lane + 1) so each cell reads which lane it aligns to. */}
|
|
547
|
+
{showCaptionRow && resolved.captions?.map(band => (
|
|
548
|
+
<div key={`caption-${band.lane}`} className="absolute inset-x-0" style={{ top: band.y, height: band.height }}>
|
|
549
|
+
<RailCell
|
|
550
|
+
height={band.height}
|
|
551
|
+
accent={palette.captionRailAccent}
|
|
552
|
+
icon={<Captions size={15} />}
|
|
553
|
+
label={resolved.captions!.length > 1 ? `Captions ${band.lane + 1}` : 'Captions'}
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
)
|
|
560
|
+
}
|