@dailies-studio/shot-editor 0.1.0 → 0.2.0
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/dist/.vite/manifest.json +382 -0
- package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
- package/dist/assets/AgentPanel-DJEphah0.css +1 -0
- package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
- package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
- package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
- package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
- package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
- package/dist/assets/CurvePane-BjFVabKX.css +1 -0
- package/dist/assets/CurvePane-R22WzllJ.js +1 -0
- package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
- package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
- package/dist/assets/FontPicker-v0IOIArM.css +1 -0
- package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
- package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
- package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
- package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
- package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
- package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
- package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
- package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
- package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
- package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
- package/dist/assets/SoundMode-BmcZru3i.js +3 -0
- package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
- package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
- package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
- package/dist/assets/board-B88EPuLO.css +1 -0
- package/dist/assets/board-CL-he7SL.js +3 -0
- package/dist/assets/components-0oOSPN0l.js +1 -0
- package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
- package/dist/assets/googleFonts-BNon6M7X.js +1 -0
- package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
- package/dist/assets/index-4F5NQmI8.css +1 -0
- package/dist/assets/index-BGHQGV5z.js +117 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/libraryModel-CpIwfFha.js +1 -0
- package/dist/assets/lucide-6iK64tTB.js +9319 -0
- package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
- package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
- package/dist/assets/webmcp-BsB9gxWC.js +2 -0
- package/dist/index.html +34 -3
- package/package.json +12 -9
- package/src/addFamilies.ts +66 -0
- package/src/api.ts +1631 -55
- package/src/app/App.tsx +977 -198
- package/src/app/lazyPanel.tsx +137 -0
- package/src/app/lazySurfaces.tsx +203 -0
- package/src/audio/index.tsx +41 -10
- package/src/audio/meter.ts +485 -0
- package/src/audio/meterMath.ts +273 -0
- package/src/audio/meterStore.ts +155 -0
- package/src/audio/offlineMix.ts +289 -0
- package/src/audio/preview.ts +928 -118
- package/src/bridge.ts +15 -5
- package/src/bridgeTypes.ts +1611 -36
- package/src/channels.ts +17 -32
- package/src/fonts/googleFonts.ts +4 -0
- package/src/hostApi.ts +174 -0
- package/src/icons.tsx +770 -0
- package/src/layerWords.ts +61 -0
- package/src/main.tsx +56 -4
- package/src/panels/ResizeHandle.tsx +33 -43
- package/src/panels/agent/AgentDots.tsx +36 -0
- package/src/panels/agent/AgentPanel.tsx +119 -43
- package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
- package/src/panels/agent/agent.css +113 -97
- package/src/panels/agent/agentGate.ts +35 -0
- package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
- package/src/panels/code/CodeStudio.tsx +373 -0
- package/src/panels/code/FileTree.tsx +265 -0
- package/src/panels/code/codeBufferGuard.ts +52 -0
- package/src/panels/code/codeParts.tsx +321 -0
- package/src/panels/comments/CommentComposer.tsx +239 -0
- package/src/panels/comments/CommentThread.tsx +346 -0
- package/src/panels/comments/CommentsPanel.tsx +336 -0
- package/src/panels/comments/InboxButton.tsx +131 -0
- package/src/panels/comments/MentionMenu.tsx +98 -0
- package/src/panels/comments/parts.tsx +171 -0
- package/src/panels/export/ExportDialog.tsx +919 -96
- package/src/panels/export/ExportScrim.tsx +57 -0
- package/src/panels/files/AssetsTab.tsx +656 -0
- package/src/panels/files/FilePanel.tsx +1466 -216
- package/src/panels/files/LayerMarks.tsx +40 -0
- package/src/panels/files/LeftColumn.tsx +37 -0
- package/src/panels/files/McpSection.tsx +690 -47
- package/src/panels/files/MissingAssetsNotice.tsx +60 -0
- package/src/panels/files/assetDrag.ts +79 -0
- package/src/panels/files/resourceParts.tsx +255 -0
- package/src/panels/fonts/FontPicker.tsx +762 -0
- package/src/panels/fonts/faceCache.ts +207 -0
- package/src/panels/fonts/fontPicker.css +271 -0
- package/src/panels/fonts/pickerModel.ts +265 -0
- package/src/panels/fonts/pickerRequest.ts +46 -0
- package/src/panels/header/AccountAvatar.tsx +76 -0
- package/src/panels/header/AgentChip.tsx +77 -0
- package/src/panels/header/AppMenu.tsx +213 -0
- package/src/panels/header/Header.tsx +564 -84
- package/src/panels/header/HistoryControls.tsx +31 -5
- package/src/panels/header/PresenceStack.tsx +329 -0
- package/src/panels/header/ProjectTitle.tsx +232 -0
- package/src/panels/header/ShareButton.tsx +59 -0
- package/src/panels/header/StatusBadges.tsx +358 -27
- package/src/panels/header/SyncDeletedDialog.tsx +88 -0
- package/src/panels/header/ZoomControls.tsx +46 -15
- package/src/panels/header/agentHandoff.ts +52 -0
- package/src/panels/header/leave.ts +26 -0
- package/src/panels/help/HelpSection.tsx +189 -163
- package/src/panels/insert/InsertDrawer.tsx +723 -0
- package/src/panels/insert/iconSet.ts +109 -0
- package/src/panels/inspector/ExposeDot.tsx +117 -0
- package/src/panels/inspector/FramingRows.tsx +314 -44
- package/src/panels/inspector/InspectorFooter.tsx +91 -0
- package/src/panels/inspector/InspectorHead.tsx +425 -0
- package/src/panels/inspector/InspectorPanel.tsx +408 -124
- package/src/panels/inspector/KeyframeDot.tsx +373 -0
- package/src/panels/inspector/KeyframeSection.tsx +257 -0
- package/src/panels/inspector/MarkerSection.tsx +55 -11
- package/src/panels/inspector/ParamRow.tsx +84 -53
- package/src/panels/inspector/RightColumn.tsx +152 -0
- package/src/panels/inspector/TimeSection.tsx +866 -0
- package/src/panels/inspector/animate/EaseControl.tsx +244 -0
- package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
- package/src/panels/inspector/animate/ShotSection.tsx +562 -48
- package/src/panels/inspector/design/AlignRow.tsx +95 -31
- package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
- package/src/panels/inspector/design/ClonerSection.tsx +323 -62
- package/src/panels/inspector/design/ComponentSection.tsx +50 -43
- package/src/panels/inspector/design/CornerSection.tsx +45 -14
- package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
- package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
- package/src/panels/inspector/design/DeviceSection.tsx +210 -0
- package/src/panels/inspector/design/FillSection.tsx +83 -51
- package/src/panels/inspector/design/FontField.tsx +196 -0
- package/src/panels/inspector/design/HtmlSection.tsx +23 -12
- package/src/panels/inspector/design/InstanceSection.tsx +373 -154
- package/src/panels/inspector/design/MaskSection.tsx +84 -0
- package/src/panels/inspector/design/MaterialSection.tsx +177 -0
- package/src/panels/inspector/design/MediaSection.tsx +273 -120
- package/src/panels/inspector/design/MeshSection.tsx +910 -82
- package/src/panels/inspector/design/PaintRows.tsx +550 -0
- package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
- package/src/panels/inspector/design/ShadowSection.tsx +221 -48
- package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
- package/src/panels/inspector/design/StrokeSection.tsx +228 -43
- package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
- package/src/panels/inspector/design/TransformSection.tsx +244 -50
- package/src/panels/inspector/design/TypographySection.tsx +679 -110
- package/src/panels/inspector/layout.ts +295 -0
- package/src/panels/inspector/propValue.ts +199 -0
- package/src/panels/inspector/scene/CameraSection.tsx +232 -22
- package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
- package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
- package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
- package/src/panels/inspector/scene/SceneSection.tsx +219 -44
- package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
- package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
- package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
- package/src/panels/inspector/timeModel.ts +190 -0
- package/src/panels/inspector/useDocWrite.ts +438 -146
- package/src/panels/library/LibraryBrowser.tsx +435 -0
- package/src/panels/library/LibraryGrid.tsx +190 -0
- package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
- package/src/panels/library/LibraryRail.tsx +84 -0
- package/src/panels/library/LibrarySoundPanel.tsx +293 -0
- package/src/panels/library/SoundHistory.tsx +245 -0
- package/src/panels/library/StockSounds.tsx +390 -0
- package/src/panels/library/libraryModel.ts +147 -0
- package/src/panels/library/stockSearch.ts +77 -0
- package/src/panels/maskMarks.tsx +117 -0
- package/src/panels/material/MaterialCommentNode.tsx +54 -0
- package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
- package/src/panels/material/MaterialGraphMenu.tsx +45 -0
- package/src/panels/material/MaterialGroupNode.tsx +56 -0
- package/src/panels/material/MaterialLibrary.tsx +253 -0
- package/src/panels/material/MaterialNode.tsx +453 -0
- package/src/panels/material/MaterialNodePalette.tsx +349 -0
- package/src/panels/material/MaterialParameterControls.tsx +525 -0
- package/src/panels/material/MaterialRerouteNode.tsx +42 -0
- package/src/panels/material/MaterialWorkspace.tsx +501 -0
- package/src/panels/material/NodePreview.tsx +70 -0
- package/src/panels/material/flowModel.ts +441 -0
- package/src/panels/material/parameterValues.ts +231 -0
- package/src/panels/material/previewScheduler.ts +235 -0
- package/src/panels/material/reactFlow.css +6 -0
- package/src/panels/material/socketGlyph.ts +85 -0
- package/src/panels/material/viewportMemory.ts +58 -0
- package/src/panels/preferences/PreferencesDialog.tsx +215 -0
- package/src/panels/share/ShareComponentDialog.tsx +247 -0
- package/src/panels/stage/StageMenu.tsx +35 -5
- package/src/panels/stage/StagePanel.tsx +338 -23
- package/src/panels/stage/cellSlots.tsx +70 -0
- package/src/panels/storyboard/BoardCanvas.tsx +739 -0
- package/src/panels/storyboard/BoardCursors.tsx +158 -0
- package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
- package/src/panels/storyboard/BoardPlan.tsx +42 -0
- package/src/panels/storyboard/ComponentSheet.tsx +187 -0
- package/src/panels/storyboard/ComponentStage.tsx +235 -0
- package/src/panels/storyboard/MiniSketch.tsx +173 -0
- package/src/panels/storyboard/PartPicker.tsx +64 -0
- package/src/panels/storyboard/PlanPanel.tsx +617 -0
- package/src/panels/storyboard/ReferencePreview.tsx +217 -0
- package/src/panels/storyboard/ShotBrief.tsx +360 -0
- package/src/panels/storyboard/ShotCard.tsx +251 -0
- package/src/panels/storyboard/StagePreview.tsx +348 -0
- package/src/panels/storyboard/StillThumbs.tsx +162 -0
- package/src/panels/storyboard/StoryboardView.tsx +79 -0
- package/src/panels/storyboard/board.css +1065 -0
- package/src/panels/storyboard/board.ts +12 -0
- package/src/panels/storyboard/boardClipboard.ts +111 -0
- package/src/panels/storyboard/boardOrder.ts +68 -0
- package/src/panels/storyboard/boardRows.tsx +425 -0
- package/src/panels/storyboard/liveCap.ts +90 -0
- package/src/panels/storyboard/plan.css +295 -0
- package/src/panels/storyboard/posterCache.ts +133 -0
- package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
- package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
- package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
- package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
- package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
- package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
- package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
- package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
- package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
- package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
- package/src/panels/storyboard/shotboard/sketch.css +1392 -0
- package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
- package/src/panels/storyboard/storyboardDrop.ts +164 -0
- package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
- package/src/panels/studio/ComponentSidePanel.tsx +355 -0
- package/src/panels/studio/ComponentStudio.tsx +243 -0
- package/src/panels/studio/ComponentsHome.tsx +318 -0
- package/src/panels/studio/ComponentsPreview.tsx +179 -0
- package/src/panels/studio/EveryUse.tsx +244 -0
- package/src/panels/studio/ThisUsePanel.tsx +679 -0
- package/src/panels/studio/componentParts.tsx +341 -0
- package/src/panels/studio/components.ts +6 -0
- package/src/panels/studio/newCodeComponent.ts +66 -0
- package/src/panels/timeline/AudioFxButton.tsx +290 -0
- package/src/panels/timeline/AudioLane.tsx +674 -85
- package/src/panels/timeline/BlockRow.tsx +154 -85
- package/src/panels/timeline/ChannelRow.tsx +378 -129
- package/src/panels/timeline/CurvePane.tsx +508 -0
- package/src/panels/timeline/Filmstrip.tsx +361 -0
- package/src/panels/timeline/GenerateDrawer.tsx +407 -0
- package/src/panels/timeline/KeyStretch.tsx +115 -0
- package/src/panels/timeline/LifespanRow.tsx +730 -47
- package/src/panels/timeline/MarkerLane.tsx +46 -8
- package/src/panels/timeline/PlayButton.tsx +91 -0
- package/src/panels/timeline/PropertyPicker.tsx +254 -0
- package/src/panels/timeline/ShotRow.tsx +400 -0
- package/src/panels/timeline/SoundMode.tsx +1406 -0
- package/src/panels/timeline/TimelineDock.tsx +2338 -272
- package/src/panels/timeline/TransportVolume.tsx +163 -0
- package/src/panels/timeline/beatRuler.ts +74 -0
- package/src/panels/timeline/curveModel.ts +500 -0
- package/src/panels/timeline/duckUnderVoice.ts +68 -0
- package/src/panels/timeline/keyframeMenu.ts +138 -0
- package/src/panels/timeline/labelWidth.ts +49 -0
- package/src/panels/timeline/marquee.ts +155 -0
- package/src/panels/timeline/model.ts +1860 -199
- package/src/panels/timeline/snap.ts +125 -43
- package/src/panels/timeline/staggerPreview.tsx +92 -0
- package/src/panels/timeline/takeMeasure.ts +72 -0
- package/src/panels/timeline/useLive.ts +48 -0
- package/src/panels/timeline/viewport.ts +234 -0
- package/src/panels/toolbar/BackChip.tsx +116 -0
- package/src/panels/toolbar/CameraViewBar.tsx +118 -49
- package/src/panels/toolbar/CanvasBar.tsx +117 -0
- package/src/panels/toolbar/InsertButton.tsx +38 -0
- package/src/panels/toolbar/IsolationTrail.tsx +156 -0
- package/src/panels/toolbar/PanelToggles.tsx +86 -0
- package/src/panels/toolbar/ShadingBar.tsx +119 -26
- package/src/panels/toolbar/ToolDockBar.tsx +329 -80
- package/src/panels/toolbar/ToolFooter.tsx +102 -0
- package/src/panels/toolbar/ViewModeBar.tsx +247 -38
- package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
- package/src/panels/toolbar/useAddRows.tsx +326 -0
- package/src/panels/toolbar/useDismiss.ts +122 -0
- package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
- package/src/panels/versions/versionModel.ts +103 -0
- package/src/panels/virtualWindow.ts +271 -0
- package/src/shotTokens.css +151 -60
- package/src/stage/FrustumGizmo.tsx +504 -84
- package/src/stage/GridPlane.tsx +225 -16
- package/src/stage/NodePreviews.tsx +628 -0
- package/src/stage/PerfHud.tsx +437 -47
- package/src/stage/SplitRenderer.tsx +816 -169
- package/src/stage/StageApp.tsx +4274 -486
- package/src/stage/StageRootBoundary.tsx +114 -0
- package/src/stage/StageTools.tsx +651 -151
- package/src/stage/TransformGizmo.tsx +696 -0
- package/src/stage/adaptivePresentation.ts +145 -0
- package/src/stage/bridgeClient.ts +2522 -41
- package/src/stage/exportWorker.ts +9 -0
- package/src/stage/feed.ts +305 -14
- package/src/stage/fontPreview.ts +176 -0
- package/src/stage/frustumGeometry.ts +319 -1
- package/src/stage/furnitureTokens.ts +33 -0
- package/src/stage/gizmoMath.ts +508 -16
- package/src/stage/gpuTimer.ts +276 -0
- package/src/stage/instanceModules.ts +194 -33
- package/src/stage/interactions/move.ts +24 -5
- package/src/stage/isolation.ts +291 -33
- package/src/stage/jsSelfProfiler.ts +325 -0
- package/src/stage/main.tsx +116 -16
- package/src/stage/modalState.ts +222 -23
- package/src/stage/moduleEpoch.ts +60 -0
- package/src/stage/motionPathStage.ts +279 -0
- package/src/stage/motionPreview.ts +74 -0
- package/src/stage/overlays/Overlays.tsx +1112 -159
- package/src/stage/overlays/Rulers.tsx +495 -0
- package/src/stage/panes.ts +159 -10
- package/src/stage/project.ts +68 -3
- package/src/stage/renderScaleController.ts +730 -0
- package/src/stage/rulers.ts +129 -0
- package/src/stage/sceneExtent.ts +75 -0
- package/src/stage/shading.ts +20 -0
- package/src/stage/splineStage.ts +632 -0
- package/src/stage/stagePerformanceTelemetry.ts +717 -0
- package/src/stage/stageTheme.ts +36 -0
- package/src/stage/stageWarmup.ts +11 -0
- package/src/stage/textAutoSize.ts +124 -0
- package/src/stage/tier1Export.ts +641 -0
- package/src/stage/tokenColor.ts +14 -9
- package/src/stage/view.ts +56 -9
- package/src/stage/workingView.ts +277 -33
- package/src/state/accountPrefs.ts +261 -0
- package/src/state/agentActions.ts +173 -15
- package/src/state/agentActivity.ts +198 -0
- package/src/state/agentQuestion.ts +239 -0
- package/src/state/bootOpen.ts +42 -0
- package/src/state/cameraVerbs.ts +139 -0
- package/src/state/clientId.ts +6 -1
- package/src/state/comments.ts +1099 -0
- package/src/state/context.tsx +19 -1
- package/src/state/editorPrefs.ts +220 -0
- package/src/state/errorReports.ts +121 -0
- package/src/state/fieldWrite.ts +161 -0
- package/src/state/fontRecents.ts +70 -0
- package/src/state/fonts.ts +656 -0
- package/src/state/graphClipboard.ts +414 -0
- package/src/state/imageThumbs.ts +216 -0
- package/src/state/insert.ts +444 -67
- package/src/state/isolationTrail.ts +92 -0
- package/src/state/keyMoves.ts +134 -0
- package/src/state/keyframeClipboard.ts +177 -0
- package/src/state/keymap.ts +1153 -117
- package/src/state/layoutPrefs.ts +272 -0
- package/src/state/layouts.ts +205 -0
- package/src/state/measureMedia.ts +167 -0
- package/src/state/menu.ts +2341 -28
- package/src/state/motionStamp.ts +7 -0
- package/src/state/panelSize.ts +375 -5
- package/src/state/pendingChanges.ts +119 -0
- package/src/state/playHold.ts +78 -0
- package/src/state/playbackPreparation.ts +562 -0
- package/src/state/posterCapture.ts +242 -0
- package/src/state/presence.ts +254 -0
- package/src/state/projectComponents.ts +160 -0
- package/src/state/renderEvents.ts +298 -18
- package/src/state/runtimeErrors.ts +46 -0
- package/src/state/selection.ts +12 -0
- package/src/state/shortcuts.ts +1606 -0
- package/src/state/sketchGuide.ts +51 -0
- package/src/state/splineRefs.ts +87 -0
- package/src/state/stageHealth.ts +259 -0
- package/src/state/stageWatchdog.ts +47 -0
- package/src/state/stillStage.ts +330 -0
- package/src/state/stillThumbs.ts +474 -0
- package/src/state/store.ts +23591 -2982
- package/src/state/storyboardModel.ts +528 -0
- package/src/state/sync.ts +1257 -0
- package/src/state/syncCache.ts +159 -0
- package/src/state/triangles.ts +13 -0
- package/src/state/types.ts +1698 -55
- package/src/state/unsavedGuard.ts +49 -0
- package/src/state/viewMode.ts +200 -0
- package/src/state/webmcp.ts +1128 -0
- package/src/state/workspace.ts +1147 -0
- package/src/styles/assets-tab.css +304 -0
- package/src/styles/canvas.css +545 -0
- package/src/styles/comments.css +1435 -0
- package/src/styles/controls.css +162 -0
- package/src/styles/dialogs.css +390 -0
- package/src/styles/insert-drawer.css +586 -0
- package/src/styles/inspector-board.css +207 -0
- package/src/styles/inspector-object.css +486 -0
- package/src/styles/inspector-shot.css +252 -0
- package/src/styles/inspector.css +360 -0
- package/src/styles/left-column.css +1628 -0
- package/src/styles/library-preview.css +575 -0
- package/src/styles/library.css +512 -0
- package/src/styles/modes/code.css +569 -0
- package/src/styles/modes/component.css +1561 -0
- package/src/styles/modes/material.css +1201 -0
- package/src/styles/modes/storyboard.css +518 -0
- package/src/styles/share-component.css +49 -0
- package/src/styles/shell.css +653 -0
- package/src/styles/shot-brief.css +360 -0
- package/src/styles/states.css +76 -0
- package/src/styles/sync.css +380 -0
- package/src/styles/timeline-graph.css +384 -0
- package/src/styles/timeline-meters.css +89 -0
- package/src/styles/timeline-rows.css +1959 -0
- package/src/styles/timeline-sound.css +1003 -0
- package/src/styles/timeline.css +754 -0
- package/src/styles/tool-footer.css +525 -0
- package/src/styles/topbar.css +886 -0
- package/src/styles/versions.css +262 -0
- package/src/styles.css +72 -2921
- package/src/tools.ts +108 -23
- package/dist/assets/index-B7notge7.css +0 -1
- package/dist/assets/index-BBE6Ta9L.js +0 -52
- package/src/panels/files/icons.tsx +0 -118
- package/src/panels/header/DocSwitcher.tsx +0 -85
- package/src/panels/header/TransportBar.tsx +0 -86
- package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
- package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
- package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
- package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
- package/src/panels/inspector/animate/useBlocks.ts +0 -43
- package/src/panels/material/MaterialPicker.tsx +0 -172
- package/src/panels/timeline/SceneStrip.tsx +0 -145
- package/src/panels/toolbar/AddMenu.tsx +0 -230
- package/src/stage/MoveGizmo.tsx +0 -411
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
// SOUND MODE: the dock as a small DAW (gap 8).
|
|
2
|
+
//
|
|
3
|
+
// ── ONE ROW PER TRACK, AND THE TRACK IS THE MIXER ───────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Rows mode draws audio by HOME: the bed at the bottom, a shot's own clips
|
|
6
|
+
// inside its group. Sound mode draws it by TRACK (`doc.audioTracks`, schema
|
|
7
|
+
// v39), one 72px row each, because the question here is "what plays with
|
|
8
|
+
// what", and a track is the answer the mixer gives. Both homes still exist and
|
|
9
|
+
// both draw on their track's row: a shot's clip wears a "Shot 02" badge and is
|
|
10
|
+
// placed from its shot's start, exactly as `SceneAudioRow` places it.
|
|
11
|
+
//
|
|
12
|
+
// A recording's `video.sound` is drawn on its track too, READ-ONLY: its
|
|
13
|
+
// fields live on the layer, and Rows mode's video row is where it is trimmed.
|
|
14
|
+
// It can still change track, because a track is all Sound mode owns.
|
|
15
|
+
//
|
|
16
|
+
// ── WHAT IS SAVED AND WHAT IS NOT ──────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// Name, level, pan and mute are the document's (`AudioTrack3D`), so an export
|
|
19
|
+
// hears them. SOLO is this tab's (`soloTracks`): a listening aid, never a mix
|
|
20
|
+
// decision, and a solo saved by accident would silence a render. The master
|
|
21
|
+
// strip is a meter and nothing else: the document has no master gain, and the
|
|
22
|
+
// preview volume (this tab's, like solo) is the transport's speaker, so the
|
|
23
|
+
// mixer never shows a second level an export ignores.
|
|
24
|
+
//
|
|
25
|
+
// ── ONE HOME FOR EACH CONTROL ──────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Level and pan live on the mixer's strips only. The track row's strip keeps
|
|
28
|
+
// the name and M/S, the two things an author reaches for while looking at the
|
|
29
|
+
// waveforms. Two faders for one gain were two places to wonder which one won.
|
|
30
|
+
//
|
|
31
|
+
// ── EVERY STRIP DRAG IS ONE UNDO ───────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// Fader and pan drags go through the dock's one history scrub
|
|
34
|
+
// (`beginBlockDrag` / `dragAudioTrack` / `endBlockDrag`), like a clip drag:
|
|
35
|
+
// one undo entry per gesture, and Escape puts the base back by reference.
|
|
36
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
37
|
+
import type {
|
|
38
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
39
|
+
MouseEvent as ReactMouseEvent,
|
|
40
|
+
PointerEvent as ReactPointerEvent,
|
|
41
|
+
ReactNode,
|
|
42
|
+
} from "react";
|
|
43
|
+
import { frameToPx, startDrag } from "@dailies-studio/timeline";
|
|
44
|
+
import { Popover, Toggle, useConfirm } from "@dailies-studio/ui";
|
|
45
|
+
import type { MenuEntry, UseContextMenuResult } from "@dailies-studio/ui";
|
|
46
|
+
import {
|
|
47
|
+
MAX_AUDIO_GAIN,
|
|
48
|
+
TEMPO_BPM_MAX,
|
|
49
|
+
TEMPO_BPM_MIN,
|
|
50
|
+
addAudioTrack,
|
|
51
|
+
audioAnalysisFileName,
|
|
52
|
+
audioTrackOf,
|
|
53
|
+
audioTracksOf,
|
|
54
|
+
authoredAudioClips,
|
|
55
|
+
sceneWindows,
|
|
56
|
+
setAudioClipTrack,
|
|
57
|
+
} from "@dailies-studio/shot/doc";
|
|
58
|
+
import type { AudioClipRef, AudioTrack3D, Doc3D, Tempo3D } from "@dailies-studio/shot/doc";
|
|
59
|
+
import { fetchAssetBytes, generationStatus } from "../../api.js";
|
|
60
|
+
import { meterChannelKey, useMeterReading, useMeterTap } from "../../audio/meter.js";
|
|
61
|
+
import { METER_FLOOR_DB } from "../../audio/meterMath.js";
|
|
62
|
+
import { MASTER_METER_KEY, readMonitor, subscribeMonitor } from "../../audio/preview.js";
|
|
63
|
+
import { ADD_ICON, CLOSE_ICON, LIBRARY_ICON, LOCK_ICON, METRONOME_ICON, SPARKLES_ICON } from "../../icons.js";
|
|
64
|
+
import { useShotEditor, useShotEditorHot, useStore } from "../../state/context.js";
|
|
65
|
+
import { gateForViewer } from "../../state/menu.js";
|
|
66
|
+
import { formatCombo, shortcutFor } from "../../state/shortcuts.js";
|
|
67
|
+
import { shotNumberOf } from "../../state/storyboardModel.js";
|
|
68
|
+
import { AUDIO_FILE_ACCEPT, AudioClipBar, AudioWaveform, clipLabel, importEach } from "./AudioLane.js";
|
|
69
|
+
import { voiceDuckState } from "./duckUnderVoice.js";
|
|
70
|
+
import type { BeatTick } from "./beatRuler.js";
|
|
71
|
+
import type { SnapPress } from "./snap.js";
|
|
72
|
+
|
|
73
|
+
type Store = ReturnType<typeof useStore>;
|
|
74
|
+
type OpenMenu = UseContextMenuResult["open"];
|
|
75
|
+
|
|
76
|
+
/** Mirrors of timeline-sound.css. */
|
|
77
|
+
export const SOUND_ROW_H = 72;
|
|
78
|
+
export const SOUND_SHOTS_ROW_H = 24;
|
|
79
|
+
/** The clip bar's inner height in a track row: the row less its 1px seam, the
|
|
80
|
+
* bar's 8px insets and its 1px border. */
|
|
81
|
+
const SOUND_WAVE_H = SOUND_ROW_H - 1 - 2 * 8 - 2;
|
|
82
|
+
/** A press that travels less than this is a click, not a rubber band. */
|
|
83
|
+
const MARQUEE_MIN_PX = 3;
|
|
84
|
+
|
|
85
|
+
const MINUS = "−";
|
|
86
|
+
|
|
87
|
+
// ── THE FADER LAW ────────────────────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// Unity sits at three quarters of the travel, as on a console, so the useful
|
|
90
|
+
// range (cuts of a few dB) gets most of the throw and +6 dB gets the last
|
|
91
|
+
// quarter. `pos = (gain / 2) ^ log2(4/3)` puts gain 1 at exactly 0.75.
|
|
92
|
+
const FADER_EXP = Math.log2(4 / 3);
|
|
93
|
+
/** Within this much travel of unity, a drag lands ON unity. */
|
|
94
|
+
const UNITY_DETENT = 0.012;
|
|
95
|
+
|
|
96
|
+
/** A track gain (0..2) as fader travel (0..1). */
|
|
97
|
+
export function faderPos(gain: number): number {
|
|
98
|
+
if (!(gain > 0)) return 0;
|
|
99
|
+
return Math.min(1, (gain / MAX_AUDIO_GAIN) ** FADER_EXP);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fader travel (0..1) as a gain (0..2), with the unity detent. Rounded to
|
|
103
|
+
* three places so a drag writes numbers a person could have typed. */
|
|
104
|
+
export function faderGain(pos: number): number {
|
|
105
|
+
const p = Math.min(1, Math.max(0, pos));
|
|
106
|
+
if (p <= 0) return 0;
|
|
107
|
+
if (Math.abs(p - faderPos(1)) < UNITY_DETENT) return 1;
|
|
108
|
+
return Math.round(MAX_AUDIO_GAIN * p ** (1 / FADER_EXP) * 1000) / 1000;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A gain as dB, one decimal: "0.0 dB", "−3.0 dB", "+1.5 dB", "−∞ dB". */
|
|
112
|
+
export function dbLabel(gain: number, unit = true): string {
|
|
113
|
+
const tail = unit ? " dB" : "";
|
|
114
|
+
if (!(gain > 0)) return `${MINUS}∞${tail}`;
|
|
115
|
+
let text = (20 * Math.log10(gain)).toFixed(1);
|
|
116
|
+
if (text === "-0.0") text = "0.0";
|
|
117
|
+
if (text.startsWith("-")) text = `${MINUS}${text.slice(1)}`;
|
|
118
|
+
else if (text !== "0.0") text = `+${text}`;
|
|
119
|
+
return `${text}${tail}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The BPM chip's text: "120 BPM · 4/4". */
|
|
123
|
+
export function tempoLabel(tempo: Tempo3D): string {
|
|
124
|
+
const bpm = Math.round(tempo.bpm * 100) / 100;
|
|
125
|
+
return `${bpm} BPM · ${Math.max(1, Math.trunc(tempo.beatsPerBar ?? 4))}/4`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── THE ROWS ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** One clip on a row, in either home, or a recording's read-only sound. */
|
|
131
|
+
interface RowClip {
|
|
132
|
+
clip: AudioClipRef;
|
|
133
|
+
/** Master frame the clip's own frame base starts at. */
|
|
134
|
+
offsetFrames: number;
|
|
135
|
+
sceneId: string | null;
|
|
136
|
+
/** Set for a recording's sound (read-only). */
|
|
137
|
+
layerId?: string;
|
|
138
|
+
masterStart: number;
|
|
139
|
+
masterEnd: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Every clip by track id, in both homes plus the recordings' sounds. */
|
|
143
|
+
function clipsByTrack(doc: Doc3D): Map<string, RowClip[]> {
|
|
144
|
+
const out = new Map<string, RowClip[]>();
|
|
145
|
+
const push = (trackId: string, row: RowClip): void => {
|
|
146
|
+
const list = out.get(trackId);
|
|
147
|
+
if (list === undefined) out.set(trackId, [row]);
|
|
148
|
+
else list.push(row);
|
|
149
|
+
};
|
|
150
|
+
const span = (clip: AudioClipRef, offset: number) => {
|
|
151
|
+
const start = offset + Math.trunc(clip.frame);
|
|
152
|
+
return { masterStart: start, masterEnd: start + Math.max(1, Math.trunc(clip.durationFrames)) };
|
|
153
|
+
};
|
|
154
|
+
for (const clip of Object.values(doc.audio)) {
|
|
155
|
+
push(audioTrackOf(doc, clip.track).id, { clip, offsetFrames: 0, sceneId: null, ...span(clip, 0) });
|
|
156
|
+
}
|
|
157
|
+
for (const w of sceneWindows(doc)) {
|
|
158
|
+
for (const clip of Object.values(doc.scenesById[w.id]?.audio ?? {})) {
|
|
159
|
+
push(audioTrackOf(doc, clip.track).id, { clip, offsetFrames: w.start, sceneId: w.id, ...span(clip, w.start) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// The recordings' sounds: the flatten is the one reader of `video.sound`,
|
|
163
|
+
// already on master frames and cut to the shot.
|
|
164
|
+
for (const m of authoredAudioClips(doc)) {
|
|
165
|
+
if (m.layerId === undefined) continue;
|
|
166
|
+
push(m.trackId, {
|
|
167
|
+
clip: m.clip,
|
|
168
|
+
offsetFrames: 0,
|
|
169
|
+
sceneId: m.sceneId,
|
|
170
|
+
layerId: m.layerId,
|
|
171
|
+
...span(m.clip, 0),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function SoundLanes(props: {
|
|
178
|
+
ppf: number;
|
|
179
|
+
labelWidth: number;
|
|
180
|
+
timeW: number;
|
|
181
|
+
/** The shot context's re-zero, 0 over the Project. */
|
|
182
|
+
origin: number;
|
|
183
|
+
/** The shot context's window, absent over the Project. */
|
|
184
|
+
window?: { origin: number; len: number } | undefined;
|
|
185
|
+
beginSnap: SnapPress;
|
|
186
|
+
/** The beat grid (`beatRulerTicks`), or empty with no tempo. */
|
|
187
|
+
grid: readonly BeatTick[];
|
|
188
|
+
/** The snap guide and the playhead, drawn through every row. */
|
|
189
|
+
overlay: ReactNode;
|
|
190
|
+
openMenu: OpenMenu;
|
|
191
|
+
onGenerate: () => void;
|
|
192
|
+
}) {
|
|
193
|
+
const { ppf, labelWidth, timeW, origin, window: shotWindow, beginSnap, grid, overlay, openMenu, onGenerate } = props;
|
|
194
|
+
const store = useStore();
|
|
195
|
+
const doc = useShotEditor((s) => s.doc);
|
|
196
|
+
const editingDef = useShotEditor((s) => s.editingDef);
|
|
197
|
+
const solo = useShotEditor((s) => s.soloTracks);
|
|
198
|
+
const notice = useShotEditor((s) => s.dockNotice);
|
|
199
|
+
const reveal = useShotEditor((s) => s.dockReveal);
|
|
200
|
+
// Hot: the cold lane sleeps through a cut the transport plays (store
|
|
201
|
+
// `set`), and the Shot pills mark what is playing. A string, so this
|
|
202
|
+
// renders at a cut and not per frame.
|
|
203
|
+
const currentShot = useShotEditorHot((s) => s.sceneId);
|
|
204
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
205
|
+
const [marquee, setMarquee] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
206
|
+
const [flashed, setFlashed] = useState<string | null>(null);
|
|
207
|
+
const { confirm, dialog } = useConfirm();
|
|
208
|
+
|
|
209
|
+
const byTrack = useMemo(() => (doc === null ? new Map<string, RowClip[]>() : clipsByTrack(doc)), [doc]);
|
|
210
|
+
|
|
211
|
+
// THE REVEAL, for the clips an agent or the library just placed. The dock's
|
|
212
|
+
// own reveal effect consumes the request after this one runs (a child's
|
|
213
|
+
// effects run first), and only scrolls layer rows.
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
const ids = reveal?.audioIds;
|
|
216
|
+
if (ids === undefined || ids.length === 0) return;
|
|
217
|
+
const root = rootRef.current;
|
|
218
|
+
const el = root?.querySelector<HTMLElement>(`[data-audio-clip="${CSS.escape(ids[0]!)}"]`);
|
|
219
|
+
if (el === null || el === undefined) return;
|
|
220
|
+
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
221
|
+
if (reveal?.flash) setFlashed(el.closest<HTMLElement>("[data-sound-row]")?.dataset["soundRow"] ?? null);
|
|
222
|
+
}, [reveal]);
|
|
223
|
+
|
|
224
|
+
if (doc === null) return null;
|
|
225
|
+
if (editingDef !== null) {
|
|
226
|
+
return (
|
|
227
|
+
<div className="sound-lanes" style={{ minWidth: labelWidth + timeW }}>
|
|
228
|
+
<p className="sound-note">A component has no master time, so it has no sound. Leave it to mix the film.</p>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const tracks = audioTracksOf(doc);
|
|
234
|
+
const inWindow = (r: RowClip): boolean =>
|
|
235
|
+
shotWindow === undefined ||
|
|
236
|
+
(r.masterEnd > shotWindow.origin && r.masterStart < shotWindow.origin + shotWindow.len);
|
|
237
|
+
const total = [...byTrack.values()].reduce((n, list) => n + list.length, 0);
|
|
238
|
+
const windows = sceneWindows(doc).filter(
|
|
239
|
+
(w) => shotWindow === undefined || (w.end > shotWindow.origin && w.start < shotWindow.origin + shotWindow.len),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/** The track row under a screen y, read live: rows can scroll mid-drag. */
|
|
243
|
+
const rowAt = (clientY: number): string | undefined => {
|
|
244
|
+
for (const row of rootRef.current?.querySelectorAll<HTMLElement>("[data-sound-row]") ?? []) {
|
|
245
|
+
const r = row.getBoundingClientRect();
|
|
246
|
+
if (clientY >= r.top && clientY < r.bottom) return row.dataset["soundRow"];
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const clipMenu = (row: RowClip) => (e: ReactMouseEvent<HTMLElement>): void => {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
const s = store.getState();
|
|
255
|
+
if (!s.selectedAudio.includes(row.clip.id)) store.selectAudio([row.clip.id]);
|
|
256
|
+
openMenu({ x: e.clientX, y: e.clientY }, gateForViewer(store.getState(), clipMenuEntries(store, doc, row)));
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const trackMenu = (track: AudioTrack3D, index: number, onRename: () => void) =>
|
|
260
|
+
(e: ReactMouseEvent<HTMLElement>): void => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
e.stopPropagation();
|
|
263
|
+
const count = byTrack.get(track.id)?.filter((r) => r.layerId === undefined).length ?? 0;
|
|
264
|
+
const entries: MenuEntry[] = [
|
|
265
|
+
{ kind: "action", id: "track-add-above", label: "Add track above", onSelect: () => store.addAudioTrackAt(index) },
|
|
266
|
+
{ kind: "action", id: "track-add-below", label: "Add track below", onSelect: () => store.addAudioTrackAt(index + 1) },
|
|
267
|
+
{ kind: "action", id: "track-rename", label: "Rename", onSelect: onRename },
|
|
268
|
+
{
|
|
269
|
+
kind: "action",
|
|
270
|
+
id: "track-up",
|
|
271
|
+
label: "Move up",
|
|
272
|
+
disabled: index === 0,
|
|
273
|
+
onSelect: () => store.reorderAudioTrack(track.id, index - 1),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
kind: "action",
|
|
277
|
+
id: "track-down",
|
|
278
|
+
label: "Move down",
|
|
279
|
+
disabled: index === tracks.length - 1,
|
|
280
|
+
onSelect: () => store.reorderAudioTrack(track.id, index + 1),
|
|
281
|
+
},
|
|
282
|
+
{ kind: "separator" },
|
|
283
|
+
{
|
|
284
|
+
kind: "action",
|
|
285
|
+
id: "track-delete",
|
|
286
|
+
label: "Delete track",
|
|
287
|
+
danger: true,
|
|
288
|
+
disabled: tracks.length <= 1,
|
|
289
|
+
title: tracks.length <= 1 ? "A film keeps at least one track." : undefined,
|
|
290
|
+
onSelect: () => {
|
|
291
|
+
if (count === 0) {
|
|
292
|
+
store.removeAudioTrack(track.id);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
void confirm({
|
|
296
|
+
title: `Delete ${track.name}?`,
|
|
297
|
+
message: `Its ${count} clip${count === 1 ? "" : "s"} go with it. Undo brings them back.`,
|
|
298
|
+
confirmLabel: "Delete track",
|
|
299
|
+
danger: true,
|
|
300
|
+
}).then((ok) => {
|
|
301
|
+
if (ok) store.removeAudioTrack(track.id);
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
openMenu({ x: e.clientX, y: e.clientY }, gateForViewer(store.getState(), entries));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* THE RUBBER BAND over clip bars. Membership is decided per move from rects
|
|
311
|
+
* frozen at the press; shift adds to the pick as it stood; Escape puts that
|
|
312
|
+
* pick back.
|
|
313
|
+
*/
|
|
314
|
+
const beginMarquee = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
315
|
+
if (e.button !== 0) return;
|
|
316
|
+
const target = e.target as HTMLElement;
|
|
317
|
+
if (!target.classList.contains("sound-lane")) return;
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
const root = e.currentTarget;
|
|
320
|
+
const at = root.getBoundingClientRect();
|
|
321
|
+
const from = { x: e.clientX - at.left, y: e.clientY - at.top };
|
|
322
|
+
const bars = [...root.querySelectorAll<HTMLElement>("[data-audio-clip]:not([data-readonly])")].map((el) => {
|
|
323
|
+
const r = el.getBoundingClientRect();
|
|
324
|
+
return {
|
|
325
|
+
id: el.dataset["audioClip"]!,
|
|
326
|
+
left: r.left - at.left,
|
|
327
|
+
right: r.right - at.left,
|
|
328
|
+
top: r.top - at.top,
|
|
329
|
+
bottom: r.bottom - at.top,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
const base = store.getState().selectedAudio;
|
|
333
|
+
const add = e.shiftKey ? base : [];
|
|
334
|
+
let painting = false;
|
|
335
|
+
let written: string | null = null;
|
|
336
|
+
startDrag(e, {
|
|
337
|
+
onMove(ev) {
|
|
338
|
+
const box = root.getBoundingClientRect();
|
|
339
|
+
const to = { x: ev.clientX - box.left, y: ev.clientY - box.top };
|
|
340
|
+
if (!painting && Math.abs(to.x - from.x) < MARQUEE_MIN_PX && Math.abs(to.y - from.y) < MARQUEE_MIN_PX) return;
|
|
341
|
+
painting = true;
|
|
342
|
+
const rect = {
|
|
343
|
+
left: Math.min(from.x, to.x),
|
|
344
|
+
right: Math.max(from.x, to.x),
|
|
345
|
+
top: Math.min(from.y, to.y),
|
|
346
|
+
bottom: Math.max(from.y, to.y),
|
|
347
|
+
};
|
|
348
|
+
setMarquee(rect);
|
|
349
|
+
const hits = [...add];
|
|
350
|
+
for (const b of bars) {
|
|
351
|
+
if (b.right > rect.left && b.left < rect.right && b.bottom > rect.top && b.top < rect.bottom) {
|
|
352
|
+
if (!hits.includes(b.id)) hits.push(b.id);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const key = hits.join("\n");
|
|
356
|
+
if (key === written) return;
|
|
357
|
+
written = key;
|
|
358
|
+
store.selectAudio(hits);
|
|
359
|
+
},
|
|
360
|
+
onEnd(commit) {
|
|
361
|
+
setMarquee(null);
|
|
362
|
+
if (!painting) {
|
|
363
|
+
// A plain click on empty lane drops the pick, as it does over rows.
|
|
364
|
+
if (commit && !e.shiftKey) store.selectAudio([]);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!commit) store.selectAudio(base);
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const split = shortcutFor("edit.split");
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
ref={rootRef}
|
|
377
|
+
className="sound-lanes"
|
|
378
|
+
data-testid="sound-lanes"
|
|
379
|
+
style={{ minWidth: labelWidth + timeW }}
|
|
380
|
+
onPointerDown={beginMarquee}
|
|
381
|
+
onAnimationEnd={(e) => {
|
|
382
|
+
if (e.animationName === "row-revealed") setFlashed(null);
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
{dialog}
|
|
386
|
+
<div className="dock-overlay sound-overlay" style={{ left: labelWidth }} aria-hidden="true">
|
|
387
|
+
{grid.map((t) =>
|
|
388
|
+
t.tier === 2 ? (
|
|
389
|
+
<span key={t.frame} className="sound-beat" style={{ left: frameToPx(t.frame, ppf) }} />
|
|
390
|
+
) : (
|
|
391
|
+
<span key={t.frame} className="sound-bar" style={{ left: frameToPx(t.frame, ppf) }} />
|
|
392
|
+
),
|
|
393
|
+
)}
|
|
394
|
+
{overlay}
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
{notice === null ? null : (
|
|
398
|
+
<div className="sound-notice" role="status" data-testid="sound-notice" style={{ left: labelWidth + 8 }}>
|
|
399
|
+
{split === undefined ? null : <kbd className="sound-notice-key">{formatCombo(split)}</kbd>}
|
|
400
|
+
<span>{notice.text}</span>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
className="btn is-icon is-ghost sound-notice-close"
|
|
404
|
+
aria-label="Dismiss"
|
|
405
|
+
onClick={() => store.dismissDockNotice()}
|
|
406
|
+
>
|
|
407
|
+
{CLOSE_ICON}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
411
|
+
|
|
412
|
+
<div className="sound-row sound-shots-row">
|
|
413
|
+
<div className="sound-strip-cell sound-shots-label" style={{ width: labelWidth }}>
|
|
414
|
+
Shots
|
|
415
|
+
</div>
|
|
416
|
+
<div className="sound-shots">
|
|
417
|
+
{windows.map((w) => (
|
|
418
|
+
<button
|
|
419
|
+
key={w.id}
|
|
420
|
+
type="button"
|
|
421
|
+
className={`sound-shot-pill${w.id === currentShot ? " is-current" : ""}`}
|
|
422
|
+
aria-current={w.id === currentShot ? "true" : undefined}
|
|
423
|
+
style={{ left: frameToPx(w.start - origin, ppf), width: Math.max(4, frameToPx(w.end - w.start, ppf) - 2) }}
|
|
424
|
+
title={doc.scenesById[w.id]?.name ?? ""}
|
|
425
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
426
|
+
onClick={() => {
|
|
427
|
+
store.pauseForScrub();
|
|
428
|
+
store.seek(w.start);
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
Shot {shotNumberOf(doc, w.id) ?? String(doc.scenes.indexOf(w.id) + 1).padStart(2, "0")}
|
|
432
|
+
</button>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
{tracks.map((track, index) => {
|
|
438
|
+
const rows = (byTrack.get(track.id) ?? []).filter(inWindow);
|
|
439
|
+
const dimmed = solo.length > 0 && !solo.includes(track.id);
|
|
440
|
+
return (
|
|
441
|
+
<TrackRow
|
|
442
|
+
key={track.id}
|
|
443
|
+
track={track}
|
|
444
|
+
index={index}
|
|
445
|
+
rows={rows}
|
|
446
|
+
doc={doc}
|
|
447
|
+
ppf={ppf}
|
|
448
|
+
labelWidth={labelWidth}
|
|
449
|
+
origin={origin}
|
|
450
|
+
beginSnap={beginSnap}
|
|
451
|
+
rowAt={rowAt}
|
|
452
|
+
dimmed={dimmed}
|
|
453
|
+
soloed={solo.includes(track.id)}
|
|
454
|
+
flashed={flashed === track.id}
|
|
455
|
+
empty={index === 0 && total === 0}
|
|
456
|
+
onClipMenu={clipMenu}
|
|
457
|
+
onTrackMenu={trackMenu}
|
|
458
|
+
onGenerate={onGenerate}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
})}
|
|
462
|
+
|
|
463
|
+
<div className="sound-row sound-add-row">
|
|
464
|
+
<div className="sound-strip-cell" style={{ width: labelWidth }}>
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
className="btn is-ghost sound-add-track"
|
|
468
|
+
data-testid="sound-add-track"
|
|
469
|
+
onClick={() => store.addAudioTrackAt()}
|
|
470
|
+
>
|
|
471
|
+
{ADD_ICON}
|
|
472
|
+
Add track
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
{marquee === null ? null : (
|
|
478
|
+
<span
|
|
479
|
+
className="dock-marquee"
|
|
480
|
+
aria-hidden="true"
|
|
481
|
+
style={{
|
|
482
|
+
left: marquee.left,
|
|
483
|
+
top: marquee.top,
|
|
484
|
+
width: marquee.right - marquee.left,
|
|
485
|
+
height: marquee.bottom - marquee.top,
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The right-click menu on a clip bar. A recording's sound offers the track
|
|
494
|
+
* move alone: its other fields live on the layer. */
|
|
495
|
+
function clipMenuEntries(store: Store, doc: Doc3D, row: RowClip): MenuEntry[] {
|
|
496
|
+
const s = store.getState();
|
|
497
|
+
const picked = s.selectedAudio.includes(row.clip.id) ? s.selectedAudio : [row.clip.id];
|
|
498
|
+
const current = audioTrackOf(doc, row.clip.track).id;
|
|
499
|
+
const moveTo = (trackId: string): void => {
|
|
500
|
+
store.edit((base) => picked.reduce((d, id) => setAudioClipTrack(d, id, trackId), base));
|
|
501
|
+
};
|
|
502
|
+
const trackItems: MenuEntry[] = [
|
|
503
|
+
...audioTracksOf(doc).map(
|
|
504
|
+
(t): MenuEntry => ({
|
|
505
|
+
kind: "action",
|
|
506
|
+
id: `clip-track-${t.id}`,
|
|
507
|
+
label: t.name,
|
|
508
|
+
checked: row.layerId === undefined ? t.id === current : t.id === audioTrackOf(doc, row.clip.track).id,
|
|
509
|
+
onSelect: () => moveTo(t.id),
|
|
510
|
+
}),
|
|
511
|
+
),
|
|
512
|
+
{ kind: "separator" },
|
|
513
|
+
{
|
|
514
|
+
kind: "action",
|
|
515
|
+
id: "clip-track-new",
|
|
516
|
+
label: "New track",
|
|
517
|
+
onSelect: () =>
|
|
518
|
+
store.edit((base) => {
|
|
519
|
+
const made = addAudioTrack(base, {});
|
|
520
|
+
return picked.reduce((d, id) => setAudioClipTrack(d, id, made.id), made.doc);
|
|
521
|
+
}),
|
|
522
|
+
},
|
|
523
|
+
];
|
|
524
|
+
const move: MenuEntry = { kind: "submenu", label: "Move to track", items: trackItems };
|
|
525
|
+
if (row.layerId !== undefined) return [move];
|
|
526
|
+
|
|
527
|
+
const f = s.frame;
|
|
528
|
+
const inside = f > row.masterStart && f < row.masterEnd;
|
|
529
|
+
const shotNo = row.sceneId === null ? null : (shotNumberOf(doc, row.sceneId) ?? null);
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
kind: "action",
|
|
533
|
+
id: "clip-split",
|
|
534
|
+
label: "Split at playhead",
|
|
535
|
+
shortcut: shortcutFor("edit.split"),
|
|
536
|
+
disabled: !inside,
|
|
537
|
+
title: inside ? undefined : "Move the playhead inside the clip to split it.",
|
|
538
|
+
onSelect: () => store.splitAtPlayhead(),
|
|
539
|
+
},
|
|
540
|
+
move,
|
|
541
|
+
{
|
|
542
|
+
kind: "action",
|
|
543
|
+
id: "clip-duplicate",
|
|
544
|
+
label: "Duplicate",
|
|
545
|
+
shortcut: shortcutFor("edit.duplicate"),
|
|
546
|
+
onSelect: () => store.duplicateSelection(),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
kind: "action",
|
|
550
|
+
id: "clip-home",
|
|
551
|
+
label: row.sceneId === null ? "Attach to shot" : `Detach from Shot ${shotNo ?? ""}`.trim(),
|
|
552
|
+
title:
|
|
553
|
+
row.sceneId === null
|
|
554
|
+
? "Attach to the shot it starts in: it moves, duplicates and ends with that shot."
|
|
555
|
+
: "Move to the bed: it spans the cuts and stops following this shot.",
|
|
556
|
+
onSelect: () => store.moveAudioHome(row.clip.id),
|
|
557
|
+
},
|
|
558
|
+
{ kind: "separator" },
|
|
559
|
+
{
|
|
560
|
+
kind: "action",
|
|
561
|
+
id: "clip-delete",
|
|
562
|
+
label: "Delete",
|
|
563
|
+
shortcut: shortcutFor("edit.delete"),
|
|
564
|
+
danger: true,
|
|
565
|
+
onSelect: () => store.deleteSelection(),
|
|
566
|
+
},
|
|
567
|
+
];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function TrackRow(props: {
|
|
571
|
+
track: AudioTrack3D;
|
|
572
|
+
index: number;
|
|
573
|
+
rows: readonly RowClip[];
|
|
574
|
+
doc: Doc3D;
|
|
575
|
+
ppf: number;
|
|
576
|
+
labelWidth: number;
|
|
577
|
+
origin: number;
|
|
578
|
+
beginSnap: SnapPress;
|
|
579
|
+
rowAt: (clientY: number) => string | undefined;
|
|
580
|
+
dimmed: boolean;
|
|
581
|
+
soloed: boolean;
|
|
582
|
+
flashed: boolean;
|
|
583
|
+
empty: boolean;
|
|
584
|
+
onClipMenu: (row: RowClip) => (e: ReactMouseEvent<HTMLElement>) => void;
|
|
585
|
+
onTrackMenu: (track: AudioTrack3D, index: number, onRename: () => void) => (e: ReactMouseEvent<HTMLElement>) => void;
|
|
586
|
+
onGenerate: () => void;
|
|
587
|
+
}) {
|
|
588
|
+
const { track, index, rows, doc, ppf, labelWidth, origin, beginSnap, rowAt } = props;
|
|
589
|
+
const store = useStore();
|
|
590
|
+
const [over, setOver] = useState(false);
|
|
591
|
+
const [importing, setImporting] = useState(false);
|
|
592
|
+
const [error, setError] = useState<string | null>(null);
|
|
593
|
+
const [renaming, setRenaming] = useState(false);
|
|
594
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
595
|
+
const muted = track.muted === true;
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<div
|
|
599
|
+
className={`sound-row sound-track-row${muted ? " is-muted" : ""}${props.dimmed ? " is-unsoloed" : ""}${
|
|
600
|
+
over ? " is-over" : ""
|
|
601
|
+
}${props.flashed ? " is-revealed" : ""}`}
|
|
602
|
+
data-sound-row={track.id}
|
|
603
|
+
data-testid={`sound-row-${track.id}`}
|
|
604
|
+
onDragOver={(e) => {
|
|
605
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
606
|
+
e.preventDefault();
|
|
607
|
+
setOver(true);
|
|
608
|
+
}}
|
|
609
|
+
onDragLeave={() => setOver(false)}
|
|
610
|
+
onDrop={(e) => {
|
|
611
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
612
|
+
e.preventDefault();
|
|
613
|
+
setOver(false);
|
|
614
|
+
importEach(store, e.dataTransfer.files, undefined, setImporting, setError, track.id);
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
<TrackStrip
|
|
618
|
+
track={track}
|
|
619
|
+
width={labelWidth}
|
|
620
|
+
soloed={props.soloed}
|
|
621
|
+
renaming={renaming}
|
|
622
|
+
setRenaming={setRenaming}
|
|
623
|
+
onContextMenu={props.onTrackMenu(track, index, () => setRenaming(true))}
|
|
624
|
+
/>
|
|
625
|
+
<div className="sound-lane">
|
|
626
|
+
{props.empty && !importing ? (
|
|
627
|
+
<span className="sound-empty" data-testid="sound-empty">
|
|
628
|
+
<span>Drop audio here, or</span>
|
|
629
|
+
<button
|
|
630
|
+
type="button"
|
|
631
|
+
className="btn is-ghost sound-empty-btn"
|
|
632
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
633
|
+
onClick={props.onGenerate}
|
|
634
|
+
>
|
|
635
|
+
+ Generate
|
|
636
|
+
</button>
|
|
637
|
+
<button
|
|
638
|
+
type="button"
|
|
639
|
+
className="btn is-ghost sound-empty-btn"
|
|
640
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
641
|
+
onClick={() => store.setLibraryBrowserOpen(true, "sounds")}
|
|
642
|
+
>
|
|
643
|
+
Library
|
|
644
|
+
</button>
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
className="btn is-ghost sound-empty-btn"
|
|
648
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
649
|
+
onClick={() => fileRef.current?.click()}
|
|
650
|
+
>
|
|
651
|
+
Import
|
|
652
|
+
</button>
|
|
653
|
+
<input
|
|
654
|
+
ref={fileRef}
|
|
655
|
+
type="file"
|
|
656
|
+
accept={AUDIO_FILE_ACCEPT}
|
|
657
|
+
multiple
|
|
658
|
+
hidden
|
|
659
|
+
data-testid="sound-file-input"
|
|
660
|
+
onChange={(e) => {
|
|
661
|
+
importEach(store, e.currentTarget.files, undefined, setImporting, setError, track.id);
|
|
662
|
+
e.currentTarget.value = "";
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
665
|
+
</span>
|
|
666
|
+
) : null}
|
|
667
|
+
{rows.map((row) =>
|
|
668
|
+
row.layerId !== undefined ? (
|
|
669
|
+
<VideoSoundBar
|
|
670
|
+
key={`v:${row.layerId}:${row.sceneId ?? ""}`}
|
|
671
|
+
row={row}
|
|
672
|
+
doc={doc}
|
|
673
|
+
ppf={ppf}
|
|
674
|
+
origin={origin}
|
|
675
|
+
onContextMenu={props.onClipMenu(row)}
|
|
676
|
+
/>
|
|
677
|
+
) : (
|
|
678
|
+
<AudioClipBar
|
|
679
|
+
key={row.clip.id}
|
|
680
|
+
clip={row.clip}
|
|
681
|
+
asset={doc.assets[row.clip.asset]}
|
|
682
|
+
fps={doc.fps}
|
|
683
|
+
ppf={ppf}
|
|
684
|
+
offsetFrames={row.offsetFrames}
|
|
685
|
+
originFrames={origin}
|
|
686
|
+
beginSnap={beginSnap}
|
|
687
|
+
waveH={SOUND_WAVE_H}
|
|
688
|
+
mix
|
|
689
|
+
rowAt={rowAt}
|
|
690
|
+
onContextMenu={props.onClipMenu(row)}
|
|
691
|
+
{...(row.sceneId === null
|
|
692
|
+
? {}
|
|
693
|
+
: {
|
|
694
|
+
ownedBy: row.sceneId,
|
|
695
|
+
limitFrames: Math.max(0, Math.trunc(doc.scenesById[row.sceneId]?.durationFrames ?? 0)),
|
|
696
|
+
badge: `Shot ${shotNumberOf(doc, row.sceneId) ?? ""}`.trim(),
|
|
697
|
+
})}
|
|
698
|
+
/>
|
|
699
|
+
),
|
|
700
|
+
)}
|
|
701
|
+
{error === null ? null : (
|
|
702
|
+
<p className="audio-error" role="status">
|
|
703
|
+
{error}
|
|
704
|
+
</p>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** A recording's sound on its track. Drawn, never dragged: its in, out and
|
|
712
|
+
* level are the video layer's, trimmed in Rows mode's video row. */
|
|
713
|
+
function VideoSoundBar(props: {
|
|
714
|
+
row: RowClip;
|
|
715
|
+
doc: Doc3D;
|
|
716
|
+
ppf: number;
|
|
717
|
+
origin: number;
|
|
718
|
+
onContextMenu: (e: ReactMouseEvent<HTMLElement>) => void;
|
|
719
|
+
}) {
|
|
720
|
+
const { row, doc, ppf, origin } = props;
|
|
721
|
+
const left = frameToPx(row.masterStart - origin, ppf);
|
|
722
|
+
const width = Math.max(8, frameToPx(row.masterEnd - row.masterStart, ppf));
|
|
723
|
+
const layer = row.sceneId === null ? undefined : doc.scenesById[row.sceneId]?.layersById[row.layerId!];
|
|
724
|
+
const name = layer?.label ?? "Video";
|
|
725
|
+
return (
|
|
726
|
+
<div
|
|
727
|
+
className={`audio-clip is-video${row.clip.muted === true ? " is-muted" : ""}`}
|
|
728
|
+
data-audio-clip={row.clip.id}
|
|
729
|
+
data-readonly=""
|
|
730
|
+
style={{ left, width }}
|
|
731
|
+
title={`${name}: the recording's own sound. Trim it on the video's row in Rows.`}
|
|
732
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
733
|
+
onContextMenu={props.onContextMenu}
|
|
734
|
+
>
|
|
735
|
+
<AudioWaveform
|
|
736
|
+
clip={{ offsetFrames: row.clip.offsetFrames ?? 0, durationFrames: row.clip.durationFrames }}
|
|
737
|
+
asset={doc.assets[row.clip.asset]}
|
|
738
|
+
fps={doc.fps}
|
|
739
|
+
widthPx={width}
|
|
740
|
+
heightPx={SOUND_WAVE_H}
|
|
741
|
+
/>
|
|
742
|
+
<span className="audio-clip-name">
|
|
743
|
+
<span className="audio-clip-lock" aria-hidden="true">
|
|
744
|
+
{LOCK_ICON}
|
|
745
|
+
</span>
|
|
746
|
+
{name} · video sound
|
|
747
|
+
</span>
|
|
748
|
+
</div>
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── THE CHANNEL STRIP ────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Begin a one-undo level drag on a track. `read` turns the pointer into the
|
|
756
|
+
* patch; it is called for the press too, so a press on the rail jumps there.
|
|
757
|
+
*/
|
|
758
|
+
function trackDrag(
|
|
759
|
+
store: Store,
|
|
760
|
+
trackId: string,
|
|
761
|
+
e: ReactPointerEvent<HTMLElement>,
|
|
762
|
+
read: (clientX: number, clientY: number) => Partial<Omit<AudioTrack3D, "id">>,
|
|
763
|
+
jump: boolean,
|
|
764
|
+
): void {
|
|
765
|
+
if (e.button !== 0) return;
|
|
766
|
+
e.preventDefault();
|
|
767
|
+
e.stopPropagation();
|
|
768
|
+
if (!store.beginBlockDrag()) return;
|
|
769
|
+
if (jump) store.dragAudioTrack(trackId, read(e.clientX, e.clientY));
|
|
770
|
+
startDrag(e, {
|
|
771
|
+
onMove: (ev) => store.dragAudioTrack(trackId, read(ev.clientX, ev.clientY)),
|
|
772
|
+
onEnd: (commit) => store.endBlockDrag(commit),
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Arrow keys on a level control: small steps, Shift for big ones. Stopped,
|
|
777
|
+
* so the timeline's own arrows (edit points, nudges) do not fire too. */
|
|
778
|
+
function stepKey(e: ReactKeyboardEvent<HTMLElement>, step: (by: number) => void): void {
|
|
779
|
+
const by =
|
|
780
|
+
e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
|
|
781
|
+
if (by === 0) return;
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
e.stopPropagation();
|
|
784
|
+
step(by * (e.shiftKey ? 5 : 1));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function TrackStrip(props: {
|
|
788
|
+
track: AudioTrack3D;
|
|
789
|
+
width: number;
|
|
790
|
+
soloed: boolean;
|
|
791
|
+
renaming: boolean;
|
|
792
|
+
setRenaming: (on: boolean) => void;
|
|
793
|
+
onContextMenu: (e: ReactMouseEvent<HTMLElement>) => void;
|
|
794
|
+
}) {
|
|
795
|
+
const { track, width, soloed, renaming, setRenaming } = props;
|
|
796
|
+
const store = useStore();
|
|
797
|
+
const muted = track.muted === true;
|
|
798
|
+
return (
|
|
799
|
+
<div
|
|
800
|
+
className="sound-strip-cell sound-strip"
|
|
801
|
+
style={{ width }}
|
|
802
|
+
data-testid={`sound-strip-${track.id}`}
|
|
803
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
804
|
+
onContextMenu={props.onContextMenu}
|
|
805
|
+
>
|
|
806
|
+
<div className="sound-strip-top">
|
|
807
|
+
<span className="sound-strip-dot" aria-hidden="true" />
|
|
808
|
+
{renaming ? (
|
|
809
|
+
<input
|
|
810
|
+
className="sound-strip-rename"
|
|
811
|
+
defaultValue={track.name}
|
|
812
|
+
aria-label="Track name"
|
|
813
|
+
autoFocus
|
|
814
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
815
|
+
onKeyDown={(e) => {
|
|
816
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
817
|
+
if (e.key === "Escape") {
|
|
818
|
+
e.currentTarget.value = track.name;
|
|
819
|
+
e.currentTarget.blur();
|
|
820
|
+
}
|
|
821
|
+
e.stopPropagation();
|
|
822
|
+
}}
|
|
823
|
+
onBlur={(e) => {
|
|
824
|
+
const name = e.currentTarget.value.trim();
|
|
825
|
+
if (name !== "" && name !== track.name) store.updateAudioTrack(track.id, { name });
|
|
826
|
+
setRenaming(false);
|
|
827
|
+
}}
|
|
828
|
+
/>
|
|
829
|
+
) : (
|
|
830
|
+
<span className="sound-strip-name" title={`${track.name}: double-click to rename`} onDoubleClick={() => setRenaming(true)}>
|
|
831
|
+
{track.name}
|
|
832
|
+
</span>
|
|
833
|
+
)}
|
|
834
|
+
<button
|
|
835
|
+
type="button"
|
|
836
|
+
className={`sound-ms is-mute${muted ? " is-on" : ""}`}
|
|
837
|
+
aria-pressed={muted}
|
|
838
|
+
aria-label={`Mute ${track.name}`}
|
|
839
|
+
title={muted ? "Muted: saved with the film, so an export is silent here too" : "Mute this track"}
|
|
840
|
+
data-testid={`sound-mute-${track.id}`}
|
|
841
|
+
onClick={() => store.updateAudioTrack(track.id, { muted: !muted })}
|
|
842
|
+
>
|
|
843
|
+
M
|
|
844
|
+
</button>
|
|
845
|
+
<button
|
|
846
|
+
type="button"
|
|
847
|
+
className={`sound-ms is-solo${soloed ? " is-on" : ""}`}
|
|
848
|
+
aria-pressed={soloed}
|
|
849
|
+
aria-label={`Solo ${track.name}`}
|
|
850
|
+
title={soloed ? "Soloed in this tab only" : "Solo: hear this track alone, in this tab only"}
|
|
851
|
+
data-testid={`sound-solo-${track.id}`}
|
|
852
|
+
onClick={() => store.toggleSoloTrack(track.id)}
|
|
853
|
+
>
|
|
854
|
+
S
|
|
855
|
+
</button>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function panLabel(pan: number): string {
|
|
862
|
+
if (Math.abs(pan) < 0.005) return "Centre";
|
|
863
|
+
return `${Math.round(Math.abs(pan) * 100)}% ${pan < 0 ? "left" : "right"}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Pan, -1..1. A vertical drag (up is right, 100px is full travel), as a
|
|
867
|
+
* knob is turned on every console; double-click centres it. */
|
|
868
|
+
function PanKnob({ track }: { track: AudioTrack3D }) {
|
|
869
|
+
const store = useStore();
|
|
870
|
+
const pan = track.pan ?? 0;
|
|
871
|
+
const angle = (pan * 135 * Math.PI) / 180;
|
|
872
|
+
const set = (p: number): number => {
|
|
873
|
+
const clamped = Math.max(-1, Math.min(1, p));
|
|
874
|
+
return Math.abs(clamped) < 0.02 ? 0 : Math.round(clamped * 100) / 100;
|
|
875
|
+
};
|
|
876
|
+
return (
|
|
877
|
+
<span
|
|
878
|
+
className="sound-pan"
|
|
879
|
+
role="slider"
|
|
880
|
+
tabIndex={0}
|
|
881
|
+
aria-label={`Pan ${track.name}`}
|
|
882
|
+
aria-valuemin={-1}
|
|
883
|
+
aria-valuemax={1}
|
|
884
|
+
aria-valuenow={pan}
|
|
885
|
+
aria-valuetext={panLabel(pan)}
|
|
886
|
+
title={`Pan: ${panLabel(pan)}. Drag up or down, double-click to centre.`}
|
|
887
|
+
data-testid={`sound-pan-${track.id}`}
|
|
888
|
+
onPointerDown={(e) => {
|
|
889
|
+
const y0 = e.clientY;
|
|
890
|
+
trackDrag(store, track.id, e, (_x, y) => ({ pan: set(pan - (y - y0) / 100) }), false);
|
|
891
|
+
}}
|
|
892
|
+
onDoubleClick={() => store.updateAudioTrack(track.id, { pan: 0 })}
|
|
893
|
+
onKeyDown={(e) => stepKey(e, (by) => store.updateAudioTrack(track.id, { pan: set(pan + by * 0.05) }))}
|
|
894
|
+
>
|
|
895
|
+
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
|
|
896
|
+
<circle cx="9" cy="9" r="7.5" />
|
|
897
|
+
<path d={`M9 9 L${(9 + 6 * Math.sin(angle)).toFixed(2)} ${(9 - 6 * Math.cos(angle)).toFixed(2)}`} />
|
|
898
|
+
</svg>
|
|
899
|
+
</span>
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ── THE MIXER COLUMN ─────────────────────────────────────────────────────
|
|
904
|
+
|
|
905
|
+
/** Sound mode's right column: the peak and the one-press duck on top, then a
|
|
906
|
+
* vertical strip per track (pan, level, its own meter), then the master's
|
|
907
|
+
* meter. The dock's only meters: Rows and Graph show none. */
|
|
908
|
+
export function SoundMixer() {
|
|
909
|
+
const store = useStore();
|
|
910
|
+
const doc = useShotEditor((s) => s.doc);
|
|
911
|
+
const solo = useShotEditor((s) => s.soloTracks);
|
|
912
|
+
const muted = useShotEditor((s) => s.previewMuted);
|
|
913
|
+
const graph = useSyncExternalStore(subscribeMonitor, readMonitor, readMonitor);
|
|
914
|
+
useMeterTap(MASTER_METER_KEY, graph?.ctx ?? null, graph?.node ?? null);
|
|
915
|
+
if (doc === null) return null;
|
|
916
|
+
const tracks = audioTracksOf(doc);
|
|
917
|
+
const duck = voiceDuckState(doc);
|
|
918
|
+
return (
|
|
919
|
+
<div className="sound-mixer" data-testid="sound-mixer">
|
|
920
|
+
<div className="sound-mixer-head">
|
|
921
|
+
<span className="sound-mixer-title">Mixer</span>
|
|
922
|
+
{solo.length > 0 ? (
|
|
923
|
+
<button
|
|
924
|
+
type="button"
|
|
925
|
+
className="sound-solo-chip"
|
|
926
|
+
title="Solo is this tab's alone. Exports hear every track. Press to hear every track again."
|
|
927
|
+
onClick={() => {
|
|
928
|
+
for (const id of solo) store.toggleSoloTrack(id);
|
|
929
|
+
}}
|
|
930
|
+
>
|
|
931
|
+
Solo · clear
|
|
932
|
+
</button>
|
|
933
|
+
) : (
|
|
934
|
+
<MixerPeak muted={muted} />
|
|
935
|
+
)}
|
|
936
|
+
<button
|
|
937
|
+
type="button"
|
|
938
|
+
className="sound-duck"
|
|
939
|
+
data-testid="sound-duck"
|
|
940
|
+
aria-pressed={duck.active}
|
|
941
|
+
disabled={!duck.available}
|
|
942
|
+
title={
|
|
943
|
+
duck.available
|
|
944
|
+
? duck.active
|
|
945
|
+
? "The other tracks dip while the voice speaks. Press to stop."
|
|
946
|
+
: "Dip the other tracks while the voice speaks"
|
|
947
|
+
: "Name a track Voice, with a clip on it, to duck the others under it"
|
|
948
|
+
}
|
|
949
|
+
onClick={() => store.setDuckUnderVoice(!duck.active)}
|
|
950
|
+
>
|
|
951
|
+
Duck under voice
|
|
952
|
+
</button>
|
|
953
|
+
</div>
|
|
954
|
+
<div className="sound-mixer-strips">
|
|
955
|
+
{tracks.map((t) => (
|
|
956
|
+
<MixerStrip
|
|
957
|
+
key={t.id}
|
|
958
|
+
track={t}
|
|
959
|
+
graph={graph}
|
|
960
|
+
dimmed={solo.length > 0 && !solo.includes(t.id)}
|
|
961
|
+
soloed={solo.includes(t.id)}
|
|
962
|
+
/>
|
|
963
|
+
))}
|
|
964
|
+
<div className="sound-mixer-strip is-master" data-testid="mixer-strip-master">
|
|
965
|
+
{/* The pan knob's slot, so the master's meters start level with
|
|
966
|
+
the tracks'. */}
|
|
967
|
+
<span className="sound-pan-slot" aria-hidden="true" />
|
|
968
|
+
<MasterHold />
|
|
969
|
+
<div className="sound-mixer-body">
|
|
970
|
+
<MeterPair meterKey={MASTER_METER_KEY} />
|
|
971
|
+
</div>
|
|
972
|
+
<span className="sound-mixer-name" title="Master: what the mix sends out, before the preview volume">
|
|
973
|
+
Master
|
|
974
|
+
</span>
|
|
975
|
+
</div>
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** The master strip's number: its held peak, the louder channel. A leaf, so
|
|
982
|
+
* a reading re-renders this span and not the strips. */
|
|
983
|
+
function MasterHold() {
|
|
984
|
+
const left = useMeterReading(meterChannelKey(MASTER_METER_KEY, "L"));
|
|
985
|
+
const right = useMeterReading(meterChannelKey(MASTER_METER_KEY, "R"));
|
|
986
|
+
return (
|
|
987
|
+
<span className="sound-mixer-db" data-testid="mixer-master-hold">
|
|
988
|
+
{dbLabel(Math.max(left.hold, right.hold), false)}
|
|
989
|
+
</span>
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** "−6.2 dB peak": the master's held peak, the louder channel. */
|
|
994
|
+
function MixerPeak({ muted }: { muted: boolean }) {
|
|
995
|
+
const left = useMeterReading(meterChannelKey(MASTER_METER_KEY, "L"));
|
|
996
|
+
const right = useMeterReading(meterChannelKey(MASTER_METER_KEY, "R"));
|
|
997
|
+
return (
|
|
998
|
+
<span className="sound-mixer-peak" data-testid="sound-mixer-peak">
|
|
999
|
+
{muted ? "Preview muted" : `${dbLabel(Math.max(left.hold, right.hold))} peak`}
|
|
1000
|
+
</span>
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function MixerStrip(props: {
|
|
1005
|
+
track: AudioTrack3D;
|
|
1006
|
+
graph: ReturnType<typeof readMonitor>;
|
|
1007
|
+
dimmed: boolean;
|
|
1008
|
+
soloed: boolean;
|
|
1009
|
+
}) {
|
|
1010
|
+
const { track, graph } = props;
|
|
1011
|
+
const store = useStore();
|
|
1012
|
+
const key = `track:${track.id}`;
|
|
1013
|
+
useMeterTap(key, graph?.ctx ?? null, graph?.tracks.get(track.id) ?? null);
|
|
1014
|
+
const gain = track.gain ?? 1;
|
|
1015
|
+
const muted = track.muted === true;
|
|
1016
|
+
return (
|
|
1017
|
+
<div
|
|
1018
|
+
className={`sound-mixer-strip${muted ? " is-muted" : ""}${props.dimmed ? " is-unsoloed" : ""}`}
|
|
1019
|
+
data-testid={`mixer-strip-${track.id}`}
|
|
1020
|
+
>
|
|
1021
|
+
<PanKnob track={track} />
|
|
1022
|
+
<span className="sound-mixer-db">{dbLabel(gain, false)}</span>
|
|
1023
|
+
<div className="sound-mixer-body">
|
|
1024
|
+
<MeterPair meterKey={key} />
|
|
1025
|
+
<VFader
|
|
1026
|
+
label={`${track.name} level`}
|
|
1027
|
+
gain={gain}
|
|
1028
|
+
onBegin={() => store.beginBlockDrag()}
|
|
1029
|
+
onDrag={(g) => store.dragAudioTrack(track.id, { gain: g })}
|
|
1030
|
+
onEnd={(commit) => store.endBlockDrag(commit)}
|
|
1031
|
+
onReset={() => store.updateAudioTrack(track.id, { gain: 1 })}
|
|
1032
|
+
onStep={(g) => store.updateAudioTrack(track.id, { gain: g })}
|
|
1033
|
+
/>
|
|
1034
|
+
</div>
|
|
1035
|
+
<span className="sound-mixer-ms">
|
|
1036
|
+
<button
|
|
1037
|
+
type="button"
|
|
1038
|
+
className={`sound-ms is-mute${muted ? " is-on" : ""}`}
|
|
1039
|
+
aria-pressed={muted}
|
|
1040
|
+
aria-label={`Mute ${track.name}`}
|
|
1041
|
+
onClick={() => store.updateAudioTrack(track.id, { muted: !muted })}
|
|
1042
|
+
>
|
|
1043
|
+
M
|
|
1044
|
+
</button>
|
|
1045
|
+
<button
|
|
1046
|
+
type="button"
|
|
1047
|
+
className={`sound-ms is-solo${props.soloed ? " is-on" : ""}`}
|
|
1048
|
+
aria-pressed={props.soloed}
|
|
1049
|
+
aria-label={`Solo ${track.name}`}
|
|
1050
|
+
onClick={() => store.toggleSoloTrack(track.id)}
|
|
1051
|
+
>
|
|
1052
|
+
S
|
|
1053
|
+
</button>
|
|
1054
|
+
</span>
|
|
1055
|
+
<span className="sound-mixer-name" title={track.name}>
|
|
1056
|
+
{track.name}
|
|
1057
|
+
</span>
|
|
1058
|
+
</div>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Linear amplitude to a percentage of the meter. Logarithmic, because a
|
|
1063
|
+
* linear meter spends four fifths of its length on the top 6 dB and shows
|
|
1064
|
+
* nothing at all for dialogue sitting at -18.
|
|
1065
|
+
*
|
|
1066
|
+
* `METER_FLOOR_DB` is `meterMath.ts`'s because the publisher's gate is
|
|
1067
|
+
* defined against this scale: two levels the floor flattens into the same 0%
|
|
1068
|
+
* are the same picture. Below the floor the meter is empty rather than
|
|
1069
|
+
* negative, here as there. */
|
|
1070
|
+
export function meterPercent(amplitude: number): number {
|
|
1071
|
+
if (!Number.isFinite(amplitude) || amplitude <= 0) return 0;
|
|
1072
|
+
const db = 20 * Math.log10(amplitude);
|
|
1073
|
+
if (db <= METER_FLOOR_DB) return 0;
|
|
1074
|
+
return Math.min(100, (1 - db / METER_FLOOR_DB) * 100);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Two vertical bars, L and R, each with its held peak. */
|
|
1078
|
+
function MeterPair({ meterKey }: { meterKey: string }) {
|
|
1079
|
+
return (
|
|
1080
|
+
<span className="sound-meter-pair" aria-hidden="true">
|
|
1081
|
+
<MeterBar meterKey={meterKey} channel="L" />
|
|
1082
|
+
<MeterBar meterKey={meterKey} channel="R" />
|
|
1083
|
+
</span>
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function MeterBar({ meterKey, channel }: { meterKey: string; channel: "L" | "R" }) {
|
|
1088
|
+
const level = useMeterReading(meterChannelKey(meterKey, channel));
|
|
1089
|
+
const lit = meterPercent(level.rms);
|
|
1090
|
+
const held = meterPercent(level.hold);
|
|
1091
|
+
return (
|
|
1092
|
+
<span className="sound-meter">
|
|
1093
|
+
<span className={`sound-meter-lit${lit > 95 ? " is-hot" : ""}`} style={{ height: `${lit}%` }} />
|
|
1094
|
+
{held > 0 ? <span className="sound-meter-hold" style={{ bottom: `${held}%` }} /> : null}
|
|
1095
|
+
</span>
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** A track's level: a vertical fader on the fader law. A press on the thumb
|
|
1100
|
+
* drags from where it is; a press on the rail jumps there first. */
|
|
1101
|
+
function VFader(props: {
|
|
1102
|
+
label: string;
|
|
1103
|
+
gain: number;
|
|
1104
|
+
onBegin?: () => boolean;
|
|
1105
|
+
onDrag: (gain: number) => void;
|
|
1106
|
+
onEnd?: (commit: boolean) => void;
|
|
1107
|
+
onReset: () => void;
|
|
1108
|
+
onStep?: (gain: number) => void;
|
|
1109
|
+
}) {
|
|
1110
|
+
const pos = faderPos(props.gain);
|
|
1111
|
+
const write = (p: number): number => faderGain(p);
|
|
1112
|
+
return (
|
|
1113
|
+
<span
|
|
1114
|
+
className="sound-vfader"
|
|
1115
|
+
role="slider"
|
|
1116
|
+
tabIndex={0}
|
|
1117
|
+
aria-orientation="vertical"
|
|
1118
|
+
aria-label={props.label}
|
|
1119
|
+
aria-valuemin={0}
|
|
1120
|
+
aria-valuemax={MAX_AUDIO_GAIN}
|
|
1121
|
+
aria-valuenow={props.gain}
|
|
1122
|
+
aria-valuetext={dbLabel(props.gain)}
|
|
1123
|
+
title={`${dbLabel(props.gain)}. Drag to set, double-click for 0 dB.`}
|
|
1124
|
+
onPointerDown={(e) => {
|
|
1125
|
+
if (e.button !== 0) return;
|
|
1126
|
+
e.preventDefault();
|
|
1127
|
+
e.stopPropagation();
|
|
1128
|
+
if (props.onBegin !== undefined && !props.onBegin()) return;
|
|
1129
|
+
const rail = e.currentTarget.getBoundingClientRect();
|
|
1130
|
+
const onThumb = (e.target as HTMLElement).classList.contains("sound-vfader-thumb");
|
|
1131
|
+
const y0 = e.clientY;
|
|
1132
|
+
const start = onThumb ? pos : (rail.bottom - e.clientY) / Math.max(1, rail.height);
|
|
1133
|
+
if (!onThumb) props.onDrag(write(start));
|
|
1134
|
+
startDrag(e, {
|
|
1135
|
+
onMove: (ev) => props.onDrag(write(start - (ev.clientY - y0) / Math.max(1, rail.height))),
|
|
1136
|
+
onEnd: (commit) => props.onEnd?.(commit),
|
|
1137
|
+
});
|
|
1138
|
+
}}
|
|
1139
|
+
onDoubleClick={props.onReset}
|
|
1140
|
+
onKeyDown={(e) => stepKey(e, (by) => (props.onStep ?? props.onDrag)(write(pos + by * 0.01)))}
|
|
1141
|
+
>
|
|
1142
|
+
<span className="sound-vfader-thumb" style={{ bottom: `${pos * 100}%` }} />
|
|
1143
|
+
</span>
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ── TEMPO ────────────────────────────────────────────────────────────────
|
|
1148
|
+
|
|
1149
|
+
/** The head's BPM chip and its popover. */
|
|
1150
|
+
export function TempoChip() {
|
|
1151
|
+
const tempo = useShotEditor((s) => s.doc?.tempo);
|
|
1152
|
+
const [open, setOpen] = useState(false);
|
|
1153
|
+
const anchor = useRef<HTMLButtonElement | null>(null);
|
|
1154
|
+
return (
|
|
1155
|
+
<>
|
|
1156
|
+
<button
|
|
1157
|
+
ref={anchor}
|
|
1158
|
+
type="button"
|
|
1159
|
+
className={`sound-bpm-chip${tempo === undefined ? " is-unset" : ""}`}
|
|
1160
|
+
data-testid="sound-bpm-chip"
|
|
1161
|
+
aria-expanded={open}
|
|
1162
|
+
title="The film's tempo: bar lines, beat snap and the bar readout"
|
|
1163
|
+
onClick={() => setOpen((v) => !v)}
|
|
1164
|
+
>
|
|
1165
|
+
{METRONOME_ICON}
|
|
1166
|
+
<span>{tempo === undefined ? "Set tempo" : tempoLabel(tempo)}</span>
|
|
1167
|
+
</button>
|
|
1168
|
+
<Popover open={open} anchor={anchor} onClose={() => setOpen(false)} className="sound-tempo-pop">
|
|
1169
|
+
{open ? <TempoPopover /> : null}
|
|
1170
|
+
</Popover>
|
|
1171
|
+
</>
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const BEATS_PER_BAR = [3, 4, 6, 7] as const;
|
|
1176
|
+
/** Taps further apart than this start a new count. */
|
|
1177
|
+
const TAP_RESET_MS = 2000;
|
|
1178
|
+
/** The tap tempo commits once the author stops tapping for this long. */
|
|
1179
|
+
const TAP_COMMIT_MS = 1500;
|
|
1180
|
+
|
|
1181
|
+
export function TempoPopover() {
|
|
1182
|
+
const store = useStore();
|
|
1183
|
+
const doc = useShotEditor((s) => s.doc);
|
|
1184
|
+
const snap = useShotEditor((s) => s.snapBeats);
|
|
1185
|
+
const tempo = doc?.tempo;
|
|
1186
|
+
const [draft, setDraft] = useState<string>(tempo === undefined ? "" : String(Math.round(tempo.bpm * 100) / 100));
|
|
1187
|
+
const [warning, setWarning] = useState<string | null>(null);
|
|
1188
|
+
const [detecting, setDetecting] = useState(false);
|
|
1189
|
+
const taps = useRef<number[]>([]);
|
|
1190
|
+
const pending = useRef<{ timer: ReturnType<typeof setTimeout>; bpm: number } | null>(null);
|
|
1191
|
+
|
|
1192
|
+
useEffect(() => {
|
|
1193
|
+
if (tempo !== undefined) setDraft(String(Math.round(tempo.bpm * 100) / 100));
|
|
1194
|
+
}, [tempo?.bpm]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1195
|
+
|
|
1196
|
+
const write = (patch: Partial<Tempo3D>): void => {
|
|
1197
|
+
const s = store.getState().doc?.tempo;
|
|
1198
|
+
const bpm = patch.bpm ?? s?.bpm ?? 120;
|
|
1199
|
+
store.setTempo({ ...(s ?? {}), ...patch, bpm });
|
|
1200
|
+
};
|
|
1201
|
+
const flush = (): void => {
|
|
1202
|
+
const p = pending.current;
|
|
1203
|
+
if (p === null) return;
|
|
1204
|
+
clearTimeout(p.timer);
|
|
1205
|
+
pending.current = null;
|
|
1206
|
+
write({ bpm: p.bpm });
|
|
1207
|
+
};
|
|
1208
|
+
// A pending tap count is not lost when the popover closes.
|
|
1209
|
+
useEffect(() => flush, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1210
|
+
|
|
1211
|
+
const commitDraft = (): void => {
|
|
1212
|
+
const bpm = Number(draft);
|
|
1213
|
+
if (draft.trim() === "" || !Number.isFinite(bpm)) return;
|
|
1214
|
+
const clamped = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, Math.round(bpm * 100) / 100));
|
|
1215
|
+
setDraft(String(clamped));
|
|
1216
|
+
if (clamped !== tempo?.bpm) write({ bpm: clamped });
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const tap = (): void => {
|
|
1220
|
+
const now = performance.now();
|
|
1221
|
+
const last = taps.current[taps.current.length - 1];
|
|
1222
|
+
taps.current = last !== undefined && now - last > TAP_RESET_MS ? [now] : [...taps.current, now].slice(-8);
|
|
1223
|
+
const t = taps.current;
|
|
1224
|
+
if (t.length < 2) return;
|
|
1225
|
+
const bpm = Math.round((60000 * (t.length - 1)) / (t[t.length - 1]! - t[0]!));
|
|
1226
|
+
const clamped = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, bpm));
|
|
1227
|
+
setDraft(String(clamped));
|
|
1228
|
+
if (pending.current !== null) clearTimeout(pending.current.timer);
|
|
1229
|
+
pending.current = { bpm: clamped, timer: setTimeout(flush, TAP_COMMIT_MS) };
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
const detect = async (): Promise<void> => {
|
|
1233
|
+
if (doc === null) return;
|
|
1234
|
+
const s = store.getState();
|
|
1235
|
+
const all = authoredAudioClips(doc).filter((m) => m.layerId === undefined);
|
|
1236
|
+
const source =
|
|
1237
|
+
all.find((m) => s.selectedAudio.includes(m.clip.id)) ??
|
|
1238
|
+
all.find((m) => s.frame >= m.clip.frame && s.frame < m.clip.frame + m.clip.durationFrames) ??
|
|
1239
|
+
all[0];
|
|
1240
|
+
if (source === undefined) {
|
|
1241
|
+
setWarning("Add a music clip first: the tempo is read from its beat analysis.");
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const asset = doc.assets[source.clip.asset];
|
|
1245
|
+
const name = clipLabel(source.clip, asset);
|
|
1246
|
+
if (asset === undefined) return;
|
|
1247
|
+
setDetecting(true);
|
|
1248
|
+
setWarning(null);
|
|
1249
|
+
try {
|
|
1250
|
+
const bytes = await fetchAssetBytes(audioAnalysisFileName(asset.path));
|
|
1251
|
+
const analysis = JSON.parse(new TextDecoder().decode(bytes)) as {
|
|
1252
|
+
beats?: { bpm: number | null; reliable: boolean; gridPhaseSeconds: number | null };
|
|
1253
|
+
};
|
|
1254
|
+
const beats = analysis.beats;
|
|
1255
|
+
if (beats === undefined || beats.bpm === null || !(beats.bpm > 0)) {
|
|
1256
|
+
setWarning(`${name} has no steady beat to read. Set the tempo by hand or tap it.`);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const bpm = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, Math.round(beats.bpm * 100) / 100));
|
|
1260
|
+
const perBar = Math.max(1, Math.trunc(store.getState().doc?.tempo?.beatsPerBar ?? 4));
|
|
1261
|
+
const fps = doc.fps.num / doc.fps.den;
|
|
1262
|
+
// The file's first grid line, on the master timeline: where the source's
|
|
1263
|
+
// second 0 sits, plus the phase. Folded back by whole bars so the grid
|
|
1264
|
+
// starts at or after frame 0.
|
|
1265
|
+
const raw = source.clip.frame - (source.clip.offsetFrames ?? 0) + (beats.gridPhaseSeconds ?? 0) * fps;
|
|
1266
|
+
const bar = ((60 * fps) / bpm) * perBar;
|
|
1267
|
+
const startFrame = Math.round(((raw % bar) + bar) % bar);
|
|
1268
|
+
write({ bpm, startFrame });
|
|
1269
|
+
setDraft(String(bpm));
|
|
1270
|
+
if (!beats.reliable) {
|
|
1271
|
+
setWarning(`${name} reads as ${bpm} BPM, but its beat grid is not reliable. Check it by ear before you snap.`);
|
|
1272
|
+
}
|
|
1273
|
+
} catch {
|
|
1274
|
+
setWarning(`${name} has no beat analysis yet. Re-import it, or set the tempo by hand.`);
|
|
1275
|
+
} finally {
|
|
1276
|
+
setDetecting(false);
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
const perBar = Math.max(1, Math.trunc(tempo?.beatsPerBar ?? 4));
|
|
1281
|
+
return (
|
|
1282
|
+
<div className="sound-tempo" data-testid="sound-tempo">
|
|
1283
|
+
<div className="sound-tempo-head">
|
|
1284
|
+
<span className="sound-tempo-title">Tempo</span>
|
|
1285
|
+
<span className="sound-tempo-scope">this doc</span>
|
|
1286
|
+
</div>
|
|
1287
|
+
<div className="sound-tempo-bpm">
|
|
1288
|
+
<input
|
|
1289
|
+
className="text-input sound-tempo-input"
|
|
1290
|
+
inputMode="decimal"
|
|
1291
|
+
aria-label="Beats per minute"
|
|
1292
|
+
data-testid="sound-tempo-bpm"
|
|
1293
|
+
value={draft}
|
|
1294
|
+
placeholder="120"
|
|
1295
|
+
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
1296
|
+
onBlur={commitDraft}
|
|
1297
|
+
onKeyDown={(e) => {
|
|
1298
|
+
if (e.key === "Enter") commitDraft();
|
|
1299
|
+
}}
|
|
1300
|
+
/>
|
|
1301
|
+
<span className="sound-tempo-unit">BPM</span>
|
|
1302
|
+
<button type="button" className="btn is-outline sound-tempo-tap" data-testid="sound-tempo-tap" onClick={tap}>
|
|
1303
|
+
Tap
|
|
1304
|
+
</button>
|
|
1305
|
+
</div>
|
|
1306
|
+
<div className="sound-tempo-field">
|
|
1307
|
+
<span className="sound-tempo-label">Beats per bar</span>
|
|
1308
|
+
<span className="sound-tempo-seg" role="radiogroup" aria-label="Beats per bar">
|
|
1309
|
+
{BEATS_PER_BAR.map((n) => (
|
|
1310
|
+
<button
|
|
1311
|
+
key={n}
|
|
1312
|
+
type="button"
|
|
1313
|
+
role="radio"
|
|
1314
|
+
aria-checked={perBar === n}
|
|
1315
|
+
className={`sound-tempo-seg-btn${perBar === n ? " is-on" : ""}`}
|
|
1316
|
+
onClick={() => write({ beatsPerBar: n })}
|
|
1317
|
+
>
|
|
1318
|
+
{n}
|
|
1319
|
+
</button>
|
|
1320
|
+
))}
|
|
1321
|
+
</span>
|
|
1322
|
+
</div>
|
|
1323
|
+
<div className="sound-tempo-field">
|
|
1324
|
+
<span className="sound-tempo-label">
|
|
1325
|
+
Snap to beats
|
|
1326
|
+
<span className="sound-tempo-hint">This tab only</span>
|
|
1327
|
+
</span>
|
|
1328
|
+
<Toggle checked={snap} onChange={(on) => store.setSnapBeats(on)} ariaLabel="Snap to beats" />
|
|
1329
|
+
</div>
|
|
1330
|
+
<span className="sound-tempo-rule" aria-hidden="true" />
|
|
1331
|
+
<div className="sound-tempo-actions">
|
|
1332
|
+
<button
|
|
1333
|
+
type="button"
|
|
1334
|
+
className="btn is-outline sound-tempo-detect"
|
|
1335
|
+
data-testid="sound-tempo-detect"
|
|
1336
|
+
disabled={detecting}
|
|
1337
|
+
onClick={() => void detect()}
|
|
1338
|
+
>
|
|
1339
|
+
{detecting ? "Reading…" : "Detect from clip"}
|
|
1340
|
+
</button>
|
|
1341
|
+
{tempo === undefined ? null : (
|
|
1342
|
+
<button type="button" className="btn is-ghost" onClick={() => store.setTempo(null)}>
|
|
1343
|
+
Remove
|
|
1344
|
+
</button>
|
|
1345
|
+
)}
|
|
1346
|
+
</div>
|
|
1347
|
+
{warning === null ? null : (
|
|
1348
|
+
<p className="sound-tempo-warn" role="status" data-testid="sound-tempo-warn">
|
|
1349
|
+
{warning}
|
|
1350
|
+
</p>
|
|
1351
|
+
)}
|
|
1352
|
+
</div>
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** The head's two sound verbs: the library's sounds and the Generate drawer.
|
|
1357
|
+
* Generate shows once the host says some kind is available, or, hosted, that
|
|
1358
|
+
* only the workspace's own ElevenLabs key is missing (`needsKey`): the drawer
|
|
1359
|
+
* then says who adds it. A local studio without the key has no dead button. */
|
|
1360
|
+
export function SoundHeadTools(props: { generateOpen: boolean; onGenerate: () => void }) {
|
|
1361
|
+
const store = useStore();
|
|
1362
|
+
const project = useShotEditor((s) => s.name);
|
|
1363
|
+
const [canGenerate, setCanGenerate] = useState(false);
|
|
1364
|
+
useEffect(() => {
|
|
1365
|
+
if (project === null) return;
|
|
1366
|
+
let live = true;
|
|
1367
|
+
generationStatus(project).then(
|
|
1368
|
+
(s) => {
|
|
1369
|
+
if (live) setCanGenerate(s.needsKey !== undefined || Object.values(s.kinds).some((k) => k.ok));
|
|
1370
|
+
},
|
|
1371
|
+
() => {
|
|
1372
|
+
if (live) setCanGenerate(false);
|
|
1373
|
+
},
|
|
1374
|
+
);
|
|
1375
|
+
return () => {
|
|
1376
|
+
live = false;
|
|
1377
|
+
};
|
|
1378
|
+
}, [project]);
|
|
1379
|
+
return (
|
|
1380
|
+
<>
|
|
1381
|
+
<button
|
|
1382
|
+
type="button"
|
|
1383
|
+
className="btn is-outline sound-head-btn"
|
|
1384
|
+
data-testid="sound-library"
|
|
1385
|
+
title="Browse the library's sounds"
|
|
1386
|
+
onClick={() => store.setLibraryBrowserOpen(true, "sounds")}
|
|
1387
|
+
>
|
|
1388
|
+
{LIBRARY_ICON}
|
|
1389
|
+
Library
|
|
1390
|
+
</button>
|
|
1391
|
+
{canGenerate || props.generateOpen ? (
|
|
1392
|
+
<button
|
|
1393
|
+
type="button"
|
|
1394
|
+
className="btn sound-head-btn is-accent"
|
|
1395
|
+
data-testid="sound-generate"
|
|
1396
|
+
aria-pressed={props.generateOpen}
|
|
1397
|
+
title="Generate a sound effect, music or a voice"
|
|
1398
|
+
onClick={props.onGenerate}
|
|
1399
|
+
>
|
|
1400
|
+
{SPARKLES_ICON}
|
|
1401
|
+
Generate
|
|
1402
|
+
</button>
|
|
1403
|
+
) : null}
|
|
1404
|
+
</>
|
|
1405
|
+
);
|
|
1406
|
+
}
|