@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
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronRight, Crop } from 'lucide-react'
|
|
3
|
+
import type { AudioTrack, EditorProject, VisualItem } from '../../schema'
|
|
4
|
+
import { setClipSpeed } from '../cuts'
|
|
5
|
+
import SpeedControl from '../timeline/SpeedControl'
|
|
6
|
+
import VolumeControl from '../timeline/VolumeControl'
|
|
7
|
+
import { cn, inspectorInputClass, NumberField, stepValue, Switch } from '../../ui'
|
|
8
|
+
import { usePersistentState } from '../../ui/usePersistentState'
|
|
9
|
+
import TabNav, { type TabNavTab } from './TabNav'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `<ClipPropertiesPanel>` — the editor's contextual right-hand properties
|
|
13
|
+
* panel for a selected VIDEO CLIP or AUDIO TRACK. The sibling of
|
|
14
|
+
* `OverlayInspector` (the overlay Transform panel, which this panel now also
|
|
15
|
+
* hosts as its clip Transform tab): the two mount in the same column and
|
|
16
|
+
* share its visual language — `var(--editor-*)` custom properties,
|
|
17
|
+
* `inspectorInputClass` inputs, the same section chrome — so the column
|
|
18
|
+
* reads as one system no matter which one is showing.
|
|
19
|
+
*
|
|
20
|
+
* These fields used to live only in the host-side `ClipInspectModal`
|
|
21
|
+
* (`montaj_assets/ui`), opened by double-click. This panel reimplements the
|
|
22
|
+
* editor-managed subset of that modal's fields (NOT the AI-generation /
|
|
23
|
+
* regenerate surface, which stays host-owned and is injected via
|
|
24
|
+
* `generationSlot`; NOT delete, which stays modal-only — see the doc comment
|
|
25
|
+
* on `AudioSection` for why).
|
|
26
|
+
*
|
|
27
|
+
* Props-driven and host-agnostic, like the rest of this package: it never
|
|
28
|
+
* reaches into a store. All persistence flows back through the six
|
|
29
|
+
* preview/commit/change callbacks, which the host wires to its own sync core.
|
|
30
|
+
*/
|
|
31
|
+
export type ClipSelection = { kind: 'clip'; item: VisualItem } | { kind: 'audio'; track: AudioTrack } | null
|
|
32
|
+
|
|
33
|
+
export interface ClipPropertiesPanelProps {
|
|
34
|
+
selection: ClipSelection
|
|
35
|
+
/** Live-preview a continuously-changing clip edit (slider drag): no undo
|
|
36
|
+
* entry, no save yet. Mirrors OverlayInspector's onPreview. */
|
|
37
|
+
onPreviewClip: (item: VisualItem) => void
|
|
38
|
+
/** Commit the last previewed clip edit as one undo step + queued save. */
|
|
39
|
+
onCommitClip: () => void
|
|
40
|
+
/** A discrete, already-final clip edit (a toggle, a preset chip). */
|
|
41
|
+
onChangeClip: (item: VisualItem) => void
|
|
42
|
+
/** Same three, for a selected audio track. */
|
|
43
|
+
onPreviewAudio: (track: AudioTrack) => void
|
|
44
|
+
onCommitAudio: () => void
|
|
45
|
+
onChangeAudio: (track: AudioTrack) => void
|
|
46
|
+
/** Transform tab body for a selected clip: the host's `OverlayInspector`
|
|
47
|
+
* instance, wired to the playback clock and the host's own sync/seek
|
|
48
|
+
* handlers — none of which this props-driven package has on hand, so the
|
|
49
|
+
* host builds it and hands it in rather than this panel importing and
|
|
50
|
+
* wiring `OverlayInspector` itself. Absent -> no Transform tab. */
|
|
51
|
+
transformSlot?: ReactNode
|
|
52
|
+
/** Present -> a Crop tab appears whose body is a single button that calls
|
|
53
|
+
* this. The caller decides WHETHER cropping applies to the current
|
|
54
|
+
* selection (e.g. only a tracks[0] video) — this panel just renders the
|
|
55
|
+
* tab it's offered and defers to a host-owned crop tool. Absent -> no
|
|
56
|
+
* Crop tab. */
|
|
57
|
+
onOpenCrop?: () => void
|
|
58
|
+
/** Generate tab body when a video clip is selected. Montaj puts its AI
|
|
59
|
+
* generation / regenerate surface here: that reads and writes
|
|
60
|
+
* `project.regenQueue`, a host-only field this package deliberately knows
|
|
61
|
+
* nothing about (see schema.ts's EditorProject index-signature comment).
|
|
62
|
+
* Absent -> no Generate tab. */
|
|
63
|
+
generationSlot?: ReactNode
|
|
64
|
+
/** Editor theme mode — light/dark. Only affects the Crop tab's icon badge
|
|
65
|
+
* hue (indigo-400 is sub-AA on a light `--editor-surface`). Absent ->
|
|
66
|
+
* dark, matching every existing caller. */
|
|
67
|
+
mode?: 'light' | 'dark'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
71
|
+
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]/55'
|
|
72
|
+
|
|
73
|
+
function basename(path: string): string {
|
|
74
|
+
return path.split('/').pop() ?? path
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One collapsible section frame, shared by the Audio track section and its
|
|
78
|
+
* Fades/Ducking sub-groups (via `nested`). Mirrors OverlayInspector's
|
|
79
|
+
* Transform section header exactly (chevron + uppercase label), scaled
|
|
80
|
+
* down for a nested sub-group. The Clip side used to share this too, before
|
|
81
|
+
* its fields became tabbed (a tab has nothing to fold). */
|
|
82
|
+
function CollapsibleSection({
|
|
83
|
+
label,
|
|
84
|
+
collapsed,
|
|
85
|
+
onToggle,
|
|
86
|
+
nested,
|
|
87
|
+
badge,
|
|
88
|
+
children,
|
|
89
|
+
}: {
|
|
90
|
+
label: string
|
|
91
|
+
collapsed: boolean
|
|
92
|
+
onToggle: () => void
|
|
93
|
+
nested?: boolean
|
|
94
|
+
badge?: ReactNode
|
|
95
|
+
children: ReactNode
|
|
96
|
+
}) {
|
|
97
|
+
return (
|
|
98
|
+
<div className={nested ? 'rounded-md border border-[var(--editor-border)] overflow-hidden' : SECTION_CLASS}>
|
|
99
|
+
<div
|
|
100
|
+
className={
|
|
101
|
+
nested
|
|
102
|
+
? 'shrink-0 flex items-center gap-1 px-2 py-1.5'
|
|
103
|
+
: 'shrink-0 flex items-center gap-1 border-b border-[var(--editor-border)] px-2 py-1.5'
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
aria-expanded={!collapsed}
|
|
109
|
+
onClick={onToggle}
|
|
110
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]/60 transition-colors hover:text-[var(--editor-text)]"
|
|
111
|
+
>
|
|
112
|
+
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
113
|
+
<span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
|
|
114
|
+
</button>
|
|
115
|
+
{badge && <div className="ml-auto">{badge}</div>}
|
|
116
|
+
</div>
|
|
117
|
+
{!collapsed && <div className={nested ? 'flex flex-col gap-2 border-t border-[var(--editor-border)] p-2' : 'flex flex-col gap-3 p-2'}>{children}</div>}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A label + control row, matching OverlayInspector's `ROW_LABEL_CLASS` rows. */
|
|
123
|
+
function Row({ label, children }: { label: string; children: ReactNode }) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<span className={ROW_LABEL_CLASS}>{label}</span>
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface DraftFieldProps {
|
|
133
|
+
ariaLabel: string
|
|
134
|
+
value: string
|
|
135
|
+
/** Raw text as the operator types it. Validate/parse here, not in this
|
|
136
|
+
* component — different fields want different rules (e.g. an empty
|
|
137
|
+
* string or a lone "-" mid-number is not yet a value to preview). */
|
|
138
|
+
onInput: (raw: string) => void
|
|
139
|
+
/** Fired on blur, or on Enter (via the same blur path). Carries the exact
|
|
140
|
+
* string on screen at that moment, so a caller that needs to resolve a
|
|
141
|
+
* final value (e.g. an empty label falling back to the source basename)
|
|
142
|
+
* can do so without keeping its own shadow copy of what was typed. */
|
|
143
|
+
onCommit: (raw: string) => void
|
|
144
|
+
className?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One TEXT box, split out so it can hold its OWN `useState` — every field in
|
|
149
|
+
* this panel needs independent "am I mid-edit" state, exactly like
|
|
150
|
+
* `OverlayInspectorField` in the sibling panel. Every NUMBER field in this
|
|
151
|
+
* panel is a shared `NumberField` (ui/NumberField.tsx) instead — this
|
|
152
|
+
* component used to serve both, but the number branch is retired now that
|
|
153
|
+
* NumberField owns the same draft/preview/commit contract plus steppers; the
|
|
154
|
+
* only field left that needs it is the audio track Label, which is text.
|
|
155
|
+
*
|
|
156
|
+
* `draft` is the fix for a real, previously-shipped bug: this whole panel can
|
|
157
|
+
* re-render from outside (a playback tick, an unrelated field's edit) while
|
|
158
|
+
* the operator is mid-keystroke. A controlled input bound straight to the
|
|
159
|
+
* incoming prop gets its keystrokes clobbered by that re-render. `draft` goes
|
|
160
|
+
* non-null the moment the operator types and stays the single source of truth
|
|
161
|
+
* for what's ON SCREEN until blur/Enter commits it, so an outside re-render
|
|
162
|
+
* arriving mid-edit can't overwrite what's displayed. While `draft` is null
|
|
163
|
+
* (not currently being typed into) the field tracks `value` live, exactly as
|
|
164
|
+
* it always did.
|
|
165
|
+
*/
|
|
166
|
+
function DraftField({ ariaLabel, value, onInput, onCommit, className }: DraftFieldProps) {
|
|
167
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
168
|
+
const shown = draft ?? value
|
|
169
|
+
|
|
170
|
+
function commit() {
|
|
171
|
+
const raw = shown
|
|
172
|
+
setDraft(null)
|
|
173
|
+
onCommit(raw)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<input
|
|
178
|
+
type="text"
|
|
179
|
+
aria-label={ariaLabel}
|
|
180
|
+
value={shown}
|
|
181
|
+
onChange={e => { setDraft(e.target.value); onInput(e.target.value) }}
|
|
182
|
+
onBlur={commit}
|
|
183
|
+
// Enter closes the typing gesture the same way blur does — it does not
|
|
184
|
+
// duplicate the commit logic, just triggers the same onBlur path.
|
|
185
|
+
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
186
|
+
className={cn(inspectorInputClass, className)}
|
|
187
|
+
/>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `setClipSpeed` (cuts.ts) operates on a whole Project — it looks the clip up
|
|
193
|
+
* by id and rewrites its track — because a speed change also re-fits the
|
|
194
|
+
* clip's timeline `end` to keep the same source window at the new rate. This
|
|
195
|
+
* panel only ever sees the single `VisualItem` it's editing, with no project
|
|
196
|
+
* in scope. Wrapping the item in a throwaway single-item, single-track
|
|
197
|
+
* project reuses `setClipSpeed`'s exact duration math with no project on
|
|
198
|
+
* hand, rather than re-deriving it here — the same "minimal project, only
|
|
199
|
+
* the fields the function touches" shape `cuts.test.ts`'s own `makeProject`
|
|
200
|
+
* fixture uses for the same function.
|
|
201
|
+
*/
|
|
202
|
+
function withResolvedSpeed(item: VisualItem, speed: number): VisualItem {
|
|
203
|
+
const scratch: EditorProject = {
|
|
204
|
+
id: 'clip-properties-panel-scratch',
|
|
205
|
+
status: 'draft',
|
|
206
|
+
settings: { resolution: [0, 0] },
|
|
207
|
+
tracks: [{ id: 'scratch', items: [item] }],
|
|
208
|
+
}
|
|
209
|
+
const next = setClipSpeed(scratch, item.id, speed)
|
|
210
|
+
return next.tracks?.[0]?.items[0] ?? item
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Clip properties tabs ────────────────────────────────────────────────
|
|
214
|
+
// Which of the selected clip's tabs is showing. Persisted per browser like
|
|
215
|
+
// every other panel preference, so the operator's habit survives a reload.
|
|
216
|
+
// 'transform' is the default — geometry is what an operator reaches for
|
|
217
|
+
// first when a clip is selected, matching OverlayInspector's own Transform
|
|
218
|
+
// tab for overlays.
|
|
219
|
+
const CLIP_PANEL_TAB_STORAGE_KEY = 'montaj.editor.clipPanelTab'
|
|
220
|
+
type ClipPanelTab = 'transform' | 'speed' | 'volume' | 'crop' | 'generate'
|
|
221
|
+
const CLIP_PANEL_TAB_IDS: readonly ClipPanelTab[] = ['transform', 'speed', 'volume', 'crop', 'generate']
|
|
222
|
+
const reviveClipPanelTab = (raw: unknown): ClipPanelTab | null =>
|
|
223
|
+
typeof raw === 'string' && (CLIP_PANEL_TAB_IDS as readonly string[]).includes(raw) ? (raw as ClipPanelTab) : null
|
|
224
|
+
|
|
225
|
+
interface ClipTabsProps {
|
|
226
|
+
item: VisualItem
|
|
227
|
+
onPreviewClip: (item: VisualItem) => void
|
|
228
|
+
onCommitClip: () => void
|
|
229
|
+
onChangeClip: (item: VisualItem) => void
|
|
230
|
+
transformSlot?: ReactNode
|
|
231
|
+
onOpenCrop?: () => void
|
|
232
|
+
generationSlot?: ReactNode
|
|
233
|
+
mode?: 'light' | 'dark'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A selected clip's properties, tabbed: **Transform · Speed · Volume · Crop
|
|
238
|
+
* · Generate**, in that fixed order. A tab appears only when it has
|
|
239
|
+
* something to show for THIS selection — an image clip with no generation
|
|
240
|
+
* slot offers only Transform and Volume; a main-track video with both slots
|
|
241
|
+
* offers all five. Replaces the old flat `ClipSection` (Volume + Mute +
|
|
242
|
+
* video-only Speed stacked above the host's `generationSlot`).
|
|
243
|
+
*/
|
|
244
|
+
function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSlot, onOpenCrop, generationSlot, mode = 'dark' }: ClipTabsProps) {
|
|
245
|
+
const tabs: TabNavTab<ClipPanelTab>[] = []
|
|
246
|
+
if (transformSlot !== undefined) tabs.push({ value: 'transform', label: 'Transform' })
|
|
247
|
+
// Speed is video-only, matching the modal this replaces. `setClipSpeed`
|
|
248
|
+
// returns the project UNCHANGED for a non-video item (cuts.ts's
|
|
249
|
+
// `item.type !== 'video'` early return), so offering the tab for an image
|
|
250
|
+
// would give the operator a slider that silently does nothing.
|
|
251
|
+
if (item.type === 'video') tabs.push({ value: 'speed', label: 'Speed' })
|
|
252
|
+
tabs.push({ value: 'volume', label: 'Volume' })
|
|
253
|
+
if (onOpenCrop) tabs.push({ value: 'crop', label: 'Crop' })
|
|
254
|
+
if (generationSlot !== undefined) tabs.push({ value: 'generate', label: 'Generate' })
|
|
255
|
+
|
|
256
|
+
const [persistedTab, setPersistedTab] = usePersistentState<ClipPanelTab>(
|
|
257
|
+
CLIP_PANEL_TAB_STORAGE_KEY,
|
|
258
|
+
'transform',
|
|
259
|
+
reviveClipPanelTab,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Stale-tab safety AT RENDER TIME, not just on load: unlike LeftPanelTabs'
|
|
263
|
+
// static rail, this tab set is DERIVED FROM THE SELECTION — an image has
|
|
264
|
+
// no Speed tab, a clip with no `generationSlot` has no Generate tab — so it
|
|
265
|
+
// changes as the operator selects different clips. A persisted/active tab
|
|
266
|
+
// that isn't in the CURRENT set (persisted 'speed', then the operator
|
|
267
|
+
// selects an image) falls back to 'transform', or to the first tab this
|
|
268
|
+
// selection actually offers if even Transform is missing, rather than
|
|
269
|
+
// rendering a blank pane.
|
|
270
|
+
const currentTab: ClipPanelTab = tabs.some(t => t.value === persistedTab)
|
|
271
|
+
? persistedTab
|
|
272
|
+
: tabs.some(t => t.value === 'transform')
|
|
273
|
+
? 'transform'
|
|
274
|
+
: (tabs[0]?.value ?? 'volume')
|
|
275
|
+
|
|
276
|
+
// Lazy mount, then KEEP MOUNTED: a tab body isn't rendered until first
|
|
277
|
+
// activated, but once mounted it stays mounted and is only hidden on
|
|
278
|
+
// switch-away — exactly `LeftPanelTabs`' policy, and load-bearing here for
|
|
279
|
+
// the same reason it's load-bearing there. The Generate tab's slot seeds
|
|
280
|
+
// its regen form (prompt, model, duration, reference images) from
|
|
281
|
+
// `useState` initializers that run ONCE per mount; unmounting it on
|
|
282
|
+
// Generate -> Speed -> Generate would silently lose everything the
|
|
283
|
+
// operator had typed. Do not "optimize" this into a plain conditional
|
|
284
|
+
// render.
|
|
285
|
+
const [mountedTabs, setMountedTabs] = useState<Set<ClipPanelTab>>(() => new Set([currentTab]))
|
|
286
|
+
if (!mountedTabs.has(currentTab)) {
|
|
287
|
+
setMountedTabs(prev => {
|
|
288
|
+
const next = new Set(prev)
|
|
289
|
+
next.add(currentTab)
|
|
290
|
+
return next
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const volume = item.volume ?? 1
|
|
295
|
+
const muted = item.muted ?? false
|
|
296
|
+
const speed = item.speed ?? 1
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
<TabNav
|
|
301
|
+
tabs={tabs}
|
|
302
|
+
value={currentTab}
|
|
303
|
+
onChange={setPersistedTab}
|
|
304
|
+
ariaLabel="Clip panel view"
|
|
305
|
+
className="shrink-0 border-b border-[var(--editor-border)] px-1"
|
|
306
|
+
/>
|
|
307
|
+
{tabs
|
|
308
|
+
.filter(tab => mountedTabs.has(tab.value))
|
|
309
|
+
.map(tab => {
|
|
310
|
+
const active = tab.value === currentTab
|
|
311
|
+
return (
|
|
312
|
+
<div
|
|
313
|
+
key={tab.value}
|
|
314
|
+
hidden={!active}
|
|
315
|
+
style={active ? undefined : { display: 'none' }}
|
|
316
|
+
className="flex flex-col overflow-hidden"
|
|
317
|
+
>
|
|
318
|
+
{/* Transform/Generate render the host slot DIRECTLY — each
|
|
319
|
+
already brings its own section chrome (OverlayInspector
|
|
320
|
+
wraps itself in the same SECTION_CLASS this file uses).
|
|
321
|
+
Wrapping them again here would double up borders/padding. */}
|
|
322
|
+
{tab.value === 'transform' && transformSlot}
|
|
323
|
+
{tab.value === 'speed' && (
|
|
324
|
+
<div className="flex flex-col gap-2 p-2">
|
|
325
|
+
<SpeedControl
|
|
326
|
+
value={speed}
|
|
327
|
+
// The drag/type gesture only ever shows the SPEED number
|
|
328
|
+
// moving — mirroring ClipInspectModal's slider, which
|
|
329
|
+
// doesn't resize the clip until its Save button is
|
|
330
|
+
// pressed. Duration is re-fit exactly once, in onCommit
|
|
331
|
+
// below, via the real function.
|
|
332
|
+
onChange={v => onPreviewClip({ ...item, speed: v })}
|
|
333
|
+
onCommit={v => {
|
|
334
|
+
onPreviewClip(withResolvedSpeed(item, v))
|
|
335
|
+
onCommitClip()
|
|
336
|
+
}}
|
|
337
|
+
label="Speed"
|
|
338
|
+
idBase="clip-properties-speed"
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{tab.value === 'volume' && (
|
|
343
|
+
<div className="flex flex-col gap-2 p-2">
|
|
344
|
+
<VolumeControl
|
|
345
|
+
value={volume}
|
|
346
|
+
onChange={v => onPreviewClip({ ...item, volume: v })}
|
|
347
|
+
onCommit={() => onCommitClip()}
|
|
348
|
+
label="Volume"
|
|
349
|
+
idBase="clip-properties-volume"
|
|
350
|
+
/>
|
|
351
|
+
<Row label="Mute">
|
|
352
|
+
<Switch checked={muted} onCheckedChange={next => onChangeClip({ ...item, muted: next })} aria-label="Mute clip" mode={mode} />
|
|
353
|
+
</Row>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
{tab.value === 'crop' && onOpenCrop && (
|
|
357
|
+
<div className="flex flex-col items-center gap-3 px-5 pt-8 pb-6 text-center">
|
|
358
|
+
<div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
|
|
359
|
+
<Crop size={20} />
|
|
360
|
+
</div>
|
|
361
|
+
<p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]/60">
|
|
362
|
+
Reframe this clip by cropping its source video. Pick the part of the frame to keep.
|
|
363
|
+
</p>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={onOpenCrop}
|
|
367
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--editor-accent)] px-3 py-1.5 text-xs font-medium text-[var(--editor-accent-foreground)] transition-opacity hover:opacity-90"
|
|
368
|
+
>
|
|
369
|
+
<Crop size={13} />
|
|
370
|
+
Open crop tool
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
{tab.value === 'generate' && generationSlot}
|
|
375
|
+
</div>
|
|
376
|
+
)
|
|
377
|
+
})}
|
|
378
|
+
</>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface AudioSectionProps {
|
|
383
|
+
track: AudioTrack
|
|
384
|
+
onPreviewAudio: (track: AudioTrack) => void
|
|
385
|
+
onCommitAudio: () => void
|
|
386
|
+
onChangeAudio: (track: AudioTrack) => void
|
|
387
|
+
/** Editor theme mode — light/dark. Only affects the Mute/Ducking switches'
|
|
388
|
+
* unchecked track colour. Absent -> dark, matching every existing caller. */
|
|
389
|
+
mode?: 'light' | 'dark'
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Every editor-managed audio-track field EXCEPT delete. Deliberate: this
|
|
394
|
+
* panel appears the moment a track is plain-selected, and a destructive
|
|
395
|
+
* action one click away from selecting is a foot-gun. `ClipInspectModal`
|
|
396
|
+
* (host-side, opened by double-click — a more deliberate action) keeps the
|
|
397
|
+
* delete control.
|
|
398
|
+
*/
|
|
399
|
+
function AudioSection({ track, onPreviewAudio, onCommitAudio, onChangeAudio, mode = 'dark' }: AudioSectionProps) {
|
|
400
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
401
|
+
const [fadesOpen, setFadesOpen] = useState(false)
|
|
402
|
+
const [duckingOpen, setDuckingOpen] = useState(false)
|
|
403
|
+
|
|
404
|
+
const volume = track.volume ?? 1
|
|
405
|
+
const muted = track.muted ?? false
|
|
406
|
+
const fadeIn = track.fadeIn ?? 0
|
|
407
|
+
const fadeOut = track.fadeOut ?? 0
|
|
408
|
+
const duckingEnabled = track.ducking?.enabled ?? false
|
|
409
|
+
const duckingDepth = track.ducking?.depth ?? -12
|
|
410
|
+
const duckingAttack = track.ducking?.attack ?? 0.3
|
|
411
|
+
const duckingRelease = track.ducking?.release ?? 0.5
|
|
412
|
+
const inPoint = track.inPoint ?? 0
|
|
413
|
+
const outPoint = track.outPoint ?? (track.sourceDuration ?? (track.end - track.start))
|
|
414
|
+
const start = track.start
|
|
415
|
+
const end = track.end
|
|
416
|
+
|
|
417
|
+
/** Constraints the retired ClipInspectModal enforced on these same fields.
|
|
418
|
+
* They are re-applied HERE, on the way out, rather than left to the number
|
|
419
|
+
* inputs' `min`/`max` attributes: a typed value bypasses those entirely, so
|
|
420
|
+
* without this an operator can commit `end <= start` (a negative-duration
|
|
421
|
+
* track), trim points outside the source, or a fade longer than the clip.
|
|
422
|
+
* Each clamp is applied only when its own field is the one being edited, so
|
|
423
|
+
* editing one field never silently rewrites another. */
|
|
424
|
+
function clampAudioPatch(patch: Partial<AudioTrack>): Partial<AudioTrack> {
|
|
425
|
+
const next = { ...patch }
|
|
426
|
+
const srcDur = track.sourceDuration ?? Infinity
|
|
427
|
+
|
|
428
|
+
// `end` must stay strictly after `start` (and vice versa). EPS rather than
|
|
429
|
+
// equality: a zero-length track is as broken as a negative one.
|
|
430
|
+
const EPS = 0.001
|
|
431
|
+
if (next.end !== undefined) next.end = Math.max(start + EPS, next.end)
|
|
432
|
+
if (next.start !== undefined) next.start = Math.min(end - EPS, Math.max(0, next.start))
|
|
433
|
+
|
|
434
|
+
// Trim points stay inside the source and never cross each other.
|
|
435
|
+
if (next.inPoint !== undefined) next.inPoint = Math.max(0, Math.min(next.inPoint, outPoint))
|
|
436
|
+
if (next.outPoint !== undefined) {
|
|
437
|
+
next.outPoint = Math.max(inPoint, srcDur === Infinity ? next.outPoint : Math.min(next.outPoint, srcDur))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// A fade can't outrun the clip it's fading, and the modal capped it at 5s.
|
|
441
|
+
const maxFade = Math.max(0, Math.min(5, (next.end ?? end) - (next.start ?? start)))
|
|
442
|
+
if (next.fadeIn !== undefined) next.fadeIn = Math.max(0, Math.min(next.fadeIn, maxFade))
|
|
443
|
+
if (next.fadeOut !== undefined) next.fadeOut = Math.max(0, Math.min(next.fadeOut, maxFade))
|
|
444
|
+
|
|
445
|
+
return next
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function previewAudio(patch: Partial<AudioTrack>) {
|
|
449
|
+
onPreviewAudio({ ...track, ...clampAudioPatch(patch) })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function previewDucking(patch: Partial<NonNullable<AudioTrack['ducking']>>) {
|
|
453
|
+
onPreviewAudio({
|
|
454
|
+
...track,
|
|
455
|
+
ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: duckingAttack, release: duckingRelease, ...patch },
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function toggleDucking(next: boolean) {
|
|
460
|
+
// Enabling seeds every field with its current-or-default value (never a
|
|
461
|
+
// half-empty object); disabling only flips the flag, so re-enabling
|
|
462
|
+
// brings back whatever the operator had tuned.
|
|
463
|
+
onChangeAudio({
|
|
464
|
+
...track,
|
|
465
|
+
ducking: next
|
|
466
|
+
? { enabled: true, depth: duckingDepth, attack: duckingAttack, release: duckingRelease }
|
|
467
|
+
: { ...track.ducking, enabled: false },
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<CollapsibleSection label="Audio track" collapsed={collapsed} onToggle={() => setCollapsed(c => !c)}>
|
|
473
|
+
<Row label="Label">
|
|
474
|
+
<DraftField
|
|
475
|
+
ariaLabel="Track label"
|
|
476
|
+
value={track.label ?? basename(track.src)}
|
|
477
|
+
onInput={raw => previewAudio({ label: raw })}
|
|
478
|
+
onCommit={raw => {
|
|
479
|
+
// Falls back to the source basename on save, exactly like
|
|
480
|
+
// ClipInspectModal's AudioInspect — an emptied label never
|
|
481
|
+
// persists as an unlabeled track.
|
|
482
|
+
const finalLabel = raw.trim() || basename(track.src)
|
|
483
|
+
onPreviewAudio({ ...track, label: finalLabel })
|
|
484
|
+
onCommitAudio()
|
|
485
|
+
}}
|
|
486
|
+
/>
|
|
487
|
+
</Row>
|
|
488
|
+
|
|
489
|
+
<VolumeControl
|
|
490
|
+
value={volume}
|
|
491
|
+
onChange={v => previewAudio({ volume: v })}
|
|
492
|
+
onCommit={() => onCommitAudio()}
|
|
493
|
+
label="Volume"
|
|
494
|
+
idBase="clip-properties-audio-volume"
|
|
495
|
+
/>
|
|
496
|
+
<Row label="Mute">
|
|
497
|
+
<Switch checked={muted} onCheckedChange={next => onChangeAudio({ ...track, muted: next })} aria-label="Mute track" mode={mode} />
|
|
498
|
+
</Row>
|
|
499
|
+
|
|
500
|
+
<CollapsibleSection label="Fades" collapsed={!fadesOpen} onToggle={() => setFadesOpen(o => !o)} nested>
|
|
501
|
+
<Row label="Fade in">
|
|
502
|
+
<NumberField
|
|
503
|
+
name="Fade in"
|
|
504
|
+
className="w-20"
|
|
505
|
+
value={fadeIn}
|
|
506
|
+
step={0.1}
|
|
507
|
+
min={0}
|
|
508
|
+
max={5}
|
|
509
|
+
// `min`/`max` on the box are a native spinner hint only (see
|
|
510
|
+
// NumberField's own doc comment). The REAL floor for this row —
|
|
511
|
+
// and for Fade out, Start, In point and Out point below — is
|
|
512
|
+
// `clampAudioPatch`, which `previewAudio` and every `onStep`
|
|
513
|
+
// handler on those fields route their patch through; it floors
|
|
514
|
+
// fadeIn/fadeOut/start/inPoint at 0 and derives floors for
|
|
515
|
+
// end/outPoint. The inline `Math.max(0, v)` calls on THIS group
|
|
516
|
+
// of fields are therefore redundant (harmless — matches the
|
|
517
|
+
// pre-refactor behaviour) rather than load-bearing.
|
|
518
|
+
//
|
|
519
|
+
// That is NOT true of the two DUCKING rows below (Attack,
|
|
520
|
+
// Release): `previewDucking` does no clamping of its own at all,
|
|
521
|
+
// so their `Math.max(0, v)` calls are the only floor those two
|
|
522
|
+
// fields have. Do not remove those thinking this comment covers
|
|
523
|
+
// them — they are the deliberate exception.
|
|
524
|
+
onPreview={v => previewAudio({ fadeIn: Math.max(0, v) })}
|
|
525
|
+
onCommit={() => onCommitAudio()}
|
|
526
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ fadeIn: stepValue(fadeIn, d, { step: 0.1, min: 0, max: 5 }) }) })}
|
|
527
|
+
/>
|
|
528
|
+
</Row>
|
|
529
|
+
<Row label="Fade out">
|
|
530
|
+
<NumberField
|
|
531
|
+
name="Fade out"
|
|
532
|
+
className="w-20"
|
|
533
|
+
value={fadeOut}
|
|
534
|
+
step={0.1}
|
|
535
|
+
min={0}
|
|
536
|
+
max={5}
|
|
537
|
+
onPreview={v => previewAudio({ fadeOut: Math.max(0, v) })}
|
|
538
|
+
onCommit={() => onCommitAudio()}
|
|
539
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ fadeOut: stepValue(fadeOut, d, { step: 0.1, min: 0, max: 5 }) }) })}
|
|
540
|
+
/>
|
|
541
|
+
</Row>
|
|
542
|
+
</CollapsibleSection>
|
|
543
|
+
|
|
544
|
+
<CollapsibleSection
|
|
545
|
+
label="Ducking"
|
|
546
|
+
collapsed={!duckingOpen}
|
|
547
|
+
onToggle={() => setDuckingOpen(o => !o)}
|
|
548
|
+
nested
|
|
549
|
+
badge={duckingEnabled
|
|
550
|
+
// ~10px accent text → the AA-safe accent-text token (indigo-600 in
|
|
551
|
+
// light, where the plain indigo-500 accent is ~4.06:1, under 4.5:1).
|
|
552
|
+
? <span className="text-[10px] text-[var(--editor-accent-text)]">On</span>
|
|
553
|
+
: undefined}
|
|
554
|
+
>
|
|
555
|
+
<Row label="Enabled">
|
|
556
|
+
<Switch checked={duckingEnabled} onCheckedChange={toggleDucking} aria-label="Enable ducking" mode={mode} />
|
|
557
|
+
</Row>
|
|
558
|
+
<Row label="Depth">
|
|
559
|
+
<NumberField
|
|
560
|
+
name="Depth"
|
|
561
|
+
className="w-20"
|
|
562
|
+
step={1}
|
|
563
|
+
disabled={!duckingEnabled}
|
|
564
|
+
value={duckingDepth}
|
|
565
|
+
// No floor: ducking depth is a dB attenuation and the modal this
|
|
566
|
+
// panel replaces never bounded it either.
|
|
567
|
+
onPreview={v => previewDucking({ depth: v })}
|
|
568
|
+
onCommit={() => onCommitAudio()}
|
|
569
|
+
onStep={d => onChangeAudio({
|
|
570
|
+
...track,
|
|
571
|
+
ducking: { enabled: duckingEnabled, depth: stepValue(duckingDepth, d, { step: 1 }), attack: duckingAttack, release: duckingRelease },
|
|
572
|
+
})}
|
|
573
|
+
/>
|
|
574
|
+
</Row>
|
|
575
|
+
<Row label="Attack">
|
|
576
|
+
<NumberField
|
|
577
|
+
name="Attack"
|
|
578
|
+
className="w-20"
|
|
579
|
+
min={0}
|
|
580
|
+
step={0.05}
|
|
581
|
+
disabled={!duckingEnabled}
|
|
582
|
+
value={duckingAttack}
|
|
583
|
+
onPreview={v => previewDucking({ attack: Math.max(0, v) })}
|
|
584
|
+
onCommit={() => onCommitAudio()}
|
|
585
|
+
onStep={d => onChangeAudio({
|
|
586
|
+
...track,
|
|
587
|
+
ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: stepValue(duckingAttack, d, { step: 0.05, min: 0 }), release: duckingRelease },
|
|
588
|
+
})}
|
|
589
|
+
/>
|
|
590
|
+
</Row>
|
|
591
|
+
<Row label="Release">
|
|
592
|
+
<NumberField
|
|
593
|
+
name="Release"
|
|
594
|
+
className="w-20"
|
|
595
|
+
min={0}
|
|
596
|
+
step={0.05}
|
|
597
|
+
disabled={!duckingEnabled}
|
|
598
|
+
value={duckingRelease}
|
|
599
|
+
onPreview={v => previewDucking({ release: Math.max(0, v) })}
|
|
600
|
+
onCommit={() => onCommitAudio()}
|
|
601
|
+
onStep={d => onChangeAudio({
|
|
602
|
+
...track,
|
|
603
|
+
ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: duckingAttack, release: stepValue(duckingRelease, d, { step: 0.05, min: 0 }) },
|
|
604
|
+
})}
|
|
605
|
+
/>
|
|
606
|
+
</Row>
|
|
607
|
+
</CollapsibleSection>
|
|
608
|
+
|
|
609
|
+
{/* Timeline position. Plain writes, exactly as the modal saved them (no
|
|
610
|
+
derived math, unlike a clip speed change): these are the same values a
|
|
611
|
+
drag on the timeline sets, offered numerically so the panel is full
|
|
612
|
+
parity with the modal it replaces and the modal can retire outright. */}
|
|
613
|
+
<Row label="Start">
|
|
614
|
+
<NumberField
|
|
615
|
+
name="Start"
|
|
616
|
+
className="w-20"
|
|
617
|
+
min={0}
|
|
618
|
+
step={0.1}
|
|
619
|
+
value={start}
|
|
620
|
+
onPreview={v => previewAudio({ start: Math.max(0, v) })}
|
|
621
|
+
onCommit={() => onCommitAudio()}
|
|
622
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ start: stepValue(start, d, { step: 0.1, min: 0 }) }) })}
|
|
623
|
+
/>
|
|
624
|
+
</Row>
|
|
625
|
+
<Row label="End">
|
|
626
|
+
<NumberField
|
|
627
|
+
name="End"
|
|
628
|
+
className="w-20"
|
|
629
|
+
min={0}
|
|
630
|
+
step={0.1}
|
|
631
|
+
value={end}
|
|
632
|
+
onPreview={v => previewAudio({ end: Math.max(0, v) })}
|
|
633
|
+
onCommit={() => onCommitAudio()}
|
|
634
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ end: stepValue(end, d, { step: 0.1, min: 0 }) }) })}
|
|
635
|
+
/>
|
|
636
|
+
</Row>
|
|
637
|
+
<Row label="In point">
|
|
638
|
+
<NumberField
|
|
639
|
+
name="In point"
|
|
640
|
+
className="w-20"
|
|
641
|
+
min={0}
|
|
642
|
+
step={0.1}
|
|
643
|
+
value={inPoint}
|
|
644
|
+
onPreview={v => previewAudio({ inPoint: Math.max(0, v) })}
|
|
645
|
+
onCommit={() => onCommitAudio()}
|
|
646
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ inPoint: stepValue(inPoint, d, { step: 0.1, min: 0 }) }) })}
|
|
647
|
+
/>
|
|
648
|
+
</Row>
|
|
649
|
+
<Row label="Out point">
|
|
650
|
+
<NumberField
|
|
651
|
+
name="Out point"
|
|
652
|
+
className="w-20"
|
|
653
|
+
min={0}
|
|
654
|
+
step={0.1}
|
|
655
|
+
value={outPoint}
|
|
656
|
+
onPreview={v => previewAudio({ outPoint: Math.max(0, v) })}
|
|
657
|
+
onCommit={() => onCommitAudio()}
|
|
658
|
+
onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ outPoint: stepValue(outPoint, d, { step: 0.1, min: 0 }) }) })}
|
|
659
|
+
/>
|
|
660
|
+
</Row>
|
|
661
|
+
</CollapsibleSection>
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export default function ClipPropertiesPanel({
|
|
666
|
+
selection,
|
|
667
|
+
onPreviewClip,
|
|
668
|
+
onCommitClip,
|
|
669
|
+
onChangeClip,
|
|
670
|
+
onPreviewAudio,
|
|
671
|
+
onCommitAudio,
|
|
672
|
+
onChangeAudio,
|
|
673
|
+
transformSlot,
|
|
674
|
+
onOpenCrop,
|
|
675
|
+
generationSlot,
|
|
676
|
+
mode = 'dark',
|
|
677
|
+
}: ClipPropertiesPanelProps) {
|
|
678
|
+
if (!selection) {
|
|
679
|
+
return (
|
|
680
|
+
<div className={SECTION_CLASS}>
|
|
681
|
+
<div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]/45">
|
|
682
|
+
Select a clip to edit its properties.
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (selection.kind === 'clip') {
|
|
689
|
+
return (
|
|
690
|
+
<ClipTabs
|
|
691
|
+
item={selection.item}
|
|
692
|
+
onPreviewClip={onPreviewClip}
|
|
693
|
+
onCommitClip={onCommitClip}
|
|
694
|
+
onChangeClip={onChangeClip}
|
|
695
|
+
transformSlot={transformSlot}
|
|
696
|
+
onOpenCrop={onOpenCrop}
|
|
697
|
+
generationSlot={generationSlot}
|
|
698
|
+
mode={mode}
|
|
699
|
+
/>
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
<AudioSection track={selection.track} onPreviewAudio={onPreviewAudio} onCommitAudio={onCommitAudio} onChangeAudio={onChangeAudio} mode={mode} />
|
|
705
|
+
)
|
|
706
|
+
}
|