@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,51 +1,92 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import type { Project } from '../../types'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from 'react'
|
|
2
|
+
import { Scissors } from 'lucide-react'
|
|
3
|
+
import { EASING_NAMES } from '@bycrux/timeline-core'
|
|
4
|
+
import type { FilmstripIndex, GetFilmstripArgs, GetWaveformPeaksArgs, PeaksData, PendingDrop, Project, ResolveFilePath, TimelineDropPlacement } from '../../types'
|
|
5
|
+
import type { EasingName, KeyframeProp, VisualItem } from '../../schema'
|
|
6
|
+
import { reflowMagneticLanes } from '../audioMagnet'
|
|
7
|
+
import { normalizeCaptionLanes } from '../captionLanes'
|
|
8
|
+
import { collapseGaps, setClipSpeed } from '../cuts'
|
|
9
|
+
import { removeKeyframesAt, setKeyframeEasing } from '../keyframeOps'
|
|
7
10
|
import { useTimelineZoom } from './useTimelineZoom'
|
|
8
11
|
import { TimelineContext, type TimelineContextValue } from './TimelineContext'
|
|
9
|
-
import {
|
|
12
|
+
import type { PlaybackClock } from '../playback-clock'
|
|
10
13
|
import Scrubber from './Scrubber'
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
14
|
+
import TrackGutter from './TrackGutter'
|
|
15
|
+
import { computeTimelineLayout, timelinePalette, type TimelineLayout, type TimelineMode } from './canvas/draw'
|
|
16
|
+
import { DEFAULT_FADE_CURVE, fadeCurveIconPoints, type FadeCurve } from './canvas/fade-curve'
|
|
17
|
+
import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
|
|
18
|
+
import { keyframeUnionTimes } from './canvas/keyframe-strip'
|
|
19
|
+
import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
|
|
15
20
|
import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
16
|
-
import
|
|
21
|
+
import { computeAutoCrossfade, computeDerivedTiming, trackItems } from './timeline-model'
|
|
22
|
+
import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
|
|
23
|
+
import type { KeyframeSelection } from './canvas/pointer-machine'
|
|
24
|
+
import { timeToX, useViewportStore, useViewportValue, xToTime, type ViewportStore } from './canvas/viewport'
|
|
25
|
+
import { useKeymap, matchesArrowLeft, matchesArrowRight, matchesDelete, matchesModKey } from '../keymap'
|
|
26
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
27
|
+
|
|
28
|
+
/** Re-exported so the host (`VideoEditor`) can name the mode it resolves from
|
|
29
|
+
* the theme without reaching into `canvas/draw` — Timeline is the seam it
|
|
30
|
+
* already imports. */
|
|
31
|
+
export type { TimelineMode } from './canvas/draw'
|
|
32
|
+
|
|
33
|
+
/** How long the auto-crossfade pass waits after an audio-timing change before
|
|
34
|
+
* it COMMITS the derived fades. Long enough that the continuous changes of a
|
|
35
|
+
* live drag keep resetting it (so a whole gesture commits its crossfade once,
|
|
36
|
+
* via `commitTimelineEdit`, not per frame); short enough that a settled edge
|
|
37
|
+
* edit or ripple-delete lands its fade promptly. See the effect below. */
|
|
38
|
+
export const CROSSFADE_COMMIT_DELAY_MS = 250
|
|
39
|
+
|
|
40
|
+
/** Imperative actions a host-level command palette can trigger on the
|
|
41
|
+
* timeline's zoom without lifting Timeline's local state up. Filled via
|
|
42
|
+
* `actionsRef` (SP5 T9) — mirrors `transportRef`'s "ref threaded down,
|
|
43
|
+
* written by the owner" shape used for PreviewPlayer. */
|
|
44
|
+
export interface TimelineActions {
|
|
45
|
+
zoomFit: () => void
|
|
46
|
+
}
|
|
17
47
|
|
|
18
48
|
interface TimelineProps {
|
|
19
49
|
project: Project
|
|
20
50
|
clock: PlaybackClock
|
|
21
51
|
onProjectChange?: (p: Project) => void
|
|
22
|
-
onCaptionEdit?: (p: Project) => void
|
|
23
52
|
onOverlayEdit?: (p: Project) => void
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
53
|
+
/** Unified selection — covers both visual items and audio tracks. Captions
|
|
54
|
+
* share this same array (D1) — a caption id is selected/moved/trimmed on
|
|
55
|
+
* the canvas timeline exactly like a clip or audio track. Caption editing
|
|
56
|
+
* UI itself (text, style, per-segment color) lives in the sidebar
|
|
57
|
+
* CaptionListPanel now, not in Timeline — see VideoEditor.tsx. */
|
|
28
58
|
selectedIds?: string[]
|
|
29
59
|
onSelectIds?: (ids: string[]) => void
|
|
30
|
-
/** Selected caption segment id — shared with the preview's selection box.
|
|
31
|
-
* Mutually exclusive with `selectedIds` (see CaptionTrackRow). */
|
|
32
|
-
selectedCaptionId?: string | null
|
|
33
|
-
onSelectCaption?: (id: string | null) => void
|
|
34
|
-
/** Commit a caption segment patch — text edit or edge-drag retime. Threaded
|
|
35
|
-
* straight to CaptionTrackRow (see makeCaptionEdit.ts). */
|
|
36
|
-
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
37
|
-
onSplit?: (at: number) => void
|
|
38
|
-
onCut?: (cut: { start: number; end: number }) => void
|
|
39
60
|
onInspectClip?: (id: string) => void
|
|
40
61
|
onInspectAudio?: (id: string) => void
|
|
41
|
-
|
|
62
|
+
/** Double-click on a caption block. Forwarded straight to
|
|
63
|
+
* `TimelineCanvas` — Timeline itself does nothing with it beyond passing it
|
|
64
|
+
* through, same as `onInspectClip`/`onInspectAudio` above. The host
|
|
65
|
+
* (VideoEditor) uses this to focus the segment's row in the sidebar
|
|
66
|
+
* CaptionListPanel, since a caption has no inspector dialog of its own. */
|
|
67
|
+
onEditCaption?: (id: string) => void
|
|
42
68
|
rippleMode?: boolean
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
69
|
+
/**
|
|
70
|
+
* CapCut's "preview axis" toggle, owned by VideoEditor's track-controls bar
|
|
71
|
+
* and OFF by default — off is exactly the behaviour this timeline has always
|
|
72
|
+
* had, so omitting it changes nothing. On, a yellow cursor line tracks the
|
|
73
|
+
* pointer and `onHoverScrub` reports the time under it.
|
|
74
|
+
*/
|
|
75
|
+
previewAxis?: boolean
|
|
76
|
+
/** The time under the pointer while the axis is on, null when it leaves.
|
|
77
|
+
* Fires per mousemove — VideoEditor routes it into an external store. */
|
|
78
|
+
onHoverScrub?: (time: number | null) => void
|
|
47
79
|
/** Resolves a waveform chunk's host path into a displayable URL. */
|
|
48
80
|
resolveFilePath?: ResolveFilePath
|
|
81
|
+
/** Zoom-bucketed peaks fetcher for the canvas timeline's waveforms (T6),
|
|
82
|
+
* threaded from `adapter.getWaveformPeaks`. Passed straight to
|
|
83
|
+
* `TimelineCanvas`. Absent → no waveforms anywhere (graceful). */
|
|
84
|
+
getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
|
|
85
|
+
/** Filmstrip-index fetcher for the canvas timeline's tile strips + hover-
|
|
86
|
+
* scrub thumb (T7), threaded from `adapter.getFilmstrip`. Passed straight
|
|
87
|
+
* to `TimelineCanvas`. Absent → no filmstrips or thumbs anywhere
|
|
88
|
+
* (graceful). */
|
|
89
|
+
getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
|
|
49
90
|
/** Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
|
|
50
91
|
* ai_video projects). The package never reads `projectType`. */
|
|
51
92
|
regenEnabled?: boolean
|
|
@@ -59,328 +100,937 @@ interface TimelineProps {
|
|
|
59
100
|
* regenQueue, storyboard, and onSave — none of which the package types know.
|
|
60
101
|
* Absent → the subcut tool is simply not rendered. */
|
|
61
102
|
renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
103
|
+
/**
|
|
104
|
+
* SP5 T9 — true while a HOST-level dialog (RenderModal, the command
|
|
105
|
+
* palette, etc.) is open, so Timeline's own keymap (arrows/delete/enter/
|
|
106
|
+
* escape) doesn't fire underneath it. Absent (PendingSurface today has no
|
|
107
|
+
* such dialogs) → the keymap's own guards apply, unchanged.
|
|
108
|
+
*/
|
|
109
|
+
modalOpen?: boolean
|
|
110
|
+
/** Opens the command palette's "go to time" input directly. Wired to the
|
|
111
|
+
* scrubber's time readout; absent → the readout is plain text. */
|
|
112
|
+
onOpenGoToTime?: () => void
|
|
113
|
+
/** Imperative zoom actions for a host-level command palette. */
|
|
114
|
+
actionsRef?: MutableRefObject<TimelineActions | null>
|
|
115
|
+
/**
|
|
116
|
+
* Which ground the timeline paints on, resolved from the host theme by
|
|
117
|
+
* `VideoEditor` (`isLightTheme`). Threaded — not looked up here — so the
|
|
118
|
+
* canvas surface, the track rail and the fade-shape icons can never disagree
|
|
119
|
+
* about the mode: three independent lookups is three chances to drift.
|
|
120
|
+
* Defaults to `'dark'`, the only mode this timeline had.
|
|
121
|
+
*/
|
|
122
|
+
mode?: TimelineMode
|
|
123
|
+
/** A drop of real OS files onto the canvas surface, threaded straight to
|
|
124
|
+
* `TimelineCanvas` — Timeline does nothing with it beyond passing it
|
|
125
|
+
* through, same as `onInspectClip`/`getFilmstrip` above. Absent → an
|
|
126
|
+
* OS-file drag is not accepted at all and the browser keeps its default
|
|
127
|
+
* handling (see the prop's own doc in types.ts). */
|
|
128
|
+
onImportFilesToTimeline?: (files: File[], placement: TimelineDropPlacement) => void
|
|
129
|
+
/** Ghost bands for the host's in-flight file imports. Passed straight to
|
|
130
|
+
* `TimelineCanvas`. Absent/empty → nothing extra is drawn. */
|
|
131
|
+
pendingDrops?: readonly PendingDrop[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Icon size for the fade-shape picker's buttons — small enough for a
|
|
135
|
+
* compact popover, large enough that `exp`/`log`'s curvature reads clearly
|
|
136
|
+
* at a glance. */
|
|
137
|
+
const FADE_CURVE_ICON_SIZE = { width: 40, height: 28 }
|
|
138
|
+
/** Inset between the icon's frame and where the curve itself is drawn, so
|
|
139
|
+
* the polyline never touches (and gets clipped by) the frame's own stroke. */
|
|
140
|
+
const FADE_CURVE_ICON_PAD = 4
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One fade-shape picker option's icon: a small inline SVG tracing the ACTUAL
|
|
144
|
+
* curve (`fadeCurveIconPoints`, sampling the same `fadeGain` the real
|
|
145
|
+
* envelope and waveform scaling use), not a decorative stand-in — "Linear"
|
|
146
|
+
* really is a straight ramp, "Logarithmic" really is concave-down (rises
|
|
147
|
+
* fast, levels near the top), "Exponential" really is concave-up (stays low,
|
|
148
|
+
* rises sharply near the end). Silent is the bottom-left corner, full volume
|
|
149
|
+
* the top-right, matching Vegas' own fade-icon convention.
|
|
150
|
+
*
|
|
151
|
+
* `active` brightens both the frame and the stroke — the same "clearer when
|
|
152
|
+
* it matters" language the timeline's other affordances (trim handles, fade
|
|
153
|
+
* grips) already use — so the picker itself shows which shape is currently
|
|
154
|
+
* set the moment it opens.
|
|
155
|
+
*/
|
|
156
|
+
function FadeCurveIcon({ curve, active, mode = 'dark' }: { curve: FadeCurve; active: boolean; mode?: TimelineMode }) {
|
|
157
|
+
const { width: w, height: h } = FADE_CURVE_ICON_SIZE
|
|
158
|
+
const points = fadeCurveIconPoints(curve, w - FADE_CURVE_ICON_PAD * 2, h - FADE_CURVE_ICON_PAD * 2)
|
|
159
|
+
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
|
160
|
+
// The ACTIVE stroke is the real envelope line, taken from the same palette
|
|
161
|
+
// the canvas paints the real fade with — the icon and the thing it sets have
|
|
162
|
+
// to be the same colour or the picker stops being a preview. The inactive
|
|
163
|
+
// pair is this icon's own: white-on-dark in dark mode, near-black-on-light
|
|
164
|
+
// in light, matching the menu ground below (which flips with `mode` too).
|
|
165
|
+
const colors = timelinePalette(mode).colors
|
|
166
|
+
const idleFrame = mode === 'light' ? 'rgba(15,23,42,0.18)' : 'rgba(255,255,255,0.15)'
|
|
167
|
+
const idleCurve = mode === 'light' ? 'rgba(15,23,42,0.7)' : 'rgba(255,255,255,0.75)'
|
|
168
|
+
return (
|
|
169
|
+
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden="true" className="block">
|
|
170
|
+
<rect
|
|
171
|
+
x={0.5} y={0.5} width={w - 1} height={h - 1} rx={3}
|
|
172
|
+
fill="none"
|
|
173
|
+
stroke={active ? colors.fadeEnvelopeLine : idleFrame}
|
|
174
|
+
strokeWidth={1}
|
|
175
|
+
/>
|
|
176
|
+
<path
|
|
177
|
+
d={d}
|
|
178
|
+
transform={`translate(${FADE_CURVE_ICON_PAD},${FADE_CURVE_ICON_PAD})`}
|
|
179
|
+
fill="none"
|
|
180
|
+
stroke={active ? colors.fadeEnvelopeLine : idleCurve}
|
|
181
|
+
strokeWidth={1.75}
|
|
182
|
+
strokeLinecap="round"
|
|
183
|
+
strokeLinejoin="round"
|
|
184
|
+
/>
|
|
185
|
+
</svg>
|
|
186
|
+
)
|
|
66
187
|
}
|
|
67
188
|
|
|
189
|
+
/** Gap between a clip's right edge and the right edge of its HTML chrome, in
|
|
190
|
+
* CSS px. Derived from the trim handle's own grab width rather than picked, so
|
|
191
|
+
* the Scissors button can never cover the band a trim has to be started in —
|
|
192
|
+
* a button sitting on the last two pixels of the handle is a trim you can't
|
|
193
|
+
* begin, which is exactly the kind of overlap a magic number drifts into. */
|
|
194
|
+
export const CLIP_CHROME_RIGHT_PAD_PX = VISUAL_EDGE_TOLERANCE_PX + 2
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Per-clip HTML chrome pinned over the canvas timeline: the subcut-regenerate
|
|
198
|
+
* Scissors button and the "queued" badge.
|
|
199
|
+
*
|
|
200
|
+
* ── Why HTML and not paint ───────────────────────────────────────────────
|
|
201
|
+
* These two affordances lived on the DOM clip rows (`VisualTrackRow`). The
|
|
202
|
+
* canvas draws clips as pixels, so there is no per-clip element to hang a
|
|
203
|
+
* button off — and a real button is what a click target with a title, an
|
|
204
|
+
* accessible name and a hover state wants to be. Adding a per-clip button
|
|
205
|
+
* sub-zone to `hit-test.ts` (plus the tap-vs-drag disambiguation that implies)
|
|
206
|
+
* is far more machinery than two pieces of chrome need, so instead each one is
|
|
207
|
+
* an absolutely-positioned HTML node placed by the SAME layout + viewport math
|
|
208
|
+
* the painter uses: `row.y`/`row.height` from `computeTimelineLayout`, and
|
|
209
|
+
* `timeToX` for the item's span.
|
|
210
|
+
*
|
|
211
|
+
* ── Why it is a SIBLING of the canvas surface, not a child ───────────────
|
|
212
|
+
* `TimelineCanvas` binds `mousedown` on its own container element, and a
|
|
213
|
+
* mousedown there STARTS A GESTURE (scrub, drag or trim). A button nested
|
|
214
|
+
* inside that container would therefore start a gesture underneath its own
|
|
215
|
+
* click — and `onMouseDown={e => e.stopPropagation()}` cannot prevent it,
|
|
216
|
+
* because React delegates synthetic events to its ROOT container: the
|
|
217
|
+
* canvas' own native listener sits between the button and that root, so it
|
|
218
|
+
* fires first and a synthetic handler is already too late. Stopping it would
|
|
219
|
+
* mean an imperative native listener per button.
|
|
220
|
+
*
|
|
221
|
+
* Rendering the chrome OUTSIDE `[data-timeline-canvas]` removes the hazard by
|
|
222
|
+
* construction — the canvas' listener is never on the propagation path at
|
|
223
|
+
* all — and costs nothing in alignment: the chrome's positioning context is
|
|
224
|
+
* the flex column that wraps the canvas and nothing else, whose content box is
|
|
225
|
+
* exactly the surface's box (one `w-full` child with an explicit height). The
|
|
226
|
+
* remaining bubble path is Timeline's own container click, which already
|
|
227
|
+
* excludes `button` from its background-click handling.
|
|
228
|
+
*
|
|
229
|
+
* `position: absolute` (not the `fixed` + clientX/clientY snapshot the fade and
|
|
230
|
+
* keyframe menus use) because this chrome is persistent: it has to stay pinned
|
|
231
|
+
* to a clip that pans and rescales, not sit where a one-shot right-click
|
|
232
|
+
* happened.
|
|
233
|
+
*/
|
|
234
|
+
function CanvasClipChrome({
|
|
235
|
+
layout,
|
|
236
|
+
viewportStore,
|
|
237
|
+
selectedIds,
|
|
238
|
+
regenEnabled,
|
|
239
|
+
isClipQueued,
|
|
240
|
+
subcutClipId,
|
|
241
|
+
setSubcutClipId,
|
|
242
|
+
}: {
|
|
243
|
+
layout: TimelineLayout
|
|
244
|
+
viewportStore: ViewportStore
|
|
245
|
+
selectedIds: string[]
|
|
246
|
+
regenEnabled?: boolean
|
|
247
|
+
isClipQueued?: (itemId: string) => boolean
|
|
248
|
+
subcutClipId: string | null
|
|
249
|
+
setSubcutClipId: (id: string | null) => void
|
|
250
|
+
}) {
|
|
251
|
+
// Subscribed HERE, not lifted to Timeline's top level — see the "Why HTML
|
|
252
|
+
// and not paint" note above this component. This chrome is the only thing
|
|
253
|
+
// that needs a re-render when the viewport pans or zooms (it has to stay
|
|
254
|
+
// pinned to a clip that just moved under it); isolating the subscription to
|
|
255
|
+
// this component means a wheel-zoom frame re-renders ONLY this chrome, not
|
|
256
|
+
// Timeline's whole subtree (`TimelineCanvas`, `TrackGutter`, `Scrubber`,
|
|
257
|
+
// etc). Same isolation `CanvasZoomBadge` already uses in TimelineCanvas.tsx,
|
|
258
|
+
// for the same reason.
|
|
259
|
+
const viewport = useViewportValue(viewportStore)
|
|
68
260
|
|
|
69
|
-
|
|
70
|
-
|
|
261
|
+
// Before the first layout pass there is no scale, so every clip would
|
|
262
|
+
// collapse onto x=0 and the chrome would stack up in the corner.
|
|
263
|
+
if (viewport.pxPerSecond <= 0) return null
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<>
|
|
267
|
+
{layout.rows.flatMap(row => row.items.flatMap(item => {
|
|
268
|
+
const isSel = selectedIds.includes(item.id)
|
|
269
|
+
const queued = isClipQueued?.(item.id) === true
|
|
270
|
+
// The gate, carried over verbatim from `VisualTrackRow`: the clip is
|
|
271
|
+
// selected, the host has enabled regeneration for this project, the
|
|
272
|
+
// clip still carries its frozen generation provenance, and it is long
|
|
273
|
+
// enough to be worth cutting into sub-shots. Queued is a BADGE, not a
|
|
274
|
+
// disable — a queued clip can be re-opened and re-submitted.
|
|
275
|
+
const showSubcut = isSel && regenEnabled && item.generation && (item.end - item.start) >= 3
|
|
276
|
+
if (!queued && !showSubcut) return []
|
|
277
|
+
|
|
278
|
+
const x0 = timeToX(item.start, viewport)
|
|
279
|
+
const x1 = timeToX(item.end, viewport)
|
|
280
|
+
if (x1 <= 0 || x0 >= viewport.widthPx) return [] // scrolled off-surface
|
|
281
|
+
// Right-anchored: the painter writes each clip's label along its LEFT
|
|
282
|
+
// edge, so the right end is the free space. Clamped so a clip whose
|
|
283
|
+
// end is off-surface keeps its chrome at the surface edge rather than
|
|
284
|
+
// parking it out of view.
|
|
285
|
+
const right = Math.max(CLIP_CHROME_RIGHT_PAD_PX, viewport.widthPx - x1 + CLIP_CHROME_RIGHT_PAD_PX)
|
|
286
|
+
// A LEFT bound too, so the box is the clip's own span and
|
|
287
|
+
// `overflow-hidden` can clip to it. The DOM clip rows got this for
|
|
288
|
+
// free: their chrome sat inside a row element that was already
|
|
289
|
+
// `overflow-hidden`. The canvas has no per-clip element to inherit
|
|
290
|
+
// that from, so right-anchored chrome on a very narrow clip used to
|
|
291
|
+
// overhang LEFTWARD past the clip's start and read as the
|
|
292
|
+
// neighbouring clip's badge. Clamped at 0 the same way `right` is, so
|
|
293
|
+
// a clip whose start is off-surface keeps its box on the surface.
|
|
294
|
+
// Content is right-aligned, so a box too narrow for both clips the
|
|
295
|
+
// badge off (it is leftmost) and keeps the button.
|
|
296
|
+
const left = Math.max(0, x0)
|
|
297
|
+
|
|
298
|
+
return [(
|
|
299
|
+
<div
|
|
300
|
+
key={item.id}
|
|
301
|
+
className="pointer-events-none absolute z-20 flex items-center justify-end gap-1 overflow-hidden"
|
|
302
|
+
style={{ top: row.y, height: row.height, left, right }}
|
|
303
|
+
>
|
|
304
|
+
{/* Deliberately dark in BOTH modes, not `--editor-*` driven — same
|
|
305
|
+
reasoning as the preview staying black and the canvas' own
|
|
306
|
+
clip labels: this chip sits over the CLIP's pixels (arbitrary
|
|
307
|
+
video/filmstrip content), not over editor chrome, so what it
|
|
308
|
+
has to contrast against is footage brightness, not the host's
|
|
309
|
+
light/dark setting. A dark plate at 70% opacity reliably shows
|
|
310
|
+
its light foreground over footage of any brightness; flipping
|
|
311
|
+
it to a light plate in a light editor theme would remove that
|
|
312
|
+
guarantee for no benefit, since the editor's theme has no
|
|
313
|
+
relationship to what's playing under it. Do not "fix" this to
|
|
314
|
+
use `--editor-surface`. */}
|
|
315
|
+
{queued && (
|
|
316
|
+
<span className="rounded bg-gray-900/70 px-1 text-[10px] font-medium text-amber-300/80">queued</span>
|
|
317
|
+
)}
|
|
318
|
+
{showSubcut && (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
className="pointer-events-auto cursor-pointer rounded bg-gray-900/70 p-0.5 text-gray-200 opacity-50 transition-opacity hover:opacity-100"
|
|
322
|
+
title="Subcut regenerate"
|
|
323
|
+
aria-label="Subcut regenerate"
|
|
324
|
+
onClick={(e) => { e.stopPropagation(); setSubcutClipId(subcutClipId === item.id ? null : item.id) }}
|
|
325
|
+
><Scissors size={10} /></button>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
)]
|
|
329
|
+
}))}
|
|
330
|
+
</>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Human-readable labels for `EASING_NAMES` (@bycrux/timeline-core) — the
|
|
335
|
+
* keyframe-strip easing picker's six buttons. Kept as a plain lookup rather
|
|
336
|
+
* than a formatter, since 'ease-in-out' has no mechanical rule that reads
|
|
337
|
+
* better than a name written out by hand. */
|
|
338
|
+
const EASING_LABELS: Record<EasingName, string> = {
|
|
339
|
+
linear: 'Linear',
|
|
340
|
+
ease: 'Ease',
|
|
341
|
+
'ease-in': 'Ease In',
|
|
342
|
+
'ease-out': 'Ease Out',
|
|
343
|
+
'ease-in-out': 'Ease In Out',
|
|
344
|
+
hold: 'Hold',
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export default function Timeline({ project, clock, onProjectChange, onOverlayEdit, selectedIds = [], onSelectIds, onInspectClip, onInspectAudio, onEditCaption, rippleMode = false, previewAxis = false, onHoverScrub, resolveFilePath, getWaveformPeaks, getFilmstrip, regenEnabled, isClipQueued, renderSubcutRegen, modalOpen = false, onOpenGoToTime, actionsRef, mode = 'dark', onImportFilesToTimeline, pendingDrops }: TimelineProps) {
|
|
71
348
|
|
|
72
349
|
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
350
|
+
// Under D1, captions share `selectedIds` with everything else, so there is
|
|
351
|
+
// no separate caption-clearing step here anymore: a plain (non-additive)
|
|
352
|
+
// select REPLACES the whole array with just this id, which drops any caption
|
|
353
|
+
// id that was in it, and a click on empty space (id === null) clears the
|
|
354
|
+
// array outright. A shift-click still ADDS to whatever is already selected,
|
|
355
|
+
// so a caption can sit alongside clips/audio in one selection — the
|
|
356
|
+
// overlay-parity ability D1 was for. VideoEditor derives its own
|
|
357
|
+
// `selectedCaptionId` (the preview's caption-box target) from this same
|
|
358
|
+
// array; see its `handleSelectCaption`.
|
|
359
|
+
/**
|
|
360
|
+
* Skip / un-skip a whole visual track.
|
|
361
|
+
*
|
|
362
|
+
* Preview-then-commit, the same two-channel shape every other timeline edit
|
|
363
|
+
* uses: `onProjectChange` applies it live (transient — no save, no undo
|
|
364
|
+
* entry), `onOverlayEdit` commits it as ONE undo step. Writing only through
|
|
365
|
+
* `onProjectChange` would leave it unsaved until some unrelated gesture
|
|
366
|
+
* flushed it.
|
|
367
|
+
*/
|
|
368
|
+
function handleToggleTrackEnabled(trackIdx: number, enabled: boolean) {
|
|
369
|
+
if (!onProjectChange) return
|
|
370
|
+
const normalized = normalizeTracks(project)
|
|
371
|
+
const tracks = (normalized.tracks ?? []).map((t, i) =>
|
|
372
|
+
i === trackIdx ? { ...t, enabled } : t,
|
|
373
|
+
)
|
|
374
|
+
const next = { ...normalized, tracks } as Project
|
|
375
|
+
onProjectChange(next)
|
|
376
|
+
onOverlayEdit?.(next)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Track-wide volume/mute, from the rail's settings popover (TrackGutter.tsx).
|
|
381
|
+
* Same preview-then-commit shape as `handleToggleTrackEnabled` above, split
|
|
382
|
+
* across two handlers because volume is a CONTINUOUS control (a slider drag
|
|
383
|
+
* must preview many times and commit once) while mute is a discrete toggle
|
|
384
|
+
* (one click, one commit) — mirrors CaptionListPanel's fontsize slider vs.
|
|
385
|
+
* its style buttons.
|
|
386
|
+
*
|
|
387
|
+
* This writes `VisualTrack.volume`/`.muted`; the value doesn't stay
|
|
388
|
+
* track-level from there. `effectiveItemAudio(track, item)` in
|
|
389
|
+
* timeline-model.ts folds it into each item's own volume/mute (multiply,
|
|
390
|
+
* either/or), and every playback and export path reads the FOLDED value at
|
|
391
|
+
* its own fold point: `clipGain` in `useVideoPlayback.ts`,
|
|
392
|
+
* `withTrackAudio` in `engine/scheduler.ts`, and the inline fold in
|
|
393
|
+
* `render.js` (`collectAllItems`, ~line 829).
|
|
394
|
+
*/
|
|
395
|
+
function handleSetTrackVolume(trackIdx: number, volume: number, commit: boolean) {
|
|
396
|
+
if (!onProjectChange) return
|
|
397
|
+
const normalized = normalizeTracks(project)
|
|
398
|
+
const tracks = (normalized.tracks ?? []).map((t, i) =>
|
|
399
|
+
i === trackIdx ? { ...t, volume } : t,
|
|
400
|
+
)
|
|
401
|
+
const next = { ...normalized, tracks } as Project
|
|
402
|
+
onProjectChange(next)
|
|
403
|
+
if (commit) onOverlayEdit?.(next)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function handleSetTrackMuted(trackIdx: number, muted: boolean) {
|
|
407
|
+
if (!onProjectChange) return
|
|
408
|
+
const normalized = normalizeTracks(project)
|
|
409
|
+
const tracks = (normalized.tracks ?? []).map((t, i) =>
|
|
410
|
+
i === trackIdx ? { ...t, muted } : t,
|
|
411
|
+
)
|
|
412
|
+
const next = { ...normalized, tracks } as Project
|
|
413
|
+
onProjectChange(next)
|
|
414
|
+
onOverlayEdit?.(next)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Track-wide speed, from the rail's settings popover's Speed control
|
|
419
|
+
* (TrackGutter.tsx / TrackSettingsPopover.tsx). Unlike volume/mute
|
|
420
|
+
* above, speed isn't a `VisualTrack` setting — it lives on each video
|
|
421
|
+
* clip — so this folds `setClipSpeed` over every video item on the track
|
|
422
|
+
* into ONE project before committing, the same way a multi-item ripple
|
|
423
|
+
* delete produces a single undo entry rather than one per clip. A bulk,
|
|
424
|
+
* one-shot edit (mirrors the clip inspect modal's own "Save speed" button),
|
|
425
|
+
* not a live preview, so there's no `commit` flag to thread.
|
|
426
|
+
*/
|
|
427
|
+
function handleApplyTrackSpeed(trackIdx: number, speed: number) {
|
|
428
|
+
if (!onProjectChange) return
|
|
429
|
+
const items = trackItems(project)[trackIdx] ?? []
|
|
430
|
+
let next = project
|
|
431
|
+
for (const item of items) {
|
|
432
|
+
if (item.type === 'video') next = setClipSpeed(next, item.id, speed)
|
|
433
|
+
}
|
|
434
|
+
if (rippleMode) next = collapseGaps(next)
|
|
435
|
+
onProjectChange(next)
|
|
436
|
+
onOverlayEdit?.(next)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Audio-LANE volume/mute, from the rail's settings popover. A lane can hold
|
|
441
|
+
* several `AudioTrack`s sharing one row (see `groupAudioLanes` in
|
|
442
|
+
* timeline-model.ts), so the popover's single control fans out to every
|
|
443
|
+
* track id in the lane via the existing `updateAudioTrack` helper — unlike
|
|
444
|
+
* the visual-track handlers above, `AudioTrack.volume`/`.muted` are already
|
|
445
|
+
* live end to end (mix-audio.js, both preview paths), no separate fold step
|
|
446
|
+
* needed.
|
|
447
|
+
*/
|
|
448
|
+
function handleSetLaneVolume(trackIds: string[], volume: number, commit: boolean) {
|
|
449
|
+
if (!onProjectChange) return
|
|
450
|
+
let next = project
|
|
451
|
+
for (const id of trackIds) next = updateAudioTrack(next, id, { volume })
|
|
452
|
+
onProjectChange(next)
|
|
453
|
+
if (commit) onOverlayEdit?.(next)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function handleSetLaneMuted(trackIds: string[], muted: boolean) {
|
|
457
|
+
if (!onProjectChange) return
|
|
458
|
+
let next = project
|
|
459
|
+
for (const id of trackIds) next = updateAudioTrack(next, id, { muted })
|
|
460
|
+
onProjectChange(next)
|
|
461
|
+
onOverlayEdit?.(next)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Audio-LANE magnet toggle — same fan-out as mute above, since a lane can
|
|
466
|
+
* hold several `AudioTrack`s. Turning it ON also collapses the lane
|
|
467
|
+
* immediately (`reflowMagneticLanes`), so flipping the switch is itself an
|
|
468
|
+
* edit rather than something that waits for the next drag to take effect.
|
|
469
|
+
* Turning it OFF just clears the flag; a lane already gapless stays exactly
|
|
470
|
+
* where it is.
|
|
471
|
+
*/
|
|
472
|
+
function handleSetLaneMagnet(trackIds: string[], magnetic: boolean) {
|
|
473
|
+
if (!onProjectChange) return
|
|
474
|
+
let next = project
|
|
475
|
+
for (const id of trackIds) next = updateAudioTrack(next, id, { magnetic })
|
|
476
|
+
if (magnetic) next = reflowMagneticLanes(next)
|
|
477
|
+
onProjectChange(next)
|
|
478
|
+
onOverlayEdit?.(next)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The fade-shape picker: a small DOM menu (Linear / Logarithmic /
|
|
483
|
+
* Exponential) that opens on a RIGHT-CLICK of a fade grip (Vegas' own
|
|
484
|
+
* gesture — see `TimelineCanvas`'s `contextMenu` handler, which is the only
|
|
485
|
+
* caller of `setFadeCurveMenu`). `x`/`y` are CLIENT coordinates, so the
|
|
486
|
+
* menu below positions itself with `position: fixed` rather than measuring
|
|
487
|
+
* anything on this component's own tree.
|
|
488
|
+
*/
|
|
489
|
+
const [fadeCurveMenu, setFadeCurveMenu] = useState<{ trackId: string; side: 'in' | 'out'; x: number; y: number } | null>(null)
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Commit a fade's shape — a discrete, one-shot pick like
|
|
493
|
+
* `handleSetLaneMagnet` above, not a continuous drag, so `onProjectChange`
|
|
494
|
+
* + `onOverlayEdit` fire together as ONE undo entry rather than splitting
|
|
495
|
+
* across a preview/commit pair.
|
|
496
|
+
*/
|
|
497
|
+
function handleSetFadeCurve(trackId: string, side: 'in' | 'out', curve: FadeCurve) {
|
|
498
|
+
setFadeCurveMenu(null)
|
|
499
|
+
if (!onProjectChange) return
|
|
500
|
+
const next = updateAudioTrack(project, trackId, side === 'in' ? { fadeInCurve: curve } : { fadeOutCurve: curve })
|
|
501
|
+
onProjectChange(next)
|
|
502
|
+
onOverlayEdit?.(next)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The keyframe-strip popup (SP9b T3.3) — `fadeCurveMenu`'s exact sibling: a
|
|
507
|
+
* small DOM menu that opens on a RIGHT-CLICK of a keyframe-strip diamond
|
|
508
|
+
* (`TimelineCanvas`'s `contextMenu` handler, the only caller of
|
|
509
|
+
* `setKeyframeMenu`). `x`/`y` are CLIENT coordinates for the same
|
|
510
|
+
* `position: fixed` reason. `props` is every keyframe track that has a
|
|
511
|
+
* point at `t` — a diamond can represent several props sharing one instant
|
|
512
|
+
* (the "union of times" the strip draws — see `keyframe-strip.ts`) — so
|
|
513
|
+
* every action below applies to ALL of them at once. `isLast` is true when
|
|
514
|
+
* `t` is the item's LAST keyframe: its easing has no next keyframe to
|
|
515
|
+
* reach, so the picker below disables (not hides) those six buttons then.
|
|
516
|
+
*/
|
|
517
|
+
const [keyframeMenu, setKeyframeMenu] = useState<{ itemId: string; t: number; props: KeyframeProp[]; isLast: boolean; x: number; y: number } | null>(null)
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The selected keyframe diamond, or null. Kind-agnostic: a diamond is a
|
|
521
|
+
* TIME on an item, so the props at that time are derived when acted on
|
|
522
|
+
* rather than stored here. Lives in Timeline rather than `VideoEditor`
|
|
523
|
+
* because every clearing trigger is already visible here and nothing
|
|
524
|
+
* outside the timeline consumes it.
|
|
525
|
+
*/
|
|
526
|
+
const [selectedKeyframe, setSelectedKeyframe] = useState<KeyframeSelection | null>(null)
|
|
527
|
+
|
|
528
|
+
// Clear when the owning item stops being selected. A keyframe selection is
|
|
529
|
+
// meaningless without its item selected — the strip is not even drawn then
|
|
530
|
+
// (draw.ts gates on selection), so a stale selection would be invisible and
|
|
531
|
+
// still armed for Delete.
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
if (selectedKeyframe && !selectedIds.includes(selectedKeyframe.itemId)) setSelectedKeyframe(null)
|
|
534
|
+
}, [selectedIds, selectedKeyframe])
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Whole-item edit, for an op that derives its own affected props (unlike
|
|
538
|
+
* `applyToKeyframedItem` below, which threads a function per prop) — e.g.
|
|
539
|
+
* `removeKeyframesAt`, which decides itself which prop tracks have a point
|
|
540
|
+
* at `t`. Same commit shape as `applyToKeyframedItem`: one `onProjectChange`
|
|
541
|
+
* + one `onOverlayEdit` = one undo entry. Skips the commit entirely when
|
|
542
|
+
* `fn` returns the SAME item reference (no prop actually had anything to
|
|
543
|
+
* change) — `removeKeyframesAt`'s reference-equality no-op — so a Delete
|
|
544
|
+
* that couldn't find its keyframe never spends an undo entry on nothing.
|
|
545
|
+
*/
|
|
546
|
+
function applyToItem(itemId: string, fn: (item: VisualItem) => VisualItem) {
|
|
547
|
+
setKeyframeMenu(null)
|
|
548
|
+
if (!onProjectChange) return
|
|
549
|
+
let changed = false
|
|
550
|
+
const tracks = mapTrackItems(project, items => items.map(item => {
|
|
551
|
+
if (item.id !== itemId) return item
|
|
552
|
+
const next = fn(item)
|
|
553
|
+
if (next !== item) changed = true
|
|
554
|
+
return next
|
|
555
|
+
}))
|
|
556
|
+
if (!changed) return
|
|
557
|
+
const next = { ...project, tracks } as Project
|
|
558
|
+
onProjectChange(next)
|
|
559
|
+
onOverlayEdit?.(next)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Apply `fn` to every prop the diamond at `t` represents, on the item
|
|
564
|
+
* `itemId`, as ONE commit — same shape as `handleSetFadeCurve` above: a
|
|
565
|
+
* discrete, one-shot pick, so `onProjectChange` + `onOverlayEdit` fire
|
|
566
|
+
* together as a single undo entry. Routes through `mapTrackItems`, the
|
|
567
|
+
* SAME item-patch mechanism the canvas pointer machine's own keyframe-drag
|
|
568
|
+
* gesture uses (`pointer-machine.ts`'s `replaceVisualItem`) — no second
|
|
569
|
+
* persistence path for keyframe edits.
|
|
570
|
+
*/
|
|
571
|
+
function applyToKeyframedItem(itemId: string, props: KeyframeProp[], fn: (prop: KeyframeProp) => (item: VisualItem) => VisualItem) {
|
|
572
|
+
setKeyframeMenu(null)
|
|
573
|
+
if (!onProjectChange) return
|
|
574
|
+
const tracks = mapTrackItems(project, items => items.map(item => {
|
|
575
|
+
if (item.id !== itemId) return item
|
|
576
|
+
let next = item
|
|
577
|
+
for (const prop of props) next = fn(prop)(next)
|
|
578
|
+
return next
|
|
579
|
+
}))
|
|
580
|
+
const next = { ...project, tracks } as Project
|
|
581
|
+
onProjectChange(next)
|
|
582
|
+
onOverlayEdit?.(next)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function handleRemoveKeyframe(itemId: string, t: number) {
|
|
586
|
+
// Clearing rule 2 (plan Task 3): the keyframe is gone, so the selection
|
|
587
|
+
// naming it must go too — the menu is the OTHER route to the same removal
|
|
588
|
+
// the Delete binding performs, and the two must not disagree.
|
|
589
|
+
if (selectedKeyframe && selectedKeyframe.itemId === itemId && selectedKeyframe.t === t) setSelectedKeyframe(null)
|
|
590
|
+
applyToItem(itemId, item => removeKeyframesAt(item, t))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function handleSetKeyframeEasing(itemId: string, t: number, props: KeyframeProp[], easing: EasingName) {
|
|
594
|
+
applyToKeyframedItem(itemId, props, prop => item => setKeyframeEasing(item, prop, t, easing))
|
|
595
|
+
}
|
|
596
|
+
|
|
82
597
|
function handleSelectItem(id: string | null, additive: boolean) {
|
|
83
598
|
if (!onSelectIds) return
|
|
84
|
-
onSelectCaption?.(null)
|
|
85
599
|
if (id === null) { onSelectIds([]); return }
|
|
86
600
|
onSelectIds(toggleSelection(selectedIds, id, additive))
|
|
87
601
|
}
|
|
88
|
-
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* A marquee's whole catch, applied at once.
|
|
605
|
+
*
|
|
606
|
+
* Deliberately a SET operation rather than a fold of `toggleSelection`:
|
|
607
|
+
* toggling would flip off any item that was already selected and happens to
|
|
608
|
+
* fall inside the box, so dragging a marquee over an existing selection would
|
|
609
|
+
* deselect exactly the clips it visibly covers. A marquee states what is
|
|
610
|
+
* selected; additive unions it with what was there.
|
|
611
|
+
*/
|
|
612
|
+
function handleSelectItems(ids: string[], additive: boolean) {
|
|
613
|
+
if (!onSelectIds) return
|
|
614
|
+
onSelectIds(additive ? [...new Set([...selectedIds, ...ids])] : ids)
|
|
615
|
+
}
|
|
616
|
+
const allTracks = trackItems(project)
|
|
89
617
|
const captionTrack = project.captions
|
|
90
618
|
const audioTracks = project.audio?.tracks ?? []
|
|
91
619
|
|
|
92
620
|
// Memoized so playback ticks (which re-render Timeline via the ctx useMemo's
|
|
93
621
|
// clock dependency) don't recompute these on every frame — they only change
|
|
94
|
-
// when the underlying tracks/audio actually change.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
]
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return { snapBoundaries, contentDuration, totalDuration }
|
|
108
|
-
}, [project.tracks, project.audio])
|
|
109
|
-
|
|
110
|
-
// Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
|
|
111
|
-
// and fade-in on the later, each equal to the overlap duration.
|
|
622
|
+
// when the underlying tracks/audio actually change. Both the DOM and canvas
|
|
623
|
+
// (T4) track-row areas import this from timeline-model.ts, so timing can't
|
|
624
|
+
// drift between the two.
|
|
625
|
+
const { snapBoundaries, contentDuration, totalDuration } = useMemo(
|
|
626
|
+
() => computeDerivedTiming(project),
|
|
627
|
+
[project.tracks, project.audio],
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
// Auto-crossfade: when two audio tracks overlap, apply fade-out on the
|
|
631
|
+
// earlier and fade-in on the later, each equal to the overlap duration. The
|
|
632
|
+
// decision logic lives in timeline-model.ts (computeAutoCrossfade) so both
|
|
633
|
+
// the DOM and canvas (T4) track-row areas share it; this effect is a thin
|
|
634
|
+
// shell that applies the result.
|
|
112
635
|
useEffect(() => {
|
|
113
|
-
if (!
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
636
|
+
if (!onOverlayEdit) return
|
|
637
|
+
const next = computeAutoCrossfade(project)
|
|
638
|
+
if (!next) return
|
|
639
|
+
// Commit the derived crossfade on a short delay, NEVER synchronously per
|
|
640
|
+
// change. Audio timing moves on every mousemove of a drag, and committing
|
|
641
|
+
// each one recorded dozens of undo entries for a single gesture — audio's
|
|
642
|
+
// version of the per-move-undo bug the video-move commit split fixed. A
|
|
643
|
+
// drag's own commit (`commitTimelineEdit`) now folds the crossfade into its
|
|
644
|
+
// one undo step, so this debounced pass is only the catch-all for audio
|
|
645
|
+
// timing that changes OUTSIDE a gesture (ripple-delete, gap-collapse): mid-
|
|
646
|
+
// drag the timer is cleared and rescheduled every frame and never fires,
|
|
647
|
+
// and right after a gesture it no-ops because `computeAutoCrossfade` is
|
|
648
|
+
// idempotent. The digest keying this effect includes the FADES on purpose —
|
|
649
|
+
// the gesture's own crossfade commit changes it, which re-runs this effect
|
|
650
|
+
// and clears the pending timer rather than letting a stale one fire. That
|
|
651
|
+
// is also why this only commits and no longer previews via `onProjectChange`:
|
|
652
|
+
// a preview would change the fade digest and clear the timer before it
|
|
653
|
+
// fired, so a ripple-delete's crossfade would never get saved.
|
|
654
|
+
const timer = setTimeout(() => onOverlayEdit(next), CROSSFADE_COMMIT_DELAY_MS)
|
|
655
|
+
return () => clearTimeout(timer)
|
|
656
|
+
// Keyed on a stable digest of audio-track timing/mute AND fades, so the pass
|
|
657
|
+
// re-runs on real edits (see above for why the fades belong in the key).
|
|
658
|
+
}, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}:${t.fadeIn ?? ''}:${t.fadeOut ?? ''}`).join('|')])
|
|
135
659
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
...project.audio,
|
|
142
|
-
tracks: (project.audio?.tracks ?? []).map(t => trackMap.get(t.id) ?? t),
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
onProjectChange(nextProject)
|
|
146
|
-
}
|
|
147
|
-
// Intentionally keyed on a stable digest of audio-track timing/mute rather
|
|
148
|
-
// than the array identity, so the crossfade pass only re-runs on real edits.
|
|
149
|
-
}, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}`).join('|')])
|
|
660
|
+
// The tabIndex={0} root below — Delete/Enter's guards below check focus is
|
|
661
|
+
// inside it before firing (see the useKeymap block), restoring the pre-T9
|
|
662
|
+
// scoping those two bindings had (arrows/Escape were already document-level
|
|
663
|
+
// pre-SP5 and stay that way — see the useKeymap block's own comment).
|
|
664
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
150
665
|
|
|
151
|
-
const [
|
|
152
|
-
const [draggingPlayhead, setDraggingPlayhead] = useState(false)
|
|
153
|
-
const [markers, setMarkers] = useState<[number | null, number | null]>([null, null])
|
|
154
|
-
const [transcriptModalOpen, setTranscriptModalOpen] = useState(false)
|
|
666
|
+
const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
|
|
155
667
|
|
|
156
|
-
const
|
|
157
|
-
const overlayDraggedRef = useRef(false)
|
|
158
|
-
const [keyNavTime, setKeyNavTime] = useState<number | null>(null)
|
|
159
|
-
const keyNavTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
668
|
+
const { scrollRef } = useTimelineZoom()
|
|
160
669
|
|
|
161
|
-
|
|
670
|
+
// ── Zoom chrome ──
|
|
671
|
+
// The canvas surface pans/zooms a px-per-second viewport; these three
|
|
672
|
+
// buttons drive it through `useCanvasZoomControls`. This used to be an
|
|
673
|
+
// adapter over two zoom models (the retired DOM rows zoomed a scroll
|
|
674
|
+
// container by a multiplier instead) — there is only the one now.
|
|
675
|
+
const viewportStore = useViewportStore()
|
|
676
|
+
const zoomControls: ZoomControls = useCanvasZoomControls(viewportStore, totalDuration)
|
|
162
677
|
|
|
163
|
-
|
|
678
|
+
// ── Timeline's own keymap — arrows (frame-step) and delete (two-step:
|
|
679
|
+
// deleteSelection + conditional collapseGaps). SP5 T9: these bindings
|
|
680
|
+
// replace Timeline's old ad hoc document listener (arrows, gated on
|
|
681
|
+
// totalDuration>0 by not registering at all) and its container-scoped
|
|
682
|
+
// onKeyDown (delete) with the shared registry — one guarded document
|
|
683
|
+
// listener instead of two different attachment mechanisms with two
|
|
684
|
+
// different guard sets.
|
|
685
|
+
//
|
|
686
|
+
// Mounted HERE, not lifted into VideoEditor's keymap: this instance runs in
|
|
687
|
+
// BOTH the pending and review surfaces (PendingSurface and ReviewSurface
|
|
688
|
+
// each render their own `<Timeline>` with their own `PlaybackClock`), same
|
|
689
|
+
// as the listeners it replaces. `modalOpen` is the host's dialogs
|
|
690
|
+
// (ReviewSurface's RenderModal/palette/etc — absent in PendingSurface).
|
|
691
|
+
const fps = project.settings?.fps ?? 30
|
|
692
|
+
const frameStep = 1 / fps
|
|
693
|
+
useKeymap([
|
|
694
|
+
// Cmd/Ctrl+Arrow jump-to-start/end. `matchesArrowLeft`/`matchesArrowRight`
|
|
695
|
+
// (keymap.ts's `matchesKey`) don't exclude modifiers, so a mod+arrow chord
|
|
696
|
+
// matches BOTH these bindings AND `timeline.frame-step` below — these two
|
|
697
|
+
// are listed FIRST so first-match-wins picks the jump, not a frame step
|
|
698
|
+
// (same precedence pattern documented on `matchesModAltKey` in keymap.ts).
|
|
699
|
+
{
|
|
700
|
+
id: 'timeline.jump-start',
|
|
701
|
+
description: 'Jump to start',
|
|
702
|
+
matches: matchesModKey('ArrowLeft'),
|
|
703
|
+
guard: () => totalDuration > 0,
|
|
704
|
+
action: () => {
|
|
705
|
+
clock.set(0)
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
id: 'timeline.jump-end',
|
|
710
|
+
description: 'Jump to end',
|
|
711
|
+
matches: matchesModKey('ArrowRight'),
|
|
712
|
+
guard: () => totalDuration > 0,
|
|
713
|
+
// `contentDuration` is the furthest item's end (max across every clip/
|
|
714
|
+
// overlay/audio track) — NOT `totalDuration`, which pads content with
|
|
715
|
+
// scroll/drop headroom (see computeDerivedTiming in timeline-model.ts).
|
|
716
|
+
// The jump belongs at the last real element, not into that headroom.
|
|
717
|
+
action: () => {
|
|
718
|
+
clock.set(contentDuration)
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
id: 'timeline.frame-step',
|
|
723
|
+
description: 'Step one frame (Shift steps ten)',
|
|
724
|
+
matches: (e) => matchesArrowLeft(e) || matchesArrowRight(e),
|
|
725
|
+
guard: () => totalDuration > 0,
|
|
726
|
+
action: (e) => {
|
|
727
|
+
// Shift is a COARSE frame step, not a wall-clock jump: ten frames,
|
|
728
|
+
// so the unit stays the same as the plain step and only the size
|
|
729
|
+
// changes. It used to be a flat 1 second, which meant the shifted
|
|
730
|
+
// step drifted against the unshifted one on every fps that isn't 10.
|
|
731
|
+
const step = e.shiftKey ? 10 * frameStep : frameStep
|
|
732
|
+
const dir = matchesArrowRight(e) ? 1 : -1
|
|
733
|
+
const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
|
|
734
|
+
clock.set(next)
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
id: 'timeline.delete-keyframe',
|
|
739
|
+
description: 'Delete selected keyframe',
|
|
740
|
+
matches: matchesDelete,
|
|
741
|
+
// Ordered BEFORE `timeline.delete-selection` and guarded on a keyframe
|
|
742
|
+
// actually being selected: useKeymap is first-match-wins (keymap.ts:80-86),
|
|
743
|
+
// so with no keyframe selected this falls straight through to clip
|
|
744
|
+
// deletion. The focus scope matches the binding below it exactly — a
|
|
745
|
+
// Delete typed outside the timeline must not reach either.
|
|
746
|
+
guard: () => {
|
|
747
|
+
if (!selectedKeyframe || !rootRef.current?.contains(document.activeElement)) return false
|
|
748
|
+
// A selection can go stale WITHOUT its item leaving `selectedIds`:
|
|
749
|
+
// dragging the diamond retimes it, the right-click menu removes it,
|
|
750
|
+
// undo rewinds it, a split moves it to the other half. `drawKeyframeStrip`
|
|
751
|
+
// matches on `t`, so nothing draws as selected then — and Delete must do
|
|
752
|
+
// what the operator SEES (delete the clip) rather than swallow the press
|
|
753
|
+
// on a keyframe that no longer exists.
|
|
754
|
+
const item = trackItems(project).flat().find(i => i.id === selectedKeyframe.itemId)
|
|
755
|
+
return !!item && keyframeUnionTimes(item).includes(selectedKeyframe.t)
|
|
756
|
+
},
|
|
757
|
+
action: () => {
|
|
758
|
+
if (!selectedKeyframe) return
|
|
759
|
+
const { itemId, t } = selectedKeyframe
|
|
760
|
+
setSelectedKeyframe(null)
|
|
761
|
+
applyToItem(itemId, item => removeKeyframesAt(item, t))
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: 'timeline.delete-selection',
|
|
766
|
+
description: 'Delete selection',
|
|
767
|
+
matches: matchesDelete,
|
|
768
|
+
// Focus-scoped (unlike arrows/Escape below): pre-SP5, delete lived on
|
|
769
|
+
// the container's own onKeyDown, so it only ever fired with the
|
|
770
|
+
// timeline focused. Restored here so a Backspace pressed elsewhere on
|
|
771
|
+
// the page (e.g. typing in the preview's overlay panel) can't delete
|
|
772
|
+
// the selected clip out from under the operator.
|
|
773
|
+
guard: () => selectedIds.length > 0 && !!rootRef.current?.contains(document.activeElement),
|
|
774
|
+
action: () => {
|
|
775
|
+
if (!onProjectChange) return
|
|
776
|
+
let updated = deleteSelection(project, selectedIds)
|
|
777
|
+
// `deleteSelection` only knows tracks/audio vocabulary (see
|
|
778
|
+
// multiSelectOps.ts) — captions never lived in project.tracks, so a
|
|
779
|
+
// selected caption segment needs its own strip here, folded into the
|
|
780
|
+
// SAME commit as the clip/audio delete (one onProjectChange + one
|
|
781
|
+
// onOverlayEdit = one undo entry covering a mixed selection).
|
|
782
|
+
if (captionTrack?.segments?.length) {
|
|
783
|
+
const targets = new Set(selectedIds)
|
|
784
|
+
const kept = captionTrack.segments.filter(seg => !seg.id || !targets.has(seg.id))
|
|
785
|
+
// Leave captions as an EMPTY-segments object when every segment is
|
|
786
|
+
// removed, never null the whole track — that's the sidebar's
|
|
787
|
+
// explicit "Remove all" action, not a side effect of Delete.
|
|
788
|
+
//
|
|
789
|
+
// `normalizeCaptionLanes` runs INSIDE this same commit: emptying a
|
|
790
|
+
// caption row leaves a hole lane, and collapsing it here means the
|
|
791
|
+
// row disappearing and its neighbours renumbering are the same undo
|
|
792
|
+
// entry as the delete that caused them, not a second one.
|
|
793
|
+
if (kept.length !== captionTrack.segments.length) {
|
|
794
|
+
updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (rippleMode) updated = collapseGaps(updated)
|
|
798
|
+
// Deleting a clip out of a magnetic audio lane leaves a gap exactly
|
|
799
|
+
// like a trim or a move would — close it the same way, on release.
|
|
800
|
+
updated = reflowMagneticLanes(updated)
|
|
801
|
+
onProjectChange(updated)
|
|
802
|
+
onOverlayEdit?.(updated)
|
|
803
|
+
onSelectIds?.([])
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
], { modalOpen })
|
|
164
807
|
|
|
808
|
+
// Close the fade-curve picker on Escape — the backdrop click below (and a
|
|
809
|
+
// pick itself) already closes it any other way a user would try.
|
|
165
810
|
useEffect(() => {
|
|
166
|
-
if (
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// (caption segments are contentEditable), arrows move the text cursor —
|
|
172
|
-
// never the playhead.
|
|
173
|
-
if (transcriptModalOpen) return
|
|
174
|
-
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
175
|
-
if ((e.target as HTMLElement).isContentEditable) return
|
|
176
|
-
if (e.key === 'Escape') { setMarkers([null, null]); return }
|
|
177
|
-
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
178
|
-
e.preventDefault()
|
|
179
|
-
const step = e.shiftKey ? 1 : frame
|
|
180
|
-
const dir = e.key === 'ArrowRight' ? 1 : -1
|
|
181
|
-
const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
|
|
182
|
-
clock.set(next)
|
|
183
|
-
setKeyNavTime(next)
|
|
184
|
-
if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
|
|
185
|
-
keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
|
|
186
|
-
}
|
|
187
|
-
document.addEventListener('keydown', onKey)
|
|
188
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
189
|
-
}, [totalDuration, clock, project.settings?.fps, transcriptModalOpen])
|
|
811
|
+
if (!fadeCurveMenu) return
|
|
812
|
+
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setFadeCurveMenu(null) }
|
|
813
|
+
document.addEventListener('keydown', onKeyDown)
|
|
814
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
815
|
+
}, [fadeCurveMenu])
|
|
190
816
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
: null
|
|
817
|
+
// `fadeCurveMenu`'s Escape handling, sibling for sibling.
|
|
818
|
+
useEffect(() => {
|
|
819
|
+
if (!keyframeMenu) return
|
|
820
|
+
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setKeyframeMenu(null) }
|
|
821
|
+
document.addEventListener('keydown', onKeyDown)
|
|
822
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
823
|
+
}, [keyframeMenu])
|
|
195
824
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
203
|
-
if ((e.target as HTMLElement).isContentEditable) return
|
|
204
|
-
|
|
205
|
-
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
|
206
|
-
e.preventDefault()
|
|
207
|
-
if (!onProjectChange) return
|
|
208
|
-
let updated = deleteSelection(project, selectedIds)
|
|
209
|
-
if (rippleMode) updated = collapseGaps(updated)
|
|
210
|
-
onProjectChange(updated)
|
|
211
|
-
onOverlayEdit?.(updated)
|
|
212
|
-
onSelectIds?.([])
|
|
213
|
-
return
|
|
214
|
-
}
|
|
825
|
+
// Hand the zoom action up to a host-level command palette.
|
|
826
|
+
useEffect(() => {
|
|
827
|
+
if (!actionsRef) return
|
|
828
|
+
actionsRef.current = { zoomFit: zoomControls.fit }
|
|
829
|
+
return () => { actionsRef.current = null }
|
|
830
|
+
})
|
|
215
831
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
setMarkers(([a, b]) => {
|
|
220
|
-
if (a === null) return [t, null]
|
|
221
|
-
if (b === null) return [a, t]
|
|
222
|
-
return [t, null]
|
|
223
|
-
})
|
|
224
|
-
}
|
|
832
|
+
// Same layout the canvas paints from, so the rail's rows line up with the
|
|
833
|
+
// clips exactly rather than by two independent calculations agreeing.
|
|
834
|
+
const canvasLayout = useMemo(() => computeTimelineLayout(project), [project.tracks, project.audio, project.captions])
|
|
225
835
|
|
|
836
|
+
const ctx = useMemo<TimelineContextValue>(() => ({
|
|
837
|
+
contentDuration, clock,
|
|
838
|
+
}), [contentDuration, clock])
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Clicks that land on the timeline column but not on any row: the readout
|
|
842
|
+
* strip above the tracks, the gaps between rows, the space under the last
|
|
843
|
+
* one. They are background, and background belongs to the timeline — not
|
|
844
|
+
* dead chrome.
|
|
845
|
+
*
|
|
846
|
+
* This reads the canvas' own x-axis — the only time axis there is now that
|
|
847
|
+
* the scrubber's overview bar is gone — so a background click does exactly
|
|
848
|
+
* what pressing bare canvas does: clear the selection, and seek to the
|
|
849
|
+
* clicked time. Clicks to the left of the canvas (over the track rail,
|
|
850
|
+
* which has no time axis) clear the selection without seeking. The canvas
|
|
851
|
+
* surface itself stops propagation — the pointer machine has already seeked
|
|
852
|
+
* on mousedown — so nothing here double-handles a real timeline click.
|
|
853
|
+
*
|
|
854
|
+
* `pointer-events` note: the controls in the strip (zoom buttons, the go-to
|
|
855
|
+
* time readout) are excluded by the `closest` test below, so isolating them
|
|
856
|
+
* costs nothing beyond it.
|
|
857
|
+
*/
|
|
226
858
|
function handleContainerClick(e: React.MouseEvent) {
|
|
227
|
-
if ((e.target as HTMLElement).closest('button, input, [contenteditable]')) return
|
|
859
|
+
if ((e.target as HTMLElement).closest('button, input, [contenteditable], [data-timeline-chrome]')) return
|
|
228
860
|
if (totalDuration === 0) return
|
|
229
|
-
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
230
|
-
if (!rect) return
|
|
231
|
-
const clickedTime = ratioFromClientX(e.clientX, rect) * totalDuration
|
|
232
|
-
const snapThreshold = (8 / rect.width) * totalDuration
|
|
233
|
-
const boundaries = snapBoundaries
|
|
234
|
-
for (const b of boundaries) {
|
|
235
|
-
if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
|
|
236
|
-
}
|
|
237
|
-
clock.set(clickedTime)
|
|
238
|
-
}
|
|
239
861
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
862
|
+
// Same order the pointer machine emits its effects in for a press on
|
|
863
|
+
// bare canvas: clear the selection, then seek.
|
|
864
|
+
handleSelectItem(null, false)
|
|
865
|
+
const surface = rootRef.current?.querySelector('[data-timeline-canvas]')
|
|
866
|
+
if (!surface) return
|
|
867
|
+
const surfaceRect = surface.getBoundingClientRect()
|
|
868
|
+
const x = e.clientX - surfaceRect.left
|
|
869
|
+
if (x < 0 || x > surfaceRect.width) return // over the rail — no time here
|
|
870
|
+
// `.get()`, not a subscription: this is an event handler, not something
|
|
871
|
+
// that renders, and `get()` is the more correct read anyway — mid-pan the
|
|
872
|
+
// last value React committed can lag a frame behind the store.
|
|
873
|
+
clock.set(Math.max(0, Math.min(totalDuration, xToTime(x, viewportStore.get()))))
|
|
874
|
+
}
|
|
243
875
|
|
|
244
876
|
return (
|
|
245
877
|
<TimelineContext.Provider value={ctx}>
|
|
246
878
|
<div
|
|
247
|
-
|
|
879
|
+
ref={rootRef}
|
|
880
|
+
// `min-h-full` so the column always fills the resizable timeline pane:
|
|
881
|
+
// that is what lets the transcript panel below sit at the BOTTOM of the
|
|
882
|
+
// pane rather than floating right under the tracks, and it makes the
|
|
883
|
+
// space between them read as timeline surface instead of page
|
|
884
|
+
// background. Content taller than the pane still scrolls.
|
|
885
|
+
className="flex min-h-full flex-col gap-2 px-3 py-3 select-none outline-none"
|
|
248
886
|
tabIndex={0}
|
|
249
|
-
onKeyDown={handleKeyDown}
|
|
250
|
-
onMouseMove={(e) => {
|
|
251
|
-
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
252
|
-
if (rect) setHoverPct(ratioFromClientX(e.clientX, rect) * 100)
|
|
253
|
-
}}
|
|
254
|
-
onMouseLeave={() => setHoverPct(null)}
|
|
255
887
|
onClick={handleContainerClick}
|
|
256
888
|
>
|
|
257
889
|
|
|
258
890
|
{/* Zoom controls */}
|
|
259
891
|
{totalDuration > 0 && (
|
|
260
|
-
<div className="flex items-center justify-end gap-0.5 -mb-1">
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
onClick={(e) => { e.stopPropagation(); zoomTo(zoomRef.current + 1) }}
|
|
271
|
-
>+</button>
|
|
272
|
-
{zoom > 1 && (
|
|
892
|
+
<div className="flex items-center justify-end gap-0.5 -mb-1 cursor-pointer">
|
|
893
|
+
<Tooltip label="Zoom out">
|
|
894
|
+
<button
|
|
895
|
+
className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
|
|
896
|
+
aria-label="Zoom out"
|
|
897
|
+
onClick={(e) => { e.stopPropagation(); zoomControls.zoomOut() }}
|
|
898
|
+
>−</button>
|
|
899
|
+
</Tooltip>
|
|
900
|
+
{zoomControls.badge}
|
|
901
|
+
<Tooltip label="Zoom in">
|
|
273
902
|
<button
|
|
274
|
-
className="text-[
|
|
275
|
-
|
|
276
|
-
onClick={(e) => { e.stopPropagation();
|
|
277
|
-
|
|
903
|
+
className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
|
|
904
|
+
aria-label="Zoom in"
|
|
905
|
+
onClick={(e) => { e.stopPropagation(); zoomControls.zoomIn() }}
|
|
906
|
+
>+</button>
|
|
907
|
+
</Tooltip>
|
|
908
|
+
{zoomControls.showFit && (
|
|
909
|
+
<Tooltip label="Fit to view" className="ml-0.5">
|
|
910
|
+
<button
|
|
911
|
+
className="text-[10px] text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 px-1.5 h-5 rounded hover:bg-[var(--editor-text)]/10 transition-colors"
|
|
912
|
+
aria-label="Fit to view"
|
|
913
|
+
onClick={(e) => { e.stopPropagation(); zoomControls.fit() }}
|
|
914
|
+
>fit</button>
|
|
915
|
+
</Tooltip>
|
|
278
916
|
)}
|
|
279
917
|
</div>
|
|
280
918
|
)}
|
|
281
919
|
|
|
282
|
-
{/*
|
|
920
|
+
{/* Horizontal overflow guard. This used to be a zoom viewport: the DOM
|
|
921
|
+
rows were laid out as percentages of the whole project, so zooming
|
|
922
|
+
meant widening an inner div to `zoom × 100%` and scrolling this
|
|
923
|
+
container over it. The canvas surface zooms its own px-per-second
|
|
924
|
+
viewport instead and always fits the width it is given, so nothing
|
|
925
|
+
in here is ever wider than the pane — this stays only as the guard
|
|
926
|
+
that keeps an unexpectedly wide child from stretching the page. */}
|
|
283
927
|
<div ref={scrollRef} className="overflow-x-auto">
|
|
284
|
-
<div style={{ width: zoom > 1 ? `${zoom * 100}%` : '100%' }} className="min-w-full">
|
|
285
928
|
|
|
286
|
-
{/*
|
|
929
|
+
{/* Readout strip + tracks, wrapped in a relative container */}
|
|
287
930
|
<div className="relative flex flex-col gap-2">
|
|
288
|
-
{hoverPct !== null && totalDuration > 0 && (
|
|
289
|
-
<div
|
|
290
|
-
className="absolute inset-y-0 w-px bg-yellow-400/80 pointer-events-none z-20"
|
|
291
|
-
style={{ left: `${hoverPct}%` }}
|
|
292
|
-
/>
|
|
293
|
-
)}
|
|
294
931
|
|
|
295
|
-
<Scrubber
|
|
296
|
-
hoverPct={hoverPct}
|
|
297
|
-
draggingPlayhead={draggingPlayhead}
|
|
298
|
-
setDraggingPlayhead={setDraggingPlayhead}
|
|
299
|
-
keyNavTime={keyNavTime}
|
|
300
|
-
onSplit={onSplit}
|
|
301
|
-
onCut={onCut}
|
|
302
|
-
cutButtonLabel={cutButtonLabel}
|
|
303
|
-
/>
|
|
932
|
+
<Scrubber onOpenGoToTime={onOpenGoToTime} />
|
|
304
933
|
|
|
305
934
|
{/* ── Tracks ── */}
|
|
306
935
|
<div className="flex flex-col gap-1">
|
|
307
936
|
{project.renderMode === 'ffmpeg-drawtext' && (
|
|
308
|
-
<div className=
|
|
937
|
+
<div className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] select-none ${mode === 'light' ? 'bg-amber-50 border border-amber-300 text-amber-800' : 'bg-amber-500/10 border border-amber-500/20 text-amber-400/70'}`}>
|
|
309
938
|
<span>⚡</span>
|
|
310
939
|
<span>ffmpeg render — overlays are preview only, final text is burned by ffmpeg</span>
|
|
311
940
|
</div>
|
|
312
941
|
)}
|
|
313
|
-
{/*
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
942
|
+
{/* The gutter is a sibling COLUMN, not an overlay: it takes width from
|
|
943
|
+
the canvas (which measures its own container) instead of painting
|
|
944
|
+
over the clips at t=0. Captions have their own row PAINTED inside
|
|
945
|
+
`TimelineCanvas` below, not a separate DOM element — the gutter's
|
|
946
|
+
own caption cell is sized from the same layout (`canvasLayout`) so
|
|
947
|
+
the two line up. `showCaptionRow` is left at its default (true)
|
|
948
|
+
here: `TrackGutter` already gates the cell on `resolved.captions`,
|
|
949
|
+
which `computeTimelineLayout` only sets when there ARE captions,
|
|
950
|
+
so passing `!!captionTrack?.segments?.length` on top can never
|
|
951
|
+
change the outcome — it exists as a host knob for other callers,
|
|
952
|
+
not for this one. */}
|
|
953
|
+
<div className="flex gap-1">
|
|
954
|
+
<TrackGutter
|
|
955
|
+
project={project}
|
|
956
|
+
layout={canvasLayout}
|
|
957
|
+
mode={mode}
|
|
958
|
+
onToggleTrackEnabled={handleToggleTrackEnabled}
|
|
959
|
+
onSetTrackVolume={handleSetTrackVolume}
|
|
960
|
+
onSetTrackMuted={handleSetTrackMuted}
|
|
961
|
+
onApplySpeed={handleApplyTrackSpeed}
|
|
962
|
+
onSetLaneVolume={handleSetLaneVolume}
|
|
963
|
+
onSetLaneMuted={handleSetLaneMuted}
|
|
964
|
+
onSetLaneMagnet={handleSetLaneMagnet}
|
|
965
|
+
/>
|
|
966
|
+
{/* `relative` makes this column the positioning context for the
|
|
967
|
+
per-clip HTML chrome below — see `CanvasClipChrome`. It holds
|
|
968
|
+
exactly one in-flow child (the canvas surface, `w-full` with an
|
|
969
|
+
explicit height), so its content box IS that surface's box and
|
|
970
|
+
the chrome lands on the same pixels the painter does. */}
|
|
971
|
+
<div className="relative flex min-w-0 flex-1 flex-col gap-1">
|
|
972
|
+
{/* ── Canvas track-row area — one surface carrying the visual
|
|
973
|
+
tracks, audio lanes and caption rows. It owns its own
|
|
974
|
+
zoom/scroll (px-per-second), so the readout strip above it
|
|
975
|
+
stays a whole-project readout rather than tracking canvas
|
|
976
|
+
zoom. ── */}
|
|
977
|
+
<TimelineCanvas
|
|
331
978
|
project={project}
|
|
979
|
+
clock={clock}
|
|
980
|
+
store={viewportStore}
|
|
981
|
+
totalDuration={totalDuration}
|
|
982
|
+
fps={fps}
|
|
332
983
|
selectedIds={selectedIds}
|
|
984
|
+
snapBoundaries={snapBoundaries}
|
|
333
985
|
rippleMode={rippleMode}
|
|
986
|
+
previewAxis={previewAxis}
|
|
987
|
+
onHoverScrub={onHoverScrub}
|
|
988
|
+
onSelectItem={handleSelectItem}
|
|
989
|
+
onSelectItems={handleSelectItems}
|
|
990
|
+
selectedKeyframe={selectedKeyframe}
|
|
991
|
+
onSelectKeyframe={setSelectedKeyframe}
|
|
334
992
|
onProjectChange={onProjectChange}
|
|
335
993
|
onOverlayEdit={onOverlayEdit}
|
|
336
|
-
onEditOverlay={onEditOverlay}
|
|
337
|
-
onSelectItem={handleSelectItem}
|
|
338
994
|
onInspectClip={onInspectClip}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
995
|
+
onInspectAudio={onInspectAudio}
|
|
996
|
+
onEditCaption={onEditCaption}
|
|
997
|
+
onFadeCurveMenu={setFadeCurveMenu}
|
|
998
|
+
onKeyframeMenu={setKeyframeMenu}
|
|
999
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
1000
|
+
getFilmstrip={getFilmstrip}
|
|
1001
|
+
resolveFilePath={resolveFilePath}
|
|
1002
|
+
mode={mode}
|
|
1003
|
+
onImportFilesToTimeline={onImportFilesToTimeline}
|
|
1004
|
+
pendingDrops={pendingDrops}
|
|
343
1005
|
/>
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const lanes = [...laneMap.entries()].sort((a, b) => a[0] - b[0])
|
|
361
|
-
|
|
362
|
-
return lanes.map(([laneIdx, laneTracks]) => (
|
|
363
|
-
<AudioTrackRow
|
|
364
|
-
key={`audio-lane-${laneIdx}`}
|
|
365
|
-
tracks={laneTracks}
|
|
366
|
-
laneIndex={laneIdx}
|
|
367
|
-
laneCount={lanes.length}
|
|
368
|
-
project={project}
|
|
369
|
-
onProjectChange={onProjectChange}
|
|
370
|
-
onOverlayEdit={onOverlayEdit}
|
|
1006
|
+
{/* ── Per-clip HTML chrome over the canvas ──
|
|
1007
|
+
The subcut-regenerate trigger and the "queued" badge, ported
|
|
1008
|
+
from the DOM clip rows. Passed the STORE, not a subscribed
|
|
1009
|
+
value: unlike the canvas itself, which reads `store.get()`
|
|
1010
|
+
inside its draw to avoid re-rendering, this chrome IS a DOM
|
|
1011
|
+
node that has to be re-laid-out by React on every viewport
|
|
1012
|
+
change or it detaches from its clip the moment you pan or
|
|
1013
|
+
zoom — so it subscribes internally, isolated to itself, the
|
|
1014
|
+
same way `CanvasZoomBadge` isolates its own subscription in
|
|
1015
|
+
TimelineCanvas.tsx. That isolation is the whole point: it
|
|
1016
|
+
keeps a wheel-zoom frame from re-rendering Timeline's entire
|
|
1017
|
+
subtree (this chrome does NOT share a subscription with the
|
|
1018
|
+
zoom badge — each owns its own). */}
|
|
1019
|
+
<CanvasClipChrome
|
|
1020
|
+
layout={canvasLayout}
|
|
1021
|
+
viewportStore={viewportStore}
|
|
371
1022
|
selectedIds={selectedIds}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1023
|
+
regenEnabled={regenEnabled}
|
|
1024
|
+
isClipQueued={isClipQueued}
|
|
1025
|
+
subcutClipId={subcutClipId}
|
|
1026
|
+
setSubcutClipId={setSubcutClipId}
|
|
376
1027
|
/>
|
|
377
|
-
|
|
378
|
-
|
|
1028
|
+
</div>
|
|
1029
|
+
</div>
|
|
379
1030
|
|
|
380
1031
|
</div>
|
|
381
1032
|
|
|
382
1033
|
</div>{/* end scrubber+tracks wrapper */}
|
|
383
|
-
</div>{/* end inner zoom div */}
|
|
384
1034
|
</div>{/* end scroll container */}
|
|
385
1035
|
|
|
386
1036
|
{/* ── Subcut regen tool (host-rendered via render-prop seam) ──
|
|
@@ -398,54 +1048,137 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
|
|
|
398
1048
|
})
|
|
399
1049
|
})()}
|
|
400
1050
|
|
|
401
|
-
{/* ──
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1051
|
+
{/* ── Fade-shape picker — right-click a fade grip (see
|
|
1052
|
+
`TimelineCanvas`'s `contextMenu` handler). `fixed`-positioned at the
|
|
1053
|
+
CLIENT coordinates the grip was clicked at, since it renders outside
|
|
1054
|
+
the canvas surface's own coordinate space. A full-surface backdrop
|
|
1055
|
+
behind it closes the menu on any click (or right-click) elsewhere —
|
|
1056
|
+
the same dismiss-on-outside-interaction every other popover here
|
|
1057
|
+
uses (TrackSettingsPopover). Options are ICONS (`FadeCurveIcon`),
|
|
1058
|
+
not text — Vegas shows the curve's shape, not its name — with the
|
|
1059
|
+
shape name kept as `title`/`aria-label` so it's still discoverable
|
|
1060
|
+
on hover and to a screen reader. */}
|
|
1061
|
+
{fadeCurveMenu && (() => {
|
|
1062
|
+
const track = project.audio?.tracks?.find(t => t.id === fadeCurveMenu.trackId)
|
|
1063
|
+
const activeCurve: FadeCurve = (fadeCurveMenu.side === 'in' ? track?.fadeInCurve : track?.fadeOutCurve) ?? DEFAULT_FADE_CURVE
|
|
1064
|
+
return (
|
|
1065
|
+
<>
|
|
1066
|
+
<div
|
|
1067
|
+
data-timeline-chrome
|
|
1068
|
+
data-testid="fade-curve-menu-backdrop"
|
|
1069
|
+
className="fixed inset-0 z-40"
|
|
1070
|
+
onClick={() => setFadeCurveMenu(null)}
|
|
1071
|
+
onContextMenu={(e) => { e.preventDefault(); setFadeCurveMenu(null) }}
|
|
1072
|
+
/>
|
|
1073
|
+
<div
|
|
1074
|
+
data-timeline-chrome
|
|
1075
|
+
/* Written per mode rather than through `--editor-*` tokens: the
|
|
1076
|
+
dark branch is the exact class list this menu has always had,
|
|
1077
|
+
so a light theme gains a light popover without the dark one
|
|
1078
|
+
shifting by a single hairline. */
|
|
1079
|
+
className={`fixed z-50 flex gap-1 rounded border p-1 shadow-xl ${mode === 'light' ? 'border-gray-300 bg-white' : 'border-gray-700 bg-gray-900'}`}
|
|
1080
|
+
style={{ left: fadeCurveMenu.x, top: fadeCurveMenu.y }}
|
|
1081
|
+
>
|
|
1082
|
+
{([
|
|
1083
|
+
['linear', 'Linear'],
|
|
1084
|
+
['log', 'Logarithmic'],
|
|
1085
|
+
['exp', 'Exponential'],
|
|
1086
|
+
] as const).map(([curve, label]) => (
|
|
1087
|
+
<button
|
|
1088
|
+
key={curve}
|
|
1089
|
+
type="button"
|
|
1090
|
+
title={label}
|
|
1091
|
+
aria-label={label}
|
|
1092
|
+
aria-pressed={activeCurve === curve}
|
|
1093
|
+
className={`rounded p-0.5 transition-colors ${
|
|
1094
|
+
activeCurve === curve
|
|
1095
|
+
? (mode === 'light' ? 'bg-gray-200 ring-1 ring-emerald-600/60' : 'bg-gray-700 ring-1 ring-emerald-400/60')
|
|
1096
|
+
: (mode === 'light' ? 'hover:bg-gray-100' : 'hover:bg-gray-800')
|
|
1097
|
+
}`}
|
|
1098
|
+
onClick={() => handleSetFadeCurve(fadeCurveMenu.trackId, fadeCurveMenu.side, curve)}
|
|
1099
|
+
>
|
|
1100
|
+
<FadeCurveIcon curve={curve} active={activeCurve === curve} mode={mode} />
|
|
1101
|
+
</button>
|
|
1102
|
+
))}
|
|
1103
|
+
</div>
|
|
1104
|
+
</>
|
|
1105
|
+
)
|
|
1106
|
+
})()}
|
|
416
1107
|
|
|
417
|
-
{/* ──
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1108
|
+
{/* ── Keyframe-strip popup (SP9b T3.3) — right-click a keyframe diamond
|
|
1109
|
+
(see `TimelineCanvas`'s `contextMenu` handler). The
|
|
1110
|
+
fade-shape picker's exact sibling: same `fixed`-positioned menu at
|
|
1111
|
+
the CLIENT coordinates the diamond was clicked at, same full-surface
|
|
1112
|
+
dismiss-on-outside-interaction backdrop. Two sections: the six
|
|
1113
|
+
EASING_NAMES (labelled "Easing to next keyframe" — easing is
|
|
1114
|
+
OUTGOING, see `Keyframe.easing`'s doc in schema.ts, so this is the
|
|
1115
|
+
one place that direction has to be said out loud or a user authors
|
|
1116
|
+
the wrong curve) and, always available regardless of `isLast`, the
|
|
1117
|
+
keyframe's own removal. */}
|
|
1118
|
+
{keyframeMenu && (() => {
|
|
1119
|
+
const item = trackItems(project).flat().find(i => i.id === keyframeMenu.itemId)
|
|
1120
|
+
const easingAt = (prop: KeyframeProp): EasingName => {
|
|
1121
|
+
const track = item?.keyframes?.find(kt => kt.prop === prop)
|
|
1122
|
+
return track?.points.find(p => p.t === keyframeMenu.t)?.easing ?? 'linear'
|
|
1123
|
+
}
|
|
1124
|
+
const easings = keyframeMenu.props.map(easingAt)
|
|
1125
|
+
const activeEasing: EasingName | null = easings.length > 0 && easings.every(e => e === easings[0]) ? easings[0] : null
|
|
1126
|
+
return (
|
|
1127
|
+
<>
|
|
1128
|
+
<div
|
|
1129
|
+
data-timeline-chrome
|
|
1130
|
+
data-testid="keyframe-menu-backdrop"
|
|
1131
|
+
className="fixed inset-0 z-40"
|
|
1132
|
+
onClick={() => setKeyframeMenu(null)}
|
|
1133
|
+
onContextMenu={(e) => { e.preventDefault(); setKeyframeMenu(null) }}
|
|
1134
|
+
/>
|
|
1135
|
+
<div
|
|
1136
|
+
data-timeline-chrome
|
|
1137
|
+
/* `fadeCurveMenu`'s exact sibling (see that menu's own comment
|
|
1138
|
+
below) — mode-conditional per class rather than through
|
|
1139
|
+
`--editor-*` tokens, so the dark branch is the exact class
|
|
1140
|
+
list this menu has always had and a light theme gains a
|
|
1141
|
+
light popover without the dark one shifting by a hairline. */
|
|
1142
|
+
className={`fixed z-50 flex flex-col gap-1.5 rounded border p-2 shadow-xl ${mode === 'light' ? 'border-gray-300 bg-white' : 'border-gray-700 bg-gray-900'}`}
|
|
1143
|
+
style={{ left: keyframeMenu.x, top: keyframeMenu.y }}
|
|
1144
|
+
>
|
|
1145
|
+
<div className={`px-0.5 text-[10px] ${mode === 'light' ? 'text-gray-600' : 'text-gray-500'}`}>Easing to next keyframe</div>
|
|
1146
|
+
<div className="flex flex-wrap gap-1 max-w-[220px]">
|
|
1147
|
+
{EASING_NAMES.map((name) => (
|
|
1148
|
+
<button
|
|
1149
|
+
key={name}
|
|
1150
|
+
type="button"
|
|
1151
|
+
title={keyframeMenu.isLast ? 'No next keyframe' : EASING_LABELS[name]}
|
|
1152
|
+
aria-label={EASING_LABELS[name]}
|
|
1153
|
+
aria-pressed={activeEasing === name}
|
|
1154
|
+
disabled={keyframeMenu.isLast}
|
|
1155
|
+
className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${
|
|
1156
|
+
keyframeMenu.isLast
|
|
1157
|
+
? (mode === 'light' ? 'text-gray-400 cursor-not-allowed' : 'text-gray-600 cursor-not-allowed')
|
|
1158
|
+
: activeEasing === name
|
|
1159
|
+
? (mode === 'light' ? 'bg-gray-200 text-gray-900 ring-1 ring-emerald-600/60' : 'bg-gray-700 text-gray-100 ring-1 ring-emerald-400/60')
|
|
1160
|
+
: (mode === 'light' ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-800')
|
|
1161
|
+
}`}
|
|
1162
|
+
onClick={() => handleSetKeyframeEasing(keyframeMenu.itemId, keyframeMenu.t, keyframeMenu.props, name)}
|
|
1163
|
+
>
|
|
1164
|
+
{EASING_LABELS[name]}
|
|
1165
|
+
</button>
|
|
1166
|
+
))}
|
|
1167
|
+
</div>
|
|
1168
|
+
<div className={`h-px ${mode === 'light' ? 'bg-gray-200' : 'bg-gray-700'}`} />
|
|
1169
|
+
<button
|
|
1170
|
+
type="button"
|
|
1171
|
+
className={`rounded px-1.5 py-1 text-left text-[11px] hover:bg-red-500/10 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}
|
|
1172
|
+
onClick={() => handleRemoveKeyframe(keyframeMenu.itemId, keyframeMenu.t)}
|
|
1173
|
+
>
|
|
1174
|
+
Remove keyframe
|
|
1175
|
+
</button>
|
|
1176
|
+
</div>
|
|
1177
|
+
</>
|
|
1178
|
+
)
|
|
1179
|
+
})()}
|
|
427
1180
|
|
|
428
1181
|
</div>
|
|
429
1182
|
</TimelineContext.Provider>
|
|
430
1183
|
)
|
|
431
1184
|
}
|
|
432
|
-
|
|
433
|
-
// The transcript views highlight the active caption segment, so they genuinely
|
|
434
|
-
// display time and must re-render every tick. Subscribing HERE (rather than in
|
|
435
|
-
// Timeline) keeps the per-tick re-render scoped to these leaves — Timeline and
|
|
436
|
-
// the track rows are unaffected.
|
|
437
|
-
function TranscriptPanelWithClock({
|
|
438
|
-
clock,
|
|
439
|
-
...rest
|
|
440
|
-
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptPanel>, 'currentTime'>) {
|
|
441
|
-
const currentTime = usePlaybackTime(clock)
|
|
442
|
-
return <TranscriptPanel currentTime={currentTime} {...rest} />
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function TranscriptModalWithClock({
|
|
446
|
-
clock,
|
|
447
|
-
...rest
|
|
448
|
-
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptModal>, 'currentTime'>) {
|
|
449
|
-
const currentTime = usePlaybackTime(clock)
|
|
450
|
-
return <TranscriptModal currentTime={currentTime} {...rest} />
|
|
451
|
-
}
|