@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -1,22 +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'
|
|
38
|
+
import ImageToneMenu from './ImageToneMenu'
|
|
39
|
+
import type { ImageTone } from './imageTone'
|
|
18
40
|
import CaptionRegenModal from './CaptionRegenModal'
|
|
19
|
-
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
|
+
]
|
|
20
101
|
|
|
21
102
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
22
103
|
// own `Project`). Montaj passes its richer Project; the index signature on
|
|
@@ -91,11 +172,18 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
91
172
|
onBackToSetup,
|
|
92
173
|
assetsPlacement = 'right',
|
|
93
174
|
renderProgressView = 'phases',
|
|
94
|
-
|
|
175
|
+
renderGenerationPanel,
|
|
95
176
|
renderSubcutRegen,
|
|
96
177
|
regenEnabled,
|
|
97
178
|
isClipQueued,
|
|
98
179
|
onProvideRenderTrigger,
|
|
180
|
+
onProvideImageTone,
|
|
181
|
+
engine,
|
|
182
|
+
sourcePreview,
|
|
183
|
+
onRegenerateCaptions,
|
|
184
|
+
captionsGenerating,
|
|
185
|
+
onImportFilesToTimeline,
|
|
186
|
+
pendingDrops,
|
|
99
187
|
}: Props<P>) {
|
|
100
188
|
const emit = onProjectChange ?? (() => {})
|
|
101
189
|
|
|
@@ -106,6 +194,22 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
106
194
|
// frames itself (video-shaped → default plain-replace reconcile).
|
|
107
195
|
const sync = useProjectSync<P>(adapter, project.id, project)
|
|
108
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
|
+
|
|
109
213
|
// Every caption segment needs a stable `id` for selection (preview drag,
|
|
110
214
|
// clickable timeline row). Segments saved before `id` existed on the schema
|
|
111
215
|
// are missing it, and `steps/lyrics/caption.py` still writes segments without
|
|
@@ -130,13 +234,41 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
130
234
|
// therefore re-fires this effect exactly once) finds nothing to do and stops.
|
|
131
235
|
// Every other re-fire — one per caption edit — is a cheap `.every()` no-op.
|
|
132
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
|
+
//
|
|
133
256
|
// `applyExternal` — no save, no undo push: this is normalization of loaded
|
|
134
257
|
// data, not a user edit, so it must not dirty the project or contend with the
|
|
135
|
-
// undo stack; the ids persist naturally the next time the operator
|
|
136
|
-
// real edit.
|
|
258
|
+
// undo stack; the ids and lanes persist naturally the next time the operator
|
|
259
|
+
// makes a real edit.
|
|
137
260
|
useEffect(() => {
|
|
138
261
|
const backfilled = backfillCaptionIds(sync.project)
|
|
139
|
-
|
|
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)
|
|
140
272
|
}, [sync.project.id, sync.project.captions])
|
|
141
273
|
|
|
142
274
|
// Notify the host of every authoritative change — edits, undo/redo, and SSE
|
|
@@ -155,11 +287,27 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
155
287
|
if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
|
|
156
288
|
}, [theme])
|
|
157
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
|
+
|
|
158
303
|
const isPending = sync.project.status === 'pending'
|
|
159
304
|
|
|
160
305
|
// ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
|
|
161
|
-
const getWaveformChunks = adapter.getWaveformChunks
|
|
162
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
|
|
163
311
|
|
|
164
312
|
if (isPending) {
|
|
165
313
|
return (
|
|
@@ -169,29 +317,42 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
169
317
|
adapter={adapter}
|
|
170
318
|
slots={slots}
|
|
171
319
|
onBackToSetup={onBackToSetup}
|
|
172
|
-
getWaveformChunks={getWaveformChunks}
|
|
173
320
|
resolveFilePath={resolveFilePath}
|
|
321
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
322
|
+
getFilmstrip={getFilmstrip}
|
|
323
|
+
engine={engine}
|
|
324
|
+
timelineMode={timelineMode}
|
|
174
325
|
/>
|
|
175
326
|
</div>
|
|
176
327
|
)
|
|
177
328
|
}
|
|
178
329
|
|
|
179
330
|
return (
|
|
180
|
-
<div ref={containerRef} className="flex flex-col h-full">
|
|
331
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
181
332
|
<ReviewSurface
|
|
182
333
|
sync={sync}
|
|
334
|
+
captionGestureRef={captionGestureRef}
|
|
183
335
|
emit={emit}
|
|
184
336
|
adapter={adapter}
|
|
185
337
|
slots={slots}
|
|
186
338
|
assetsPlacement={assetsPlacement}
|
|
187
339
|
renderProgressView={renderProgressView}
|
|
188
|
-
getWaveformChunks={getWaveformChunks}
|
|
189
340
|
resolveFilePath={resolveFilePath}
|
|
190
|
-
|
|
341
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
342
|
+
getFilmstrip={getFilmstrip}
|
|
343
|
+
renderGenerationPanel={renderGenerationPanel}
|
|
191
344
|
renderSubcutRegen={renderSubcutRegen}
|
|
192
345
|
regenEnabled={regenEnabled}
|
|
193
346
|
isClipQueued={isClipQueued}
|
|
194
347
|
onProvideRenderTrigger={onProvideRenderTrigger}
|
|
348
|
+
onProvideImageTone={onProvideImageTone}
|
|
349
|
+
engine={engine}
|
|
350
|
+
sourcePreview={sourcePreview}
|
|
351
|
+
onRegenerateCaptions={onRegenerateCaptions}
|
|
352
|
+
captionsGenerating={captionsGenerating}
|
|
353
|
+
onImportFilesToTimeline={onImportFilesToTimeline}
|
|
354
|
+
pendingDrops={pendingDrops}
|
|
355
|
+
timelineMode={timelineMode}
|
|
195
356
|
/>
|
|
196
357
|
</div>
|
|
197
358
|
)
|
|
@@ -202,12 +363,92 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
202
363
|
function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adapter'], project: P) {
|
|
203
364
|
const [versions, setVersions] = useState<{ hash: string; message: string; timestamp: string }[]>([])
|
|
204
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])
|
|
205
374
|
|
|
206
375
|
useEffect(() => {
|
|
207
|
-
|
|
208
|
-
}, [
|
|
376
|
+
void fetchVersions()
|
|
377
|
+
}, [fetchVersions, project.status])
|
|
209
378
|
|
|
210
|
-
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
|
+
)
|
|
211
452
|
}
|
|
212
453
|
|
|
213
454
|
// ── Pending / processing surface (former LiveView) ───────────────────────────
|
|
@@ -218,9 +459,16 @@ interface SurfaceProps<P extends Project> {
|
|
|
218
459
|
slots?: VideoEditorProps<P>['slots']
|
|
219
460
|
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
220
461
|
renderProgressView?: VideoEditorProps<P>['renderProgressView']
|
|
221
|
-
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
222
462
|
resolveFilePath: (path: string) => string
|
|
463
|
+
getWaveformPeaks?: VideoEditorProps<P>['adapter']['getWaveformPeaks']
|
|
464
|
+
getFilmstrip?: VideoEditorProps<P>['adapter']['getFilmstrip']
|
|
223
465
|
onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
|
|
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
|
|
224
472
|
}
|
|
225
473
|
|
|
226
474
|
function PendingSurface<P extends Project>({
|
|
@@ -228,8 +476,11 @@ function PendingSurface<P extends Project>({
|
|
|
228
476
|
adapter,
|
|
229
477
|
slots,
|
|
230
478
|
onBackToSetup,
|
|
231
|
-
getWaveformChunks,
|
|
232
479
|
resolveFilePath,
|
|
480
|
+
getWaveformPeaks,
|
|
481
|
+
getFilmstrip,
|
|
482
|
+
engine,
|
|
483
|
+
timelineMode,
|
|
233
484
|
}: SurfaceProps<P> & { onBackToSetup?: () => void }) {
|
|
234
485
|
const project = sync.project
|
|
235
486
|
// The playhead lives in an external store (not useState) so ~60Hz ticks only
|
|
@@ -239,13 +490,17 @@ function PendingSurface<P extends Project>({
|
|
|
239
490
|
const clock = clockRef.current
|
|
240
491
|
const [skillPath, setSkillPath] = useState<string | null>(null)
|
|
241
492
|
const [copied, setCopied] = useState(false)
|
|
242
|
-
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)
|
|
243
498
|
|
|
244
499
|
useEffect(() => {
|
|
245
500
|
adapter.getInfo?.().then(info => setSkillPath(info.root_skill_path ?? null)).catch(() => {})
|
|
246
501
|
}, [adapter])
|
|
247
502
|
|
|
248
|
-
const clips = project
|
|
503
|
+
const clips = trackItems(project)[0] ?? []
|
|
249
504
|
const hasTrimmedClips = clips.some(c => c.inPoint !== undefined && c.outPoint !== undefined)
|
|
250
505
|
// The back-to-setup affordance is gated on the host supplying it AND the
|
|
251
506
|
// project being safe to discard (no manual trims yet). Mirrors LiveView's
|
|
@@ -263,9 +518,27 @@ function PendingSurface<P extends Project>({
|
|
|
263
518
|
console.error(e)
|
|
264
519
|
} finally {
|
|
265
520
|
setRestoring(null)
|
|
521
|
+
await refreshVersions()
|
|
266
522
|
}
|
|
267
523
|
}
|
|
268
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
|
+
|
|
269
542
|
return (
|
|
270
543
|
<div className="flex flex-1 overflow-hidden">
|
|
271
544
|
{/* Main */}
|
|
@@ -280,6 +553,7 @@ function PendingSurface<P extends Project>({
|
|
|
280
553
|
watchFile={adapter.watchFile}
|
|
281
554
|
fileUrl={adapter.fileUrl}
|
|
282
555
|
resolveCaptionTemplate={adapter.resolveCaptionTemplate}
|
|
556
|
+
engine={engine}
|
|
283
557
|
/>
|
|
284
558
|
) : (
|
|
285
559
|
<div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
|
|
@@ -293,8 +567,28 @@ function PendingSurface<P extends Project>({
|
|
|
293
567
|
</div>
|
|
294
568
|
{skillPath && (
|
|
295
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">
|
|
296
|
-
|
|
297
|
-
|
|
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">
|
|
298
592
|
<span className="text-gray-200 text-[12px] leading-relaxed break-all">
|
|
299
593
|
There is a new project pending: "{project.name ?? project.id}". Please see @{skillPath} and start. Talk to me if you run into questions.
|
|
300
594
|
</span>
|
|
@@ -307,7 +601,7 @@ function PendingSurface<P extends Project>({
|
|
|
307
601
|
setTimeout(() => setCopied(false), 2000)
|
|
308
602
|
}}
|
|
309
603
|
className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
|
|
310
|
-
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'
|
|
311
605
|
}`}
|
|
312
606
|
title="Copy prompt"
|
|
313
607
|
>
|
|
@@ -318,11 +612,11 @@ function PendingSurface<P extends Project>({
|
|
|
318
612
|
)}
|
|
319
613
|
</>
|
|
320
614
|
)}
|
|
321
|
-
<p className="text-
|
|
615
|
+
<p className="text-gray-100/40 text-xs font-mono">project id: {project.id}</p>
|
|
322
616
|
{canGoBack && (
|
|
323
617
|
<button
|
|
324
618
|
onClick={onBackToSetup}
|
|
325
|
-
className="text-xs text-
|
|
619
|
+
className="text-xs text-gray-100/60 hover:text-gray-100 transition-colors underline underline-offset-2"
|
|
326
620
|
>
|
|
327
621
|
← Back to setup
|
|
328
622
|
</button>
|
|
@@ -335,9 +629,10 @@ function PendingSurface<P extends Project>({
|
|
|
335
629
|
<Timeline
|
|
336
630
|
project={project}
|
|
337
631
|
clock={clock}
|
|
338
|
-
getWaveformChunks={getWaveformChunks}
|
|
339
632
|
resolveFilePath={resolveFilePath}
|
|
340
|
-
|
|
633
|
+
getWaveformPeaks={getWaveformPeaks}
|
|
634
|
+
getFilmstrip={getFilmstrip}
|
|
635
|
+
mode={timelineMode}
|
|
341
636
|
/>
|
|
342
637
|
</div>
|
|
343
638
|
</div>
|
|
@@ -345,35 +640,84 @@ function PendingSurface<P extends Project>({
|
|
|
345
640
|
{/* Right sidebar — version history (hidden when the capability is absent) */}
|
|
346
641
|
{adapter.listVersionHistory && (
|
|
347
642
|
<div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
348
|
-
<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} />
|
|
349
644
|
</div>
|
|
350
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
|
+
)}
|
|
351
660
|
</div>
|
|
352
661
|
)
|
|
353
662
|
}
|
|
354
663
|
|
|
355
664
|
// ── Draft / final surface (former ReviewView) ────────────────────────────────
|
|
356
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
|
+
|
|
357
678
|
function ReviewSurface<P extends Project>({
|
|
358
679
|
sync,
|
|
680
|
+
captionGestureRef,
|
|
359
681
|
emit,
|
|
360
682
|
adapter,
|
|
361
683
|
slots,
|
|
362
684
|
assetsPlacement = 'right',
|
|
363
685
|
renderProgressView = 'phases',
|
|
364
|
-
getWaveformChunks,
|
|
365
686
|
resolveFilePath,
|
|
366
|
-
|
|
687
|
+
getWaveformPeaks,
|
|
688
|
+
getFilmstrip,
|
|
689
|
+
renderGenerationPanel,
|
|
367
690
|
renderSubcutRegen,
|
|
368
691
|
regenEnabled,
|
|
369
692
|
isClipQueued,
|
|
370
693
|
onProvideRenderTrigger,
|
|
694
|
+
onProvideImageTone,
|
|
695
|
+
engine,
|
|
696
|
+
sourcePreview,
|
|
697
|
+
onRegenerateCaptions,
|
|
698
|
+
captionsGenerating,
|
|
699
|
+
onImportFilesToTimeline,
|
|
700
|
+
pendingDrops,
|
|
701
|
+
timelineMode,
|
|
371
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>
|
|
372
707
|
emit: (p: P) => void
|
|
373
|
-
|
|
708
|
+
renderGenerationPanel?: VideoEditorProps<P>['renderGenerationPanel']
|
|
374
709
|
renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
|
|
375
710
|
regenEnabled?: boolean
|
|
376
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']
|
|
377
721
|
}) {
|
|
378
722
|
const project = sync.project
|
|
379
723
|
// Playhead in an external store, not useState — ~60Hz ticks re-render only the
|
|
@@ -383,25 +727,330 @@ function ReviewSurface<P extends Project>({
|
|
|
383
727
|
if (!clockRef.current) clockRef.current = createPlaybackClock()
|
|
384
728
|
const clock = clockRef.current
|
|
385
729
|
// Multi-select: all currently-selected timeline item ids. Single-select
|
|
386
|
-
// 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].
|
|
387
733
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
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])
|
|
395
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])
|
|
396
952
|
const [showControls, setShowControls] = useState(false)
|
|
397
953
|
// Source-crop mode: when on, the VideoSourceCropModal opens for the selected
|
|
398
954
|
// tracks[0] video item. Cleared when selection changes.
|
|
399
955
|
const [cropMode, setCropMode] = useState(false)
|
|
400
956
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
401
957
|
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
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])
|
|
405
1054
|
|
|
406
1055
|
// Render trigger — marks the project final, saves, and opens the RenderModal.
|
|
407
1056
|
// Kept stable (the sync mutators/ref are stable; `emit` read via ref) so a host
|
|
@@ -411,6 +1060,132 @@ function ReviewSurface<P extends Project>({
|
|
|
411
1060
|
// canonical and persists it.
|
|
412
1061
|
const { mutate: syncMutate, projectRef: syncProjectRef } = sync
|
|
413
1062
|
const emitRef = useRef(emit); emitRef.current = emit
|
|
1063
|
+
|
|
1064
|
+
// Persist the HDR image color mapping into project settings. A real user
|
|
1065
|
+
// edit: goes through sync.mutate so it saves and participates in undo.
|
|
1066
|
+
const handleImageToneChange = useCallback((tone: ImageTone) => {
|
|
1067
|
+
void syncMutate(() => {
|
|
1068
|
+
const cur = syncProjectRef.current
|
|
1069
|
+
return { ...cur, settings: { ...cur.settings, imageTone: tone } } as P
|
|
1070
|
+
})
|
|
1071
|
+
}, [syncMutate, syncProjectRef])
|
|
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
|
+
|
|
1109
|
+
// Host-chrome placement of the image-tone setting (mirrors
|
|
1110
|
+
// onProvideRenderTrigger): push the current state up whenever it changes,
|
|
1111
|
+
// and null for SDR projects so the host hides the control.
|
|
1112
|
+
const isHdrProject = !!project.settings?.colorSpace?.startsWith('hdr')
|
|
1113
|
+
const currentImageTone = project.settings?.imageTone
|
|
1114
|
+
useEffect(() => {
|
|
1115
|
+
if (!onProvideImageTone) return
|
|
1116
|
+
onProvideImageTone(isHdrProject ? { value: currentImageTone ?? 'vivid', set: handleImageToneChange } : null)
|
|
1117
|
+
}, [onProvideImageTone, isHdrProject, currentImageTone, handleImageToneChange])
|
|
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
|
+
|
|
414
1189
|
const openRender = useCallback(() => {
|
|
415
1190
|
const final = { ...syncProjectRef.current, status: 'final' } as P
|
|
416
1191
|
emitRef.current(final)
|
|
@@ -419,7 +1194,11 @@ function ReviewSurface<P extends Project>({
|
|
|
419
1194
|
}, [syncMutate, syncProjectRef])
|
|
420
1195
|
useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
|
|
421
1196
|
|
|
422
|
-
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)
|
|
423
1202
|
|
|
424
1203
|
// Repair caption segments whose words[] text has diverged from edited seg.text.
|
|
425
1204
|
// Inline caption edits update seg.text but not seg.words; this normalizes the
|
|
@@ -453,8 +1232,19 @@ function ReviewSurface<P extends Project>({
|
|
|
453
1232
|
sync.applyExternal({ ...project, captions: repaired } as P)
|
|
454
1233
|
}, [project.id, project.captions])
|
|
455
1234
|
|
|
456
|
-
const clips = project
|
|
457
|
-
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])
|
|
458
1248
|
|
|
459
1249
|
// The selected tracks[0] video item, if any — the only thing source-crop mode
|
|
460
1250
|
// can target. Source crop is a tracks[0]-video primitive (the renderer applies
|
|
@@ -468,82 +1258,261 @@ function ReviewSurface<P extends Project>({
|
|
|
468
1258
|
if (!cropTarget && cropMode) setCropMode(false)
|
|
469
1259
|
}, [cropTarget, cropMode])
|
|
470
1260
|
|
|
471
|
-
// Overlay
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
487
1290
|
|
|
488
1291
|
function withItemProps(base: P, id: string, nextProps: Record<string, unknown>): P {
|
|
489
1292
|
return {
|
|
490
1293
|
...base,
|
|
491
|
-
tracks: (base
|
|
492
|
-
|
|
1294
|
+
tracks: mapTrackItems(base, items =>
|
|
1295
|
+
items.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
|
|
493
1296
|
),
|
|
494
1297
|
} as P
|
|
495
1298
|
}
|
|
496
1299
|
// Live preview: reflect the in-progress edit locally (transient — no save, no
|
|
497
1300
|
// undo push) so the overlay re-renders as the operator tweaks. `commit()` on
|
|
498
|
-
//
|
|
1301
|
+
// the field's blur persists the accumulated transient state as one undo step.
|
|
499
1302
|
function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
|
|
500
1303
|
sync.mutateTransient(p => withItemProps(p, id, nextProps))
|
|
501
1304
|
}
|
|
502
|
-
//
|
|
503
|
-
//
|
|
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.
|
|
504
1309
|
function commitOverlayEdit() {
|
|
505
1310
|
void sync.commit()
|
|
506
|
-
editOriginalRef.current = null
|
|
507
|
-
setEditingOverlayId(null)
|
|
508
|
-
}
|
|
509
|
-
// Cancel/Esc/close: discard the live preview by restoring the pre-edit snapshot
|
|
510
|
-
// (no save, no undo push).
|
|
511
|
-
function cancelOverlayEdit() {
|
|
512
|
-
if (editOriginalRef.current) sync.applyExternal(editOriginalRef.current)
|
|
513
|
-
editOriginalRef.current = null
|
|
514
|
-
setEditingOverlayId(null)
|
|
515
1311
|
}
|
|
516
|
-
// The primary-selected JSX overlay, if any —
|
|
1312
|
+
// The primary-selected JSX overlay, if any — the Content/Transform panel's target.
|
|
517
1313
|
const selectedOverlayItem = primarySelectedId
|
|
518
1314
|
? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
|
|
519
1315
|
: null
|
|
520
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
|
+
|
|
521
1339
|
// Edits coming from the timeline (drag/move/track changes): route through the
|
|
522
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
|
+
*/
|
|
523
1359
|
function handleProjectChange(p: Project) {
|
|
524
|
-
|
|
1360
|
+
captionGestureRef.current = true
|
|
1361
|
+
sync.mutateTransient(() => p as P)
|
|
525
1362
|
}
|
|
526
1363
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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()
|
|
536
1396
|
}
|
|
537
1397
|
|
|
538
1398
|
function handleOverlayChange(id: string, changes: OverlayChanges) {
|
|
539
1399
|
void sync.mutate(p => ({
|
|
540
1400
|
...p,
|
|
541
|
-
tracks: (p
|
|
542
|
-
|
|
1401
|
+
tracks: mapTrackItems(p, items =>
|
|
1402
|
+
items.map(item => item.id !== id ? item : { ...item, ...changes })
|
|
543
1403
|
),
|
|
544
1404
|
} as P))
|
|
545
1405
|
}
|
|
546
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
|
+
|
|
547
1516
|
// Commit a per-segment caption change (preview drag → offsetX/offsetY/scale).
|
|
548
1517
|
// Routed through `makeCaptionEdit` so there is exactly one project-mutation
|
|
549
1518
|
// path for caption edits — it addresses the segment by id and leaves the
|
|
@@ -555,22 +1524,80 @@ function ReviewSurface<P extends Project>({
|
|
|
555
1524
|
makeCaptionEdit(segmentId, syncProjectRef.current, (p) => void syncMutate(() => p as P))(patch)
|
|
556
1525
|
}, [syncProjectRef, syncMutate])
|
|
557
1526
|
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
// `
|
|
564
|
-
|
|
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.
|
|
565
1557
|
const handleSelectCaption = useCallback((id: string | null) => {
|
|
566
|
-
|
|
567
|
-
if (id !== null) setSelectedIds([])
|
|
1558
|
+
setSelectedIds(id ? [id] : [])
|
|
568
1559
|
}, [])
|
|
569
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
|
+
|
|
570
1574
|
function handleSplit(at?: number) {
|
|
571
1575
|
const base = syncProjectRef.current
|
|
572
|
-
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)
|
|
573
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
|
+
}
|
|
574
1601
|
void sync.mutate(() => updated as P)
|
|
575
1602
|
}
|
|
576
1603
|
|
|
@@ -584,22 +1611,305 @@ function ReviewSurface<P extends Project>({
|
|
|
584
1611
|
}
|
|
585
1612
|
}
|
|
586
1613
|
|
|
587
|
-
//
|
|
588
|
-
//
|
|
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)
|
|
589
1699
|
useEffect(() => {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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() })
|
|
599
1892
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
+
}
|
|
603
1913
|
|
|
604
1914
|
async function handleRestoreVersion(hash: string) {
|
|
605
1915
|
if (!adapter.restoreVersion) return
|
|
@@ -612,17 +1922,52 @@ function ReviewSurface<P extends Project>({
|
|
|
612
1922
|
console.error(e)
|
|
613
1923
|
} finally {
|
|
614
1924
|
setRestoring(null)
|
|
1925
|
+
await refreshVersions()
|
|
615
1926
|
}
|
|
616
1927
|
}
|
|
617
1928
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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">
|
|
626
1971
|
<div
|
|
627
1972
|
className="relative h-full max-w-full"
|
|
628
1973
|
style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
|
|
@@ -632,7 +1977,10 @@ function ReviewSurface<P extends Project>({
|
|
|
632
1977
|
clock={clock}
|
|
633
1978
|
selectedOverlayId={primarySelectedId ?? undefined}
|
|
634
1979
|
onOverlayChange={handleOverlayChange}
|
|
635
|
-
|
|
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}
|
|
636
1984
|
compileOverlay={adapter.compileOverlay}
|
|
637
1985
|
clearOverlayCache={adapter.clearOverlayCache}
|
|
638
1986
|
watchFile={adapter.watchFile}
|
|
@@ -641,175 +1989,766 @@ function ReviewSurface<P extends Project>({
|
|
|
641
1989
|
selectedCaptionId={selectedCaptionId ?? undefined}
|
|
642
1990
|
onSelectCaption={handleSelectCaption}
|
|
643
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}
|
|
644
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} />
|
|
645
2011
|
</div>
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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>
|
|
649
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 }}>
|
|
650
2102
|
|
|
651
|
-
|
|
652
|
-
|
|
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">
|
|
653
2119
|
<button
|
|
654
2120
|
onClick={() => setShowControls(true)}
|
|
655
|
-
title="Editor controls & shortcuts"
|
|
656
2121
|
aria-label="Editor controls & shortcuts"
|
|
657
|
-
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"
|
|
658
2123
|
>
|
|
659
|
-
<
|
|
2124
|
+
<HelpCircle size={14} />
|
|
2125
|
+
<span className="text-[10px] leading-none">Controls</span>
|
|
660
2126
|
</button>
|
|
2127
|
+
</Tooltip>
|
|
2128
|
+
<Tooltip label="Undo" keys={['⌘', 'Z']}>
|
|
661
2129
|
<button
|
|
662
2130
|
onClick={sync.undo}
|
|
663
2131
|
disabled={!sync.canUndo}
|
|
664
|
-
title="Undo (Cmd/Ctrl+Z)"
|
|
665
2132
|
aria-label="Undo"
|
|
666
|
-
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"
|
|
667
2134
|
>
|
|
668
2135
|
<Undo2 size={12} />
|
|
669
2136
|
</button>
|
|
2137
|
+
</Tooltip>
|
|
2138
|
+
<Tooltip label="Redo" keys={['⌘', '⇧', 'Z']}>
|
|
670
2139
|
<button
|
|
671
2140
|
onClick={sync.redo}
|
|
672
2141
|
disabled={!sync.canRedo}
|
|
673
|
-
title="Redo (Cmd/Ctrl+Shift+Z)"
|
|
674
2142
|
aria-label="Redo"
|
|
675
|
-
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"
|
|
676
2144
|
>
|
|
677
2145
|
<Redo2 size={12} />
|
|
678
2146
|
</button>
|
|
2147
|
+
</Tooltip>
|
|
2148
|
+
<Tooltip label={previewAxis ? 'Preview axis on — hover to preview' : 'Preview axis off'} keys={['⌘', 'A']}>
|
|
679
2149
|
<button
|
|
680
|
-
onClick={() =>
|
|
681
|
-
|
|
682
|
-
|
|
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
|
+
}`}
|
|
683
2158
|
>
|
|
684
|
-
<
|
|
685
|
-
<line x1="6" y1="0" x2="6" y2="12" />
|
|
686
|
-
<polyline points="3,3 6,6 9,3" />
|
|
687
|
-
<polyline points="3,9 6,6 9,9" />
|
|
688
|
-
</svg>
|
|
2159
|
+
<SeparatorVertical size={12} />
|
|
689
2160
|
</button>
|
|
2161
|
+
</Tooltip>
|
|
2162
|
+
<Tooltip label={rippleMode ? 'Ripple on — gaps close' : 'Ripple: close the gap'}>
|
|
690
2163
|
<button
|
|
691
2164
|
onClick={handleRippleToggle}
|
|
692
|
-
|
|
2165
|
+
aria-label="Ripple mode"
|
|
693
2166
|
aria-pressed={rippleMode}
|
|
694
2167
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
695
2168
|
rippleMode
|
|
696
|
-
? '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')
|
|
697
2170
|
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
698
2171
|
}`}
|
|
699
2172
|
>
|
|
700
2173
|
<Magnet size={12} />
|
|
701
2174
|
</button>
|
|
2175
|
+
</Tooltip>
|
|
2176
|
+
<Tooltip
|
|
2177
|
+
label={!cropTarget ? 'Select a clip to crop' : cropMode ? 'Exit crop' : 'Crop source'}
|
|
2178
|
+
>
|
|
702
2179
|
<button
|
|
703
2180
|
onClick={() => setCropMode(m => !m)}
|
|
704
2181
|
disabled={!cropTarget}
|
|
705
|
-
|
|
706
|
-
!cropTarget
|
|
707
|
-
? 'Select a video clip to crop its source'
|
|
708
|
-
: cropMode ? 'Exit source crop' : 'Crop source — non-destructively crop the selected clip'
|
|
709
|
-
}
|
|
2182
|
+
aria-label="Crop source"
|
|
710
2183
|
aria-pressed={cropMode}
|
|
711
|
-
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 ${
|
|
712
2185
|
cropMode
|
|
713
|
-
? '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')
|
|
714
2187
|
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
715
2188
|
}`}
|
|
716
2189
|
>
|
|
717
2190
|
<Crop size={12} />
|
|
718
2191
|
</button>
|
|
719
|
-
|
|
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">
|
|
720
2213
|
<button
|
|
721
|
-
onClick={
|
|
722
|
-
|
|
723
|
-
aria-label="Edit overlay"
|
|
2214
|
+
onClick={handleAudioPolish}
|
|
2215
|
+
aria-label="Polish audio"
|
|
724
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)]"
|
|
725
2217
|
>
|
|
726
|
-
<
|
|
727
|
-
</button>
|
|
728
|
-
)}
|
|
729
|
-
{/* Default placement. A host that sets onProvideRenderTrigger renders
|
|
730
|
-
Render in its own chrome instead, so the toolbar button is hidden. */}
|
|
731
|
-
{!onProvideRenderTrigger && (
|
|
732
|
-
<button
|
|
733
|
-
onClick={openRender}
|
|
734
|
-
className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
|
|
735
|
-
>
|
|
736
|
-
Render →
|
|
2218
|
+
<Wand2 size={12} />
|
|
737
2219
|
</button>
|
|
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}
|
|
2672
|
+
/>
|
|
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
|
+
)
|
|
738
2689
|
)}
|
|
739
2690
|
</div>
|
|
2691
|
+
</div>
|
|
2692
|
+
</>
|
|
2693
|
+
)
|
|
740
2694
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
isClipQueued={isClipQueued}
|
|
764
|
-
renderSubcutRegen={renderSubcutRegen}
|
|
765
|
-
onRegenerateCaptions={adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined}
|
|
766
|
-
/>
|
|
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}
|
|
767
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}
|
|
768
2732
|
</div>
|
|
769
2733
|
|
|
770
2734
|
{/* Assets — dedicated separate column to the LEFT of the version rail
|
|
771
2735
|
(assetsPlacement: 'right', two distinct columns). The Montaj-local OS
|
|
772
2736
|
layout uses 'sidebar' instead (stacked into the version rail below).
|
|
773
2737
|
The host's panel manages its own scroll. */}
|
|
774
|
-
{
|
|
775
|
-
<div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
776
|
-
{slots.assetsPanel}
|
|
777
|
-
</div>
|
|
778
|
-
)}
|
|
2738
|
+
{assetsColumn}
|
|
779
2739
|
|
|
780
2740
|
{/* Right rail — version history + run history slot, and (in 'sidebar'
|
|
781
2741
|
placement) the assets panel stacked beneath them in the SAME column.
|
|
782
2742
|
This is the historical Montaj-local OS layout: versions on top, assets
|
|
783
2743
|
right below, one column — not a separate assets column. */}
|
|
784
|
-
{
|
|
785
|
-
(assetsPlacement === 'sidebar' && slots?.assetsPanel)) && (
|
|
786
|
-
<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`}>
|
|
787
|
-
{adapter.listVersionHistory && (
|
|
788
|
-
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
789
|
-
)}
|
|
790
|
-
{/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
|
|
791
|
-
RunSnapshot / project.history are host-only types — the package never
|
|
792
|
-
reads them. When absent nothing is rendered. */}
|
|
793
|
-
{slots?.runHistory}
|
|
794
|
-
{/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
|
|
795
|
-
host's panel manages its own scroll; flex-1 lets it take the
|
|
796
|
-
remaining rail height. A top border separates it from the runs. */}
|
|
797
|
-
{assetsPlacement === 'sidebar' && slots?.assetsPanel && (
|
|
798
|
-
<div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
|
|
799
|
-
{slots.assetsPanel}
|
|
800
|
-
</div>
|
|
801
|
-
)}
|
|
802
|
-
</div>
|
|
803
|
-
)}
|
|
2744
|
+
{rightRail}
|
|
804
2745
|
</div>
|
|
805
2746
|
|
|
806
2747
|
{/* Project media / assets — full-width region stacked BELOW the editor
|
|
807
2748
|
(assetsPlacement: 'bottom'). Preferred by width-constrained hosts (Hub).
|
|
808
2749
|
The host's panel manages its own scroll. */}
|
|
809
|
-
{
|
|
810
|
-
|
|
811
|
-
{slots.assetsPanel}
|
|
812
|
-
</div>
|
|
2750
|
+
{bottomAssets}
|
|
2751
|
+
</>
|
|
813
2752
|
)}
|
|
814
2753
|
|
|
815
2754
|
{/* Source-crop modal — drag-to-pan, aspect presets, zoom. Commits
|
|
@@ -846,6 +2785,18 @@ function ReviewSurface<P extends Project>({
|
|
|
846
2785
|
/>
|
|
847
2786
|
)}
|
|
848
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
|
+
|
|
849
2800
|
{/* Render modal — adapter.render stream + host export controls */}
|
|
850
2801
|
{renderOpen && (
|
|
851
2802
|
<RenderModal
|
|
@@ -853,8 +2804,30 @@ function ReviewSurface<P extends Project>({
|
|
|
853
2804
|
adapter={adapter}
|
|
854
2805
|
exportActions={slots?.exportActions}
|
|
855
2806
|
progressView={renderProgressView}
|
|
2807
|
+
preRenderOptions={preRenderOptions}
|
|
856
2808
|
onClose={() => setRenderOpen(false)}
|
|
857
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}
|
|
858
2831
|
/>
|
|
859
2832
|
)}
|
|
860
2833
|
|
|
@@ -863,38 +2836,51 @@ function ReviewSurface<P extends Project>({
|
|
|
863
2836
|
montaj persists the regenerated captions server-side and the SSE frame
|
|
864
2837
|
reconciles, so a saveProject here would double-write. applyExternal keeps
|
|
865
2838
|
it out of the undo stack (server-authored, not a user edit). */}
|
|
866
|
-
{regenCaptionsOpen && adapter.generateCaptions && (
|
|
2839
|
+
{regenCaptionsOpen && adapter.generateCaptions && !onRegenerateCaptions && (
|
|
867
2840
|
<CaptionRegenModal
|
|
868
2841
|
adapter={adapter}
|
|
869
2842
|
projectId={project.id}
|
|
2843
|
+
existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
|
|
870
2844
|
onClose={() => setRegenCaptionsOpen(false)}
|
|
871
2845
|
onDone={(captions) => {
|
|
872
2846
|
sync.applyExternal({ ...syncProjectRef.current, captions } as P)
|
|
873
2847
|
setRegenCaptionsOpen(false)
|
|
874
2848
|
}}
|
|
2849
|
+
mode={timelineMode}
|
|
875
2850
|
/>
|
|
876
2851
|
)}
|
|
877
2852
|
|
|
878
|
-
{/*
|
|
879
|
-
|
|
880
|
-
the
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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}
|
|
890
2870
|
/>
|
|
891
2871
|
)}
|
|
892
2872
|
|
|
893
|
-
{/* Clip / audio inspector — host-rendered via render-prop seam. */}
|
|
894
|
-
{inspecting && renderClipInspector?.({
|
|
895
|
-
item: inspecting,
|
|
896
|
-
onClose: () => setInspecting(null),
|
|
897
|
-
})}
|
|
898
2873
|
</div>
|
|
899
2874
|
)
|
|
900
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
|
+
}
|