@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -1,24 +1,103 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Crop,
|
|
1
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
|
|
2
|
+
import { Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
|
+
import type { AudioTrack, VisualItem } from '../schema'
|
|
4
5
|
import { useProjectSync, type UseProjectSync } from '../state/use-project-sync'
|
|
5
6
|
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
6
7
|
import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
|
|
8
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
9
|
+
import { reviveNumberInRange, usePersistentState } from '../ui/usePersistentState'
|
|
7
10
|
import { getOverlayDesignCanvas } from './design-canvas'
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
11
|
+
import { availableResolutionTiers, availableFpsTiers, currentResolutionTier, maxExportFps } from './export-limits'
|
|
12
|
+
import { applyTheme, defaultMontajTheme, isLightTheme } from '../theme'
|
|
13
|
+
import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
|
|
10
14
|
import { repairCaptionWords } from './captionRepair'
|
|
11
|
-
import
|
|
15
|
+
import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
|
|
16
|
+
import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
|
|
17
|
+
import { computeAutoCrossfade, computeDerivedTiming, enabledTrackItems, mapTrackItems, trackItems } from './timeline/timeline-model'
|
|
12
18
|
import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
|
|
13
|
-
import PreviewPlayer from './preview/PreviewPlayer'
|
|
14
|
-
import {
|
|
19
|
+
import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
|
|
20
|
+
import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
|
|
21
|
+
import type { SocialPreviewPlatform } from './preview/SocialSafeZoneOverlay'
|
|
22
|
+
import { createPlaybackClock, usePlaybackTime, type PlaybackClock } from './playback-clock'
|
|
23
|
+
import { createHoverScrub } from './hover-scrub'
|
|
24
|
+
import { createScrubSource, type ScrubSource } from '../engine/scrub-source'
|
|
25
|
+
import { createScrubResolver } from '../engine/scrub-resolve'
|
|
26
|
+
import { useSourcePreview, type SourcePreviewStore } from './source-preview'
|
|
27
|
+
import { formatTimecode } from './timecode'
|
|
15
28
|
import type { OverlayChanges } from './preview/useDragOverlay'
|
|
16
|
-
import VersionPanel from './VersionPanel'
|
|
29
|
+
import VersionPanel, { listVersions } from './VersionPanel'
|
|
30
|
+
import OverlayInspector from './OverlayInspector'
|
|
31
|
+
import LeftPanelTabs, { type LeftPanelTab } from './panels/LeftPanelTabs'
|
|
32
|
+
import ClipPropertiesPanel, { type ClipSelection } from './panels/ClipPropertiesPanel'
|
|
33
|
+
import OverlayContentPanel from './panels/OverlayContentPanel'
|
|
34
|
+
import TabNav from './panels/TabNav'
|
|
35
|
+
import VersionCompare from './VersionCompare'
|
|
36
|
+
import CaptionListPanel, { type CaptionListPanelProps, type CaptionEditFocusRequest, nextEditFocus } from './CaptionListPanel'
|
|
17
37
|
import RenderModal from './RenderModal'
|
|
18
38
|
import ImageToneMenu from './ImageToneMenu'
|
|
19
39
|
import type { ImageTone } from './imageTone'
|
|
20
40
|
import CaptionRegenModal from './CaptionRegenModal'
|
|
21
|
-
import
|
|
41
|
+
import AudioPolishModal from './AudioPolishModal'
|
|
42
|
+
import CommandPalette, { type PaletteCommand } from './CommandPalette'
|
|
43
|
+
import { createShuttleController } from './shuttle'
|
|
44
|
+
import { useKeymap, matchesKey, matchesModAltKey, matchesModKey, matchesPlainKey, matchesRedo, matchesShiftDelete, matchesUndo } from './keymap'
|
|
45
|
+
import { useReportContext } from './use-report-context'
|
|
46
|
+
import { copySelection, duplicateSelection, pasteAt, pasteAttributes, type ClipboardPayload } from './clipboard-ops'
|
|
47
|
+
|
|
48
|
+
// ── Layout preferences ───────────────────────────────────────────────────
|
|
49
|
+
// Persisted per browser (not per project): how tall the timeline pane is, and
|
|
50
|
+
// whether the caption row is shown. Keys are namespaced so they can't collide
|
|
51
|
+
// with a host's own localStorage.
|
|
52
|
+
|
|
53
|
+
const TIMELINE_PANE_STORAGE_KEY = 'montaj.editor.timelinePaneHeight'
|
|
54
|
+
/** Starting height of the timeline pane — roughly the base track, one overlay
|
|
55
|
+
* row, an audio lane and the caption row, which is what the fixed layout used
|
|
56
|
+
* to come out at. */
|
|
57
|
+
const DEFAULT_TIMELINE_PANE_PX = 300
|
|
58
|
+
/** Floor: the toolbar plus enough of the scrubber to still aim at. Below this
|
|
59
|
+
* the pane stops being a timeline. */
|
|
60
|
+
const MIN_TIMELINE_PANE_PX = 140
|
|
61
|
+
/** Ceiling, so a stored height from a much taller window can't open the editor
|
|
62
|
+
* with the preview pushed off-screen. */
|
|
63
|
+
const MAX_TIMELINE_PANE_PX = 1200
|
|
64
|
+
/** Always left to the preview, so the divider can never strand the picture at
|
|
65
|
+
* zero height. */
|
|
66
|
+
const MIN_PREVIEW_PANE_PX = 160
|
|
67
|
+
|
|
68
|
+
const RAIL_WIDTH_STORAGE_KEY = 'montaj.editor.railWidth'
|
|
69
|
+
/** Floor/ceiling for the right rail, and the width always left to the editor
|
|
70
|
+
* beside it — the vertical counterpart of the timeline pane's clamps. */
|
|
71
|
+
const MIN_RAIL_PX = 150
|
|
72
|
+
const MAX_RAIL_PX = 720
|
|
73
|
+
/** Default/reset rail width, wide enough for the sidebar CaptionListPanel's
|
|
74
|
+
* list and controls (previously 224 in 'sidebar' placement, 192 elsewhere —
|
|
75
|
+
* now one shared default across placements). */
|
|
76
|
+
const DEFAULT_RAIL_PX = 300
|
|
77
|
+
const MIN_MAIN_PX = 320
|
|
78
|
+
|
|
79
|
+
// ── CapCut media-panel width (opt-in via slots.mediaPanel) ─────────────────
|
|
80
|
+
// The left media column's width, mirroring the right rail on the same axis.
|
|
81
|
+
// Only consulted by the CapCut layout branch; classic layouts never read it.
|
|
82
|
+
const MEDIA_PANEL_WIDTH_STORAGE_KEY = 'montaj.editor.mediaPanelWidth'
|
|
83
|
+
/** Default matches today's `w-72` assets column so the switch feels familiar. */
|
|
84
|
+
const DEFAULT_MEDIA_PANEL_PX = 288
|
|
85
|
+
const MIN_MEDIA_PANEL_PX = 200
|
|
86
|
+
const MAX_MEDIA_PANEL_PX = 640
|
|
87
|
+
|
|
88
|
+
// ── Overlay properties panel tab ──────────────────────────────────────────
|
|
89
|
+
// Which of the right column's two overlay tabs is showing. Persisted per
|
|
90
|
+
// browser like every other panel preference, so the operator's habit survives
|
|
91
|
+
// a reload. 'content' is the default: what an overlay SAYS is what you reach
|
|
92
|
+
// for first, and it is the tab that replaced the double-click dialog.
|
|
93
|
+
const OVERLAY_PANEL_TAB_STORAGE_KEY = 'montaj.editor.overlayPanelTab'
|
|
94
|
+
type OverlayPanelTab = 'content' | 'transform'
|
|
95
|
+
const reviveOverlayPanelTab = (raw: unknown): OverlayPanelTab | null =>
|
|
96
|
+
raw === 'content' || raw === 'transform' ? raw : null
|
|
97
|
+
const OVERLAY_PANEL_TABS: readonly { value: OverlayPanelTab; label: string }[] = [
|
|
98
|
+
{ value: 'content', label: 'Content' },
|
|
99
|
+
{ value: 'transform', label: 'Transform' },
|
|
100
|
+
]
|
|
22
101
|
|
|
23
102
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
24
103
|
// own `Project`). Montaj passes its richer Project; the index signature on
|
|
@@ -93,12 +172,18 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
93
172
|
onBackToSetup,
|
|
94
173
|
assetsPlacement = 'right',
|
|
95
174
|
renderProgressView = 'phases',
|
|
96
|
-
|
|
175
|
+
renderGenerationPanel,
|
|
97
176
|
renderSubcutRegen,
|
|
98
177
|
regenEnabled,
|
|
99
178
|
isClipQueued,
|
|
100
179
|
onProvideRenderTrigger,
|
|
101
180
|
onProvideImageTone,
|
|
181
|
+
engine,
|
|
182
|
+
sourcePreview,
|
|
183
|
+
onRegenerateCaptions,
|
|
184
|
+
captionsGenerating,
|
|
185
|
+
onImportFilesToTimeline,
|
|
186
|
+
pendingDrops,
|
|
102
187
|
}: Props<P>) {
|
|
103
188
|
const emit = onProjectChange ?? (() => {})
|
|
104
189
|
|
|
@@ -109,6 +194,22 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
109
194
|
// frames itself (video-shaped → default plain-replace reconcile).
|
|
110
195
|
const sync = useProjectSync<P>(adapter, project.id, project)
|
|
111
196
|
|
|
197
|
+
// Set for the duration of a caption drag gesture (ReviewSurface's
|
|
198
|
+
// `handleProjectChange` → `commitTimelineEdit`), read by the lane-
|
|
199
|
+
// normalization effect just below. A cross-row drag deliberately leaves a
|
|
200
|
+
// HOLE lane open for the whole gesture (pointer-machine.ts normalizes only
|
|
201
|
+
// at commit, so the vacated lane's band stays visible as a drop target and
|
|
202
|
+
// the timeline doesn't jump under the pointer). Every `handleProjectChange`
|
|
203
|
+
// frame is a fresh `mutateTransient` call, which gives `captions` a new
|
|
204
|
+
// identity each mousemove — without this flag the effect below would see
|
|
205
|
+
// that hole on the very first move and call `sync.applyExternal`, which
|
|
206
|
+
// clears the sync core's transient baseline (`use-project-sync.ts`) and
|
|
207
|
+
// costs the whole gesture its single undo entry (`commitTimelineEdit`
|
|
208
|
+
// would then re-seed the baseline from the already-moved mid-drag state).
|
|
209
|
+
// A ref, not state: this must be readable synchronously inside the same
|
|
210
|
+
// handlers that flip it, with no re-render in between.
|
|
211
|
+
const captionGestureRef = useRef(false)
|
|
212
|
+
|
|
112
213
|
// Every caption segment needs a stable `id` for selection (preview drag,
|
|
113
214
|
// clickable timeline row). Segments saved before `id` existed on the schema
|
|
114
215
|
// are missing it, and `steps/lyrics/caption.py` still writes segments without
|
|
@@ -133,13 +234,41 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
133
234
|
// therefore re-fires this effect exactly once) finds nothing to do and stops.
|
|
134
235
|
// Every other re-fire — one per caption edit — is a cheap `.every()` no-op.
|
|
135
236
|
//
|
|
237
|
+
// The SECOND pass in the same effect is caption LANES. A project.json can
|
|
238
|
+
// arrive with sparse or hand-authored lanes (`lane: 7` on the only segment
|
|
239
|
+
// that has one, written by an agent or edited by hand), and every reader
|
|
240
|
+
// downstream — the bands the painter emits, the row the hit-test addresses,
|
|
241
|
+
// the fan-out a cross-row drag searches — assumes lanes are dense from 0. So
|
|
242
|
+
// normalize on load: `lane: 7` opens as row 1, not as eight rows of mostly
|
|
243
|
+
// nothing. `normalizeCaptionLanes` honours the same same-reference contract
|
|
244
|
+
// as `backfillCaptionIds`, so it is loop-proof for the same reason.
|
|
245
|
+
//
|
|
246
|
+
// Both passes share ONE effect, and the lane pass reads the BACKFILLED
|
|
247
|
+
// project rather than `sync.project`, on purpose: two effects with the same
|
|
248
|
+
// deps both close over the same pre-effect `sync.project`, so the second
|
|
249
|
+
// `applyExternal` of a commit lands a project derived from the state as it
|
|
250
|
+
// was BEFORE the first one and drops the ids the backfill just minted. It
|
|
251
|
+
// recovers on the next pass (the effect re-fires and re-mints), but only
|
|
252
|
+
// after publishing a half-normalized project to the host through
|
|
253
|
+
// `onProjectChange` and paying an extra render for it. Chained, the host only
|
|
254
|
+
// ever sees the input or the finished result.
|
|
255
|
+
//
|
|
136
256
|
// `applyExternal` — no save, no undo push: this is normalization of loaded
|
|
137
257
|
// data, not a user edit, so it must not dirty the project or contend with the
|
|
138
|
-
// undo stack; the ids persist naturally the next time the operator
|
|
139
|
-
// real edit.
|
|
258
|
+
// undo stack; the ids and lanes persist naturally the next time the operator
|
|
259
|
+
// makes a real edit.
|
|
140
260
|
useEffect(() => {
|
|
141
261
|
const backfilled = backfillCaptionIds(sync.project)
|
|
142
|
-
|
|
262
|
+
// Lane normalization is for captions ARRIVING from outside (mount, SSE, regen,
|
|
263
|
+
// a hand-authored project.json) -- never for a mid-gesture transient frame. A
|
|
264
|
+
// cross-row drag deliberately leaves a HOLE lane open for the whole gesture
|
|
265
|
+
// (pointer-machine normalizes at commit), and `applyExternal` clears the sync
|
|
266
|
+
// core's transient baseline, which would cost the gesture its single undo entry.
|
|
267
|
+
const captions = captionGestureRef.current
|
|
268
|
+
? backfilled.captions
|
|
269
|
+
: normalizeCaptionLanes(backfilled.captions)
|
|
270
|
+
const normalized = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
|
|
271
|
+
if (normalized !== sync.project) sync.applyExternal(normalized)
|
|
143
272
|
}, [sync.project.id, sync.project.captions])
|
|
144
273
|
|
|
145
274
|
// Notify the host of every authoritative change — edits, undo/redo, and SSE
|
|
@@ -158,11 +287,27 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
158
287
|
if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
|
|
159
288
|
}, [theme])
|
|
160
289
|
|
|
290
|
+
// The chrome flips via the `--editor-*` vars `applyTheme` just wrote; the
|
|
291
|
+
// canvas timeline cannot read a CSS variable, so it is handed the answer
|
|
292
|
+
// directly. Classified ONCE here, off the same theme object, rather than
|
|
293
|
+
// per-surface — three consumers (canvas, track rail, fade-shape icons)
|
|
294
|
+
// resolving it independently is three chances to disagree. Memoized on
|
|
295
|
+
// `theme` so the prop identity is stable across the many re-renders an edit
|
|
296
|
+
// causes, and `TimelineCanvas`'s repaint-on-mode-change effect fires only
|
|
297
|
+
// when the mode has actually moved.
|
|
298
|
+
const timelineMode = useMemo<TimelineMode>(
|
|
299
|
+
() => (isLightTheme(theme ?? defaultMontajTheme) ? 'light' : 'dark'),
|
|
300
|
+
[theme],
|
|
301
|
+
)
|
|
302
|
+
|
|
161
303
|
const isPending = sync.project.status === 'pending'
|
|
162
304
|
|
|
163
305
|
// ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
|
|
164
|
-
const getWaveformChunks = adapter.getWaveformChunks
|
|
165
306
|
const resolveFilePath = adapter.fileUrl
|
|
307
|
+
// T6 — canvas-timeline waveforms; absent → Timeline renders none (graceful).
|
|
308
|
+
const getWaveformPeaks = adapter.getWaveformPeaks
|
|
309
|
+
// T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
|
|
310
|
+
const getFilmstrip = adapter.getFilmstrip
|
|
166
311
|
|
|
167
312
|
if (isPending) {
|
|
168
313
|
return (
|
|
@@ -172,30 +317,42 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
172
317
|
adapter={adapter}
|
|
173
318
|
slots={slots}
|
|
174
319
|
onBackToSetup={onBackToSetup}
|
|
175
|
-
getWaveformChunks={getWaveformChunks}
|
|
176
320
|
resolveFilePath={resolveFilePath}
|
|
321
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
322
|
+
getFilmstrip={getFilmstrip}
|
|
323
|
+
engine={engine}
|
|
324
|
+
timelineMode={timelineMode}
|
|
177
325
|
/>
|
|
178
326
|
</div>
|
|
179
327
|
)
|
|
180
328
|
}
|
|
181
329
|
|
|
182
330
|
return (
|
|
183
|
-
<div ref={containerRef} className="flex flex-col h-full">
|
|
331
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
184
332
|
<ReviewSurface
|
|
185
333
|
sync={sync}
|
|
334
|
+
captionGestureRef={captionGestureRef}
|
|
186
335
|
emit={emit}
|
|
187
336
|
adapter={adapter}
|
|
188
337
|
slots={slots}
|
|
189
338
|
assetsPlacement={assetsPlacement}
|
|
190
339
|
renderProgressView={renderProgressView}
|
|
191
|
-
getWaveformChunks={getWaveformChunks}
|
|
192
340
|
resolveFilePath={resolveFilePath}
|
|
193
|
-
|
|
341
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
342
|
+
getFilmstrip={getFilmstrip}
|
|
343
|
+
renderGenerationPanel={renderGenerationPanel}
|
|
194
344
|
renderSubcutRegen={renderSubcutRegen}
|
|
195
345
|
regenEnabled={regenEnabled}
|
|
196
346
|
isClipQueued={isClipQueued}
|
|
197
347
|
onProvideRenderTrigger={onProvideRenderTrigger}
|
|
198
348
|
onProvideImageTone={onProvideImageTone}
|
|
349
|
+
engine={engine}
|
|
350
|
+
sourcePreview={sourcePreview}
|
|
351
|
+
onRegenerateCaptions={onRegenerateCaptions}
|
|
352
|
+
captionsGenerating={captionsGenerating}
|
|
353
|
+
onImportFilesToTimeline={onImportFilesToTimeline}
|
|
354
|
+
pendingDrops={pendingDrops}
|
|
355
|
+
timelineMode={timelineMode}
|
|
199
356
|
/>
|
|
200
357
|
</div>
|
|
201
358
|
)
|
|
@@ -206,12 +363,92 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
206
363
|
function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adapter'], project: P) {
|
|
207
364
|
const [versions, setVersions] = useState<{ hash: string; message: string; timestamp: string }[]>([])
|
|
208
365
|
const [restoring, setRestoring] = useState<string | null>(null)
|
|
366
|
+
const [saving, setSaving] = useState(false)
|
|
367
|
+
|
|
368
|
+
// Extracted so ad-hoc callers (post-restore, post-save, post-render) can
|
|
369
|
+
// re-run the same fetch on demand via `refresh`, without duplicating the
|
|
370
|
+
// adapter call or bypassing the auto-refetch effect below.
|
|
371
|
+
const fetchVersions = useCallback(() => {
|
|
372
|
+
return adapter.listVersionHistory?.(project.id).then(setVersions).catch(() => {}) ?? Promise.resolve()
|
|
373
|
+
}, [adapter, project.id])
|
|
209
374
|
|
|
210
375
|
useEffect(() => {
|
|
211
|
-
|
|
212
|
-
}, [
|
|
376
|
+
void fetchVersions()
|
|
377
|
+
}, [fetchVersions, project.status])
|
|
213
378
|
|
|
214
|
-
return { versions, restoring, setRestoring }
|
|
379
|
+
return { versions, restoring, setRestoring, saving, setSaving, refresh: fetchVersions }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Footage-bin source-scrub overlay (opt-in) ────────────────────────────────
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A paused `<video>` overlaid on the main preview stage, driven by the host's
|
|
386
|
+
* footage bin: while a bin clip card is hovered the host writes `{ url, fraction }`
|
|
387
|
+
* to the `sourcePreview` store and this parks the video on `fraction × duration`,
|
|
388
|
+
* so the operator source-scrubs an OFF-TIMELINE clip in the big preview without
|
|
389
|
+
* disturbing the playhead or PreviewPlayer's own clock.
|
|
390
|
+
*
|
|
391
|
+
* Structurally a sibling of `hover-scrub`: the subscription lives HERE, in a leaf
|
|
392
|
+
* that renders one element, so a high-frequency hover over a card repaints only
|
|
393
|
+
* this overlay — never ReviewSurface (toolbar + timeline + every context
|
|
394
|
+
* consumer). The seek is imperative (an effect subscribed to the store writes
|
|
395
|
+
* `currentTime` directly) rather than React state, so a fraction move never
|
|
396
|
+
* re-mounts or reloads the `<video>`; only a url change (a different clip) does.
|
|
397
|
+
*
|
|
398
|
+
* Entirely inert when the host omits the store: `useSourcePreview(undefined)`
|
|
399
|
+
* returns null, so this renders nothing and the classic/Hub/LP preview is
|
|
400
|
+
* unchanged.
|
|
401
|
+
*/
|
|
402
|
+
function SourcePreviewOverlay({ store }: { store?: SourcePreviewStore }) {
|
|
403
|
+
const value = useSourcePreview(store)
|
|
404
|
+
const videoRef = useRef<HTMLVideoElement | null>(null)
|
|
405
|
+
|
|
406
|
+
// Imperative seek. Re-subscribes only when the url changes (a different clip);
|
|
407
|
+
// fraction moves flow through the store subscription without a dep change, and
|
|
408
|
+
// the `<video>` element/src stay put. A fresh src resets metadata, so seeking
|
|
409
|
+
// is deferred to `loadedmetadata` when `duration` isn't finite yet — that
|
|
410
|
+
// handler reads the CURRENT fraction, so a hover that moved while the proxy
|
|
411
|
+
// loaded still lands on the right frame.
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
const v = videoRef.current
|
|
414
|
+
if (!v || !store) return
|
|
415
|
+
const applySeek = () => {
|
|
416
|
+
const cur = store.get()
|
|
417
|
+
if (!cur) return
|
|
418
|
+
const dur = v.duration
|
|
419
|
+
if (Number.isFinite(dur) && dur > 0) v.currentTime = cur.fraction * dur
|
|
420
|
+
}
|
|
421
|
+
v.addEventListener('loadedmetadata', applySeek)
|
|
422
|
+
const unsub = store.subscribe(applySeek)
|
|
423
|
+
applySeek()
|
|
424
|
+
return () => {
|
|
425
|
+
v.removeEventListener('loadedmetadata', applySeek)
|
|
426
|
+
unsub()
|
|
427
|
+
}
|
|
428
|
+
}, [store, value?.url])
|
|
429
|
+
|
|
430
|
+
if (!value) return null
|
|
431
|
+
return (
|
|
432
|
+
<video
|
|
433
|
+
ref={videoRef}
|
|
434
|
+
src={value.url}
|
|
435
|
+
muted
|
|
436
|
+
playsInline
|
|
437
|
+
preload="metadata"
|
|
438
|
+
// Display-only frame scrubber: never `.play()`, never steal pointer
|
|
439
|
+
// interaction from the preview beneath it.
|
|
440
|
+
style={{
|
|
441
|
+
position: 'absolute',
|
|
442
|
+
inset: 0,
|
|
443
|
+
width: '100%',
|
|
444
|
+
height: '100%',
|
|
445
|
+
objectFit: 'contain',
|
|
446
|
+
background: 'black',
|
|
447
|
+
zIndex: 20,
|
|
448
|
+
pointerEvents: 'none',
|
|
449
|
+
}}
|
|
450
|
+
/>
|
|
451
|
+
)
|
|
215
452
|
}
|
|
216
453
|
|
|
217
454
|
// ── Pending / processing surface (former LiveView) ───────────────────────────
|
|
@@ -222,10 +459,16 @@ interface SurfaceProps<P extends Project> {
|
|
|
222
459
|
slots?: VideoEditorProps<P>['slots']
|
|
223
460
|
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
224
461
|
renderProgressView?: VideoEditorProps<P>['renderProgressView']
|
|
225
|
-
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
226
462
|
resolveFilePath: (path: string) => string
|
|
463
|
+
getWaveformPeaks?: VideoEditorProps<P>['adapter']['getWaveformPeaks']
|
|
464
|
+
getFilmstrip?: VideoEditorProps<P>['adapter']['getFilmstrip']
|
|
227
465
|
onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
|
|
228
466
|
onProvideImageTone?: VideoEditorProps<P>['onProvideImageTone']
|
|
467
|
+
engine?: VideoEditorProps<P>['engine']
|
|
468
|
+
/** Light/dark for the canvas timeline, resolved from the host theme by
|
|
469
|
+
* `VideoEditor` (the canvas can't read the CSS vars the rest of the chrome
|
|
470
|
+
* uses). Both surfaces render a `Timeline`, so both need it. */
|
|
471
|
+
timelineMode: TimelineMode
|
|
229
472
|
}
|
|
230
473
|
|
|
231
474
|
function PendingSurface<P extends Project>({
|
|
@@ -233,8 +476,11 @@ function PendingSurface<P extends Project>({
|
|
|
233
476
|
adapter,
|
|
234
477
|
slots,
|
|
235
478
|
onBackToSetup,
|
|
236
|
-
getWaveformChunks,
|
|
237
479
|
resolveFilePath,
|
|
480
|
+
getWaveformPeaks,
|
|
481
|
+
getFilmstrip,
|
|
482
|
+
engine,
|
|
483
|
+
timelineMode,
|
|
238
484
|
}: SurfaceProps<P> & { onBackToSetup?: () => void }) {
|
|
239
485
|
const project = sync.project
|
|
240
486
|
// The playhead lives in an external store (not useState) so ~60Hz ticks only
|
|
@@ -244,13 +490,17 @@ function PendingSurface<P extends Project>({
|
|
|
244
490
|
const clock = clockRef.current
|
|
245
491
|
const [skillPath, setSkillPath] = useState<string | null>(null)
|
|
246
492
|
const [copied, setCopied] = useState(false)
|
|
247
|
-
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
493
|
+
const { versions, restoring, setRestoring, saving, setSaving, refresh: refreshVersions } = useVersionHistory(adapter, project)
|
|
494
|
+
// The version-compare view: the LEFT hash it was opened for, or null when
|
|
495
|
+
// closed. Lives beside `versions` since VersionCompare's picker is seeded
|
|
496
|
+
// from the same list VersionPanel renders.
|
|
497
|
+
const [compareOpen, setCompareOpen] = useState<string | null>(null)
|
|
248
498
|
|
|
249
499
|
useEffect(() => {
|
|
250
500
|
adapter.getInfo?.().then(info => setSkillPath(info.root_skill_path ?? null)).catch(() => {})
|
|
251
501
|
}, [adapter])
|
|
252
502
|
|
|
253
|
-
const clips = project
|
|
503
|
+
const clips = trackItems(project)[0] ?? []
|
|
254
504
|
const hasTrimmedClips = clips.some(c => c.inPoint !== undefined && c.outPoint !== undefined)
|
|
255
505
|
// The back-to-setup affordance is gated on the host supplying it AND the
|
|
256
506
|
// project being safe to discard (no manual trims yet). Mirrors LiveView's
|
|
@@ -268,9 +518,27 @@ function PendingSurface<P extends Project>({
|
|
|
268
518
|
console.error(e)
|
|
269
519
|
} finally {
|
|
270
520
|
setRestoring(null)
|
|
521
|
+
await refreshVersions()
|
|
271
522
|
}
|
|
272
523
|
}
|
|
273
524
|
|
|
525
|
+
async function handleSaveVersion(name?: string) {
|
|
526
|
+
if (!adapter.saveVersion) return
|
|
527
|
+
setSaving(true)
|
|
528
|
+
try {
|
|
529
|
+
await adapter.saveVersion(project.id, name)
|
|
530
|
+
await refreshVersions()
|
|
531
|
+
} catch (e) {
|
|
532
|
+
console.error(e)
|
|
533
|
+
} finally {
|
|
534
|
+
setSaving(false)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function handleCompareVersion(hash: string) {
|
|
539
|
+
setCompareOpen(hash)
|
|
540
|
+
}
|
|
541
|
+
|
|
274
542
|
return (
|
|
275
543
|
<div className="flex flex-1 overflow-hidden">
|
|
276
544
|
{/* Main */}
|
|
@@ -285,6 +553,7 @@ function PendingSurface<P extends Project>({
|
|
|
285
553
|
watchFile={adapter.watchFile}
|
|
286
554
|
fileUrl={adapter.fileUrl}
|
|
287
555
|
resolveCaptionTemplate={adapter.resolveCaptionTemplate}
|
|
556
|
+
engine={engine}
|
|
288
557
|
/>
|
|
289
558
|
) : (
|
|
290
559
|
<div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
|
|
@@ -298,8 +567,28 @@ function PendingSurface<P extends Project>({
|
|
|
298
567
|
</div>
|
|
299
568
|
{skillPath && (
|
|
300
569
|
<div className="w-full rounded-xl border-2 border-[var(--editor-accent)]/50 bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
|
|
301
|
-
|
|
302
|
-
|
|
570
|
+
{/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
|
|
571
|
+
`--editor-surface`, and 12px bold is still NORMAL text under WCAG
|
|
572
|
+
(large starts at 18.66px bold), so it needs the 4.5:1 floor. The
|
|
573
|
+
border/shadow around it stay accent - this is small-TEXT only. */}
|
|
574
|
+
<p className={`text-xs font-bold uppercase tracking-widest ${timelineMode === 'light' ? 'text-indigo-600' : 'text-[var(--editor-accent)]'}`}>Send this to your agent</p>
|
|
575
|
+
{/* Deliberately hardcoded dark chrome, not `--editor-*` tokens: this
|
|
576
|
+
is the literal text the user copies and pastes to their coding
|
|
577
|
+
agent, styled as terminal/code chrome — same precedent as the
|
|
578
|
+
video preview's black canvas, which also stays dark regardless
|
|
579
|
+
of editor theme.
|
|
580
|
+
|
|
581
|
+
This was `bg-black/60`, which composited against whatever sat
|
|
582
|
+
behind it: near-black over the dark surface, but only ~#666
|
|
583
|
+
over a light one — ~3:1 for 12px copyable text, below AA.
|
|
584
|
+
It is now an OPAQUE colour so the box no longer depends on
|
|
585
|
+
its backdrop. The specific value is not arbitrary: #070a10 is
|
|
586
|
+
exactly what `rgba(0,0,0,0.6)` over the dark theme's
|
|
587
|
+
`--editor-surface` (#111827) composited to, so dark mode is
|
|
588
|
+
pixel-identical to before while light mode is fixed. Text is
|
|
589
|
+
pinned to #e5e7eb (gray-200, matching this file's original),
|
|
590
|
+
so it is likewise unchanged in dark mode and ~16:1 in both. */}
|
|
591
|
+
<div className="flex items-start justify-between bg-[#070a10] border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
|
|
303
592
|
<span className="text-gray-200 text-[12px] leading-relaxed break-all">
|
|
304
593
|
There is a new project pending: "{project.name ?? project.id}". Please see @{skillPath} and start. Talk to me if you run into questions.
|
|
305
594
|
</span>
|
|
@@ -312,7 +601,7 @@ function PendingSurface<P extends Project>({
|
|
|
312
601
|
setTimeout(() => setCopied(false), 2000)
|
|
313
602
|
}}
|
|
314
603
|
className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
|
|
315
|
-
copied ? 'bg-green-700 text-green-200' : 'bg-
|
|
604
|
+
copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-[#f3f4f6]/80 hover:bg-white/20 hover:text-gray-100'
|
|
316
605
|
}`}
|
|
317
606
|
title="Copy prompt"
|
|
318
607
|
>
|
|
@@ -323,11 +612,11 @@ function PendingSurface<P extends Project>({
|
|
|
323
612
|
)}
|
|
324
613
|
</>
|
|
325
614
|
)}
|
|
326
|
-
<p className="text-
|
|
615
|
+
<p className="text-gray-100/40 text-xs font-mono">project id: {project.id}</p>
|
|
327
616
|
{canGoBack && (
|
|
328
617
|
<button
|
|
329
618
|
onClick={onBackToSetup}
|
|
330
|
-
className="text-xs text-
|
|
619
|
+
className="text-xs text-gray-100/60 hover:text-gray-100 transition-colors underline underline-offset-2"
|
|
331
620
|
>
|
|
332
621
|
← Back to setup
|
|
333
622
|
</button>
|
|
@@ -340,9 +629,10 @@ function PendingSurface<P extends Project>({
|
|
|
340
629
|
<Timeline
|
|
341
630
|
project={project}
|
|
342
631
|
clock={clock}
|
|
343
|
-
getWaveformChunks={getWaveformChunks}
|
|
344
632
|
resolveFilePath={resolveFilePath}
|
|
345
|
-
|
|
633
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
634
|
+
getFilmstrip={getFilmstrip}
|
|
635
|
+
mode={timelineMode}
|
|
346
636
|
/>
|
|
347
637
|
</div>
|
|
348
638
|
</div>
|
|
@@ -350,36 +640,84 @@ function PendingSurface<P extends Project>({
|
|
|
350
640
|
{/* Right sidebar — version history (hidden when the capability is absent) */}
|
|
351
641
|
{adapter.listVersionHistory && (
|
|
352
642
|
<div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
353
|
-
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
643
|
+
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} onSaveVersion={handleSaveVersion} saving={saving} onCompareVersion={adapter.versionFrameUrl ? handleCompareVersion : undefined} mode={timelineMode} />
|
|
354
644
|
</div>
|
|
355
645
|
)}
|
|
646
|
+
|
|
647
|
+
{/* Visual A/B version compare — opened from a VersionPanel entry's
|
|
648
|
+
Compare button. Gated on the adapter capability so a host without
|
|
649
|
+
`versionFrameUrl` never sees an unusable Compare affordance's modal. */}
|
|
650
|
+
{compareOpen != null && adapter.versionFrameUrl && (
|
|
651
|
+
<VersionCompare
|
|
652
|
+
projectId={project.id}
|
|
653
|
+
versions={listVersions(versions)}
|
|
654
|
+
initialLeftHash={compareOpen}
|
|
655
|
+
frameUrl={adapter.versionFrameUrl}
|
|
656
|
+
onClose={() => setCompareOpen(null)}
|
|
657
|
+
mode={timelineMode}
|
|
658
|
+
/>
|
|
659
|
+
)}
|
|
356
660
|
</div>
|
|
357
661
|
)
|
|
358
662
|
}
|
|
359
663
|
|
|
360
664
|
// ── Draft / final surface (former ReviewView) ────────────────────────────────
|
|
361
665
|
|
|
666
|
+
// Every visual item id (across all tracks) plus every audio track id in a
|
|
667
|
+
// project. Used by the clipboard paste/duplicate handlers below to diff
|
|
668
|
+
// before/after a `pasteAt`/`duplicateSelection` call and find the ids those
|
|
669
|
+
// pure ops minted (`newClipId()`), so the freshly pasted/duplicated items can
|
|
670
|
+
// be selected — neither op returns the new ids directly.
|
|
671
|
+
function collectAllIds(project: Project): Set<string> {
|
|
672
|
+
const ids = new Set<string>()
|
|
673
|
+
for (const item of trackItems(project).flat()) ids.add(item.id)
|
|
674
|
+
for (const track of project.audio?.tracks ?? []) ids.add(track.id)
|
|
675
|
+
return ids
|
|
676
|
+
}
|
|
677
|
+
|
|
362
678
|
function ReviewSurface<P extends Project>({
|
|
363
679
|
sync,
|
|
680
|
+
captionGestureRef,
|
|
364
681
|
emit,
|
|
365
682
|
adapter,
|
|
366
683
|
slots,
|
|
367
684
|
assetsPlacement = 'right',
|
|
368
685
|
renderProgressView = 'phases',
|
|
369
|
-
getWaveformChunks,
|
|
370
686
|
resolveFilePath,
|
|
371
|
-
|
|
687
|
+
getWaveformPeaks,
|
|
688
|
+
getFilmstrip,
|
|
689
|
+
renderGenerationPanel,
|
|
372
690
|
renderSubcutRegen,
|
|
373
691
|
regenEnabled,
|
|
374
692
|
isClipQueued,
|
|
375
693
|
onProvideRenderTrigger,
|
|
376
694
|
onProvideImageTone,
|
|
695
|
+
engine,
|
|
696
|
+
sourcePreview,
|
|
697
|
+
onRegenerateCaptions,
|
|
698
|
+
captionsGenerating,
|
|
699
|
+
onImportFilesToTimeline,
|
|
700
|
+
pendingDrops,
|
|
701
|
+
timelineMode,
|
|
377
702
|
}: SurfaceProps<P> & {
|
|
703
|
+
// See the definition beside `sync` in VideoEditor above — set for the
|
|
704
|
+
// duration of a caption drag gesture so the lane-normalization effect
|
|
705
|
+
// (also up in VideoEditor) leaves a mid-drag hole lane alone.
|
|
706
|
+
captionGestureRef: MutableRefObject<boolean>
|
|
378
707
|
emit: (p: P) => void
|
|
379
|
-
|
|
708
|
+
renderGenerationPanel?: VideoEditorProps<P>['renderGenerationPanel']
|
|
380
709
|
renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
|
|
381
710
|
regenEnabled?: boolean
|
|
382
711
|
isClipQueued?: (itemId: string) => boolean
|
|
712
|
+
sourcePreview?: VideoEditorProps<P>['sourcePreview']
|
|
713
|
+
onRegenerateCaptions?: VideoEditorProps<P>['onRegenerateCaptions']
|
|
714
|
+
captionsGenerating?: VideoEditorProps<P>['captionsGenerating']
|
|
715
|
+
// The filesystem-drop seam. On the REVIEW surface only: PendingSurface's
|
|
716
|
+
// timeline is a read-only preview of a project the agent is still building
|
|
717
|
+
// (it passes no `onProjectChange`/`onOverlayEdit` either), so there is
|
|
718
|
+
// nothing there for a dropped file to become.
|
|
719
|
+
onImportFilesToTimeline?: VideoEditorProps<P>['onImportFilesToTimeline']
|
|
720
|
+
pendingDrops?: VideoEditorProps<P>['pendingDrops']
|
|
383
721
|
}) {
|
|
384
722
|
const project = sync.project
|
|
385
723
|
// Playhead in an external store, not useState — ~60Hz ticks re-render only the
|
|
@@ -389,25 +727,330 @@ function ReviewSurface<P extends Project>({
|
|
|
389
727
|
if (!clockRef.current) clockRef.current = createPlaybackClock()
|
|
390
728
|
const clock = clockRef.current
|
|
391
729
|
// Multi-select: all currently-selected timeline item ids. Single-select
|
|
392
|
-
// consumers (canvas preview, cut/split) use
|
|
730
|
+
// consumers (canvas preview, cut/split) use primarySelectedId, derived below
|
|
731
|
+
// once captionIdSet exists — it has to skip caption ids, so it can't just be
|
|
732
|
+
// selectedIds[0].
|
|
393
733
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
734
|
+
// Canvas-timeline double-click → sidebar focus request (Phase 6). `nonce`
|
|
735
|
+
// is load-bearing: CaptionListPanel only re-focuses a row when `nonce`
|
|
736
|
+
// CHANGES (see its `lastHandledNonceRef` guard), so double-clicking the
|
|
737
|
+
// SAME caption twice must still produce two distinct requests — the bare
|
|
738
|
+
// id would be identical both times and the second double-click would be a
|
|
739
|
+
// silent no-op. Incrementing off the previous state (not a plain counter
|
|
740
|
+
// ref) keeps this correct even if two edits interleave with other renders.
|
|
741
|
+
const [editFocusId, setEditFocusId] = useState<CaptionEditFocusRequest | null>(null)
|
|
742
|
+
// Ids present in project.captions.segments — memoized so DERIVING
|
|
743
|
+
// selectedCaptionId below doesn't rescan every segment on every render, only
|
|
744
|
+
// when captions actually change.
|
|
745
|
+
const captionIdSet = useMemo(
|
|
746
|
+
() => new Set((project.captions?.segments ?? []).map(s => s.id).filter((id): id is string => !!id)),
|
|
747
|
+
[project.captions?.segments],
|
|
748
|
+
)
|
|
749
|
+
// The first NON-caption id in the selection, deliberately. Under D1 captions
|
|
750
|
+
// share `selectedIds` with clips and audio (see Timeline.tsx's
|
|
751
|
+
// `handleSelectItem`), but every consumer of this value — cropTarget,
|
|
752
|
+
// selectedOverlayItem, handleSplit's scope, PreviewPlayer's overlay
|
|
753
|
+
// selection box — speaks clip/audio vocabulary. Taking selectedIds[0]
|
|
754
|
+
// verbatim let a caption id at index 0 blank the selected clip's crop and
|
|
755
|
+
// overlay-edit affordances, and scoped Split to an id no track item
|
|
756
|
+
// matches, so `splitAtTime` returned its input unchanged and `S` silently
|
|
757
|
+
// did nothing. Captions are addressed separately, via `selectedCaptionId`
|
|
758
|
+
// below.
|
|
759
|
+
const primarySelectedId = selectedIds.find(id => !captionIdSet.has(id)) ?? null
|
|
760
|
+
// Selected caption segment id — DERIVED from `selectedIds`, not tracked as
|
|
761
|
+
// its own state. Under D1 a caption id is just another member of
|
|
762
|
+
// `selectedIds` (selected on the canvas timeline exactly like a clip or
|
|
763
|
+
// audio track — see Timeline.tsx's `handleSelectItem`); this is
|
|
764
|
+
// VideoEditor's mirror of "the first caption id in there, if any" for
|
|
765
|
+
// PreviewPlayer's selection box, the one remaining consumer that predates
|
|
766
|
+
// D1 and only understands a single id (CaptionListPanel, its other former
|
|
767
|
+
// sibling, derives its own selected segment straight from `selectedIds`
|
|
768
|
+
// instead of taking this prop). A marquee can legitimately put more than
|
|
769
|
+
// one caption id in `selectedIds` — taking the first is correct, the
|
|
770
|
+
// preview only ever shows one box.
|
|
771
|
+
const selectedCaptionId = selectedIds.find(id => captionIdSet.has(id)) ?? null
|
|
772
|
+
// Publish what we are looking at, so an agent can resolve "this section".
|
|
773
|
+
// No-ops entirely on a host that does not implement reportContext.
|
|
774
|
+
useReportContext({
|
|
775
|
+
adapter,
|
|
776
|
+
projectId: project.id,
|
|
777
|
+
clock,
|
|
778
|
+
selectedIds,
|
|
779
|
+
selectedCaptionId,
|
|
780
|
+
})
|
|
781
|
+
// Selecting a caption anywhere (timeline, preview box, or the caption list —
|
|
782
|
+
// all funnel through `selectedIds` → `selectedCaptionId`) jumps the left
|
|
783
|
+
// panel to its Captions tab. A per-selection nonce, not the id itself, so
|
|
784
|
+
// re-selecting the same caption after the user switched tabs still snaps
|
|
785
|
+
// back; only a truthy selection bumps it, so deselecting never yanks the tab.
|
|
786
|
+
const [captionTabNonce, setCaptionTabNonce] = useState(0)
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
if (selectedCaptionId) setCaptionTabNonce(n => n + 1)
|
|
789
|
+
}, [selectedCaptionId])
|
|
401
790
|
const [rippleMode, setRippleMode] = useState(false)
|
|
791
|
+
// CapCut's "preview axis", off by default. Off changes nothing: clicking the
|
|
792
|
+
// timeline moves the red playhead and the preview follows it, as always. On,
|
|
793
|
+
// a yellow cursor line tracks the pointer across the timeline and the preview
|
|
794
|
+
// shows THAT frame while the playhead stays put — hover to look around, click
|
|
795
|
+
// to actually go there.
|
|
796
|
+
const [previewAxis, setPreviewAxis] = useState(false)
|
|
797
|
+
|
|
798
|
+
// Copy/paste/duplicate clipboard (T2). A ref, not state: nothing in this
|
|
799
|
+
// surface needs to re-render when it changes — the keymap guards below read
|
|
800
|
+
// it synchronously at keydown time, and `paletteCommands` (built fresh every
|
|
801
|
+
// render) reads it whenever the palette's own `setPaletteOpen` call triggers
|
|
802
|
+
// that render.
|
|
803
|
+
const clipboardRef = useRef<ClipboardPayload | null>(null)
|
|
804
|
+
|
|
805
|
+
// ── Preview / timeline split ───────────────────────────────────────────
|
|
806
|
+
// The timeline pane owns an explicit height and the preview takes the rest,
|
|
807
|
+
// so one number describes the whole split. Persisted per browser, not per
|
|
808
|
+
// project: it's a property of the screen you're working on.
|
|
809
|
+
const splitRef = useRef<HTMLDivElement | null>(null)
|
|
810
|
+
const [timelinePaneHeight, setTimelinePaneHeight] = usePersistentState(
|
|
811
|
+
TIMELINE_PANE_STORAGE_KEY,
|
|
812
|
+
DEFAULT_TIMELINE_PANE_PX,
|
|
813
|
+
reviveNumberInRange(MIN_TIMELINE_PANE_PX, MAX_TIMELINE_PANE_PX),
|
|
814
|
+
)
|
|
815
|
+
// The right rail's width, same deal on the other axis. 300px (up from
|
|
816
|
+
// 224/192) is the new default so the sidebar CaptionListPanel's list and
|
|
817
|
+
// its "Caption style" controls aren't cramped — persisted per browser, so
|
|
818
|
+
// only a fresh/cleared localStorage picks up the new default.
|
|
819
|
+
const workAreaRef = useRef<HTMLDivElement | null>(null)
|
|
820
|
+
const [railWidth, setRailWidth] = usePersistentState(
|
|
821
|
+
RAIL_WIDTH_STORAGE_KEY,
|
|
822
|
+
DEFAULT_RAIL_PX,
|
|
823
|
+
reviveNumberInRange(MIN_RAIL_PX, MAX_RAIL_PX),
|
|
824
|
+
)
|
|
825
|
+
// The left media column's width (CapCut layout only). Same persist/clamp
|
|
826
|
+
// pattern as the rail; ignored entirely unless `slots.mediaPanel` is present.
|
|
827
|
+
const [mediaPanelWidth, setMediaPanelWidth] = usePersistentState(
|
|
828
|
+
MEDIA_PANEL_WIDTH_STORAGE_KEY,
|
|
829
|
+
DEFAULT_MEDIA_PANEL_PX,
|
|
830
|
+
reviveNumberInRange(MIN_MEDIA_PANEL_PX, MAX_MEDIA_PANEL_PX),
|
|
831
|
+
)
|
|
832
|
+
// Which overlay tab the right column opens on. Same persist-per-browser
|
|
833
|
+
// pattern as the widths above; the reviver rejects anything that isn't one
|
|
834
|
+
// of the two tab names, so a stale key from an older build falls back to
|
|
835
|
+
// 'content' rather than rendering a blank pane.
|
|
836
|
+
const [overlayPanelTab, setOverlayPanelTab] = usePersistentState<OverlayPanelTab>(
|
|
837
|
+
OVERLAY_PANEL_TAB_STORAGE_KEY,
|
|
838
|
+
'content',
|
|
839
|
+
reviveOverlayPanelTab,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
/** Drag the rail divider. Mirrors `startSplitDrag` on the horizontal axis;
|
|
843
|
+
* dragging LEFT widens the rail, hence the inverted delta. */
|
|
844
|
+
const startRailDrag = useCallback((e: React.MouseEvent) => {
|
|
845
|
+
e.preventDefault()
|
|
846
|
+
const startX = e.clientX
|
|
847
|
+
const startWidth = railWidth
|
|
848
|
+
const available = workAreaRef.current?.getBoundingClientRect().width ?? 0
|
|
849
|
+
const max = Math.max(MIN_RAIL_PX, Math.min(MAX_RAIL_PX, available - MIN_MAIN_PX))
|
|
850
|
+
|
|
851
|
+
let latest = startWidth
|
|
852
|
+
const onMove = (ev: MouseEvent) => {
|
|
853
|
+
latest = Math.max(MIN_RAIL_PX, Math.min(max, startWidth - (ev.clientX - startX)))
|
|
854
|
+
setRailWidth(latest, { persist: false })
|
|
855
|
+
}
|
|
856
|
+
const onUp = () => {
|
|
857
|
+
document.removeEventListener('mousemove', onMove)
|
|
858
|
+
document.removeEventListener('mouseup', onUp)
|
|
859
|
+
document.body.style.cursor = ''
|
|
860
|
+
document.body.style.userSelect = ''
|
|
861
|
+
setRailWidth(latest)
|
|
862
|
+
}
|
|
863
|
+
document.addEventListener('mousemove', onMove)
|
|
864
|
+
document.addEventListener('mouseup', onUp)
|
|
865
|
+
document.body.style.cursor = 'col-resize'
|
|
866
|
+
document.body.style.userSelect = 'none'
|
|
867
|
+
}, [railWidth, setRailWidth])
|
|
868
|
+
|
|
869
|
+
/** Drag the media-column divider (CapCut layout). The divider sits on the
|
|
870
|
+
* column's RIGHT edge, so dragging RIGHT must WIDEN it — hence the delta is
|
|
871
|
+
* ADDED (the mirror image of `startRailDrag`, whose rail grows leftward).
|
|
872
|
+
* `MIN_MAIN_PX` still guards the preview column's minimum width. */
|
|
873
|
+
const startMediaPanelDrag = useCallback((e: React.MouseEvent) => {
|
|
874
|
+
e.preventDefault()
|
|
875
|
+
const startX = e.clientX
|
|
876
|
+
const startWidth = mediaPanelWidth
|
|
877
|
+
const available = workAreaRef.current?.getBoundingClientRect().width ?? 0
|
|
878
|
+
const max = Math.max(MIN_MEDIA_PANEL_PX, Math.min(MAX_MEDIA_PANEL_PX, available - MIN_MAIN_PX))
|
|
879
|
+
|
|
880
|
+
let latest = startWidth
|
|
881
|
+
const onMove = (ev: MouseEvent) => {
|
|
882
|
+
latest = Math.max(MIN_MEDIA_PANEL_PX, Math.min(max, startWidth + (ev.clientX - startX)))
|
|
883
|
+
setMediaPanelWidth(latest, { persist: false })
|
|
884
|
+
}
|
|
885
|
+
const onUp = () => {
|
|
886
|
+
document.removeEventListener('mousemove', onMove)
|
|
887
|
+
document.removeEventListener('mouseup', onUp)
|
|
888
|
+
document.body.style.cursor = ''
|
|
889
|
+
document.body.style.userSelect = ''
|
|
890
|
+
setMediaPanelWidth(latest)
|
|
891
|
+
}
|
|
892
|
+
document.addEventListener('mousemove', onMove)
|
|
893
|
+
document.addEventListener('mouseup', onUp)
|
|
894
|
+
document.body.style.cursor = 'col-resize'
|
|
895
|
+
document.body.style.userSelect = 'none'
|
|
896
|
+
}, [mediaPanelWidth, setMediaPanelWidth])
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Drag the divider. Bound to `document` rather than the handle so the drag
|
|
900
|
+
* survives the pointer outrunning a 5px target — the same reason the timeline's
|
|
901
|
+
* own gestures listen on the document.
|
|
902
|
+
*
|
|
903
|
+
* The clamp has two jobs: keep the timeline usable (`MIN_TIMELINE_PANE_PX`),
|
|
904
|
+
* and always leave the preview a real area to draw in, so the divider can
|
|
905
|
+
* never be dragged to the top of the window and strand the picture at zero
|
|
906
|
+
* height. `document.body.style.cursor` holds the resize cursor for the whole
|
|
907
|
+
* drag, otherwise it flickers back whenever the pointer crosses a child that
|
|
908
|
+
* sets its own.
|
|
909
|
+
*/
|
|
910
|
+
const startSplitDrag = useCallback((e: React.MouseEvent) => {
|
|
911
|
+
e.preventDefault()
|
|
912
|
+
const startY = e.clientY
|
|
913
|
+
const startHeight = timelinePaneHeight
|
|
914
|
+
const available = splitRef.current?.getBoundingClientRect().height ?? 0
|
|
915
|
+
const max = Math.max(MIN_TIMELINE_PANE_PX, Math.min(MAX_TIMELINE_PANE_PX, available - MIN_PREVIEW_PANE_PX))
|
|
916
|
+
|
|
917
|
+
let latest = startHeight
|
|
918
|
+
const onMove = (ev: MouseEvent) => {
|
|
919
|
+
latest = Math.max(MIN_TIMELINE_PANE_PX, Math.min(max, startHeight - (ev.clientY - startY)))
|
|
920
|
+
setTimelinePaneHeight(latest, { persist: false })
|
|
921
|
+
}
|
|
922
|
+
const onUp = () => {
|
|
923
|
+
document.removeEventListener('mousemove', onMove)
|
|
924
|
+
document.removeEventListener('mouseup', onUp)
|
|
925
|
+
document.body.style.cursor = ''
|
|
926
|
+
document.body.style.userSelect = ''
|
|
927
|
+
setTimelinePaneHeight(latest) // the write that actually persists
|
|
928
|
+
}
|
|
929
|
+
document.addEventListener('mousemove', onMove)
|
|
930
|
+
document.addEventListener('mouseup', onUp)
|
|
931
|
+
document.body.style.cursor = 'row-resize'
|
|
932
|
+
document.body.style.userSelect = 'none'
|
|
933
|
+
}, [timelinePaneHeight, setTimelinePaneHeight])
|
|
934
|
+
|
|
935
|
+
// The hovered frame, in an external store so a mousemove repaints the preview
|
|
936
|
+
// and nothing else — see `hover-scrub.ts` for why this is not the clock.
|
|
937
|
+
const hoverScrubRef = useRef(createHoverScrub())
|
|
938
|
+
const hoverScrub = hoverScrubRef.current
|
|
939
|
+
|
|
940
|
+
// Any real playhead movement cancels the hover preview: a playback tick, an
|
|
941
|
+
// arrow-key step, a scrubber drag, a click that seeks. Without this a hover
|
|
942
|
+
// left standing would pin the preview to a frame the playhead has since left,
|
|
943
|
+
// and pressing Space would play audio against a frozen picture.
|
|
944
|
+
useEffect(() => clock.subscribe(() => hoverScrub.set(null)), [clock, hoverScrub])
|
|
945
|
+
|
|
946
|
+
const handleHoverScrub = useCallback((time: number | null) => {
|
|
947
|
+
// Never while playing: the playback hooks drive the frame themselves, and
|
|
948
|
+
// an override would fight them for the <video>/engine's seek position.
|
|
949
|
+
if (time !== null && transportRef.current?.isPlaying()) return
|
|
950
|
+
hoverScrub.set(time)
|
|
951
|
+
}, [hoverScrub])
|
|
402
952
|
const [showControls, setShowControls] = useState(false)
|
|
403
953
|
// Source-crop mode: when on, the VideoSourceCropModal opens for the selected
|
|
404
954
|
// tracks[0] video item. Cleared when selection changes.
|
|
405
955
|
const [cropMode, setCropMode] = useState(false)
|
|
406
956
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
407
957
|
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
958
|
+
// Opens the caption-regeneration modal — CaptionListPanel's toolbar button.
|
|
959
|
+
// `onRegenerateCaptions` (host prop) wins when provided: a host that passes
|
|
960
|
+
// it is asserting it owns the caption job (running it as a background task
|
|
961
|
+
// instead of this component's blocking modal), so its trigger takes over
|
|
962
|
+
// even though `adapter.generateCaptions` is also present — the host uses
|
|
963
|
+
// that same adapter method to actually run the job it owns. Otherwise, the
|
|
964
|
+
// built-in modal path is provided only when the host adapter supports
|
|
965
|
+
// `generateCaptions`; absent → the "Regenerate" button is hidden there.
|
|
966
|
+
const handleRegenerateCaptions =
|
|
967
|
+
onRegenerateCaptions ?? (adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined)
|
|
968
|
+
const [polishOpen, setPolishOpen] = useState(false)
|
|
969
|
+
// Opens AudioPolishModal — toolbar button and command palette entry.
|
|
970
|
+
// Provided only when the host adapter supports `analyzeAudioPolish`; absent →
|
|
971
|
+
// neither entry point renders, exactly like `handleRegenerateCaptions` above.
|
|
972
|
+
const handleAudioPolish = adapter.analyzeAudioPolish ? () => setPolishOpen(true) : undefined
|
|
973
|
+
// SP5 T9 — command palette (Cmd/Ctrl+K). `'goto'` opens straight into the
|
|
974
|
+
// timecode input (the scrubber's time-readout click); `'list'` opens the
|
|
975
|
+
// filtered command list.
|
|
976
|
+
const [paletteOpen, setPaletteOpen] = useState<false | 'list' | 'goto'>(false)
|
|
977
|
+
|
|
978
|
+
// ── T9 keymap plumbing (continued after `anyModalOpen`, below) ──
|
|
979
|
+
// The transport seam — filled by PreviewPlayer from whichever playback path
|
|
980
|
+
// (legacy or engine) is active. The keymap and palette use it for play/
|
|
981
|
+
// pause; the shuttle polls `isPlaying()` to detect a real transport change.
|
|
982
|
+
const transportRef = useRef<TransportHandle | null>(null)
|
|
983
|
+
// The audible drag-scrub source's engine seam — mirrors `transportRef`.
|
|
984
|
+
// Filled by PreviewPlayer only on the WebCodecs engine path; stays null on
|
|
985
|
+
// the legacy `<video>` fallback (see the scrub-source effect below).
|
|
986
|
+
const scrubHandleRef = useRef<ScrubHandle | null>(null)
|
|
987
|
+
// Marker/zoom actions Timeline exposes for the palette, mirroring
|
|
988
|
+
// `transportRef`'s shape.
|
|
989
|
+
const timelineActionsRef = useRef<TimelineActions | null>(null)
|
|
990
|
+
|
|
991
|
+
// Declared ahead of the scrub effect below (rather than alongside
|
|
992
|
+
// currentSocialPreview/currentImageTone further down) because that effect
|
|
993
|
+
// reads it to seed the scrubber's initial enabled state.
|
|
994
|
+
const currentAudibleScrub = project.settings?.audibleScrub ?? false
|
|
995
|
+
|
|
996
|
+
// Audible drag-scrub: one grain-per-move scrubber for the life of the
|
|
997
|
+
// surface, attached to the same hover store the preview reads. `resolve`
|
|
998
|
+
// re-checks `scrubHandleRef.current` on every call (not just at
|
|
999
|
+
// construction) so it stays silent — same as a gap or a canvas project —
|
|
1000
|
+
// until PreviewPlayer's own effect fills the ref, and goes silent again if
|
|
1001
|
+
// the project is on the legacy `<video>` fallback, which never fills it.
|
|
1002
|
+
const scrubberRef = useRef<ScrubSource | null>(null)
|
|
1003
|
+
useEffect(() => {
|
|
1004
|
+
const resolve = createScrubResolver(() => sync.projectRef.current)
|
|
1005
|
+
const scrubber = createScrubSource({
|
|
1006
|
+
acquireDemux: (src) => scrubHandleRef.current!.acquireDemux(src),
|
|
1007
|
+
resolve: (projectS) => (scrubHandleRef.current ? resolve(projectS) : null),
|
|
1008
|
+
onError: (message) => console.error('[montaj] scrub-source:', message),
|
|
1009
|
+
})
|
|
1010
|
+
scrubber.setEnabled(currentAudibleScrub)
|
|
1011
|
+
scrubberRef.current = scrubber
|
|
1012
|
+
const detach = scrubber.attach(hoverScrub)
|
|
1013
|
+
return () => {
|
|
1014
|
+
detach()
|
|
1015
|
+
scrubber.dispose()
|
|
1016
|
+
scrubberRef.current = null
|
|
1017
|
+
}
|
|
1018
|
+
}, [hoverScrub, sync.projectRef])
|
|
1019
|
+
|
|
1020
|
+
// Flip the live scrubber's enabled flag when the settings toggle changes,
|
|
1021
|
+
// without tearing down and recreating it — `setEnabled` also stops any
|
|
1022
|
+
// in-flight grain on disable (scrub-source.ts).
|
|
1023
|
+
useEffect(() => {
|
|
1024
|
+
scrubberRef.current?.setEnabled(currentAudibleScrub)
|
|
1025
|
+
}, [currentAudibleScrub])
|
|
1026
|
+
|
|
1027
|
+
// Duration for shuttle clamping and the palette's "go to time" clamp —
|
|
1028
|
+
// read fresh off the sync core's project ref rather than captured once, so
|
|
1029
|
+
// neither goes stale across edits.
|
|
1030
|
+
const getTotalDuration = useCallback(
|
|
1031
|
+
() => computeDerivedTiming(sync.projectRef.current).totalDuration,
|
|
1032
|
+
[sync.projectRef],
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
// J/K/L shuttle. Created once (lazy ref init) — its deps are all stable
|
|
1036
|
+
// (clock, transportRef, the duration getter above), so there's nothing to
|
|
1037
|
+
// recreate it over. See shuttle.ts for the rate-stepping/cancellation design.
|
|
1038
|
+
const shuttleRef = useRef<ReturnType<typeof createShuttleController> | null>(null)
|
|
1039
|
+
if (!shuttleRef.current) {
|
|
1040
|
+
shuttleRef.current = createShuttleController({
|
|
1041
|
+
clock,
|
|
1042
|
+
getDuration: getTotalDuration,
|
|
1043
|
+
isPlaying: () => transportRef.current?.isPlaying() ?? false,
|
|
1044
|
+
play: () => { if (!transportRef.current?.isPlaying()) transportRef.current?.togglePlay() },
|
|
1045
|
+
pause: () => { if (transportRef.current?.isPlaying()) transportRef.current.togglePlay() },
|
|
1046
|
+
setRate: (rate) => transportRef.current?.setRate(rate),
|
|
1047
|
+
})
|
|
1048
|
+
}
|
|
1049
|
+
const shuttle = shuttleRef.current
|
|
1050
|
+
// The loop is rAF-driven and neither of its cancellation guards fires after
|
|
1051
|
+
// unmount (nothing is playing, and nothing else writes the clock), so stop it
|
|
1052
|
+
// explicitly rather than let it run out the timeline against a dead clock.
|
|
1053
|
+
useEffect(() => () => shuttle.stop(), [shuttle])
|
|
411
1054
|
|
|
412
1055
|
// Render trigger — marks the project final, saves, and opens the RenderModal.
|
|
413
1056
|
// Kept stable (the sync mutators/ref are stable; `emit` read via ref) so a host
|
|
@@ -427,6 +1070,42 @@ function ReviewSurface<P extends Project>({
|
|
|
427
1070
|
})
|
|
428
1071
|
}, [syncMutate, syncProjectRef])
|
|
429
1072
|
|
|
1073
|
+
// Persist the audible drag-scrub toggle into project settings — same
|
|
1074
|
+
// save-then-sync idiom as handleImageToneChange above. SET-always (unlike
|
|
1075
|
+
// handleSocialPreviewChange's omit-key below): a boolean has no natural
|
|
1076
|
+
// "unset" state, and default-off means an explicit `true` has to persist
|
|
1077
|
+
// the operator's opt-in.
|
|
1078
|
+
const handleAudibleScrubChange = useCallback((on: boolean) => {
|
|
1079
|
+
void syncMutate(() => {
|
|
1080
|
+
const cur = syncProjectRef.current
|
|
1081
|
+
return { ...cur, settings: { ...cur.settings, audibleScrub: on } } as P
|
|
1082
|
+
})
|
|
1083
|
+
}, [syncMutate, syncProjectRef])
|
|
1084
|
+
|
|
1085
|
+
// Persist the social-media preview platform into project settings — same
|
|
1086
|
+
// shape as handleImageToneChange above. `null` clears it (the picker's
|
|
1087
|
+
// "None" entry), which the settings-spread below has to do by explicitly
|
|
1088
|
+
// OMITTING the key rather than writing `socialPreview: undefined`: a
|
|
1089
|
+
// spread with an `undefined` value still enumerates the key, so a
|
|
1090
|
+
// JSON-serialized save would round-trip it back as `null`/absent
|
|
1091
|
+
// inconsistently across hosts — omitting it keeps "no selection" and
|
|
1092
|
+
// "field never written" the same on-disk shape.
|
|
1093
|
+
const handleSocialPreviewChange = useCallback((platform: SocialPreviewPlatform | null) => {
|
|
1094
|
+
void syncMutate(() => {
|
|
1095
|
+
const cur = syncProjectRef.current
|
|
1096
|
+
const { socialPreview: _drop, ...restSettings } = cur.settings
|
|
1097
|
+
return {
|
|
1098
|
+
...cur,
|
|
1099
|
+
settings: platform ? { ...restSettings, socialPreview: platform } : restSettings,
|
|
1100
|
+
} as P
|
|
1101
|
+
})
|
|
1102
|
+
}, [syncMutate, syncProjectRef])
|
|
1103
|
+
const currentSocialPreview = project.settings?.socialPreview ?? null
|
|
1104
|
+
// The active pick's glyph/badge, for the controls-row trigger button
|
|
1105
|
+
// (see its render site below) — `null` on "None", same as `platformOption`
|
|
1106
|
+
// itself returns for a `null` platform.
|
|
1107
|
+
const activeSocialPreviewOption = platformOption(currentSocialPreview)
|
|
1108
|
+
|
|
430
1109
|
// Host-chrome placement of the image-tone setting (mirrors
|
|
431
1110
|
// onProvideRenderTrigger): push the current state up whenever it changes,
|
|
432
1111
|
// and null for SDR projects so the host hides the control.
|
|
@@ -437,6 +1116,76 @@ function ReviewSurface<P extends Project>({
|
|
|
437
1116
|
onProvideImageTone(isHdrProject ? { value: currentImageTone ?? 'vivid', set: handleImageToneChange } : null)
|
|
438
1117
|
}, [onProvideImageTone, isHdrProject, currentImageTone, handleImageToneChange])
|
|
439
1118
|
|
|
1119
|
+
// Pre-render options for the export dialog (RenderModal). `keeps` are the
|
|
1120
|
+
// track-0 video windows the modal samples cover/thumbnail frames from —
|
|
1121
|
+
// memoized so the modal sees a stable list. `name`/`durationSec` seed the
|
|
1122
|
+
// dialog's Name field and duration footer (duration = the video spine's last
|
|
1123
|
+
// frame, which defines the render length). `isHdr` gates the HDR-only controls
|
|
1124
|
+
// (export format, image color, SDR tone curve); the dialog itself now opens for
|
|
1125
|
+
// every project the montaj editor renders.
|
|
1126
|
+
//
|
|
1127
|
+
// Enabled tracks only (`enabledTrackItems`, not `trackItems`): render.js
|
|
1128
|
+
// computes the exported duration and poster frames from the same enabled
|
|
1129
|
+
// family, so a skipped base track must not leave this dialog advertising a
|
|
1130
|
+
// duration or cover/thumbnail windows the exported file won't actually have.
|
|
1131
|
+
const renderKeeps = useMemo(
|
|
1132
|
+
() => (enabledTrackItems(project)[0] ?? [])
|
|
1133
|
+
.filter(item => item.type === 'video')
|
|
1134
|
+
.map(item => ({ start: item.start, end: item.end })),
|
|
1135
|
+
[project.tracks],
|
|
1136
|
+
)
|
|
1137
|
+
// Persist the export resolution / fps tier into project settings — same
|
|
1138
|
+
// save-then-sync idiom as handleImageToneChange above. Mutating
|
|
1139
|
+
// settings.resolution to a same-aspect higher tier doesn't perturb the
|
|
1140
|
+
// editor preview (design-canvas only reads it for aspect), so this is safe
|
|
1141
|
+
// to fire straight from the export dialog's tier picker.
|
|
1142
|
+
const handleExportResolutionChange = useCallback((res: [number, number]) => {
|
|
1143
|
+
void syncMutate(() => {
|
|
1144
|
+
const cur = syncProjectRef.current
|
|
1145
|
+
return { ...cur, settings: { ...cur.settings, resolution: res } } as P
|
|
1146
|
+
})
|
|
1147
|
+
}, [syncMutate, syncProjectRef])
|
|
1148
|
+
|
|
1149
|
+
const handleExportFpsChange = useCallback((fps: number) => {
|
|
1150
|
+
void syncMutate(() => {
|
|
1151
|
+
const cur = syncProjectRef.current
|
|
1152
|
+
return { ...cur, settings: { ...cur.settings, fps } } as P
|
|
1153
|
+
})
|
|
1154
|
+
}, [syncMutate, syncProjectRef])
|
|
1155
|
+
|
|
1156
|
+
// Source-capped tier lists for the export dialog's resolution/fps pickers.
|
|
1157
|
+
// Memoized separately from preRenderOptions so a project mutation unrelated
|
|
1158
|
+
// to tracks/resolution/fps doesn't recompute the (mildly more expensive)
|
|
1159
|
+
// source-scan in availableResolutionTiers.
|
|
1160
|
+
const availableRes = useMemo(() => availableResolutionTiers(project), [project])
|
|
1161
|
+
const availableFpsList = useMemo(() => availableFpsTiers(project), [project])
|
|
1162
|
+
|
|
1163
|
+
const preRenderOptions = useMemo(() => ({
|
|
1164
|
+
isHdr: isHdrProject,
|
|
1165
|
+
keeps: renderKeeps,
|
|
1166
|
+
imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
|
|
1167
|
+
name: project.name?.trim() || undefined,
|
|
1168
|
+
durationSec: renderKeeps.reduce((m, k) => Math.max(m, k.end), 0),
|
|
1169
|
+
aspectRatio: (() => {
|
|
1170
|
+
const r = project.settings?.resolution
|
|
1171
|
+
return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
|
|
1172
|
+
})(),
|
|
1173
|
+
resolution: {
|
|
1174
|
+
value: currentResolutionTier(project) ?? project.settings.resolution,
|
|
1175
|
+
available: availableRes,
|
|
1176
|
+
set: handleExportResolutionChange,
|
|
1177
|
+
},
|
|
1178
|
+
fps: {
|
|
1179
|
+
value: maxExportFps(project),
|
|
1180
|
+
available: availableFpsList,
|
|
1181
|
+
set: handleExportFpsChange,
|
|
1182
|
+
},
|
|
1183
|
+
}), [
|
|
1184
|
+
isHdrProject, renderKeeps, currentImageTone, handleImageToneChange,
|
|
1185
|
+
project.name, project.settings?.resolution, project.settings?.fps,
|
|
1186
|
+
availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
|
|
1187
|
+
])
|
|
1188
|
+
|
|
440
1189
|
const openRender = useCallback(() => {
|
|
441
1190
|
const final = { ...syncProjectRef.current, status: 'final' } as P
|
|
442
1191
|
emitRef.current(final)
|
|
@@ -445,7 +1194,11 @@ function ReviewSurface<P extends Project>({
|
|
|
445
1194
|
}, [syncMutate, syncProjectRef])
|
|
446
1195
|
useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
|
|
447
1196
|
|
|
448
|
-
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
1197
|
+
const { versions, restoring, setRestoring, saving, setSaving, refresh: refreshVersions } = useVersionHistory(adapter, project)
|
|
1198
|
+
// The version-compare view: the LEFT hash it was opened for, or null when
|
|
1199
|
+
// closed. Mirrors PendingSurface's `compareOpen` — each surface mounts its
|
|
1200
|
+
// own VersionPanel, so each owns its own compare state.
|
|
1201
|
+
const [compareOpen, setCompareOpen] = useState<string | null>(null)
|
|
449
1202
|
|
|
450
1203
|
// Repair caption segments whose words[] text has diverged from edited seg.text.
|
|
451
1204
|
// Inline caption edits update seg.text but not seg.words; this normalizes the
|
|
@@ -479,8 +1232,19 @@ function ReviewSurface<P extends Project>({
|
|
|
479
1232
|
sync.applyExternal({ ...project, captions: repaired } as P)
|
|
480
1233
|
}, [project.id, project.captions])
|
|
481
1234
|
|
|
482
|
-
const clips = project
|
|
483
|
-
const hasContent = clips.length > 0 || (project.
|
|
1235
|
+
const clips = trackItems(project)[0] ?? []
|
|
1236
|
+
const hasContent = clips.length > 0 || (trackItems(project).slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
|
|
1237
|
+
|
|
1238
|
+
// Preview controls row's timecode readout. `currentTime` is the same
|
|
1239
|
+
// `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
|
|
1240
|
+
// lower down — a second, independent call here rather than threading its
|
|
1241
|
+
// value up, since it's a plain hook and this is a different render leaf.
|
|
1242
|
+
// `previewDuration` is the actual playable length (contentDuration), NOT
|
|
1243
|
+
// `getTotalDuration()`'s zoom/scroll-padded value used for shuttle/seek
|
|
1244
|
+
// clamping — that one runs ~20% past the last clip so the timeline canvas
|
|
1245
|
+
// has room to drag into, which would read as a wrong total here.
|
|
1246
|
+
const currentTime = usePlaybackTime(clock)
|
|
1247
|
+
const previewDuration = useMemo(() => computeDerivedTiming(project).contentDuration, [project])
|
|
484
1248
|
|
|
485
1249
|
// The selected tracks[0] video item, if any — the only thing source-crop mode
|
|
486
1250
|
// can target. Source crop is a tracks[0]-video primitive (the renderer applies
|
|
@@ -494,82 +1258,261 @@ function ReviewSurface<P extends Project>({
|
|
|
494
1258
|
if (!cropTarget && cropMode) setCropMode(false)
|
|
495
1259
|
}, [cropTarget, cropMode])
|
|
496
1260
|
|
|
497
|
-
// Overlay
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1261
|
+
// Overlay content editing used to be a floating dialog VideoEditor owned the
|
|
1262
|
+
// open/closed state for, shared by the preview double-click, the controls bar
|
|
1263
|
+
// and the timeline block. It is the right column's **Content** tab now (see
|
|
1264
|
+
// `propertiesPanel` below), which is never "open": selecting an overlay IS
|
|
1265
|
+
// opening it. So there is no `editingOverlayId`, and no pre-open project
|
|
1266
|
+
// snapshot for a Cancel to restore — undo is the revert path, and it works
|
|
1267
|
+
// because each committed gesture is exactly one undo step. Requesting an
|
|
1268
|
+
// edit is therefore just selecting the overlay. Replaces the whole selection
|
|
1269
|
+
// rather than extending it, matching a plain non-additive click: the panel
|
|
1270
|
+
// renders `primarySelectedId`, so leaving a previous id in front of this one
|
|
1271
|
+
// would double-click an overlay and show a different item's properties.
|
|
1272
|
+
const selectOverlayForEditing = useCallback((id: string) => {
|
|
1273
|
+
setSelectedIds([id])
|
|
1274
|
+
}, [])
|
|
1275
|
+
const allVisualItems = trackItems(project).flat()
|
|
1276
|
+
|
|
1277
|
+
// T9 keymap plumbing: every dialog/panel this surface can have open, ORed
|
|
1278
|
+
// into one flag so the keymap (and Timeline's own arrows/delete/enter/
|
|
1279
|
+
// escape keymap, via the `modalOpen` prop passed to it below) suppresses
|
|
1280
|
+
// every binding while any of them is up — including the palette itself, so
|
|
1281
|
+
// typing in a filter field elsewhere can't leak into a single-key
|
|
1282
|
+
// shortcut. There is no general "is a modal open" concept anywhere else in
|
|
1283
|
+
// the codebase (today's handlers didn't check this at all); this derives
|
|
1284
|
+
// it from state ReviewSurface already owns rather than inventing new
|
|
1285
|
+
// cross-file plumbing. The overlay props dialog used to be a term here; the
|
|
1286
|
+
// Content tab that replaced it is a PANEL, and a panel must not suppress the
|
|
1287
|
+
// timeline's own keys the way a modal does.
|
|
1288
|
+
const anyModalOpen = renderOpen || regenCaptionsOpen || polishOpen
|
|
1289
|
+
|| showControls || cropMode || !!paletteOpen
|
|
513
1290
|
|
|
514
1291
|
function withItemProps(base: P, id: string, nextProps: Record<string, unknown>): P {
|
|
515
1292
|
return {
|
|
516
1293
|
...base,
|
|
517
|
-
tracks: (base
|
|
518
|
-
|
|
1294
|
+
tracks: mapTrackItems(base, items =>
|
|
1295
|
+
items.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
|
|
519
1296
|
),
|
|
520
1297
|
} as P
|
|
521
1298
|
}
|
|
522
1299
|
// Live preview: reflect the in-progress edit locally (transient — no save, no
|
|
523
1300
|
// undo push) so the overlay re-renders as the operator tweaks. `commit()` on
|
|
524
|
-
//
|
|
1301
|
+
// the field's blur persists the accumulated transient state as one undo step.
|
|
525
1302
|
function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
|
|
526
1303
|
sync.mutateTransient(p => withItemProps(p, id, nextProps))
|
|
527
1304
|
}
|
|
528
|
-
//
|
|
529
|
-
//
|
|
1305
|
+
// Closes a Content-tab typing gesture (fired on the field's blur) as one undo
|
|
1306
|
+
// step + queued save: the last preview already applied the final props
|
|
1307
|
+
// transiently, so this only has to persist them. Identical in shape to
|
|
1308
|
+
// `commitOverlayInspectorChange` below — the two tabs of the same panel.
|
|
530
1309
|
function commitOverlayEdit() {
|
|
531
1310
|
void sync.commit()
|
|
532
|
-
editOriginalRef.current = null
|
|
533
|
-
setEditingOverlayId(null)
|
|
534
1311
|
}
|
|
535
|
-
//
|
|
536
|
-
// (no save, no undo push).
|
|
537
|
-
function cancelOverlayEdit() {
|
|
538
|
-
if (editOriginalRef.current) sync.applyExternal(editOriginalRef.current)
|
|
539
|
-
editOriginalRef.current = null
|
|
540
|
-
setEditingOverlayId(null)
|
|
541
|
-
}
|
|
542
|
-
// The primary-selected JSX overlay, if any — drives the controls-bar edit button.
|
|
1312
|
+
// The primary-selected JSX overlay, if any — the Content/Transform panel's target.
|
|
543
1313
|
const selectedOverlayItem = primarySelectedId
|
|
544
1314
|
? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
|
|
545
1315
|
: null
|
|
546
1316
|
|
|
1317
|
+
// The right properties panel's non-overlay target, resolved from the SAME
|
|
1318
|
+
// primary selection `selectedOverlayItem` above uses and derived the same
|
|
1319
|
+
// way — against `trackItems(project)` for a visual item, then against
|
|
1320
|
+
// `project.audio.tracks`. Precedence is overlay → clip → audio: an overlay
|
|
1321
|
+
// IS a visual item, so the clip branch has to exclude `type === 'overlay'`
|
|
1322
|
+
// or a selected overlay would show clip properties instead of its Transform
|
|
1323
|
+
// inspector. Audio is reached only when the id names no visual item at all —
|
|
1324
|
+
// ids are unique across the project, so that is a plain fallback, not a
|
|
1325
|
+
// guess. Exactly one of {overlay, clip, audio, nothing} holds at a time.
|
|
1326
|
+
const selectedVisualItem = primarySelectedId
|
|
1327
|
+
? allVisualItems.find(i => i.id === primarySelectedId) ?? null
|
|
1328
|
+
: null
|
|
1329
|
+
const selectedAudioTrack = primarySelectedId && !selectedVisualItem
|
|
1330
|
+
? project.audio?.tracks.find(t => t.id === primarySelectedId) ?? null
|
|
1331
|
+
: null
|
|
1332
|
+
const clipSelection: ClipSelection =
|
|
1333
|
+
selectedVisualItem && selectedVisualItem.type !== 'overlay'
|
|
1334
|
+
? { kind: 'clip', item: selectedVisualItem }
|
|
1335
|
+
: selectedAudioTrack
|
|
1336
|
+
? { kind: 'audio', track: selectedAudioTrack }
|
|
1337
|
+
: null
|
|
1338
|
+
|
|
547
1339
|
// Edits coming from the timeline (drag/move/track changes): route through the
|
|
548
1340
|
// sync core — one undo step + queued save + rollback-on-failure.
|
|
1341
|
+
/**
|
|
1342
|
+
* A LIVE, uncommitted edit — one per mousemove of a timeline gesture (and per
|
|
1343
|
+
* tick of the caption font slider). Transient: applied locally, but no save
|
|
1344
|
+
* and no undo entry.
|
|
1345
|
+
*
|
|
1346
|
+
* This used to be a full `mutate`, which pushed an undo entry per move. A
|
|
1347
|
+
* single drag across the timeline therefore recorded dozens of them, and undo
|
|
1348
|
+
* walked the clip back a few pixels at a time instead of putting it where it
|
|
1349
|
+
* started. `commitTimelineEdit` closes the gesture with ONE entry covering
|
|
1350
|
+
* the whole motion.
|
|
1351
|
+
*
|
|
1352
|
+
* Sets `captionGestureRef` BEFORE the transient apply: the lane-
|
|
1353
|
+
* normalization effect in VideoEditor is a dep of `sync.project.captions`,
|
|
1354
|
+
* so it can fire off the very state update `mutateTransient` triggers here —
|
|
1355
|
+
* the flag has to already be true when that happens, or the first mousemove
|
|
1356
|
+
* of a cross-row caption drag collapses the hole lane and clears the sync
|
|
1357
|
+
* core's transient baseline (see the comment beside `captionGestureRef`).
|
|
1358
|
+
*/
|
|
549
1359
|
function handleProjectChange(p: Project) {
|
|
550
|
-
|
|
1360
|
+
captionGestureRef.current = true
|
|
1361
|
+
sync.mutateTransient(() => p as P)
|
|
551
1362
|
}
|
|
552
1363
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1364
|
+
/**
|
|
1365
|
+
* The end of a gesture: persist the accumulated transient state as one save
|
|
1366
|
+
* and one undo step, taken from the snapshot before the gesture's first move.
|
|
1367
|
+
*
|
|
1368
|
+
* Applies `p` transiently first rather than trusting the last preview to have
|
|
1369
|
+
* been identical — a few callers (ripple delete, auto-crossfade) compute the
|
|
1370
|
+
* final project themselves and hand it straight here, and a caller that DID
|
|
1371
|
+
* already preview it lands a no-op.
|
|
1372
|
+
*
|
|
1373
|
+
* Clears `captionGestureRef` BEFORE the transient apply, for the same
|
|
1374
|
+
* before-the-state-update reason `handleProjectChange` sets it: this commit
|
|
1375
|
+
* IS the point a cross-row drag's hole lane should collapse, and the lane-
|
|
1376
|
+
* normalization effect must see the flag already false when it re-fires off
|
|
1377
|
+
* this project update.
|
|
1378
|
+
*
|
|
1379
|
+
* Known limitation: a gesture that calls `handleProjectChange` but never
|
|
1380
|
+
* reaches a commit (e.g. the component unmounts mid-drag) leaves the flag
|
|
1381
|
+
* stuck true. The only cost is that lane normalization for an externally-
|
|
1382
|
+
* arrived sparse project (SSE, regen, a hand-authored project.json) waits
|
|
1383
|
+
* one more caption edit before it applies — the next real `commitTimelineEdit`
|
|
1384
|
+
* clears the flag and the effect catches up.
|
|
1385
|
+
*/
|
|
1386
|
+
function commitTimelineEdit(p: Project) {
|
|
1387
|
+
captionGestureRef.current = false
|
|
1388
|
+
// Fold the auto-crossfade into the SAME commit as the gesture, so an audio
|
|
1389
|
+
// drag/trim that ends overlapping a neighbour lands as ONE undo step (the
|
|
1390
|
+
// move and its derived fade together) rather than the move here plus a
|
|
1391
|
+
// separate fade commit. Idempotent — a project needing no fade comes back
|
|
1392
|
+
// unchanged — so video moves and non-overlapping audio moves are untouched.
|
|
1393
|
+
const faded = computeAutoCrossfade(p) ?? p
|
|
1394
|
+
sync.mutateTransient(() => faded as P)
|
|
1395
|
+
void sync.commit()
|
|
562
1396
|
}
|
|
563
1397
|
|
|
564
1398
|
function handleOverlayChange(id: string, changes: OverlayChanges) {
|
|
565
1399
|
void sync.mutate(p => ({
|
|
566
1400
|
...p,
|
|
567
|
-
tracks: (p
|
|
568
|
-
|
|
1401
|
+
tracks: mapTrackItems(p, items =>
|
|
1402
|
+
items.map(item => item.id !== id ? item : { ...item, ...changes })
|
|
569
1403
|
),
|
|
570
1404
|
} as P))
|
|
571
1405
|
}
|
|
572
1406
|
|
|
1407
|
+
// OverlayInspector edits (T3.2). Unlike handleOverlayChange above, this
|
|
1408
|
+
// takes a WHOLE replacement item rather than a partial-changes patch:
|
|
1409
|
+
// OverlayInspector itself decides (via keyframeOps) whether an edit writes
|
|
1410
|
+
// a static scalar or drops a keyframe, and disabling keyframing has to
|
|
1411
|
+
// REMOVE `item.keyframes` entirely (see keyframeOps.withTrack) — something
|
|
1412
|
+
// a `{ ...item, ...changes }` merge can't express, since spreading in
|
|
1413
|
+
// `keyframes: undefined` leaves the key present rather than absent.
|
|
1414
|
+
// Shared with the clip properties panel below: a whole-item replacement is
|
|
1415
|
+
// exactly what that one needs too (a speed change rewrites `end` alongside
|
|
1416
|
+
// `speed`), so both inspectors write back through this.
|
|
1417
|
+
function replaceVisualItem(p: P, nextItem: VisualItem): P {
|
|
1418
|
+
return {
|
|
1419
|
+
...p,
|
|
1420
|
+
tracks: mapTrackItems(p, items => items.map(item => (item.id === nextItem.id ? nextItem : item))),
|
|
1421
|
+
} as P
|
|
1422
|
+
}
|
|
1423
|
+
// Live preview for a continuously-typed number field: no undo entry, no
|
|
1424
|
+
// save yet. Mirrors previewOverlayProps below.
|
|
1425
|
+
function previewOverlayInspectorChange(nextItem: VisualItem) {
|
|
1426
|
+
sync.mutateTransient(p => replaceVisualItem(p, nextItem))
|
|
1427
|
+
}
|
|
1428
|
+
// Closes a typing gesture (fired on the field's blur) as one undo step +
|
|
1429
|
+
// queued save. Mirrors commitOverlayEdit below — the last preview already
|
|
1430
|
+
// applied the final value, so this only has to persist it.
|
|
1431
|
+
function commitOverlayInspectorChange() {
|
|
1432
|
+
void sync.commit()
|
|
1433
|
+
}
|
|
1434
|
+
// A discrete, already-final edit (the keyframe diamond toggle) — there is
|
|
1435
|
+
// no separate blur to commit on, so preview + commit fire back to back as
|
|
1436
|
+
// one user-visible action.
|
|
1437
|
+
function applyOverlayInspectorChange(nextItem: VisualItem) {
|
|
1438
|
+
previewOverlayInspectorChange(nextItem)
|
|
1439
|
+
commitOverlayInspectorChange()
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Move the playhead to an absolute timeline time, clamped to the project —
|
|
1443
|
+
// the same clamp the command palette's "go to time" applies. `clock.set` is
|
|
1444
|
+
// the whole of "seek" in this surface: audible drag-scrub hangs off the
|
|
1445
|
+
// hover store, not the clock (a clock tick CANCELS a hover preview, see the
|
|
1446
|
+
// subscription above), so there is no extra side effect to route through.
|
|
1447
|
+
// OverlayInspector's keyframe-nav arrows are the only caller; without it
|
|
1448
|
+
// they render disabled.
|
|
1449
|
+
const seekTo = useCallback(
|
|
1450
|
+
(time: number) => clock.set(Math.max(0, Math.min(getTotalDuration(), time))),
|
|
1451
|
+
[clock, getTotalDuration],
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
// ── Clip / audio properties-panel edits ────────────────────────────────────
|
|
1455
|
+
// The same preview/commit/change trio as the overlay inspector above, on the
|
|
1456
|
+
// same `sync` machinery — ClipPropertiesPanel is its sibling in the right
|
|
1457
|
+
// column and hands back a whole replacement item / track the same way.
|
|
1458
|
+
|
|
1459
|
+
/** Set by a preview that changed the selected clip's timeline DURATION
|
|
1460
|
+
* (today only a speed change does). Read and cleared by the commit below,
|
|
1461
|
+
* which is where ripple has to run. A ref, not state: it is gesture
|
|
1462
|
+
* bookkeeping, and flipping it must not re-render mid-drag. */
|
|
1463
|
+
const clipDurationChangedRef = useRef(false)
|
|
1464
|
+
|
|
1465
|
+
// Cleared whenever the selection changes. Today `SpeedControl` sets the flag
|
|
1466
|
+
// and commits atomically, so it cannot survive a gesture — but nothing in
|
|
1467
|
+
// the type system says so, and a future control that previews a duration
|
|
1468
|
+
// change WITHOUT committing (an abandoned drag, a discarded transient, a
|
|
1469
|
+
// selection change mid-gesture) would leave it set and ripple the timeline
|
|
1470
|
+
// on the next unrelated commit. Resetting here makes that impossible rather
|
|
1471
|
+
// than merely unlikely.
|
|
1472
|
+
useEffect(() => { clipDurationChangedRef.current = false }, [primarySelectedId])
|
|
1473
|
+
|
|
1474
|
+
function previewClipChange(nextItem: VisualItem) {
|
|
1475
|
+
const before = trackItems(sync.projectRef.current).flat().find(i => i.id === nextItem.id)
|
|
1476
|
+
if (before && (before.end - before.start) !== (nextItem.end - nextItem.start)) {
|
|
1477
|
+
clipDurationChangedRef.current = true
|
|
1478
|
+
}
|
|
1479
|
+
sync.mutateTransient(p => replaceVisualItem(p, nextItem))
|
|
1480
|
+
}
|
|
1481
|
+
function commitClipChange() {
|
|
1482
|
+
// Ripple, which the retired ClipInspectModal used to do itself: shrinking a
|
|
1483
|
+
// clip (a speed-up) leaves a gap, and with the magnet on the timeline
|
|
1484
|
+
// closes it. ClipPropertiesPanel cannot — it only ever sees the one item,
|
|
1485
|
+
// and `collapseGaps` moves that item's SIBLINGS — so the commit handler
|
|
1486
|
+
// owns it. Folded into the same transient gesture, so the speed change and
|
|
1487
|
+
// the gap it closed undo as one step.
|
|
1488
|
+
if (clipDurationChangedRef.current && rippleMode) {
|
|
1489
|
+
sync.mutateTransient(p => collapseGaps(p))
|
|
1490
|
+
}
|
|
1491
|
+
clipDurationChangedRef.current = false
|
|
1492
|
+
void sync.commit()
|
|
1493
|
+
}
|
|
1494
|
+
function applyClipChange(nextItem: VisualItem) {
|
|
1495
|
+
previewClipChange(nextItem)
|
|
1496
|
+
commitClipChange()
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function replaceAudioTrack(p: P, nextTrack: AudioTrack): P {
|
|
1500
|
+
return {
|
|
1501
|
+
...p,
|
|
1502
|
+
audio: { ...p.audio, tracks: (p.audio?.tracks ?? []).map(t => (t.id === nextTrack.id ? nextTrack : t)) },
|
|
1503
|
+
} as P
|
|
1504
|
+
}
|
|
1505
|
+
function previewAudioChange(nextTrack: AudioTrack) {
|
|
1506
|
+
sync.mutateTransient(p => replaceAudioTrack(p, nextTrack))
|
|
1507
|
+
}
|
|
1508
|
+
function commitAudioChange() {
|
|
1509
|
+
void sync.commit()
|
|
1510
|
+
}
|
|
1511
|
+
function applyAudioChange(nextTrack: AudioTrack) {
|
|
1512
|
+
previewAudioChange(nextTrack)
|
|
1513
|
+
commitAudioChange()
|
|
1514
|
+
}
|
|
1515
|
+
|
|
573
1516
|
// Commit a per-segment caption change (preview drag → offsetX/offsetY/scale).
|
|
574
1517
|
// Routed through `makeCaptionEdit` so there is exactly one project-mutation
|
|
575
1518
|
// path for caption edits — it addresses the segment by id and leaves the
|
|
@@ -581,22 +1524,80 @@ function ReviewSurface<P extends Project>({
|
|
|
581
1524
|
makeCaptionEdit(segmentId, syncProjectRef.current, (p) => void syncMutate(() => p as P))(patch)
|
|
582
1525
|
}, [syncProjectRef, syncMutate])
|
|
583
1526
|
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
// `
|
|
590
|
-
|
|
1527
|
+
// Delete one caption segment by id — CaptionListPanel's per-row trash
|
|
1528
|
+
// button. Captions have no "add" affordance (R4: they come from
|
|
1529
|
+
// transcription; Regenerate rebuilds the whole track), so this is the only
|
|
1530
|
+
// track-mutating action the sidebar list performs beyond per-segment
|
|
1531
|
+
// patches — narrow enough to get its own channel rather than stretching
|
|
1532
|
+
// `onCaptionEdit`'s whole-project-commit signature for a single id.
|
|
1533
|
+
const handleCaptionSegmentDelete = useCallback((segmentId: string) => {
|
|
1534
|
+
const base = syncProjectRef.current
|
|
1535
|
+
// Captured into a local rather than re-read as `base.captions` below: a
|
|
1536
|
+
// property narrowing does NOT survive into the `syncMutate` closure, since
|
|
1537
|
+
// the compiler cannot prove the property was not reassigned in between, so
|
|
1538
|
+
// the spread there would widen `style` back to optional and no longer
|
|
1539
|
+
// satisfy `Captions`.
|
|
1540
|
+
const captions = base.captions
|
|
1541
|
+
if (!captions) return
|
|
1542
|
+
const segments = captions.segments.filter(s => s.id !== segmentId)
|
|
1543
|
+
if (segments.length === captions.segments.length) return
|
|
1544
|
+
// `normalizeCaptionLanes` (same call Timeline.tsx's Delete keymap makes)
|
|
1545
|
+
// so deleting the LAST caption in a row collapses that hole lane in the
|
|
1546
|
+
// same commit, instead of persisting a sparse lane to disk that only the
|
|
1547
|
+
// canvas timeline's own Delete key used to close.
|
|
1548
|
+
void syncMutate(() => ({ ...base, captions: normalizeCaptionLanes({ ...captions, segments }) } as P))
|
|
1549
|
+
}, [syncProjectRef, syncMutate])
|
|
1550
|
+
|
|
1551
|
+
// Selecting a caption from the preview (click the selection box) is just
|
|
1552
|
+
// setting the unified selection — `selectedCaptionId` above is DERIVED from
|
|
1553
|
+
// `selectedIds`, so there is no second selection model left to keep in
|
|
1554
|
+
// sync. `id !== null` replaces the whole array with just that caption
|
|
1555
|
+
// (matching a plain, non-additive click anywhere else on the timeline);
|
|
1556
|
+
// `null` clears it.
|
|
591
1557
|
const handleSelectCaption = useCallback((id: string | null) => {
|
|
592
|
-
|
|
593
|
-
if (id !== null) setSelectedIds([])
|
|
1558
|
+
setSelectedIds(id ? [id] : [])
|
|
594
1559
|
}, [])
|
|
595
1560
|
|
|
1561
|
+
// Canvas double-click on a caption (Timeline's `onEditCaption`, Phase 6):
|
|
1562
|
+
// selects it exactly like a click would, AND asks the sidebar to scroll to
|
|
1563
|
+
// and focus that segment's text field — `nextEditFocus` (CaptionListPanel.tsx)
|
|
1564
|
+
// is what makes a second double-click on the same segment re-focus it (see
|
|
1565
|
+
// `editFocusId` above for why the bare id can't carry that signal alone).
|
|
1566
|
+
// Extracted to a pure, unit-tested function rather than inlined here, since
|
|
1567
|
+
// this increment is load-bearing and a source-level test alone can't prove
|
|
1568
|
+
// it does the right arithmetic, only that it looks like it does.
|
|
1569
|
+
const handleEditCaption = useCallback((id: string) => {
|
|
1570
|
+
handleSelectCaption(id)
|
|
1571
|
+
setEditFocusId(prev => nextEditFocus(prev, id))
|
|
1572
|
+
}, [handleSelectCaption])
|
|
1573
|
+
|
|
596
1574
|
function handleSplit(at?: number) {
|
|
597
1575
|
const base = syncProjectRef.current
|
|
598
|
-
const
|
|
1576
|
+
const time = at ?? clock.get()
|
|
1577
|
+
// With nothing selected, split ONLY the main video track (tracks[0]) at
|
|
1578
|
+
// `time` — NOT every track under it. Passing `null` to `splitAtTime`
|
|
1579
|
+
// razors every visual track AND every audio track at once, which is not
|
|
1580
|
+
// what "Split with nothing selected" should do (Sam: main track only). So
|
|
1581
|
+
// resolve the base-track clip under `time` and scope the split to its id;
|
|
1582
|
+
// if nothing on the main track sits under `time`, there's nothing to split
|
|
1583
|
+
// — a no-op, rather than cutting overlays/audio too.
|
|
1584
|
+
let targetId = primarySelectedId ?? null
|
|
1585
|
+
if (targetId === null) {
|
|
1586
|
+
const mainItem = (trackItems(base)[0] ?? []).find(it => time > it.start && time < it.end)
|
|
1587
|
+
if (!mainItem) return
|
|
1588
|
+
targetId = mainItem.id
|
|
1589
|
+
}
|
|
1590
|
+
let updated = splitAtTime(base, time, targetId)
|
|
599
1591
|
if (updated === base) return
|
|
1592
|
+
// `splitAtTime` reaches `applyCutToCaptions`, which can DROP a caption
|
|
1593
|
+
// segment at the cut instead of splitting it; dropping a row's last
|
|
1594
|
+
// caption leaves a hole lane, so densify in the same commit — same guard
|
|
1595
|
+
// `handleRippleDelete` uses, and free when nothing changed
|
|
1596
|
+
// (`normalizeCaptionLanes` returns the same reference).
|
|
1597
|
+
if (updated.captions) {
|
|
1598
|
+
const dense = normalizeCaptionLanes(updated.captions)
|
|
1599
|
+
if (dense !== updated.captions) updated = { ...updated, captions: dense } as P
|
|
1600
|
+
}
|
|
600
1601
|
void sync.mutate(() => updated as P)
|
|
601
1602
|
}
|
|
602
1603
|
|
|
@@ -610,22 +1611,305 @@ function ReviewSurface<P extends Project>({
|
|
|
610
1611
|
}
|
|
611
1612
|
}
|
|
612
1613
|
|
|
613
|
-
//
|
|
614
|
-
//
|
|
1614
|
+
// Ripple-delete the primary selection (T8's `rippleDelete`) — Shift+Delete
|
|
1615
|
+
// and the palette's "Ripple-delete selection" entry. Goes through
|
|
1616
|
+
// `sync.mutate` directly (one undo step, one queued save), the same commit
|
|
1617
|
+
// path every other destructive edit in this surface uses (handleSplit,
|
|
1618
|
+
// handleRippleToggle above).
|
|
1619
|
+
function handleRippleDelete() {
|
|
1620
|
+
if (!primarySelectedId) return
|
|
1621
|
+
const base = syncProjectRef.current
|
|
1622
|
+
let updated = rippleDelete(base, primarySelectedId)
|
|
1623
|
+
if (updated === base) return
|
|
1624
|
+
// `rippleDelete` reaches `applyCutToCaptions`, which DROPS caption segments
|
|
1625
|
+
// falling inside the removed span rather than shifting them. Dropping the last
|
|
1626
|
+
// caption in a row leaves a hole lane, so densify in the same commit. Free when
|
|
1627
|
+
// nothing changed: `normalizeCaptionLanes` returns the same reference.
|
|
1628
|
+
if (updated.captions) {
|
|
1629
|
+
const dense = normalizeCaptionLanes(updated.captions)
|
|
1630
|
+
if (dense !== updated.captions) updated = { ...updated, captions: dense } as P
|
|
1631
|
+
}
|
|
1632
|
+
void sync.mutate(() => updated as P)
|
|
1633
|
+
setSelectedIds([])
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Copy the current selection into `clipboardRef` (T2). Visual items and
|
|
1637
|
+
// audio tracks only — `copySelection` (clipboard-ops.ts) ignores caption
|
|
1638
|
+
// ids by construction. Not a `sync.mutate`: nothing about the project
|
|
1639
|
+
// changes, only local clipboard state.
|
|
1640
|
+
function handleCopy() {
|
|
1641
|
+
if (selectedIds.length === 0) return
|
|
1642
|
+
clipboardRef.current = copySelection(syncProjectRef.current, selectedIds)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Paste the clipboard at the playhead. One `sync.mutate`, same
|
|
1646
|
+
// `if (updated === base) return` no-op guard `handleRippleDelete` uses
|
|
1647
|
+
// above. `pasteAt` mints fresh ids for every pasted item, which
|
|
1648
|
+
// `collectAllIds` doesn't get back directly — diffing the id sets before
|
|
1649
|
+
// and after is what selects the newly pasted items afterward.
|
|
1650
|
+
function handlePaste() {
|
|
1651
|
+
const payload = clipboardRef.current
|
|
1652
|
+
if (!payload) return
|
|
1653
|
+
const base = syncProjectRef.current
|
|
1654
|
+
const beforeIds = collectAllIds(base)
|
|
1655
|
+
const updated = pasteAt(base, payload, clock.get())
|
|
1656
|
+
if (updated === base) return
|
|
1657
|
+
void sync.mutate(() => updated as P)
|
|
1658
|
+
setSelectedIds([...collectAllIds(updated)].filter(id => !beforeIds.has(id)))
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Duplicate the current selection in place. Same commit + id-diff pattern
|
|
1662
|
+
// as `handlePaste` above.
|
|
1663
|
+
function handleDuplicate() {
|
|
1664
|
+
if (selectedIds.length === 0) return
|
|
1665
|
+
const base = syncProjectRef.current
|
|
1666
|
+
const beforeIds = collectAllIds(base)
|
|
1667
|
+
const updated = duplicateSelection(base, selectedIds)
|
|
1668
|
+
if (updated === base) return
|
|
1669
|
+
void sync.mutate(() => updated as P)
|
|
1670
|
+
setSelectedIds([...collectAllIds(updated)].filter(id => !beforeIds.has(id)))
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Copy the clipboard's "look" attributes onto every selected item
|
|
1674
|
+
// (`pasteAttributes`, clipboard-ops.ts — type-gated per item/track kind).
|
|
1675
|
+
// No selection change: unlike paste/duplicate this never creates items.
|
|
1676
|
+
function handlePasteAttributes() {
|
|
1677
|
+
const payload = clipboardRef.current
|
|
1678
|
+
if (!payload || selectedIds.length === 0) return
|
|
1679
|
+
const base = syncProjectRef.current
|
|
1680
|
+
const updated = pasteAttributes(base, payload, selectedIds)
|
|
1681
|
+
if (updated === base) return
|
|
1682
|
+
void sync.mutate(() => updated as P)
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const openPalette = useCallback(() => setPaletteOpen('list'), [])
|
|
1686
|
+
const openGoToTime = useCallback(() => setPaletteOpen('goto'), [])
|
|
1687
|
+
const closePalette = useCallback(() => setPaletteOpen(false), [])
|
|
1688
|
+
|
|
1689
|
+
// Fullscreen preview (T5). `previewRegionRef` is attached to the
|
|
1690
|
+
// `previewRegion` wrapper div below — the ONE shared node both editor
|
|
1691
|
+
// layouts (CapCut and classic) render, so a single ref/toggle covers both.
|
|
1692
|
+
// `isFullscreen` is kept in sync with the REAL fullscreen state via the
|
|
1693
|
+
// `fullscreenchange` listener, not just set optimistically on toggle: the
|
|
1694
|
+
// browser can exit fullscreen on its own (Escape, tab switch) without ever
|
|
1695
|
+
// calling `toggleFullscreen`, and the button/palette label would otherwise
|
|
1696
|
+
// go stale. No Escape handling of our own — that's native.
|
|
1697
|
+
const previewRegionRef = useRef<HTMLDivElement | null>(null)
|
|
1698
|
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
615
1699
|
useEffect(() => {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1700
|
+
const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === previewRegionRef.current)
|
|
1701
|
+
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
1702
|
+
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
1703
|
+
}, [])
|
|
1704
|
+
const toggleFullscreen = useCallback(() => {
|
|
1705
|
+
if (document.fullscreenElement) void document.exitFullscreen()
|
|
1706
|
+
else void previewRegionRef.current?.requestFullscreen()
|
|
1707
|
+
}, [])
|
|
1708
|
+
|
|
1709
|
+
// Social-media preview chrome (mirrors CapCut's "Preview your video for
|
|
1710
|
+
// social media" picker) — a viewing aid only, off ("None") by default.
|
|
1711
|
+
// Drawn inside the aspect-ratio box (over the video) rather than the
|
|
1712
|
+
// controls row below it, by SocialSafeZoneOverlay itself. Persisted into
|
|
1713
|
+
// project settings (see handleSocialPreviewChange below) the same way
|
|
1714
|
+
// handleImageToneChange persists the HDR image-tone pick — a real user
|
|
1715
|
+
// preference, not per-render state, so it survives a reload.
|
|
1716
|
+
const [socialPreviewMenuOpen, setSocialPreviewMenuOpen] = useState(false)
|
|
1717
|
+
const socialPreviewTriggerRef = useRef<HTMLButtonElement>(null)
|
|
1718
|
+
|
|
1719
|
+
// Command palette. Bindings for split/undo/redo/ripple-delete/palette-open
|
|
1720
|
+
// double as their own registry entries here; a few are palette-only
|
|
1721
|
+
// (zoom-fit, go-to-time, marker set/clear) — see `paletteCommands` below.
|
|
1722
|
+
// Cmd/Ctrl+K, and the J/K/L shuttle, are new to T9 and only ever lived at
|
|
1723
|
+
// this ReviewSurface level (same scope split/undo/redo already had — the
|
|
1724
|
+
// pending surface has no editing chrome).
|
|
1725
|
+
useKeymap([
|
|
1726
|
+
{
|
|
1727
|
+
id: 'video.split',
|
|
1728
|
+
description: 'Split at the playhead, or the preview axis when it is on',
|
|
1729
|
+
keyHint: ['S'],
|
|
1730
|
+
matches: matchesKey('s'),
|
|
1731
|
+
// When the preview axis (⌘A) is on, `S` splits at the AXIS time
|
|
1732
|
+
// (`hoverScrub`) rather than the playhead — split where you're looking,
|
|
1733
|
+
// as CapCut does. Falls back to the playhead when the axis is off or
|
|
1734
|
+
// nothing is being hovered (`get()` is null). Targeting is unchanged:
|
|
1735
|
+
// `handleSplit` → `splitAtTime(base, at, primarySelectedId ?? null)`
|
|
1736
|
+
// splits the selected item at that time, or the base track when nothing
|
|
1737
|
+
// is selected (a no-op if the axis isn't over the item).
|
|
1738
|
+
action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
|
|
1739
|
+
},
|
|
1740
|
+
{
|
|
1741
|
+
id: 'video.undo',
|
|
1742
|
+
description: 'Undo',
|
|
1743
|
+
keyHint: ['⌘', 'Z'],
|
|
1744
|
+
matches: matchesUndo,
|
|
1745
|
+
action: () => sync.undo(),
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
id: 'video.redo',
|
|
1749
|
+
description: 'Redo',
|
|
1750
|
+
keyHint: ['⌘', '⇧', 'Z'],
|
|
1751
|
+
matches: matchesRedo,
|
|
1752
|
+
action: () => sync.redo(),
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
id: 'video.ripple-delete',
|
|
1756
|
+
description: 'Ripple-delete selection',
|
|
1757
|
+
keyHint: ['⇧', 'Delete'],
|
|
1758
|
+
matches: matchesShiftDelete,
|
|
1759
|
+
guard: () => !!primarySelectedId,
|
|
1760
|
+
action: () => handleRippleDelete(),
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
// `preventDefault: false` so the browser's own text copy is untouched —
|
|
1764
|
+
// this only ever ADDS a project-level copy on top, it never replaces
|
|
1765
|
+
// whatever native selection copy would otherwise do.
|
|
1766
|
+
id: 'video.copy',
|
|
1767
|
+
description: 'Copy selection',
|
|
1768
|
+
keyHint: ['⌘', 'C'],
|
|
1769
|
+
matches: matchesModKey('c'),
|
|
1770
|
+
guard: () => selectedIds.length > 0,
|
|
1771
|
+
preventDefault: false,
|
|
1772
|
+
action: () => handleCopy(),
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
// MUST be registered before `video.paste`: `matchesModKey('v')` does
|
|
1776
|
+
// NOT exclude `altKey`, so Cmd+Opt+V matches BOTH this binding and the
|
|
1777
|
+
// plain-paste one below — first match wins, so paste-attributes has to
|
|
1778
|
+
// come first or Cmd+Opt+V would silently fall through to a plain paste.
|
|
1779
|
+
// See keymap.ts's `matchesModAltKey` doc comment for the same note.
|
|
1780
|
+
id: 'video.paste-attributes',
|
|
1781
|
+
description: 'Paste attributes',
|
|
1782
|
+
keyHint: ['⌘', '⌥', 'V'],
|
|
1783
|
+
matches: matchesModAltKey('v'),
|
|
1784
|
+
guard: () => !!clipboardRef.current && selectedIds.length > 0,
|
|
1785
|
+
action: () => handlePasteAttributes(),
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
id: 'video.paste',
|
|
1789
|
+
description: 'Paste',
|
|
1790
|
+
keyHint: ['⌘', 'V'],
|
|
1791
|
+
matches: matchesModKey('v'),
|
|
1792
|
+
guard: () => !!clipboardRef.current,
|
|
1793
|
+
action: () => handlePaste(),
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
id: 'video.duplicate',
|
|
1797
|
+
description: 'Duplicate selection',
|
|
1798
|
+
keyHint: ['⌘', 'D'],
|
|
1799
|
+
matches: matchesModKey('d'),
|
|
1800
|
+
guard: () => selectedIds.length > 0,
|
|
1801
|
+
action: () => handleDuplicate(),
|
|
1802
|
+
},
|
|
1803
|
+
{
|
|
1804
|
+
id: 'video.fullscreen',
|
|
1805
|
+
description: 'Toggle fullscreen preview',
|
|
1806
|
+
keyHint: ['F'],
|
|
1807
|
+
matches: matchesPlainKey('f'),
|
|
1808
|
+
action: () => toggleFullscreen(),
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
// A for Axis. CapCut binds this to plain `S`, which Split owns here —
|
|
1812
|
+
// and `video.split` is `matchesKey('s')`, a bare key test with no
|
|
1813
|
+
// modifier check (deliberately: it reproduces the pre-keymap split
|
|
1814
|
+
// handler verbatim), so any S-based chord would be swallowed by it.
|
|
1815
|
+
//
|
|
1816
|
+
// `matchesModKey` is meta-OR-ctrl (keymap.ts's `mod`), so this is Cmd+A
|
|
1817
|
+
// and Ctrl+A alike. That shadows the browser's Select All, which this
|
|
1818
|
+
// surface has no use for — and only outside a typing surface: every
|
|
1819
|
+
// binding sits behind `isTypingTarget`, so Cmd+A in a caption, an input,
|
|
1820
|
+
// or a textarea still selects text natively.
|
|
1821
|
+
id: 'video.preview-axis',
|
|
1822
|
+
description: 'Toggle preview axis',
|
|
1823
|
+
keyHint: ['⌘', 'A'],
|
|
1824
|
+
matches: matchesModKey('a'),
|
|
1825
|
+
action: () => setPreviewAxis(v => !v),
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
id: 'video.open-palette',
|
|
1829
|
+
description: 'Open command palette',
|
|
1830
|
+
keyHint: ['⌘', 'K'],
|
|
1831
|
+
matches: matchesModKey('k'),
|
|
1832
|
+
guard: () => !paletteOpen,
|
|
1833
|
+
action: () => openPalette(),
|
|
1834
|
+
paletteHidden: true,
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
id: 'video.shuttle-forward',
|
|
1838
|
+
description: 'Shuttle forward',
|
|
1839
|
+
keyHint: ['L'],
|
|
1840
|
+
matches: matchesPlainKey('l'),
|
|
1841
|
+
action: () => shuttle.press(1),
|
|
1842
|
+
paletteHidden: true,
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
id: 'video.shuttle-backward',
|
|
1846
|
+
description: 'Shuttle backward',
|
|
1847
|
+
keyHint: ['J'],
|
|
1848
|
+
matches: matchesPlainKey('j'),
|
|
1849
|
+
action: () => shuttle.press(-1),
|
|
1850
|
+
paletteHidden: true,
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
id: 'video.shuttle-stop',
|
|
1854
|
+
description: 'Stop shuttle',
|
|
1855
|
+
keyHint: ['K'],
|
|
1856
|
+
matches: matchesPlainKey('k'),
|
|
1857
|
+
action: () => shuttle.stop(),
|
|
1858
|
+
paletteHidden: true,
|
|
1859
|
+
},
|
|
1860
|
+
// No `modalOpen` gating here — split/undo/redo never had a modal guard
|
|
1861
|
+
// (see the file-header note above `anyModalOpen`), and RenderModal being
|
|
1862
|
+
// open mid-render is a real flow that still needs Cmd+Z to work (the
|
|
1863
|
+
// existing "undo restores the pre-mutation project" test drives exactly
|
|
1864
|
+
// that: click Render → RenderModal opens → Cmd+Z must still undo). The
|
|
1865
|
+
// typing-surface guard alone (shared by every binding, unconditionally)
|
|
1866
|
+
// already keeps these from firing while the palette's own filter input has
|
|
1867
|
+
// focus — modal-gating would only add protection for the edge case of a
|
|
1868
|
+
// dialog open with focus OUTSIDE it, which isn't worth the regression risk.
|
|
1869
|
+
])
|
|
1870
|
+
|
|
1871
|
+
// The palette's command list — split/undo/redo/ripple-delete/play-pause
|
|
1872
|
+
// read live state so entries only appear when they'd actually do
|
|
1873
|
+
// something (no selection → no ripple-delete row; nothing to undo → no
|
|
1874
|
+
// undo row). Zoom-fit routes through `timelineActionsRef` (Timeline-local
|
|
1875
|
+
// state — see Timeline.tsx's `TimelineActions`). Roll/slip/
|
|
1876
|
+
// slide are drag gestures and deliberately have no palette variant.
|
|
1877
|
+
const paletteCommands: PaletteCommand[] = [
|
|
1878
|
+
{ id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
|
|
1879
|
+
{ id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
|
|
1880
|
+
]
|
|
1881
|
+
if (primarySelectedId) {
|
|
1882
|
+
paletteCommands.push({ id: 'ripple-delete', label: 'Ripple-delete selection', keyHint: ['⇧', 'Delete'], run: () => handleRippleDelete() })
|
|
1883
|
+
}
|
|
1884
|
+
if (selectedIds.length > 0) {
|
|
1885
|
+
paletteCommands.push({ id: 'copy', label: 'Copy', keyHint: ['⌘', 'C'], run: () => handleCopy() })
|
|
1886
|
+
paletteCommands.push({ id: 'duplicate', label: 'Duplicate', keyHint: ['⌘', 'D'], run: () => handleDuplicate() })
|
|
1887
|
+
}
|
|
1888
|
+
if (clipboardRef.current) {
|
|
1889
|
+
paletteCommands.push({ id: 'paste', label: 'Paste', keyHint: ['⌘', 'V'], run: () => handlePaste() })
|
|
1890
|
+
if (selectedIds.length > 0) {
|
|
1891
|
+
paletteCommands.push({ id: 'paste-attributes', label: 'Paste attributes', keyHint: ['⌘', '⌥', 'V'], run: () => handlePasteAttributes() })
|
|
625
1892
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1893
|
+
}
|
|
1894
|
+
if (sync.canUndo) paletteCommands.push({ id: 'undo', label: 'Undo', keyHint: ['⌘', 'Z'], run: () => sync.undo() })
|
|
1895
|
+
if (sync.canRedo) paletteCommands.push({ id: 'redo', label: 'Redo', keyHint: ['⌘', '⇧', 'Z'], run: () => sync.redo() })
|
|
1896
|
+
paletteCommands.push({
|
|
1897
|
+
id: 'preview-axis',
|
|
1898
|
+
label: previewAxis ? 'Preview axis: turn off' : 'Preview axis: turn on',
|
|
1899
|
+
keyHint: ['⌘', 'A'],
|
|
1900
|
+
run: () => setPreviewAxis(v => !v),
|
|
1901
|
+
})
|
|
1902
|
+
paletteCommands.push({
|
|
1903
|
+
id: 'fullscreen',
|
|
1904
|
+
label: isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen',
|
|
1905
|
+
keyHint: ['F'],
|
|
1906
|
+
run: () => toggleFullscreen(),
|
|
1907
|
+
})
|
|
1908
|
+
paletteCommands.push({ id: 'zoom-fit', label: 'Zoom to fit', run: () => timelineActionsRef.current?.zoomFit() })
|
|
1909
|
+
paletteCommands.push({ id: 'goto', label: 'Go to time…', run: () => openGoToTime() })
|
|
1910
|
+
if (handleAudioPolish) {
|
|
1911
|
+
paletteCommands.push({ id: 'audio-polish', label: 'Polish audio…', run: () => handleAudioPolish() })
|
|
1912
|
+
}
|
|
629
1913
|
|
|
630
1914
|
async function handleRestoreVersion(hash: string) {
|
|
631
1915
|
if (!adapter.restoreVersion) return
|
|
@@ -638,17 +1922,52 @@ function ReviewSurface<P extends Project>({
|
|
|
638
1922
|
console.error(e)
|
|
639
1923
|
} finally {
|
|
640
1924
|
setRestoring(null)
|
|
1925
|
+
await refreshVersions()
|
|
641
1926
|
}
|
|
642
1927
|
}
|
|
643
1928
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1929
|
+
async function handleSaveVersion(name?: string) {
|
|
1930
|
+
if (!adapter.saveVersion) return
|
|
1931
|
+
setSaving(true)
|
|
1932
|
+
try {
|
|
1933
|
+
await adapter.saveVersion(project.id, name)
|
|
1934
|
+
await refreshVersions()
|
|
1935
|
+
} catch (e) {
|
|
1936
|
+
console.error(e)
|
|
1937
|
+
} finally {
|
|
1938
|
+
setSaving(false)
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function handleCompareVersion(hash: string) {
|
|
1943
|
+
setCompareOpen(hash)
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// ── Shared layout pieces ─────────────────────────────────────────────────
|
|
1947
|
+
// The preview, row divider, timeline pane and right rail are identical in both
|
|
1948
|
+
// layouts; only their arrangement differs (classic column vs. CapCut top-row +
|
|
1949
|
+
// full-width timeline). Factoring them into local render values keeps the
|
|
1950
|
+
// classic path byte-for-byte unchanged and lets the CapCut branch reuse the
|
|
1951
|
+
// exact same nodes rather than duplicating this JSX.
|
|
1952
|
+
|
|
1953
|
+
// The preview column: video area on top, a slim controls row on chrome
|
|
1954
|
+
// underneath it. `previewRegionRef` stays on this outermost node — it is
|
|
1955
|
+
// both the fullscreen target and the one shared node both editor layouts
|
|
1956
|
+
// render, and keeping the controls row INSIDE it means the row is still
|
|
1957
|
+
// reachable once fullscreened (a bare video with no chrome at all would
|
|
1958
|
+
// leave fullscreen viewers with no way back out except Escape).
|
|
1959
|
+
//
|
|
1960
|
+
// The fullscreen button used to be a 28px corner overlay on the video
|
|
1961
|
+
// itself (`absolute top-2 right-2`, bg-black/50 for contrast against
|
|
1962
|
+
// whatever frame happened to be playing) — invisible on bright footage
|
|
1963
|
+
// and competing with the picture. It now lives as a normal child of this
|
|
1964
|
+
// row, on chrome, sized and styled like every other row button below
|
|
1965
|
+
// (the track-controls bar's Ripple/Crop toggles) rather than floating.
|
|
1966
|
+
const previewRegion = (
|
|
1967
|
+
<div ref={previewRegionRef} className="flex-1 min-h-0 flex flex-col bg-black overflow-hidden">
|
|
1968
|
+
{hasContent ? (
|
|
1969
|
+
<>
|
|
1970
|
+
<div className="flex-1 min-h-0 flex items-center justify-center overflow-hidden p-2">
|
|
652
1971
|
<div
|
|
653
1972
|
className="relative h-full max-w-full"
|
|
654
1973
|
style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
|
|
@@ -658,7 +1977,10 @@ function ReviewSurface<P extends Project>({
|
|
|
658
1977
|
clock={clock}
|
|
659
1978
|
selectedOverlayId={primarySelectedId ?? undefined}
|
|
660
1979
|
onOverlayChange={handleOverlayChange}
|
|
661
|
-
|
|
1980
|
+
// Double-click on the preview. It used to open the props
|
|
1981
|
+
// dialog; it SELECTS now, which is what puts the overlay's
|
|
1982
|
+
// props on screen in the right column's Content tab.
|
|
1983
|
+
onEditOverlay={selectOverlayForEditing}
|
|
662
1984
|
compileOverlay={adapter.compileOverlay}
|
|
663
1985
|
clearOverlayCache={adapter.clearOverlayCache}
|
|
664
1986
|
watchFile={adapter.watchFile}
|
|
@@ -667,184 +1989,766 @@ function ReviewSurface<P extends Project>({
|
|
|
667
1989
|
selectedCaptionId={selectedCaptionId ?? undefined}
|
|
668
1990
|
onSelectCaption={handleSelectCaption}
|
|
669
1991
|
onCaptionSegmentChange={handleCaptionSegmentChange}
|
|
1992
|
+
engine={engine}
|
|
1993
|
+
transportRef={transportRef}
|
|
1994
|
+
scrubHandleRef={scrubHandleRef}
|
|
1995
|
+
hoverScrub={hoverScrub}
|
|
1996
|
+
// Social-media preview chrome (mirrors CapCut's "Preview your
|
|
1997
|
+
// video for social media" picker) — it previews what platform
|
|
1998
|
+
// UI would sit ON TOP of the picture, so PreviewPlayer mounts
|
|
1999
|
+
// it INSIDE its own preview surface (same coordinate space AND
|
|
2000
|
+
// stacking context as the picture, rather than as a sibling
|
|
2001
|
+
// here) — see the `socialPreview` prop doc on
|
|
2002
|
+
// PreviewPlayerProps. "None" (null) by default; the component
|
|
2003
|
+
// itself no-ops on an unset/unknown platform.
|
|
2004
|
+
socialPreview={currentSocialPreview ?? undefined}
|
|
670
2005
|
/>
|
|
2006
|
+
{/* Footage-bin source scrub (opt-in). A paused <video> parked above the
|
|
2007
|
+
timeline preview, showing an OFF-TIMELINE clip's frame while the
|
|
2008
|
+
host hovers a bin card. Inert unless a host supplies `sourcePreview`
|
|
2009
|
+
AND sets a value — see SourcePreviewOverlay / source-preview.ts. */}
|
|
2010
|
+
<SourcePreviewOverlay store={sourcePreview} />
|
|
671
2011
|
</div>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
2012
|
+
</div>
|
|
2013
|
+
{/* Preview controls row — chrome, not video. Timecode readout on the
|
|
2014
|
+
left; zoom-to-fit, safe-zone preview and fullscreen on the right,
|
|
2015
|
+
styled like the track-controls bar's toggles below (Ripple/Crop):
|
|
2016
|
+
w-5 h-5 icon buttons, `aria-pressed` colouring for true toggles,
|
|
2017
|
+
a styled `Tooltip` on every button instead of a native `title=`. */}
|
|
2018
|
+
<div className="shrink-0 flex items-center gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
2019
|
+
<span
|
|
2020
|
+
data-testid="preview-timecode"
|
|
2021
|
+
className="mr-auto text-[10px] font-mono tabular-nums text-[var(--editor-text)]/60 select-none"
|
|
2022
|
+
>
|
|
2023
|
+
{/* The playhead can sit past `previewDuration` while parked in
|
|
2024
|
+
`getTotalDuration()`'s ~20% trailing headroom (drag room for
|
|
2025
|
+
the timeline canvas) — clamp the DISPLAYED current time only,
|
|
2026
|
+
so the readout never shows e.g. "1:10.0 / 1:00.0". The clock
|
|
2027
|
+
itself and the total are untouched. */}
|
|
2028
|
+
{formatTimecode(Math.min(currentTime, previewDuration))} / {formatTimecode(previewDuration)}
|
|
2029
|
+
</span>
|
|
2030
|
+
{/* Zoom-to-fit lives in the timeline zoom chrome ("fit" next to the
|
|
2031
|
+
+/- zoom buttons, Timeline.tsx) — no duplicate control here. */}
|
|
2032
|
+
<Tooltip label="Preview for social media">
|
|
2033
|
+
<button
|
|
2034
|
+
ref={socialPreviewTriggerRef}
|
|
2035
|
+
onClick={() => setSocialPreviewMenuOpen(v => !v)}
|
|
2036
|
+
aria-label="Preview for social media"
|
|
2037
|
+
aria-haspopup="menu"
|
|
2038
|
+
aria-expanded={socialPreviewMenuOpen}
|
|
2039
|
+
aria-pressed={currentSocialPreview !== null}
|
|
2040
|
+
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2041
|
+
currentSocialPreview !== null
|
|
2042
|
+
? (timelineMode === 'light' ? 'text-sky-600 bg-sky-400/15 hover:bg-sky-400/25' : 'text-sky-400 bg-sky-400/15 hover:bg-sky-400/25')
|
|
2043
|
+
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
2044
|
+
}`}
|
|
2045
|
+
>
|
|
2046
|
+
{/* Trigger reflects the active selection: the platform's own
|
|
2047
|
+
glyph (TikTok/YouTube/Instagram) once one is picked, the
|
|
2048
|
+
plain Smartphone icon otherwise. */}
|
|
2049
|
+
{activeSocialPreviewOption
|
|
2050
|
+
? <PlatformGlyph icon={activeSocialPreviewOption.icon} badgeClassName={activeSocialPreviewOption.badgeClassName} size={14} />
|
|
2051
|
+
: <Smartphone size={12} />}
|
|
2052
|
+
</button>
|
|
2053
|
+
</Tooltip>
|
|
2054
|
+
{socialPreviewMenuOpen && (
|
|
2055
|
+
<SocialPreviewMenu
|
|
2056
|
+
anchorRef={socialPreviewTriggerRef}
|
|
2057
|
+
value={currentSocialPreview}
|
|
2058
|
+
onChange={handleSocialPreviewChange}
|
|
2059
|
+
onClose={() => setSocialPreviewMenuOpen(false)}
|
|
2060
|
+
mode={timelineMode}
|
|
2061
|
+
/>
|
|
2062
|
+
)}
|
|
2063
|
+
<Tooltip label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} keys={['F']}>
|
|
2064
|
+
<button
|
|
2065
|
+
onClick={toggleFullscreen}
|
|
2066
|
+
aria-label="Toggle fullscreen"
|
|
2067
|
+
aria-pressed={isFullscreen}
|
|
2068
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
|
|
2069
|
+
>
|
|
2070
|
+
{isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
|
2071
|
+
</button>
|
|
2072
|
+
</Tooltip>
|
|
2073
|
+
</div>
|
|
2074
|
+
</>
|
|
2075
|
+
) : (
|
|
2076
|
+
<div className="flex-1 min-h-0 flex items-center justify-center p-2">
|
|
2077
|
+
<p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
|
|
675
2078
|
</div>
|
|
2079
|
+
)}
|
|
2080
|
+
</div>
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
// The divider. `row-resize` plus a hairline that lights up on hover is the
|
|
2084
|
+
// whole affordance — a 5px hit area is comfortable to grab without stealing a
|
|
2085
|
+
// visible row from either pane. Double-click restores the default split.
|
|
2086
|
+
const rowDivider = (
|
|
2087
|
+
<div
|
|
2088
|
+
role="separator"
|
|
2089
|
+
aria-orientation="horizontal"
|
|
2090
|
+
aria-label="Resize timeline"
|
|
2091
|
+
onMouseDown={startSplitDrag}
|
|
2092
|
+
onDoubleClick={() => setTimelinePaneHeight(DEFAULT_TIMELINE_PANE_PX)}
|
|
2093
|
+
className="group shrink-0 h-[5px] cursor-row-resize bg-transparent"
|
|
2094
|
+
>
|
|
2095
|
+
<div className="h-px w-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
|
|
2096
|
+
</div>
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
// Timeline pane — the half the divider sizes.
|
|
2100
|
+
const timelinePane = (
|
|
2101
|
+
<div className="shrink-0 flex flex-col overflow-hidden" style={{ height: timelinePaneHeight }}>
|
|
676
2102
|
|
|
677
|
-
|
|
678
|
-
|
|
2103
|
+
{/* Track controls bar — controls + undo/redo + preview axis + ripple +
|
|
2104
|
+
crop + render. Split is deliberately NOT here: it is a one-key verb
|
|
2105
|
+
(S, listed in the controls modal), and a glyph for it only crowded a
|
|
2106
|
+
row whose other buttons are modes you can't type your way into.
|
|
2107
|
+
Every button carries a styled `Tooltip` instead of a native `title=`:
|
|
2108
|
+
the native one is delayed ~1s and rendered in OS chrome, which on a
|
|
2109
|
+
row of 12px glyphs meant the affordances were effectively unlabelled.
|
|
2110
|
+
`aria-label` stays on each button — the tooltip is a hover affordance,
|
|
2111
|
+
not an accessible name. Disabled buttons get `pointer-events-none` so
|
|
2112
|
+
hover still reaches the wrapper and can explain WHY they're disabled,
|
|
2113
|
+
and fade to 50% rather than 30%: at 30% a 12px glyph on this surface
|
|
2114
|
+
reads as ABSENT, and Crop is disabled whenever no clip is selected —
|
|
2115
|
+
which is most of the time, so the control looked like it had been
|
|
2116
|
+
removed rather than like it was waiting on a selection. */}
|
|
2117
|
+
<div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
2118
|
+
<Tooltip label="Controls & shortcuts" className="mr-auto">
|
|
679
2119
|
<button
|
|
680
2120
|
onClick={() => setShowControls(true)}
|
|
681
|
-
title="Editor controls & shortcuts"
|
|
682
2121
|
aria-label="Editor controls & shortcuts"
|
|
683
|
-
className="flex items-center
|
|
2122
|
+
className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[var(--editor-text)]/75 bg-transparent hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/10"
|
|
684
2123
|
>
|
|
685
|
-
<
|
|
2124
|
+
<HelpCircle size={14} />
|
|
2125
|
+
<span className="text-[10px] leading-none">Controls</span>
|
|
686
2126
|
</button>
|
|
2127
|
+
</Tooltip>
|
|
2128
|
+
<Tooltip label="Undo" keys={['⌘', 'Z']}>
|
|
687
2129
|
<button
|
|
688
2130
|
onClick={sync.undo}
|
|
689
2131
|
disabled={!sync.canUndo}
|
|
690
|
-
title="Undo (Cmd/Ctrl+Z)"
|
|
691
2132
|
aria-label="Undo"
|
|
692
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-
|
|
2133
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
|
|
693
2134
|
>
|
|
694
2135
|
<Undo2 size={12} />
|
|
695
2136
|
</button>
|
|
2137
|
+
</Tooltip>
|
|
2138
|
+
<Tooltip label="Redo" keys={['⌘', '⇧', 'Z']}>
|
|
696
2139
|
<button
|
|
697
2140
|
onClick={sync.redo}
|
|
698
2141
|
disabled={!sync.canRedo}
|
|
699
|
-
title="Redo (Cmd/Ctrl+Shift+Z)"
|
|
700
2142
|
aria-label="Redo"
|
|
701
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-
|
|
2143
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
|
|
702
2144
|
>
|
|
703
2145
|
<Redo2 size={12} />
|
|
704
2146
|
</button>
|
|
2147
|
+
</Tooltip>
|
|
2148
|
+
<Tooltip label={previewAxis ? 'Preview axis on — hover to preview' : 'Preview axis off'} keys={['⌘', 'A']}>
|
|
705
2149
|
<button
|
|
706
|
-
onClick={() =>
|
|
707
|
-
|
|
708
|
-
|
|
2150
|
+
onClick={() => setPreviewAxis(v => !v)}
|
|
2151
|
+
aria-label="Preview axis"
|
|
2152
|
+
aria-pressed={previewAxis}
|
|
2153
|
+
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2154
|
+
previewAxis
|
|
2155
|
+
? (timelineMode === 'light' ? 'text-yellow-700 bg-yellow-400/15 hover:bg-yellow-400/25' : 'text-yellow-400 bg-yellow-400/15 hover:bg-yellow-400/25')
|
|
2156
|
+
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
2157
|
+
}`}
|
|
709
2158
|
>
|
|
710
|
-
<
|
|
711
|
-
<line x1="6" y1="0" x2="6" y2="12" />
|
|
712
|
-
<polyline points="3,3 6,6 9,3" />
|
|
713
|
-
<polyline points="3,9 6,6 9,9" />
|
|
714
|
-
</svg>
|
|
2159
|
+
<SeparatorVertical size={12} />
|
|
715
2160
|
</button>
|
|
2161
|
+
</Tooltip>
|
|
2162
|
+
<Tooltip label={rippleMode ? 'Ripple on — gaps close' : 'Ripple: close the gap'}>
|
|
716
2163
|
<button
|
|
717
2164
|
onClick={handleRippleToggle}
|
|
718
|
-
|
|
2165
|
+
aria-label="Ripple mode"
|
|
719
2166
|
aria-pressed={rippleMode}
|
|
720
2167
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
721
2168
|
rippleMode
|
|
722
|
-
? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
|
|
2169
|
+
? (timelineMode === 'light' ? 'text-teal-600 bg-teal-400/15 hover:bg-teal-400/25' : 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25')
|
|
723
2170
|
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
724
2171
|
}`}
|
|
725
2172
|
>
|
|
726
2173
|
<Magnet size={12} />
|
|
727
2174
|
</button>
|
|
2175
|
+
</Tooltip>
|
|
2176
|
+
<Tooltip
|
|
2177
|
+
label={!cropTarget ? 'Select a clip to crop' : cropMode ? 'Exit crop' : 'Crop source'}
|
|
2178
|
+
>
|
|
728
2179
|
<button
|
|
729
2180
|
onClick={() => setCropMode(m => !m)}
|
|
730
2181
|
disabled={!cropTarget}
|
|
731
|
-
|
|
732
|
-
!cropTarget
|
|
733
|
-
? 'Select a video clip to crop its source'
|
|
734
|
-
: cropMode ? 'Exit source crop' : 'Crop source — non-destructively crop the selected clip'
|
|
735
|
-
}
|
|
2182
|
+
aria-label="Crop source"
|
|
736
2183
|
aria-pressed={cropMode}
|
|
737
|
-
className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-
|
|
2184
|
+
className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
|
738
2185
|
cropMode
|
|
739
|
-
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
|
2186
|
+
? (timelineMode === 'light' ? 'text-amber-600 bg-amber-400/15 hover:bg-amber-400/25' : 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25')
|
|
740
2187
|
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
741
2188
|
}`}
|
|
742
2189
|
>
|
|
743
2190
|
<Crop size={12} />
|
|
744
2191
|
</button>
|
|
745
|
-
|
|
2192
|
+
</Tooltip>
|
|
2193
|
+
{/* The Pencil "Edit overlay" button used to sit here, opening the
|
|
2194
|
+
floating props dialog. Both are retired: selecting an overlay now
|
|
2195
|
+
shows its props in the right column's Content tab, so a button whose
|
|
2196
|
+
only job was "open the thing that is already open" had nothing left
|
|
2197
|
+
to do. */}
|
|
2198
|
+
{/* Image color mapping. HDR projects only (the tone has no effect on
|
|
2199
|
+
SDR renders). Hidden when the host surfaces the setting in its own
|
|
2200
|
+
chrome via onProvideImageTone, mirroring the Render button. */}
|
|
2201
|
+
{!onProvideImageTone && isHdrProject && (
|
|
2202
|
+
<ImageToneMenu
|
|
2203
|
+
value={currentImageTone}
|
|
2204
|
+
onChange={handleImageToneChange}
|
|
2205
|
+
mode={timelineMode}
|
|
2206
|
+
/>
|
|
2207
|
+
)}
|
|
2208
|
+
{/* Audio polish — silence/fillers/loudness/voice cleanup. Hidden when the
|
|
2209
|
+
host adapter doesn't implement `analyzeAudioPolish`, exactly like the
|
|
2210
|
+
caption-regen entry point above. */}
|
|
2211
|
+
{handleAudioPolish && (
|
|
2212
|
+
<Tooltip label="Polish audio">
|
|
746
2213
|
<button
|
|
747
|
-
onClick={
|
|
748
|
-
|
|
749
|
-
aria-label="Edit overlay"
|
|
2214
|
+
onClick={handleAudioPolish}
|
|
2215
|
+
aria-label="Polish audio"
|
|
750
2216
|
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
|
|
751
2217
|
>
|
|
752
|
-
<
|
|
2218
|
+
<Wand2 size={12} />
|
|
753
2219
|
</button>
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
2220
|
+
</Tooltip>
|
|
2221
|
+
)}
|
|
2222
|
+
{/* Audible drag-scrub toggle — sits next to Polish audio as the
|
|
2223
|
+
other audio tool in this row (moved out of the preview's
|
|
2224
|
+
controls row, which is chrome, not editing tools). */}
|
|
2225
|
+
<Tooltip label={currentAudibleScrub ? 'Mute drag-scrub audio' : 'Unmute drag-scrub audio'}>
|
|
2226
|
+
<button
|
|
2227
|
+
onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
|
|
2228
|
+
aria-label="Toggle audible drag-scrub"
|
|
2229
|
+
aria-pressed={currentAudibleScrub}
|
|
2230
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
|
|
2231
|
+
>
|
|
2232
|
+
{currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
|
|
2233
|
+
</button>
|
|
2234
|
+
</Tooltip>
|
|
2235
|
+
{/* Default placement. A host that sets onProvideRenderTrigger renders
|
|
2236
|
+
Render in its own chrome instead, so the toolbar button is hidden. */}
|
|
2237
|
+
{!onProvideRenderTrigger && (
|
|
2238
|
+
<button
|
|
2239
|
+
onClick={openRender}
|
|
2240
|
+
className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
|
|
2241
|
+
>
|
|
2242
|
+
Render →
|
|
2243
|
+
</button>
|
|
2244
|
+
)}
|
|
2245
|
+
</div>
|
|
2246
|
+
|
|
2247
|
+
{/* `data-timeline-scroll`: the canvas timeline measures against this
|
|
2248
|
+
viewport to grow its surface down into the empty space below the
|
|
2249
|
+
tracks (see TimelineCanvas's pane-fill effect). Its height is fixed by
|
|
2250
|
+
the resizable pane, so the measurement never feeds back. */}
|
|
2251
|
+
<div data-timeline-scroll className="flex-1 min-h-0 overflow-y-auto border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
2252
|
+
<Timeline
|
|
2253
|
+
project={project}
|
|
2254
|
+
clock={clock}
|
|
2255
|
+
onProjectChange={handleProjectChange}
|
|
2256
|
+
onOverlayEdit={commitTimelineEdit}
|
|
2257
|
+
previewAxis={previewAxis}
|
|
2258
|
+
onHoverScrub={handleHoverScrub}
|
|
2259
|
+
selectedIds={selectedIds}
|
|
2260
|
+
onSelectIds={setSelectedIds}
|
|
2261
|
+
// No `onInspectClip` / `onInspectAudio`: the clip-inspect modal they
|
|
2262
|
+
// opened is retired. Single-click selection now populates the right
|
|
2263
|
+
// properties panel, so a double-click on a clip/audio bar is simply a
|
|
2264
|
+
// no-op. The Timeline props still exist (that is its API) — this
|
|
2265
|
+
// editor just has nothing left to do with them.
|
|
2266
|
+
onEditCaption={handleEditCaption}
|
|
2267
|
+
rippleMode={rippleMode}
|
|
2268
|
+
resolveFilePath={resolveFilePath}
|
|
2269
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
2270
|
+
getFilmstrip={getFilmstrip}
|
|
2271
|
+
regenEnabled={regenEnabled}
|
|
2272
|
+
isClipQueued={isClipQueued}
|
|
2273
|
+
renderSubcutRegen={renderSubcutRegen}
|
|
2274
|
+
modalOpen={anyModalOpen}
|
|
2275
|
+
onOpenGoToTime={openGoToTime}
|
|
2276
|
+
actionsRef={timelineActionsRef}
|
|
2277
|
+
mode={timelineMode}
|
|
2278
|
+
onImportFilesToTimeline={onImportFilesToTimeline}
|
|
2279
|
+
pendingDrops={pendingDrops}
|
|
2280
|
+
/>
|
|
2281
|
+
</div>
|
|
2282
|
+
</div>
|
|
2283
|
+
)
|
|
2284
|
+
|
|
2285
|
+
// Assets — dedicated separate column to the LEFT of the version rail
|
|
2286
|
+
// (assetsPlacement: 'right'). Classic layouts only.
|
|
2287
|
+
const assetsColumn = assetsPlacement === 'right' && slots?.assetsPanel && (
|
|
2288
|
+
<div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
2289
|
+
{slots.assetsPanel}
|
|
2290
|
+
</div>
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
// ── Pieces shared by the two layouts' side columns ────────────────────────
|
|
2294
|
+
// The caption editor and the version list live in the CLASSIC right rail and
|
|
2295
|
+
// in the CapCut LEFT panel's Captions / Versions tabs. Built once here, with
|
|
2296
|
+
// one set of props, so the two layouts can never drift apart — only one of
|
|
2297
|
+
// them mounts per render, so sharing the element is free.
|
|
2298
|
+
const captionListPanel = (
|
|
2299
|
+
<CaptionListPanelWithClock
|
|
2300
|
+
captionTrack={project.captions}
|
|
2301
|
+
project={project}
|
|
2302
|
+
selectedIds={selectedIds}
|
|
2303
|
+
onSelectCaption={handleSelectCaption}
|
|
2304
|
+
onCaptionSegmentChange={handleCaptionSegmentChange}
|
|
2305
|
+
onCaptionEdit={(p) => void sync.mutate(() => p as P)}
|
|
2306
|
+
onProjectChange={handleProjectChange}
|
|
2307
|
+
onCaptionSegmentDelete={handleCaptionSegmentDelete}
|
|
2308
|
+
onRegenerateCaptions={handleRegenerateCaptions}
|
|
2309
|
+
// Disables the panel's generate/regenerate trigger while a job is in
|
|
2310
|
+
// flight, from either source: the host's own background job
|
|
2311
|
+
// (`captionsGenerating` prop, set when the host owns the trigger via
|
|
2312
|
+
// `onRegenerateCaptions`) OR'd with this component's internal modal-
|
|
2313
|
+
// open state. The internal half is defensive rather than load-bearing
|
|
2314
|
+
// — CaptionRegenModal is a full-screen blocking portal, so the panel
|
|
2315
|
+
// underneath can't be clicked anyway — but it keeps the panel honest
|
|
2316
|
+
// on its own terms and re-enables on close, so a failed job never
|
|
2317
|
+
// leaves a permanently dead button. The host half is load-bearing:
|
|
2318
|
+
// it's the editor's only signal that an off-component job is running.
|
|
2319
|
+
captionsGenerating={captionsGenerating || regenCaptionsOpen}
|
|
2320
|
+
fps={project.settings?.fps ?? 30}
|
|
2321
|
+
clock={clock}
|
|
2322
|
+
editFocusId={editFocusId}
|
|
2323
|
+
compileOverlay={adapter.compileOverlay}
|
|
2324
|
+
resolveCaptionTemplate={adapter.resolveCaptionTemplate}
|
|
2325
|
+
mode={timelineMode}
|
|
2326
|
+
/>
|
|
2327
|
+
)
|
|
2328
|
+
const versionPanel = adapter.listVersionHistory && (
|
|
2329
|
+
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} onSaveVersion={handleSaveVersion} saving={saving} onCompareVersion={adapter.versionFrameUrl ? handleCompareVersion : undefined} mode={timelineMode} />
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
/**
|
|
2333
|
+
* The selected overlay's properties, in two tabs: **Content** (its own props
|
|
2334
|
+
* — text, colors, numbers, toggles, images) and **Transform** (the
|
|
2335
|
+
* keyframeable geometry). Content is the default, and it is what the retired
|
|
2336
|
+
* floating "Edit overlay" dialog used to show.
|
|
2337
|
+
*
|
|
2338
|
+
* ONE node, rendered by BOTH layouts — the classic right rail below and the
|
|
2339
|
+
* CapCut `propertiesPanel` further down. That is not incidental tidiness: the
|
|
2340
|
+
* dialog this replaces was mounted at the top level and its Pencil button
|
|
2341
|
+
* lived in the controls bar, so BOTH were layout-independent. Giving the tabs
|
|
2342
|
+
* only to the CapCut column would have left classic hosts (Hub, LP) with a
|
|
2343
|
+
* Transform inspector and no way to reach an overlay's text at all.
|
|
2344
|
+
*
|
|
2345
|
+
* A COMPACT INLINE tab strip, deliberately not `LeftPanelTabs`: that
|
|
2346
|
+
* component's `w-16` vertical icon rail spends a fifth of a 300px column on
|
|
2347
|
+
* chrome. Two buttons in the same underline language as CaptionListPanel's
|
|
2348
|
+
* Style/Captions pair cost a row of height and nothing horizontally. Built
|
|
2349
|
+
* from the shared `TabNav` (`./panels/TabNav`) — the same underline strip
|
|
2350
|
+
* `ClipPropertiesPanel`'s own clip tabs use — rather than a hand-rolled one,
|
|
2351
|
+
* so every small in-panel tab switch in the editor speaks the same visual
|
|
2352
|
+
* and accessible language.
|
|
2353
|
+
*
|
|
2354
|
+
* `aria-pressed` rather than `role="tab"`, for CaptionListPanel's reason (and
|
|
2355
|
+
* `TabNav`'s own doc comment): the CapCut LEFT rail is a real tablist, and a
|
|
2356
|
+
* second set of tabs in the same tree makes `getByRole('tab', …)` ambiguous
|
|
2357
|
+
* in the host's own tests.
|
|
2358
|
+
*
|
|
2359
|
+
* With nothing (or a non-overlay) selected this falls through to
|
|
2360
|
+
* `OverlayInspector`'s own "Select an overlay…" empty state rather than a
|
|
2361
|
+
* bare tab strip over an empty pane — the classic rail renders this
|
|
2362
|
+
* unconditionally and relied on exactly that empty state before.
|
|
2363
|
+
*/
|
|
2364
|
+
const overlayPropertiesPanel = selectedOverlayItem ? (
|
|
2365
|
+
<>
|
|
2366
|
+
<TabNav
|
|
2367
|
+
tabs={OVERLAY_PANEL_TABS}
|
|
2368
|
+
value={overlayPanelTab}
|
|
2369
|
+
onChange={setOverlayPanelTab}
|
|
2370
|
+
ariaLabel="Overlay panel view"
|
|
2371
|
+
className="shrink-0 border-b border-[var(--editor-border)]"
|
|
2372
|
+
/>
|
|
2373
|
+
{overlayPanelTab === 'content' ? (
|
|
2374
|
+
<OverlayContentPanel
|
|
2375
|
+
item={selectedOverlayItem}
|
|
2376
|
+
onPreview={next => previewOverlayProps(selectedOverlayItem.id, next)}
|
|
2377
|
+
onCommit={commitOverlayEdit}
|
|
2378
|
+
fileUrl={adapter.fileUrl}
|
|
2379
|
+
uploadFile={file => adapter.uploadFile(file, project.id)}
|
|
2380
|
+
mode={timelineMode}
|
|
2381
|
+
/>
|
|
2382
|
+
) : (
|
|
2383
|
+
<OverlayInspector
|
|
2384
|
+
item={selectedOverlayItem}
|
|
2385
|
+
clock={clock}
|
|
2386
|
+
onPreview={previewOverlayInspectorChange}
|
|
2387
|
+
onCommit={commitOverlayInspectorChange}
|
|
2388
|
+
onChange={applyOverlayInspectorChange}
|
|
2389
|
+
onSeek={seekTo}
|
|
2390
|
+
/>
|
|
2391
|
+
)}
|
|
2392
|
+
</>
|
|
2393
|
+
) : (
|
|
2394
|
+
<OverlayInspector
|
|
2395
|
+
item={null}
|
|
2396
|
+
clock={clock}
|
|
2397
|
+
onPreview={previewOverlayInspectorChange}
|
|
2398
|
+
onCommit={commitOverlayInspectorChange}
|
|
2399
|
+
onChange={applyOverlayInspectorChange}
|
|
2400
|
+
onSeek={seekTo}
|
|
2401
|
+
/>
|
|
2402
|
+
)
|
|
2403
|
+
|
|
2404
|
+
// Right rail — CLASSIC LAYOUTS ONLY (Hub / LP). The CapCut layout replaces it
|
|
2405
|
+
// with the properties-only `propertiesPanel` below and moves this rail's
|
|
2406
|
+
// captions and versions into the left panel's tabs.
|
|
2407
|
+
// Version history + run history slot, the sidebar caption list,
|
|
2408
|
+
// and (in 'sidebar' placement) the assets panel stacked beneath them in the
|
|
2409
|
+
// SAME column (its own col-resize divider is
|
|
2410
|
+
// included). `!!project.captions || !!handleRegenerateCaptions` is a new
|
|
2411
|
+
// disjunct (SP5-captions Phase 5): CaptionListPanel now lives in this rail
|
|
2412
|
+
// instead of a bottom panel, so the rail must appear whenever THAT panel
|
|
2413
|
+
// has anything to offer — either existing captions, or (on a host that
|
|
2414
|
+
// supports `generateCaptions`) the means to create them from scratch. The
|
|
2415
|
+
// second half matters even with zero captions: the retired bottom
|
|
2416
|
+
// TranscriptPanel showed "Regenerate" whenever the host supported it,
|
|
2417
|
+
// regardless of caption count, and dropping that would make caption
|
|
2418
|
+
// generation unreachable from the editor on a bare project with no other
|
|
2419
|
+
// rail content.
|
|
2420
|
+
// `!!selectedOverlayItem` is a further disjunct (SP9b T3.2): the overlay
|
|
2421
|
+
// inspector below lives in this same rail, so the rail must appear whenever
|
|
2422
|
+
// an overlay is selected — even on a project with no versions, captions, or
|
|
2423
|
+
// assets to otherwise justify the rail's existence.
|
|
2424
|
+
const rightRail = (adapter.listVersionHistory || slots?.runHistory ||
|
|
2425
|
+
(assetsPlacement === 'sidebar' && slots?.assetsPanel) ||
|
|
2426
|
+
!!project.captions || !!handleRegenerateCaptions || !!selectedOverlayItem) && (
|
|
2427
|
+
<>
|
|
2428
|
+
{/* Vertical divider, the same affordance as the preview/timeline one. */}
|
|
2429
|
+
<div
|
|
2430
|
+
role="separator"
|
|
2431
|
+
aria-orientation="vertical"
|
|
2432
|
+
aria-label="Resize sidebar"
|
|
2433
|
+
onMouseDown={startRailDrag}
|
|
2434
|
+
onDoubleClick={() => setRailWidth(DEFAULT_RAIL_PX)}
|
|
2435
|
+
className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
|
|
2436
|
+
>
|
|
2437
|
+
<div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
|
|
2438
|
+
</div>
|
|
2439
|
+
|
|
2440
|
+
<div
|
|
2441
|
+
style={{ width: railWidth }}
|
|
2442
|
+
className="shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden"
|
|
2443
|
+
>
|
|
2444
|
+
|
|
2445
|
+
{/* SP9b T3.2 — the selected overlay's properties. Above VersionPanel:
|
|
2446
|
+
it's the thing the operator is actively editing,
|
|
2447
|
+
versions/captions/assets are reference material below it. Was the
|
|
2448
|
+
Transform inspector alone; it is the Content/Transform tab pair now
|
|
2449
|
+
(see `overlayPropertiesPanel`), because the floating dialog that
|
|
2450
|
+
used to carry Content was reachable from this layout too. Still
|
|
2451
|
+
renders its own "Select an overlay…" empty state when nothing, or a
|
|
2452
|
+
non-overlay, is selected. */}
|
|
2453
|
+
{overlayPropertiesPanel}
|
|
2454
|
+
|
|
2455
|
+
{versionPanel}
|
|
2456
|
+
|
|
2457
|
+
{/* Sidebar caption editor, directly below version history. Its own
|
|
2458
|
+
flex-1 wrapper so the list scrolls independently of the rest of
|
|
2459
|
+
the rail — mirrors the assetsPanel wrapper just below. Gated the
|
|
2460
|
+
same way that one is: nothing to show, nothing rendered, rather
|
|
2461
|
+
than an empty bordered box (CaptionListPanel has its own internal
|
|
2462
|
+
guard too, for callers that can't check this ahead of time). */}
|
|
2463
|
+
{(project.captions || handleRegenerateCaptions) && (
|
|
2464
|
+
<div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
|
|
2465
|
+
{captionListPanel}
|
|
2466
|
+
</div>
|
|
2467
|
+
)}
|
|
2468
|
+
{/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
|
|
2469
|
+
RunSnapshot / project.history are host-only types — the package never
|
|
2470
|
+
reads them. When absent nothing is rendered. */}
|
|
2471
|
+
{slots?.runHistory}
|
|
2472
|
+
{/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
|
|
2473
|
+
host's panel manages its own scroll; flex-1 lets it take the
|
|
2474
|
+
remaining rail height. A top border separates it from the runs. */}
|
|
2475
|
+
{assetsPlacement === 'sidebar' && slots?.assetsPanel && (
|
|
2476
|
+
<div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
|
|
2477
|
+
{slots.assetsPanel}
|
|
2478
|
+
</div>
|
|
2479
|
+
)}
|
|
2480
|
+
</div>
|
|
2481
|
+
</>
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
// Project media / assets — full-width region stacked BELOW the editor
|
|
2485
|
+
// (assetsPlacement: 'bottom'). Classic layouts only.
|
|
2486
|
+
const bottomAssets = assetsPlacement === 'bottom' && slots?.assetsPanel && (
|
|
2487
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col max-h-[45%] overflow-hidden">
|
|
2488
|
+
{slots.assetsPanel}
|
|
2489
|
+
</div>
|
|
2490
|
+
)
|
|
2491
|
+
|
|
2492
|
+
// Left panel (CapCut layout only): the editor's browser column — Media,
|
|
2493
|
+
// Captions and Versions behind a vertical icon rail — in a width-resizable
|
|
2494
|
+
// column, with a col-resize divider on its RIGHT edge. Captions and version
|
|
2495
|
+
// history used to stack into the right rail; in this layout that rail is
|
|
2496
|
+
// properties-only, so they live here now. Each tab is added only when it has
|
|
2497
|
+
// something to show, so the rail never offers a dead icon.
|
|
2498
|
+
const leftPanelTabs: LeftPanelTab[] = []
|
|
2499
|
+
if (slots?.mediaPanel) {
|
|
2500
|
+
leftPanelTabs.push({ id: 'media', label: 'Media', icon: <Film size={16} />, content: slots.mediaPanel })
|
|
2501
|
+
}
|
|
2502
|
+
// Same gate the classic rail uses for its caption section — existing
|
|
2503
|
+
// captions, or (on a host that supports `generateCaptions`) the means to
|
|
2504
|
+
// create them from scratch.
|
|
2505
|
+
if (project.captions || handleRegenerateCaptions) {
|
|
2506
|
+
leftPanelTabs.push({
|
|
2507
|
+
id: 'captions',
|
|
2508
|
+
label: 'Captions',
|
|
2509
|
+
icon: <Captions size={16} />,
|
|
2510
|
+
// The same flex-1 wrapper the rail gives it, minus the rail's top
|
|
2511
|
+
// border: inside a tab panel there is nothing above to divide from.
|
|
2512
|
+
content: <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{captionListPanel}</div>,
|
|
2513
|
+
})
|
|
2514
|
+
}
|
|
2515
|
+
if (adapter.listVersionHistory || slots?.runHistory) {
|
|
2516
|
+
leftPanelTabs.push({
|
|
2517
|
+
id: 'versions',
|
|
2518
|
+
label: 'Versions',
|
|
2519
|
+
icon: <History size={16} />,
|
|
2520
|
+
content: (
|
|
2521
|
+
<>
|
|
2522
|
+
{versionPanel}
|
|
2523
|
+
{/* The host's "Previous runs" list, directly beneath version history —
|
|
2524
|
+
the same adjacency it had in the rail. RunSnapshot / project.history
|
|
2525
|
+
are host-only types; the package never reads them. */}
|
|
2526
|
+
{slots?.runHistory}
|
|
2527
|
+
</>
|
|
2528
|
+
),
|
|
2529
|
+
})
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const leftPanel = (
|
|
2533
|
+
<>
|
|
2534
|
+
<div
|
|
2535
|
+
style={{ width: mediaPanelWidth }}
|
|
2536
|
+
className="shrink-0 border-r border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden min-h-0"
|
|
2537
|
+
>
|
|
2538
|
+
<LeftPanelTabs tabs={leftPanelTabs} defaultTabId="captions" activationRequest={{ id: 'captions', nonce: captionTabNonce }} className="flex-1 min-h-0" />
|
|
2539
|
+
</div>
|
|
2540
|
+
{/* Divider on the left panel's RIGHT edge — drag right widens the column.
|
|
2541
|
+
Kept under its original "Resize media panel" name: it is the same
|
|
2542
|
+
affordance on the same persisted width, and renaming it would break
|
|
2543
|
+
every host/test that reaches for it. */}
|
|
2544
|
+
<div
|
|
2545
|
+
role="separator"
|
|
2546
|
+
aria-orientation="vertical"
|
|
2547
|
+
aria-label="Resize media panel"
|
|
2548
|
+
onMouseDown={startMediaPanelDrag}
|
|
2549
|
+
onDoubleClick={() => setMediaPanelWidth(DEFAULT_MEDIA_PANEL_PX)}
|
|
2550
|
+
className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
|
|
2551
|
+
>
|
|
2552
|
+
<div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
|
|
2553
|
+
</div>
|
|
2554
|
+
</>
|
|
2555
|
+
)
|
|
2556
|
+
|
|
2557
|
+
// Right properties panel (CapCut layout only) — the contextual inspector for
|
|
2558
|
+
// whatever is selected, in place of the classic stacked rail.
|
|
2559
|
+
//
|
|
2560
|
+
// ALWAYS rendered, never gated on the selection (Sam): a column that came and
|
|
2561
|
+
// went would resize the preview every time the operator clicked from a clip
|
|
2562
|
+
// to empty space, so it holds its width and shows an empty state instead.
|
|
2563
|
+
// Three branches: a selected clip → the tabbed `ClipPropertiesPanel`, whose
|
|
2564
|
+
// Transform tab is the SAME `OverlayInspector` instance the overlay branch
|
|
2565
|
+
// below uses (passed in as `transformSlot`, wired to the identical
|
|
2566
|
+
// preview/commit/change trio) — a clip and an overlay's geometry controls
|
|
2567
|
+
// are one component either way, just reached through a different tab shell;
|
|
2568
|
+
// a selected audio track → the same `ClipPropertiesPanel`, which renders its
|
|
2569
|
+
// untabbed `AudioSection` instead (audio has no Transform/Speed/Crop, so
|
|
2570
|
+
// there is nothing to tab between); a selected overlay → the Content/
|
|
2571
|
+
// Transform pair (`overlayPropertiesPanel`); nothing selected → a centered
|
|
2572
|
+
// empty state, the host's `slots.propertiesEmptyState` when supplied (Montaj
|
|
2573
|
+
// brands it with its logo) or the generic "Select an element" default
|
|
2574
|
+
// otherwise.
|
|
2575
|
+
const propertiesPanel = (
|
|
2576
|
+
<>
|
|
2577
|
+
{/* Vertical divider, the same affordance as the preview/timeline one. */}
|
|
2578
|
+
<div
|
|
2579
|
+
role="separator"
|
|
2580
|
+
aria-orientation="vertical"
|
|
2581
|
+
aria-label="Resize sidebar"
|
|
2582
|
+
onMouseDown={startRailDrag}
|
|
2583
|
+
onDoubleClick={() => setRailWidth(DEFAULT_RAIL_PX)}
|
|
2584
|
+
className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
|
|
2585
|
+
>
|
|
2586
|
+
<div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
|
|
2587
|
+
</div>
|
|
2588
|
+
|
|
2589
|
+
<div
|
|
2590
|
+
style={{ width: railWidth }}
|
|
2591
|
+
className="shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden"
|
|
2592
|
+
>
|
|
2593
|
+
{/* Scrolls as one: both panels are stacks of shrink-0 sections, and the
|
|
2594
|
+
audio track's fades/ducking groups (or a tall generationSlot) run
|
|
2595
|
+
past the bottom of a short window. */}
|
|
2596
|
+
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
|
2597
|
+
{clipSelection ? (
|
|
2598
|
+
<ClipPropertiesPanel
|
|
2599
|
+
selection={clipSelection}
|
|
2600
|
+
onPreviewClip={previewClipChange}
|
|
2601
|
+
onCommitClip={commitClipChange}
|
|
2602
|
+
onChangeClip={applyClipChange}
|
|
2603
|
+
onPreviewAudio={previewAudioChange}
|
|
2604
|
+
onCommitAudio={commitAudioChange}
|
|
2605
|
+
onChangeAudio={applyAudioChange}
|
|
2606
|
+
// Transform tab body: the SAME OverlayInspector instance (and the
|
|
2607
|
+
// same preview/commit/change trio) the overlay branch below uses —
|
|
2608
|
+
// a clip's geometry is edited by the identical control, just
|
|
2609
|
+
// reached through this panel's tab shell instead of the overlay
|
|
2610
|
+
// Content/Transform pair. Undefined for an audio-track selection
|
|
2611
|
+
// (audio has no geometry to transform), which also means
|
|
2612
|
+
// ClipPropertiesPanel offers no Transform tab for it.
|
|
2613
|
+
transformSlot={
|
|
2614
|
+
clipSelection.kind === 'clip' ? (
|
|
2615
|
+
<OverlayInspector
|
|
2616
|
+
item={clipSelection.item}
|
|
2617
|
+
clock={clock}
|
|
2618
|
+
onPreview={previewOverlayInspectorChange}
|
|
2619
|
+
onCommit={commitOverlayInspectorChange}
|
|
2620
|
+
onChange={applyOverlayInspectorChange}
|
|
2621
|
+
onSeek={seekTo}
|
|
2622
|
+
/>
|
|
2623
|
+
) : undefined
|
|
2624
|
+
}
|
|
2625
|
+
// Crop tab: offered only when the selected clip IS the source-crop
|
|
2626
|
+
// target — `cropTarget` (above) already encodes that rule (tracks[0]
|
|
2627
|
+
// video with a src), so this just checks the current clip against
|
|
2628
|
+
// it rather than re-deriving the condition. Unlike the toolbar
|
|
2629
|
+
// button (above), which is an honest toggle, this is a one-way
|
|
2630
|
+
// ENTER: the tab body stays mounted once opened, so a toggle here
|
|
2631
|
+
// would silently exit crop mode on a second click with no
|
|
2632
|
+
// feedback from the button itself (no aria-pressed, no label
|
|
2633
|
+
// change). Exiting stays reachable through the toolbar button and
|
|
2634
|
+
// the crop modal's own close path.
|
|
2635
|
+
onOpenCrop={
|
|
2636
|
+
clipSelection.kind === 'clip' && cropTarget?.id === clipSelection.item.id
|
|
2637
|
+
? () => setCropMode(true)
|
|
2638
|
+
: undefined
|
|
2639
|
+
}
|
|
2640
|
+
// The host's generation panel is per-CLIP — it draws that clip's
|
|
2641
|
+
// prompt/model/refImages — so the editor, which owns the
|
|
2642
|
+
// selection, resolves the node here rather than taking a static
|
|
2643
|
+
// one the host would have to track selection to build. Gated on
|
|
2644
|
+
// the clip actually being a regenerable generation (project has
|
|
2645
|
+
// regen enabled AND the clip still carries its frozen generation
|
|
2646
|
+
// provenance), not merely on being a video: `ClipTabs` shows a
|
|
2647
|
+
// Generate tab whenever this slot is defined, so a bare
|
|
2648
|
+
// video-type check would put a dead, empty tab on every ordinary
|
|
2649
|
+
// video clip in every non-ai_video project.
|
|
2650
|
+
// KEYED ON THE CLIP ID, and that key is load-bearing. The host's
|
|
2651
|
+
// panel seeds its regen form (prompt, model, duration, ref
|
|
2652
|
+
// images) from the clip in `useState` initializers, which run
|
|
2653
|
+
// ONCE per mount. Selecting a different video clip keeps
|
|
2654
|
+
// `clipSelection.kind === 'clip'`, so without a key React
|
|
2655
|
+
// reconciles this subtree IN PLACE and the form keeps the
|
|
2656
|
+
// PREVIOUS clip's content while `clipId` points at the new one —
|
|
2657
|
+
// queueing a regeneration that spends credits rendering the
|
|
2658
|
+
// wrong clip's prompt. The retired modal never hit this because
|
|
2659
|
+
// it remounted on every double-click. Keying here rather than in
|
|
2660
|
+
// the host means every host gets that guarantee from the seam
|
|
2661
|
+
// itself instead of having to know about it.
|
|
2662
|
+
generationSlot={
|
|
2663
|
+
clipSelection.kind === 'clip'
|
|
2664
|
+
&& clipSelection.item.type === 'video'
|
|
2665
|
+
&& regenEnabled
|
|
2666
|
+
&& clipSelection.item.generation
|
|
2667
|
+
&& renderGenerationPanel
|
|
2668
|
+
? <Fragment key={clipSelection.item.id}>{renderGenerationPanel({ clipId: clipSelection.item.id })}</Fragment>
|
|
2669
|
+
: undefined
|
|
2670
|
+
}
|
|
2671
|
+
mode={timelineMode}
|
|
762
2672
|
/>
|
|
763
|
-
)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
2673
|
+
) : selectedOverlayItem ? (
|
|
2674
|
+
/* The selected overlay's Content/Transform tabs — the SAME node the
|
|
2675
|
+
classic right rail renders (see `overlayPropertiesPanel`), so the
|
|
2676
|
+
two layouts can never drift apart on which tab is showing or what
|
|
2677
|
+
either one edits. */
|
|
2678
|
+
overlayPropertiesPanel
|
|
2679
|
+
) : (
|
|
2680
|
+
/* Nothing selected: the host's branded empty state, or the
|
|
2681
|
+
package's generic centered default. */
|
|
2682
|
+
slots?.propertiesEmptyState ?? (
|
|
2683
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
|
|
2684
|
+
<SquareDashedMousePointer size={26} className="text-[var(--editor-text)]/25" />
|
|
2685
|
+
<p className="text-xs font-medium text-[var(--editor-text)]/60">Select an element</p>
|
|
2686
|
+
<p className="text-[11px] text-[var(--editor-text)]/40">Choose a clip, overlay, or track to edit its properties.</p>
|
|
2687
|
+
</div>
|
|
2688
|
+
)
|
|
773
2689
|
)}
|
|
774
2690
|
</div>
|
|
2691
|
+
</div>
|
|
2692
|
+
</>
|
|
2693
|
+
)
|
|
775
2694
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
isClipQueued={isClipQueued}
|
|
799
|
-
renderSubcutRegen={renderSubcutRegen}
|
|
800
|
-
onRegenerateCaptions={adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined}
|
|
801
|
-
/>
|
|
2695
|
+
return (
|
|
2696
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
2697
|
+
{slots?.mediaPanel ? (
|
|
2698
|
+
/* CapCut layout (opt-in via slots.mediaPanel): three columns across the
|
|
2699
|
+
top — [left panel | preview | properties] — with a full-width timeline
|
|
2700
|
+
strip below. `splitRef` wraps the WHOLE top-row + timeline region so the
|
|
2701
|
+
row divider trades height between them (MIN_PREVIEW_PANE_PX now guards
|
|
2702
|
+
the entire top row); `workAreaRef` is the top row, so the rail/media
|
|
2703
|
+
width drags still measure against the space those three columns share.
|
|
2704
|
+
`previewRegion` / `timelinePane` / `rowDivider` are the exact same nodes
|
|
2705
|
+
the classic layout renders. The two side columns are NOT shared: this
|
|
2706
|
+
layout browses (media/captions/versions) on the left and inspects the
|
|
2707
|
+
selection on the right, where the classic layout stacks all of it into
|
|
2708
|
+
one right rail. */
|
|
2709
|
+
<div ref={splitRef} className="flex flex-col flex-1 overflow-hidden min-h-0">
|
|
2710
|
+
<div ref={workAreaRef} className="flex flex-1 overflow-hidden min-h-0">
|
|
2711
|
+
{leftPanel}
|
|
2712
|
+
{previewRegion}
|
|
2713
|
+
{propertiesPanel}
|
|
2714
|
+
</div>
|
|
2715
|
+
{rowDivider}
|
|
2716
|
+
{timelinePane}
|
|
802
2717
|
</div>
|
|
2718
|
+
) : (
|
|
2719
|
+
/* Classic layout — byte-for-byte the pre-media-panel editor (Hub / LP). */
|
|
2720
|
+
<>
|
|
2721
|
+
{/* Work area — editor body + version rail, side by side */}
|
|
2722
|
+
<div ref={workAreaRef} className="flex flex-1 overflow-hidden min-h-0">
|
|
2723
|
+
{/* Main: preview + timeline, split by a draggable divider. The preview
|
|
2724
|
+
takes whatever the timeline pane does not — so dragging the divider up
|
|
2725
|
+
trades preview area for timeline area and vice versa. */}
|
|
2726
|
+
<div ref={splitRef} className="flex flex-col flex-1 overflow-hidden">
|
|
2727
|
+
{previewRegion}
|
|
2728
|
+
|
|
2729
|
+
{rowDivider}
|
|
2730
|
+
|
|
2731
|
+
{timelinePane}
|
|
803
2732
|
</div>
|
|
804
2733
|
|
|
805
2734
|
{/* Assets — dedicated separate column to the LEFT of the version rail
|
|
806
2735
|
(assetsPlacement: 'right', two distinct columns). The Montaj-local OS
|
|
807
2736
|
layout uses 'sidebar' instead (stacked into the version rail below).
|
|
808
2737
|
The host's panel manages its own scroll. */}
|
|
809
|
-
{
|
|
810
|
-
<div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
811
|
-
{slots.assetsPanel}
|
|
812
|
-
</div>
|
|
813
|
-
)}
|
|
2738
|
+
{assetsColumn}
|
|
814
2739
|
|
|
815
2740
|
{/* Right rail — version history + run history slot, and (in 'sidebar'
|
|
816
2741
|
placement) the assets panel stacked beneath them in the SAME column.
|
|
817
2742
|
This is the historical Montaj-local OS layout: versions on top, assets
|
|
818
2743
|
right below, one column — not a separate assets column. */}
|
|
819
|
-
{
|
|
820
|
-
(assetsPlacement === 'sidebar' && slots?.assetsPanel)) && (
|
|
821
|
-
<div className={`${assetsPlacement === 'sidebar' ? 'w-56' : 'w-48'} shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden`}>
|
|
822
|
-
{adapter.listVersionHistory && (
|
|
823
|
-
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
824
|
-
)}
|
|
825
|
-
{/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
|
|
826
|
-
RunSnapshot / project.history are host-only types — the package never
|
|
827
|
-
reads them. When absent nothing is rendered. */}
|
|
828
|
-
{slots?.runHistory}
|
|
829
|
-
{/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
|
|
830
|
-
host's panel manages its own scroll; flex-1 lets it take the
|
|
831
|
-
remaining rail height. A top border separates it from the runs. */}
|
|
832
|
-
{assetsPlacement === 'sidebar' && slots?.assetsPanel && (
|
|
833
|
-
<div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
|
|
834
|
-
{slots.assetsPanel}
|
|
835
|
-
</div>
|
|
836
|
-
)}
|
|
837
|
-
</div>
|
|
838
|
-
)}
|
|
2744
|
+
{rightRail}
|
|
839
2745
|
</div>
|
|
840
2746
|
|
|
841
2747
|
{/* Project media / assets — full-width region stacked BELOW the editor
|
|
842
2748
|
(assetsPlacement: 'bottom'). Preferred by width-constrained hosts (Hub).
|
|
843
2749
|
The host's panel manages its own scroll. */}
|
|
844
|
-
{
|
|
845
|
-
|
|
846
|
-
{slots.assetsPanel}
|
|
847
|
-
</div>
|
|
2750
|
+
{bottomAssets}
|
|
2751
|
+
</>
|
|
848
2752
|
)}
|
|
849
2753
|
|
|
850
2754
|
{/* Source-crop modal — drag-to-pan, aspect presets, zoom. Commits
|
|
@@ -881,6 +2785,18 @@ function ReviewSurface<P extends Project>({
|
|
|
881
2785
|
/>
|
|
882
2786
|
)}
|
|
883
2787
|
|
|
2788
|
+
{/* Command palette — Cmd/Ctrl+K, or the scrubber's time-readout click
|
|
2789
|
+
(opens straight into "go to time"). */}
|
|
2790
|
+
{paletteOpen && (
|
|
2791
|
+
<CommandPalette
|
|
2792
|
+
commands={paletteCommands}
|
|
2793
|
+
initialMode={paletteOpen === 'goto' ? 'goto' : 'list'}
|
|
2794
|
+
onGoToTime={(seconds) => clock.set(Math.max(0, Math.min(getTotalDuration(), seconds)))}
|
|
2795
|
+
onClose={closePalette}
|
|
2796
|
+
themeMode={timelineMode}
|
|
2797
|
+
/>
|
|
2798
|
+
)}
|
|
2799
|
+
|
|
884
2800
|
{/* Render modal — adapter.render stream + host export controls */}
|
|
885
2801
|
{renderOpen && (
|
|
886
2802
|
<RenderModal
|
|
@@ -888,8 +2804,30 @@ function ReviewSurface<P extends Project>({
|
|
|
888
2804
|
adapter={adapter}
|
|
889
2805
|
exportActions={slots?.exportActions}
|
|
890
2806
|
progressView={renderProgressView}
|
|
2807
|
+
preRenderOptions={preRenderOptions}
|
|
891
2808
|
onClose={() => setRenderOpen(false)}
|
|
892
2809
|
onCancel={() => setRenderOpen(false)}
|
|
2810
|
+
onRenderComplete={() => { void refreshVersions() }}
|
|
2811
|
+
mode={timelineMode}
|
|
2812
|
+
/>
|
|
2813
|
+
)}
|
|
2814
|
+
|
|
2815
|
+
{/* Visual A/B version compare — opened from a VersionPanel entry's
|
|
2816
|
+
Compare button. Gated on the adapter capability so a host without
|
|
2817
|
+
`versionFrameUrl` never sees an unusable Compare affordance's modal.
|
|
2818
|
+
`durationSeconds` reuses `preRenderOptions.durationSec` — the same
|
|
2819
|
+
"video spine's last frame" figure RenderModal's export dialog
|
|
2820
|
+
already computes, so the scrub slider covers the real project
|
|
2821
|
+
length without a second duration pass. */}
|
|
2822
|
+
{compareOpen != null && adapter.versionFrameUrl && (
|
|
2823
|
+
<VersionCompare
|
|
2824
|
+
projectId={project.id}
|
|
2825
|
+
versions={listVersions(versions)}
|
|
2826
|
+
initialLeftHash={compareOpen}
|
|
2827
|
+
frameUrl={adapter.versionFrameUrl}
|
|
2828
|
+
durationSeconds={preRenderOptions.durationSec}
|
|
2829
|
+
onClose={() => setCompareOpen(null)}
|
|
2830
|
+
mode={timelineMode}
|
|
893
2831
|
/>
|
|
894
2832
|
)}
|
|
895
2833
|
|
|
@@ -898,38 +2836,51 @@ function ReviewSurface<P extends Project>({
|
|
|
898
2836
|
montaj persists the regenerated captions server-side and the SSE frame
|
|
899
2837
|
reconciles, so a saveProject here would double-write. applyExternal keeps
|
|
900
2838
|
it out of the undo stack (server-authored, not a user edit). */}
|
|
901
|
-
{regenCaptionsOpen && adapter.generateCaptions && (
|
|
2839
|
+
{regenCaptionsOpen && adapter.generateCaptions && !onRegenerateCaptions && (
|
|
902
2840
|
<CaptionRegenModal
|
|
903
2841
|
adapter={adapter}
|
|
904
2842
|
projectId={project.id}
|
|
2843
|
+
existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
|
|
905
2844
|
onClose={() => setRegenCaptionsOpen(false)}
|
|
906
2845
|
onDone={(captions) => {
|
|
907
2846
|
sync.applyExternal({ ...syncProjectRef.current, captions } as P)
|
|
908
2847
|
setRegenCaptionsOpen(false)
|
|
909
2848
|
}}
|
|
2849
|
+
mode={timelineMode}
|
|
910
2850
|
/>
|
|
911
2851
|
)}
|
|
912
2852
|
|
|
913
|
-
{/*
|
|
914
|
-
|
|
915
|
-
the
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
2853
|
+
{/* Audio polish modal — owns its own transient/commit/discard cycle via
|
|
2854
|
+
`sync`'s three primitives, passed individually (not as a `sync` object:
|
|
2855
|
+
that's the modal's own prop contract). `project={sync.project}` sources
|
|
2856
|
+
the modal's baseline snapshot AND its watch for an external (SSE) frame
|
|
2857
|
+
landing over the preview mid-review; it cannot be re-derived from a prop
|
|
2858
|
+
that changes as drafts are pushed. */}
|
|
2859
|
+
{polishOpen && adapter.analyzeAudioPolish && (
|
|
2860
|
+
<AudioPolishModal
|
|
2861
|
+
projectId={project.id}
|
|
2862
|
+
adapter={adapter}
|
|
2863
|
+
project={sync.project}
|
|
2864
|
+
selectionIds={selectedIds}
|
|
2865
|
+
mutateTransient={sync.mutateTransient}
|
|
2866
|
+
commit={sync.commit}
|
|
2867
|
+
discardTransient={sync.discardTransient}
|
|
2868
|
+
onClose={() => setPolishOpen(false)}
|
|
2869
|
+
mode={timelineMode}
|
|
925
2870
|
/>
|
|
926
2871
|
)}
|
|
927
2872
|
|
|
928
|
-
{/* Clip / audio inspector — host-rendered via render-prop seam. */}
|
|
929
|
-
{inspecting && renderClipInspector?.({
|
|
930
|
-
item: inspecting,
|
|
931
|
-
onClose: () => setInspecting(null),
|
|
932
|
-
})}
|
|
933
2873
|
</div>
|
|
934
2874
|
)
|
|
935
2875
|
}
|
|
2876
|
+
|
|
2877
|
+
// CaptionListPanel displays the active-segment highlight, so it genuinely
|
|
2878
|
+
// needs to re-render every tick. Subscribing HERE (rather than inside
|
|
2879
|
+
// ReviewSurface) keeps that per-tick re-render scoped to this leaf instead of
|
|
2880
|
+
// the whole review surface (toolbar + timeline + every context consumer) —
|
|
2881
|
+
// the same reasoning, and the same shape, as Timeline.tsx's
|
|
2882
|
+
// `TranscriptPanelWithClock` for the now-retired bottom transcript panel.
|
|
2883
|
+
function CaptionListPanelWithClock({ clock, ...rest }: Omit<CaptionListPanelProps, 'currentTime'>) {
|
|
2884
|
+
const currentTime = usePlaybackTime(clock)
|
|
2885
|
+
return <CaptionListPanel currentTime={currentTime} clock={clock} {...rest} />
|
|
2886
|
+
}
|