@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
package/src/audio/preview.ts
CHANGED
|
@@ -12,17 +12,48 @@
|
|
|
12
12
|
// the fade maths is exactly how preview stops matching export, and the F3-A
|
|
13
13
|
// review caught that divergence in the wild once already.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// That includes the DE-CLICK: the engine ramps every clip edge the author did
|
|
16
|
+
// not fade, so nothing here opens or closes a voice by hand. A voice scheduled
|
|
17
|
+
// from the middle of a clip gets the same ramp for the same reason — it is a
|
|
18
|
+
// transport artefact, and the engine is where that is decided.
|
|
18
19
|
//
|
|
19
20
|
// ── TIME MODEL ──────────────────────────────────────────────────────────
|
|
20
21
|
//
|
|
21
22
|
// The chrome's rAF transport is the authority; this anchors frame→audio-time as
|
|
22
|
-
// t(f) = anchorTime + framesToSeconds(f - anchor, fps) per
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// t(f) = anchorTime + framesToSeconds(f - anchor, fps), ONCE per session. The
|
|
24
|
+
// anchor pair is STORED, so everything that joins the session later — a late
|
|
25
|
+
// decode, a clip the author just added — lands on the same timeline as the
|
|
26
|
+
// voices already playing rather than on a fresh reading of two clocks taken a
|
|
27
|
+
// moment apart.
|
|
28
|
+
//
|
|
29
|
+
// The play edge's session anchors on the TRANSPORT'S ORIGIN (`playOrigin`,
|
|
30
|
+
// bridge v51): the frame the run started on, at the audio time of the wall
|
|
31
|
+
// instant it started. The picture clock (Header.tsx) counts from that same
|
|
32
|
+
// instant, so picture and sound start together rather than a React commit
|
|
33
|
+
// apart. A later session of the same run (an unmute, a re-anchor) takes its
|
|
34
|
+
// own reading, as the picture does after a rate or window change.
|
|
35
|
+
//
|
|
36
|
+
// Drift between the rAF clock and the AudioContext clock is CHECKED rather than
|
|
37
|
+
// assumed away: when the picture's frame and the audio clock disagree by more
|
|
38
|
+
// than a frame, the session re-anchors. A scrub and a loop wrap are that
|
|
39
|
+
// disagreement in the large. A main-thread stall is NOT: the transport derives
|
|
40
|
+
// its frame from absolute wall time (Header.tsx), so after a stall the picture
|
|
41
|
+
// catches up to where the audio already is and the two still agree. It is
|
|
42
|
+
// checked only when the FRAME moved: a notification that moved anything else
|
|
43
|
+
// says nothing about the picture's clock, and the audio has run on since the
|
|
44
|
+
// last frame by up to a frame of its own. The audio may also be one refresh
|
|
45
|
+
// further AHEAD (`DRIFT_REFRESH_S`): the transport picks a frame for the start
|
|
46
|
+
// of the refresh it draws on (rAF's timestamp), and this reads its clock later
|
|
47
|
+
// in that same tick.
|
|
48
|
+
//
|
|
49
|
+
// ── DECODED AHEAD ───────────────────────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// Once the stage has presented the open document whole (its ready edge), the
|
|
52
|
+
// heard clips decode one at a time, nearest the playhead first, at low fetch
|
|
53
|
+
// priority, into the cache the session schedules from. A decode needs no
|
|
54
|
+
// realtime context (`AudioPreviewDeps.decode`), so none exists before the
|
|
55
|
+
// first press: a queued Play's ARM creates or resumes it inside the press,
|
|
56
|
+
// and the transport starts later by itself.
|
|
26
57
|
//
|
|
27
58
|
// ── AND IT MOUNTS FROM THE CHROME ───────────────────────────────────────
|
|
28
59
|
//
|
|
@@ -33,10 +64,35 @@
|
|
|
33
64
|
//
|
|
34
65
|
// No DOM lib types: every Web Audio surface is a minimal structural *Like type
|
|
35
66
|
// and every effect is injected, so the jsdom tests drive a hand-rolled fake.
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
import {
|
|
68
|
+
AUDIO_EDGE_RAMP_SAMPLES,
|
|
69
|
+
MIX_SAMPLE_RATE,
|
|
70
|
+
audioClipWindow,
|
|
71
|
+
audioEnvelopePoints,
|
|
72
|
+
audioSourceTimeAt,
|
|
73
|
+
framesToSeconds,
|
|
74
|
+
panGains,
|
|
75
|
+
wirePan,
|
|
76
|
+
} from "@dailies-studio/engine";
|
|
77
|
+
import type {
|
|
78
|
+
PanContextLike as EnginePanContextLike,
|
|
79
|
+
PanStage as EnginePanStage,
|
|
80
|
+
Rational,
|
|
81
|
+
} from "@dailies-studio/engine";
|
|
82
|
+
import {
|
|
83
|
+
asEngineClip,
|
|
84
|
+
audioAnalysisFileName,
|
|
85
|
+
audioGainSampler,
|
|
86
|
+
audioPlayPath,
|
|
87
|
+
audioTracksOf,
|
|
88
|
+
derivedGainTracks,
|
|
89
|
+
docDurationFrames,
|
|
90
|
+
duckSourceAssets,
|
|
91
|
+
isAudioFxFileName,
|
|
92
|
+
masterAudioClips,
|
|
93
|
+
sceneWindows,
|
|
94
|
+
} from "@dailies-studio/shot/doc";
|
|
95
|
+
import type { AudioAnalysis, AudioClipRef, Doc3D, MasterAudioClip, ShotTrack } from "@dailies-studio/shot/doc";
|
|
40
96
|
|
|
41
97
|
// --- minimal structural Web Audio surface (fakes stay trivial) ---------------
|
|
42
98
|
|
|
@@ -44,12 +100,30 @@ export interface AudioParamLike {
|
|
|
44
100
|
value: number;
|
|
45
101
|
setValueAtTime(value: number, time: number): unknown;
|
|
46
102
|
linearRampToValueAtTime(value: number, time: number): unknown;
|
|
103
|
+
/** Needed to re-automate a voice that is already sounding: an edit has to
|
|
104
|
+
* drop the future the old envelope scheduled before it writes its own. */
|
|
105
|
+
cancelScheduledValues(time: number): unknown;
|
|
47
106
|
}
|
|
48
107
|
|
|
49
108
|
export interface GainNodeLike {
|
|
50
109
|
readonly gain: AudioParamLike;
|
|
51
|
-
|
|
52
|
-
|
|
110
|
+
/** `output`/`input` pick a channel on a splitter or merger (the pan stage). */
|
|
111
|
+
connect(destination: unknown, output?: number, input?: number): unknown;
|
|
112
|
+
/** Written only by the pan stage, which needs a mono source UPMIXED before
|
|
113
|
+
* its splitter. Optional so the fakes that never pan stay as they are. */
|
|
114
|
+
channelCount?: number;
|
|
115
|
+
channelCountMode?: string;
|
|
116
|
+
channelInterpretation?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The SELECTIVE form is optional here and required by the meter.
|
|
119
|
+
*
|
|
120
|
+
* A tap hangs off the master node and must unwire only itself
|
|
121
|
+
* (`meter.ts`'s `release`): calling the no-argument form on the monitored
|
|
122
|
+
* node would drop its real output as well, which is the whole mix. Declared
|
|
123
|
+
* as an optional parameter rather than a second method so every existing
|
|
124
|
+
* fake — each of which spells `disconnect(): void` — still satisfies it.
|
|
125
|
+
*/
|
|
126
|
+
disconnect(destination?: unknown): void;
|
|
53
127
|
}
|
|
54
128
|
|
|
55
129
|
export interface AudioBufferLike {
|
|
@@ -62,11 +136,37 @@ export interface AudioBufferSourceNodeLike {
|
|
|
62
136
|
start(when?: number, offset?: number, duration?: number): void;
|
|
63
137
|
stop(when?: number): void;
|
|
64
138
|
disconnect(): void;
|
|
139
|
+
/** Set so a voice released with a ramp disconnects ITSELF once the stop it
|
|
140
|
+
* scheduled has actually happened. Disconnecting earlier would cut the ramp
|
|
141
|
+
* off, which is the click the ramp exists to remove. */
|
|
142
|
+
onended: (() => void) | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A ChannelSplitter or ChannelMerger, as far as the pan stage wires one. */
|
|
146
|
+
export interface ChannelNodeLike {
|
|
147
|
+
connect(destination: unknown, output?: number, input?: number): unknown;
|
|
148
|
+
disconnect(): void;
|
|
65
149
|
}
|
|
66
150
|
|
|
67
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The part of a context the pan stage (engine's `wirePan`) needs. The splitter
|
|
153
|
+
* pair is OPTIONAL so a jsdom fake that never pans stays trivial; the offline
|
|
154
|
+
* mix refuses a panned clip without it rather than mixing it centred.
|
|
155
|
+
*/
|
|
156
|
+
export type PanContextLike = EnginePanContextLike<GainNodeLike, ChannelNodeLike>;
|
|
157
|
+
|
|
158
|
+
/** One voice's pan stage, as this lane's node types. */
|
|
159
|
+
export type PanStage = EnginePanStage<GainNodeLike, ChannelNodeLike>;
|
|
160
|
+
|
|
161
|
+
export interface AudioContextLike extends PanContextLike {
|
|
68
162
|
readonly currentTime: number;
|
|
69
163
|
readonly destination: unknown;
|
|
164
|
+
/** The device's rate. Nothing in the scheduler reads it — the envelope is
|
|
165
|
+
* the engine's and works in seconds — but the meter's worklet derives its
|
|
166
|
+
* own `dt` from it, and `MeterContextLike` (meter.ts) is satisfied by
|
|
167
|
+
* exactly this field. Declaring it here is what lets the monitor hand a
|
|
168
|
+
* real context straight to `attachMeter` with no cast. */
|
|
169
|
+
readonly sampleRate: number;
|
|
70
170
|
createGain(): GainNodeLike;
|
|
71
171
|
createBufferSource(): AudioBufferSourceNodeLike;
|
|
72
172
|
resume?(): unknown;
|
|
@@ -74,13 +174,25 @@ export interface AudioContextLike {
|
|
|
74
174
|
}
|
|
75
175
|
|
|
76
176
|
export interface AudioPreviewDeps {
|
|
77
|
-
/** Called lazily
|
|
78
|
-
*
|
|
177
|
+
/** Called lazily inside the first press that could sound: a queued Play's
|
|
178
|
+
* arm, or a play edge that starts at once. Both run in the press's own call
|
|
179
|
+
* stack, so the real context is born unblocked by autoplay policy. */
|
|
79
180
|
createContext(): AudioContextLike;
|
|
80
|
-
|
|
81
|
-
|
|
181
|
+
/** `"low"` for a decode ahead, which must not compete with the stage's own
|
|
182
|
+
* loads; absent for a play edge's, which the author is waiting on. */
|
|
183
|
+
fetchBytes(url: string, priority?: "low"): Promise<ArrayBuffer>;
|
|
184
|
+
/**
|
|
185
|
+
* Decode with NO realtime context, so a clip can decode before the first
|
|
186
|
+
* press. The browser's is one `OfflineAudioContext(2, 1, MIX_SAMPLE_RATE)`,
|
|
187
|
+
* the export mix's rate (audio/index.tsx); a buffer plays on any context,
|
|
188
|
+
* resampled to its rate.
|
|
189
|
+
*/
|
|
190
|
+
decode(bytes: ArrayBuffer): Promise<AudioBufferLike>;
|
|
82
191
|
/** Audio-clock override for tests; defaults to ctx.currentTime. */
|
|
83
192
|
now?(): number;
|
|
193
|
+
/** Wall-clock override for tests; defaults to `performance.now()`, the clock
|
|
194
|
+
* `TransportSnapshot.origin.at` is read on. */
|
|
195
|
+
wallNow?(): number;
|
|
84
196
|
}
|
|
85
197
|
|
|
86
198
|
/** The transport truth the host reads off the store on every change. */
|
|
@@ -88,6 +200,66 @@ export interface TransportSnapshot {
|
|
|
88
200
|
playing: boolean;
|
|
89
201
|
frame: number;
|
|
90
202
|
doc: Doc3D | null;
|
|
203
|
+
/**
|
|
204
|
+
* Whether the MASTER timeline is what is playing, AT ITS OWN SPEED.
|
|
205
|
+
*
|
|
206
|
+
* Two things make it false, and they are one thing twice: a second clock the
|
|
207
|
+
* picture's cannot agree with. `transportAudible` in state/types.ts is the
|
|
208
|
+
* one place both are decided, and `audio/index.tsx` is its only caller — this
|
|
209
|
+
* scheduler is handed the answer rather than forming a second opinion.
|
|
210
|
+
*
|
|
211
|
+
* A COMPONENT CONTEXT drives a definition's own clock and `frame` does not
|
|
212
|
+
* move at all. Without this the preview heard a play edge, started the whole
|
|
213
|
+
* document's soundtrack from a static master frame, and played it over a
|
|
214
|
+
* definition looping on its own — with nothing to re-anchor it, because the
|
|
215
|
+
* frame it was watching never changed.
|
|
216
|
+
*
|
|
217
|
+
* ANY SHUTTLE RATE BUT 1 IS SILENT, and that is a refusal rather than a
|
|
218
|
+
* missing feature. Reverse needs reversed buffers and a pitch-preserved 2x
|
|
219
|
+
* needs a time stretcher; each is a second scheduler running its own
|
|
220
|
+
* arithmetic beside the picture's, and the first time the two round
|
|
221
|
+
* differently the author hears one frame while watching another. Playing the
|
|
222
|
+
* buffers at `playbackRate` instead would be worse still: it is a pitch shift
|
|
223
|
+
* the export will never make, so the preview would be wrong about what the
|
|
224
|
+
* film sounds like — the one thing this lane refuses above all.
|
|
225
|
+
*/
|
|
226
|
+
audible: boolean;
|
|
227
|
+
/** The shot the author has ENTERED, whose window the transport loops inside;
|
|
228
|
+
* null on the master timeline. A voice must not outlive that window. */
|
|
229
|
+
scene: string | null;
|
|
230
|
+
/**
|
|
231
|
+
* The tracks this tab SOLOS, or absent/empty for the whole mix.
|
|
232
|
+
*
|
|
233
|
+
* Monitoring, not document state: it lives in the editor beside
|
|
234
|
+
* `previewMuted`, only `reconcile` reads it, and neither export mix ever
|
|
235
|
+
* sees it. A soloed tab hears a partial mix, which the mixer shows by dimming
|
|
236
|
+
* every track that is not soloed.
|
|
237
|
+
*/
|
|
238
|
+
solo?: readonly string[];
|
|
239
|
+
/**
|
|
240
|
+
* A QUEUED PLAY waits (`playIntent`, bridge v51): the press has happened and
|
|
241
|
+
* the transport starts later, by itself, outside it. So the ARM is where the
|
|
242
|
+
* realtime context is created, or resumed after the page suspended it.
|
|
243
|
+
*/
|
|
244
|
+
armed?: boolean;
|
|
245
|
+
/**
|
|
246
|
+
* WHERE AND WHEN THE TRANSPORT STARTED (`playOrigin`): its frame, and the
|
|
247
|
+
* `performance.now()` of the start. The play edge that brings a new one
|
|
248
|
+
* anchors its session on it, as the picture clock does.
|
|
249
|
+
*/
|
|
250
|
+
origin?: TransportOrigin | null;
|
|
251
|
+
/**
|
|
252
|
+
* The stage has PRESENTED the open subject whole: one is attached and owes
|
|
253
|
+
* no presentation (`stageBusy` false). Its rising edge is the ready edge the
|
|
254
|
+
* decode ahead waits for.
|
|
255
|
+
*/
|
|
256
|
+
presented?: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `ShotEditorState.playOrigin`, as the scheduler reads it. */
|
|
260
|
+
export interface TransportOrigin {
|
|
261
|
+
readonly frame: number;
|
|
262
|
+
readonly at: number;
|
|
91
263
|
}
|
|
92
264
|
|
|
93
265
|
export interface AudioPreview {
|
|
@@ -107,47 +279,245 @@ export interface AudioPreview {
|
|
|
107
279
|
dispose(): void;
|
|
108
280
|
}
|
|
109
281
|
|
|
110
|
-
|
|
111
|
-
|
|
282
|
+
// ── THE MONITOR TAP: WHERE A METER FACE FINDS THE GRAPH ────────────────────
|
|
283
|
+
//
|
|
284
|
+
// `audio/meter.ts` can measure any gain node, and there was no way for a
|
|
285
|
+
// component to GET one. The preview owns the only audio graph this chrome
|
|
286
|
+
// builds, it builds it lazily inside `ensureContext` on the first press (an
|
|
287
|
+
// arm or a play edge), and the preview object itself lives inside
|
|
288
|
+
// `AudioPreviewBridge`'s effect, which renders null and is reachable from
|
|
289
|
+
// nothing. So the one node worth metering — the master gain every voice is
|
|
290
|
+
// summed into, which is exactly what the author is hearing — is published
|
|
291
|
+
// here and subscribed to by the face.
|
|
292
|
+
//
|
|
293
|
+
// A MODULE-LEVEL SLOT AND NOT THE SHOT STORE, for `meterStore.ts`'s reason one
|
|
294
|
+
// level up: this is a live Web Audio node, it is not document state, it must
|
|
295
|
+
// never enter the undo history, and a face holding it through the store would
|
|
296
|
+
// pin the graph across every notification. It is also not the meter store,
|
|
297
|
+
// which carries READINGS (three floats at ~60 Hz) and knows nothing about
|
|
298
|
+
// nodes.
|
|
299
|
+
//
|
|
300
|
+
// IDENTITY IS THE CONTRACT, the same one `meterStore.readMeter` keeps:
|
|
301
|
+
// `readMonitor` is a `useSyncExternalStore` getter called during render, so it
|
|
302
|
+
// returns the SAME object until the graph actually changes and allocates
|
|
303
|
+
// nothing. Null until the first press, which is why `useMeterTap` takes a
|
|
304
|
+
// null source and starts measuring when one arrives.
|
|
305
|
+
//
|
|
306
|
+
// ponytail: ONE slot for the page, not one per preview. Exactly one
|
|
307
|
+
// `AudioPreviewBridge` mounts in this chrome and it owns the page's only
|
|
308
|
+
// AudioContext; a second preview would overwrite the first's publication.
|
|
309
|
+
// `dispose` therefore clears only its OWN graph, so the surviving one keeps
|
|
310
|
+
// its meter rather than both going dark. Key the slot by preview instance if a
|
|
311
|
+
// second bridge is ever mounted.
|
|
312
|
+
|
|
313
|
+
/** The channel a master meter reads, spelled once so the tap and the face
|
|
314
|
+
* cannot disagree about which name the readings arrive under. */
|
|
315
|
+
export const MASTER_METER_KEY = "master";
|
|
316
|
+
|
|
317
|
+
/** What a face needs to hang a tap: the context to compile the worklet into,
|
|
318
|
+
* and the node to measure. */
|
|
319
|
+
export interface MonitorGraph {
|
|
320
|
+
ctx: AudioContextLike;
|
|
321
|
+
node: GainNodeLike;
|
|
322
|
+
/**
|
|
323
|
+
* One unity BUS per audio track, keyed by track id, every voice on that
|
|
324
|
+
* track summed into it before the master. A tap on it reads that track
|
|
325
|
+
* alone (Sound mode's channel strips). Unity gain and a plain connect, so
|
|
326
|
+
* the master sum is the same with or without the buses. A NEW map whenever
|
|
327
|
+
* a bus is added, so a face's `useSyncExternalStore` sees the change.
|
|
328
|
+
*/
|
|
329
|
+
tracks: ReadonlyMap<string, GainNodeLike>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let monitor: MonitorGraph | null = null;
|
|
333
|
+
const monitorListeners = new Set<() => void>();
|
|
334
|
+
|
|
335
|
+
/** The live monitor graph, or null before the first press. */
|
|
336
|
+
export function readMonitor(): MonitorGraph | null {
|
|
337
|
+
return monitor;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Subscribe to the graph appearing or going away. */
|
|
341
|
+
export function subscribeMonitor(listener: () => void): () => void {
|
|
342
|
+
monitorListeners.add(listener);
|
|
343
|
+
return () => {
|
|
344
|
+
monitorListeners.delete(listener);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A listener that THROWS must not stop the others hearing about it — the
|
|
349
|
+
* posture `publishMeter` already takes, and for the same reason: a meter face
|
|
350
|
+
* is decoration and may not take the preview down with it. */
|
|
351
|
+
function publishMonitor(next: MonitorGraph | null): void {
|
|
352
|
+
if (next === monitor) return;
|
|
353
|
+
monitor = next;
|
|
354
|
+
for (const listener of [...monitorListeners]) {
|
|
355
|
+
try {
|
|
356
|
+
listener();
|
|
357
|
+
} catch {
|
|
358
|
+
// See above.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Test hook, and the one way to empty the slot without a real dispose. */
|
|
364
|
+
export function resetMonitor(): void {
|
|
365
|
+
publishMonitor(null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* How far the picture and the audio clock may disagree before the session
|
|
370
|
+
* re-anchors, in frames.
|
|
371
|
+
*
|
|
372
|
+
* ponytail: one frame, the smallest disagreement that is not rounding. Raise it
|
|
373
|
+
* if a loaded machine is measured re-anchoring on its own.
|
|
374
|
+
*/
|
|
375
|
+
export const DRIFT_FRAMES = 1;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* How much further the audio clock may run AHEAD of the picture than behind
|
|
379
|
+
* it before the session re-anchors, in seconds: one refresh.
|
|
380
|
+
*
|
|
381
|
+
* The transport picks each frame for the start of the refresh it draws on
|
|
382
|
+
* (rAF's timestamp, Header.tsx), its boundaries placed up to half a refresh
|
|
383
|
+
* off the origin, and this reads the audio clock later in that same tick. So
|
|
384
|
+
* a picture that agrees with the audio begins up to a refresh before the
|
|
385
|
+
* reading, besides the frame it has run into. With a frame alone allowed, a
|
|
386
|
+
* 60 fps run whose base sat late would re-anchor at nearly every frame.
|
|
387
|
+
*
|
|
388
|
+
* ponytail: a 60 Hz refresh assumed, as renderScaleController.ts does; a
|
|
389
|
+
* slower display's late tick reads as drift. Hand the measured refresh over
|
|
390
|
+
* if one ever must not.
|
|
391
|
+
*/
|
|
392
|
+
export const DRIFT_REFRESH_S = 1 / 60;
|
|
393
|
+
|
|
394
|
+
/** The engine's de-click, in seconds — the release a voice fades over when the
|
|
395
|
+
* TRANSPORT stops it, which is not something the envelope can express. */
|
|
396
|
+
const RAMP_SECONDS = AUDIO_EDGE_RAMP_SAMPLES / MIX_SAMPLE_RATE;
|
|
112
397
|
|
|
113
398
|
type CacheEntry =
|
|
114
399
|
| { status: "pending" }
|
|
115
400
|
| { status: "ready"; buffer: AudioBufferLike }
|
|
116
401
|
| { status: "failed" };
|
|
117
402
|
|
|
403
|
+
/** The same three states for an analysis sidecar. */
|
|
404
|
+
type AnalysisEntry =
|
|
405
|
+
| { status: "pending" }
|
|
406
|
+
| { status: "ready"; analysis: AudioAnalysis }
|
|
407
|
+
| { status: "failed" };
|
|
408
|
+
|
|
118
409
|
interface Voice {
|
|
119
410
|
source: AudioBufferSourceNodeLike;
|
|
120
411
|
gain: GainNodeLike;
|
|
412
|
+
/** The pan stage, or null on a context with no splitter pair. */
|
|
413
|
+
pan: PanStage | null;
|
|
414
|
+
/** What would make this voice the WRONG SOURCE WINDOW — a move, a trim, a
|
|
415
|
+
* slip, a different asset. Changing it means a new node. */
|
|
416
|
+
timing: string;
|
|
417
|
+
/** The file it plays (`audioPlayPath`): the asset, or its processed file. */
|
|
418
|
+
path: string;
|
|
419
|
+
/** What is only a LEVEL — gain, fades, the automation track. Changing it is
|
|
420
|
+
* re-automated on the node that is already sounding. */
|
|
421
|
+
shape: string;
|
|
121
422
|
}
|
|
122
423
|
|
|
123
|
-
/**
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
424
|
+
/** One audible session: the anchor pair every voice in it shares, the window
|
|
425
|
+
* its voices end at, and the voices themselves. */
|
|
426
|
+
interface Session {
|
|
427
|
+
anchorFrame: number;
|
|
428
|
+
anchorTime: number;
|
|
429
|
+
endFrame: number;
|
|
430
|
+
voices: Map<string, Voice>;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Re-exported from the data plane, where `dailies check` also needs it. One
|
|
434
|
+
* widening, not two, and now not three. */
|
|
435
|
+
export { asEngineClip };
|
|
436
|
+
|
|
437
|
+
/** The frame a voice may sound until: the entered shot's window end, or the
|
|
438
|
+
* whole document's. A voice scheduled to the document end while the transport
|
|
439
|
+
* loops inside one shot would keep playing past the loop point. */
|
|
440
|
+
function endFrameOf(doc: Doc3D, scene: string | null): number {
|
|
441
|
+
if (scene === null) return docDurationFrames(doc);
|
|
442
|
+
return sceneWindows(doc).find((w) => w.id === scene)?.end ?? docDurationFrames(doc);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** What makes a voice the wrong SOURCE WINDOW. Asset PATH, not id: an id is
|
|
446
|
+
* only unique within its own document. */
|
|
447
|
+
/** The file a `timingOf` key names. */
|
|
448
|
+
const playPathOf = (timing: string): string => (JSON.parse(timing) as unknown[])[2] as string;
|
|
449
|
+
|
|
450
|
+
function timingOf(clip: AudioClipRef, assetPath: string | undefined, trackId: string): string {
|
|
451
|
+
return JSON.stringify([
|
|
452
|
+
// The TRACK too: a voice is wired to its track's bus, and a clip moved to
|
|
453
|
+
// another track has to sum into the other bus or that strip's meter lies.
|
|
454
|
+
trackId,
|
|
455
|
+
clip.frame,
|
|
456
|
+
assetPath,
|
|
457
|
+
clip.offsetFrames ?? 0,
|
|
458
|
+
clip.durationFrames,
|
|
459
|
+
clip.muted === true,
|
|
460
|
+
]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What is only a level, and can be re-automated in place.
|
|
465
|
+
*
|
|
466
|
+
* Takes the RESOLVED track rather than reading `clip.gainTrack`, because a
|
|
467
|
+
* ducked bed's level changes when nothing on the bed does: the voice above it
|
|
468
|
+
* moved, or its analysis just landed. Hashing the authored field would leave
|
|
469
|
+
* the old envelope sounding with no edit to notice.
|
|
470
|
+
*/
|
|
471
|
+
function shapeOf(clip: AudioClipRef, track: ShotTrack | undefined, pan: number): string {
|
|
472
|
+
return JSON.stringify([
|
|
473
|
+
clip.gain ?? 1,
|
|
474
|
+
clip.fadeInFrames ?? 0,
|
|
475
|
+
clip.fadeOutFrames ?? 0,
|
|
476
|
+
track ?? null,
|
|
477
|
+
// PAN IS A LEVEL, so a pan-knob drag re-automates the two side gains of
|
|
478
|
+
// the voice already sounding instead of restarting it.
|
|
479
|
+
pan,
|
|
480
|
+
]);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Whether a flattened clip is heard in this tab: not muted (the track's mute
|
|
484
|
+
* is already folded in), and on a soloed track when any track is soloed. */
|
|
485
|
+
function heard(m: MasterAudioClip, solo: readonly string[] | undefined): boolean {
|
|
486
|
+
if (m.clip.muted === true) return false;
|
|
487
|
+
return solo === undefined || solo.length === 0 || solo.includes(m.trackId);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function sameSolo(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
|
|
491
|
+
const x = a ?? [];
|
|
492
|
+
const y = b ?? [];
|
|
493
|
+
return x.length === y.length && x.every((id, i) => id === y[i]);
|
|
138
494
|
}
|
|
139
495
|
|
|
140
496
|
export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
|
|
141
497
|
let ctx: AudioContextLike | null = null;
|
|
142
498
|
let ctxFailed = false;
|
|
143
499
|
let master: GainNodeLike | null = null;
|
|
500
|
+
/** Per-track unity buses, created as tracks first get a voice; see
|
|
501
|
+
* `MonitorGraph.tracks`. They live as long as the context does. */
|
|
502
|
+
let buses: Map<string, GainNodeLike> = new Map();
|
|
144
503
|
let masterMuted = false;
|
|
145
504
|
let masterVolume = 1;
|
|
146
505
|
let disposed = false;
|
|
147
|
-
let last: TransportSnapshot = {
|
|
506
|
+
let last: TransportSnapshot = {
|
|
507
|
+
playing: false,
|
|
508
|
+
frame: 0,
|
|
509
|
+
doc: null,
|
|
510
|
+
audible: true,
|
|
511
|
+
scene: null,
|
|
512
|
+
};
|
|
148
513
|
|
|
149
514
|
/**
|
|
150
|
-
* AudioBuffer cache by asset PATH
|
|
515
|
+
* AudioBuffer cache by asset PATH, filled by the decode ahead and by a play
|
|
516
|
+
* edge alike. A "failed" entry is retried on the next PLAY EDGE, never inside
|
|
517
|
+
* a session and never by the decode ahead, so one dead URL cannot turn the
|
|
518
|
+
* transport into a fetch loop and one transient 5xx cannot silence a clip for
|
|
519
|
+
* the rest of the page's life — which used to make the preview and the export
|
|
520
|
+
* disagree about what the document sounds like.
|
|
151
521
|
*
|
|
152
522
|
* NOT by asset id. `ShotAsset.id` is a slug of the filename uniquified only
|
|
153
523
|
* against the ids already in ITS OWN document (`freshId(usedIdsOf(doc), …)`),
|
|
@@ -156,19 +526,90 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
|
|
|
156
526
|
* that owns this preview never remounts — so an id key serves one shot's
|
|
157
527
|
* audio to another shot with the same filename. `asset.path` is the
|
|
158
528
|
* content-addressed `<hash8>-<name>` the asset store writes, which is unique
|
|
159
|
-
* across every document and identical for identical bytes
|
|
160
|
-
*
|
|
529
|
+
* across every document and identical for identical bytes.
|
|
530
|
+
*
|
|
531
|
+
* A clip's PROCESSED file (schema v49) leaves once no clip of the document
|
|
532
|
+
* plays it (`reconcile`): each setting is a whole decoded sound, and a knob
|
|
533
|
+
* turned during playback would otherwise keep every one of them.
|
|
161
534
|
*/
|
|
162
535
|
const buffers = new Map<string, CacheEntry>();
|
|
163
536
|
|
|
164
|
-
/**
|
|
165
|
-
*
|
|
166
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Analysis sidecars by asset PATH, on the same terms as the buffers above:
|
|
539
|
+
* content-addressed key, one fetch per asset, a failure retried on the next
|
|
540
|
+
* play edge rather than latched for the life of the page.
|
|
541
|
+
*
|
|
542
|
+
* A missing analysis is not an error here. `duckedGainTrack` returns
|
|
543
|
+
* undefined without one, so the bed simply plays at its authored level until
|
|
544
|
+
* the sidecar arrives and `reconcile` re-automates it. The EXPORT is the
|
|
545
|
+
* strict one: it refuses rather than shipping the level the author has
|
|
546
|
+
* stopped hearing.
|
|
547
|
+
*/
|
|
548
|
+
const analyses = new Map<string, AnalysisEntry>();
|
|
549
|
+
/** Bumped whenever an analysis lands, so the derived-track memo below knows
|
|
550
|
+
* the answer may have changed even though the document did not. */
|
|
551
|
+
let analysisGeneration = 0;
|
|
552
|
+
let derivedFor: { doc: Doc3D; generation: number; tracks: Map<string, ShotTrack> } | null = null;
|
|
553
|
+
|
|
554
|
+
/** Every ducked clip's derived level, recomputed only when the document
|
|
555
|
+
* object or the set of landed analyses actually changes. A gain drag mints
|
|
556
|
+
* a new document per pointermove, so this runs at gesture rate. */
|
|
557
|
+
function derivedTracks(doc: Doc3D): Map<string, ShotTrack> {
|
|
558
|
+
if (derivedFor !== null && derivedFor.doc === doc && derivedFor.generation === analysisGeneration) {
|
|
559
|
+
return derivedFor.tracks;
|
|
560
|
+
}
|
|
561
|
+
const tracks = derivedGainTracks(doc, (asset) => {
|
|
562
|
+
const entry = analyses.get(asset.path);
|
|
563
|
+
return entry?.status === "ready" ? entry.analysis : undefined;
|
|
564
|
+
});
|
|
565
|
+
derivedFor = { doc, generation: analysisGeneration, tracks };
|
|
566
|
+
return tracks;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The live session, or null while paused, muted or inaudible. */
|
|
570
|
+
let session: Session | null = null;
|
|
571
|
+
/** The document id the stage last presented whole (its ready edge): the
|
|
572
|
+
* one the decode ahead may fetch for. */
|
|
573
|
+
let readyDoc: string | null = null;
|
|
574
|
+
/** A decode ahead in flight: one at a time, so the nearest lands first. */
|
|
575
|
+
let ahead = false;
|
|
167
576
|
|
|
168
577
|
function now(): number {
|
|
169
578
|
return deps.now ? deps.now() : ctx!.currentTime;
|
|
170
579
|
}
|
|
171
580
|
|
|
581
|
+
function wallNow(): number {
|
|
582
|
+
return deps.wallNow ? deps.wallNow() : performance.now();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function fpsOf(): Rational {
|
|
586
|
+
return last.doc!.fps;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Audio time of a picture frame, on the CURRENT session's anchor. */
|
|
590
|
+
function t(frame: number): number {
|
|
591
|
+
const s = session!;
|
|
592
|
+
return s.anchorTime + framesToSeconds(frame - s.anchorFrame, fpsOf());
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The frame the audio clock has reached, rounded UP so anything scheduled
|
|
597
|
+
* from it is scheduled in the future.
|
|
598
|
+
*
|
|
599
|
+
* EPSILON before the ceil, because the common case is an exact boundary: the
|
|
600
|
+
* transport ticks frame by frame and the elapsed time is n/fps, which in
|
|
601
|
+
* doubles lands a few ULPs either side of the integer. Ceiling that lands on
|
|
602
|
+
* n + 1 half the time, and a reconcile or a late decode scheduled from one
|
|
603
|
+
* frame later than the audio actually is drops a frame of it.
|
|
604
|
+
*/
|
|
605
|
+
function frameNow(): number {
|
|
606
|
+
const s = session!;
|
|
607
|
+
const fps = fpsOf();
|
|
608
|
+
const elapsed = now() - s.anchorTime;
|
|
609
|
+
const frames = (elapsed * fps.num) / fps.den;
|
|
610
|
+
return s.anchorFrame + Math.max(0, Math.ceil(frames - 1e-9));
|
|
611
|
+
}
|
|
612
|
+
|
|
172
613
|
function ensureContext(): boolean {
|
|
173
614
|
if (ctxFailed) return false;
|
|
174
615
|
if (!ctx) {
|
|
@@ -184,126 +625,445 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
|
|
|
184
625
|
master = ctx.createGain();
|
|
185
626
|
master.gain.value = masterVolume;
|
|
186
627
|
master.connect(ctx.destination);
|
|
628
|
+
// THE GRAPH NOW EXISTS, so anything waiting to meter it can start. After
|
|
629
|
+
// the connect, so a tap can never be attached to a node that is not yet
|
|
630
|
+
// in the graph.
|
|
631
|
+
// The document's buses go up WITH the master, so the first press
|
|
632
|
+
// publishes once and a face re-renders once.
|
|
633
|
+
buses = new Map();
|
|
634
|
+
if (last.doc !== null) addBuses(last.doc);
|
|
635
|
+
publishMonitor({ ctx, node: master, tracks: buses });
|
|
187
636
|
}
|
|
188
637
|
try {
|
|
189
|
-
void ctx.resume?.(); // autoplay policy: nudge on every
|
|
638
|
+
void ctx.resume?.(); // autoplay policy: nudge on every press
|
|
190
639
|
} catch {
|
|
191
640
|
// resume is best-effort; a suspended context just stays silent
|
|
192
641
|
}
|
|
193
642
|
return true;
|
|
194
643
|
}
|
|
195
644
|
|
|
196
|
-
/**
|
|
645
|
+
/** Create or resume the realtime context, for a transport that can sound. */
|
|
646
|
+
function wake(): boolean {
|
|
647
|
+
return last.doc !== null && !masterMuted && last.audible && ensureContext();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Stop a voice the way the TRANSPORT stops it: a short release, then the
|
|
652
|
+
* stop, then teardown once that stop has happened.
|
|
653
|
+
*
|
|
654
|
+
* The envelope's own ramps cannot cover this. They describe the CLIP, and
|
|
655
|
+
* pausing at frame 200 is not something the clip has an opinion about — the
|
|
656
|
+
* gain is whatever it is when the author lets go, and cutting a source off
|
|
657
|
+
* there is a step discontinuity in the output buffer. Which is a click, on
|
|
658
|
+
* every pause, every scrub and every loop wrap.
|
|
659
|
+
*/
|
|
660
|
+
/**
|
|
661
|
+
* Give every track of `doc` its bus, into a NEW map when any is missing.
|
|
662
|
+
* True when it added one, so the caller publishes.
|
|
663
|
+
*
|
|
664
|
+
* ponytail: a deleted track's bus stays wired, idle, until the context
|
|
665
|
+
* closes. Drop it here if a session ever churns through many tracks.
|
|
666
|
+
*/
|
|
667
|
+
function addBuses(doc: Doc3D): boolean {
|
|
668
|
+
const missing = audioTracksOf(doc).filter((t) => !buses.has(t.id));
|
|
669
|
+
if (missing.length === 0) return false;
|
|
670
|
+
const next = new Map(buses);
|
|
671
|
+
for (const track of missing) {
|
|
672
|
+
const bus = ctx!.createGain();
|
|
673
|
+
bus.gain.value = 1;
|
|
674
|
+
bus.connect(master!);
|
|
675
|
+
next.set(track.id, bus);
|
|
676
|
+
}
|
|
677
|
+
buses = next;
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The bus a track's voices sum into. `reconcile` has already made one for
|
|
682
|
+
* every track of the document, so this only builds on a stale snapshot. */
|
|
683
|
+
function busFor(trackId: string): GainNodeLike {
|
|
684
|
+
const existing = buses.get(trackId);
|
|
685
|
+
if (existing !== undefined) return existing;
|
|
686
|
+
const bus = ctx!.createGain();
|
|
687
|
+
bus.gain.value = 1;
|
|
688
|
+
bus.connect(master!);
|
|
689
|
+
buses = new Map(buses).set(trackId, bus);
|
|
690
|
+
publishMonitor({ ctx: ctx!, node: master!, tracks: buses });
|
|
691
|
+
return bus;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function release(voice: Voice): void {
|
|
695
|
+
const t0 = now();
|
|
696
|
+
const param = voice.gain.gain;
|
|
697
|
+
const drop = (): void => {
|
|
698
|
+
voice.source.disconnect();
|
|
699
|
+
voice.gain.disconnect();
|
|
700
|
+
if (voice.pan !== null) {
|
|
701
|
+
voice.pan.split.disconnect();
|
|
702
|
+
voice.pan.l.disconnect();
|
|
703
|
+
voice.pan.r.disconnect();
|
|
704
|
+
voice.pan.merge.disconnect();
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
try {
|
|
708
|
+
param.cancelScheduledValues(t0);
|
|
709
|
+
param.setValueAtTime(param.value, t0);
|
|
710
|
+
param.linearRampToValueAtTime(0, t0 + RAMP_SECONDS);
|
|
711
|
+
} catch {
|
|
712
|
+
// A param that refuses a schedule (a context already closed) still has to
|
|
713
|
+
// reach the stop below.
|
|
714
|
+
}
|
|
715
|
+
voice.source.onended = drop;
|
|
716
|
+
try {
|
|
717
|
+
voice.source.stop(t0 + RAMP_SECONDS);
|
|
718
|
+
} catch {
|
|
719
|
+
drop(); // already ended — its nodes are inert, and onended will not fire
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Schedule one clip's voice into the CURRENT session. */
|
|
197
724
|
function scheduleClip(
|
|
198
|
-
|
|
725
|
+
m: MasterAudioClip,
|
|
199
726
|
buffer: AudioBufferLike,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
727
|
+
fromFrame: number,
|
|
728
|
+
timing: string,
|
|
729
|
+
shape: string,
|
|
730
|
+
track: ShotTrack | undefined,
|
|
204
731
|
): void {
|
|
732
|
+
const ref = m.clip;
|
|
733
|
+
const s = session!;
|
|
734
|
+
const fps = fpsOf();
|
|
205
735
|
const clip = asEngineClip(ref);
|
|
206
736
|
const { start, end } = audioClipWindow(clip);
|
|
207
|
-
const schedEnd = Math.min(end,
|
|
208
|
-
const first = Math.max(start,
|
|
737
|
+
const schedEnd = Math.min(end, s.endFrame);
|
|
738
|
+
const first = Math.max(start, fromFrame); // mid-window start advances the offset
|
|
209
739
|
if (first >= schedEnd) return;
|
|
210
740
|
const offset = audioSourceTimeAt(clip, first, fps);
|
|
211
741
|
if (offset >= buffer.duration) return; // trimmed past the source: silence is legal
|
|
212
|
-
const t = (f: number): number => anchorTime + framesToSeconds(f - anchorFrame, fps);
|
|
213
742
|
const c = ctx!;
|
|
214
743
|
const source = c.createBufferSource();
|
|
215
744
|
source.buffer = buffer;
|
|
216
745
|
const gain = c.createGain();
|
|
217
746
|
source.connect(gain);
|
|
218
|
-
|
|
747
|
+
// ALWAYS through the pan stage when the context has one, even at centre,
|
|
748
|
+
// so a later pan drag is a parameter write and never a reschedule.
|
|
749
|
+
const bus = busFor(m.trackId);
|
|
750
|
+
const pan = wirePan(c, gain, bus, m.pan ?? 0);
|
|
751
|
+
if (pan === null) gain.connect(bus);
|
|
219
752
|
// The preview===export envelope: the engine's ONE point list, rendered as
|
|
220
|
-
// set-then-ramps; the source stops at the last point's frame.
|
|
221
|
-
|
|
753
|
+
// set-then-ramps; the source stops at the last point's frame. `ref.frame`
|
|
754
|
+
// is the MASTER start the flatten put this clip at, and `gainTrack`'s
|
|
755
|
+
// frames are local to exactly that. The de-click at either edge is already
|
|
756
|
+
// in that list, including the one a mid-window `first` needs.
|
|
757
|
+
const points = audioEnvelopePoints(
|
|
758
|
+
clip,
|
|
759
|
+
first,
|
|
760
|
+
s.endFrame,
|
|
761
|
+
fps,
|
|
762
|
+
audioGainSampler(track, ref.frame, fps),
|
|
763
|
+
);
|
|
222
764
|
if (points.length === 0) return;
|
|
223
|
-
|
|
765
|
+
// A START ALREADY BEHIND THE AUDIO CLOCK. The session anchors on the
|
|
766
|
+
// transport's origin, and a context resumed at the play edge reads only a
|
|
767
|
+
// few ms by then, so the anchor, and a voice that starts on it, can lie
|
|
768
|
+
// before zero (Web Audio refuses a negative time: the throw ended the
|
|
769
|
+
// hand-over, and the transport never started) or merely in the past. The
|
|
770
|
+
// voice is already sounding there: it starts now, that much further into
|
|
771
|
+
// its source (source time runs with the timeline's), and no automation
|
|
772
|
+
// point is set before now.
|
|
773
|
+
const current = now();
|
|
774
|
+
const when = t(first);
|
|
775
|
+
const late = Math.max(0, current - when);
|
|
776
|
+
const length = framesToSeconds(points.at(-1)!.frame - first, fps) - late;
|
|
777
|
+
if (length <= 0) {
|
|
778
|
+
gain.disconnect();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const at = (frame: number): number => Math.max(current, t(frame));
|
|
782
|
+
gain.gain.setValueAtTime(points[0]!.gain, at(points[0]!.frame));
|
|
224
783
|
for (const p of points.slice(1)) {
|
|
225
|
-
gain.gain.linearRampToValueAtTime(p.gain,
|
|
784
|
+
gain.gain.linearRampToValueAtTime(p.gain, at(p.frame));
|
|
226
785
|
}
|
|
227
|
-
source.start(
|
|
228
|
-
|
|
786
|
+
source.start(when + late, offset + late, length);
|
|
787
|
+
s.voices.set(clip.id, { source, gain, pan, timing, shape, path: playPathOf(timing) });
|
|
229
788
|
}
|
|
230
789
|
|
|
231
|
-
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
|
|
790
|
+
/**
|
|
791
|
+
* Follow an edited LEVEL on the node that is already sounding.
|
|
792
|
+
*
|
|
793
|
+
* The whole point of separating `shape` from `timing`. A gain-band drag mints
|
|
794
|
+
* a new document object per pointermove (the history scrub re-derives from
|
|
795
|
+
* the base), so treating any document change as a reschedule stopped and
|
|
796
|
+
* restarted every voice sixty times a second — and that is precisely the
|
|
797
|
+
* gesture an author performs WHILE LISTENING.
|
|
798
|
+
*
|
|
799
|
+
* The glide is the envelope's OWN next point rather than a ramp of our
|
|
800
|
+
* choosing: hold what is sounding, then ramp to where the new envelope says
|
|
801
|
+
* to be at the next point. At most one frame, and click-free by construction.
|
|
802
|
+
*/
|
|
803
|
+
function reautomate(
|
|
804
|
+
voice: Voice,
|
|
805
|
+
ref: AudioClipRef,
|
|
806
|
+
shape: string,
|
|
807
|
+
track: ShotTrack | undefined,
|
|
808
|
+
pan: number,
|
|
809
|
+
): void {
|
|
810
|
+
const s = session!;
|
|
811
|
+
const fps = fpsOf();
|
|
812
|
+
const clip = asEngineClip(ref);
|
|
813
|
+
const points = audioEnvelopePoints(
|
|
814
|
+
clip,
|
|
815
|
+
audioClipWindow(clip).start,
|
|
816
|
+
s.endFrame,
|
|
817
|
+
fps,
|
|
818
|
+
audioGainSampler(track, ref.frame, fps),
|
|
819
|
+
);
|
|
820
|
+
const t0 = now();
|
|
821
|
+
const param = voice.gain.gain;
|
|
822
|
+
param.cancelScheduledValues(t0);
|
|
823
|
+
param.setValueAtTime(param.value, t0);
|
|
824
|
+
for (const p of points) {
|
|
825
|
+
if (t(p.frame) > t0) param.linearRampToValueAtTime(p.gain, t(p.frame));
|
|
826
|
+
}
|
|
827
|
+
if (voice.pan !== null) {
|
|
828
|
+
// The side gains glide over the engine's de-click, so a knob drag never
|
|
829
|
+
// steps the waveform.
|
|
830
|
+
const g = panGains(pan);
|
|
831
|
+
for (const [side, value] of [
|
|
832
|
+
[voice.pan.l, g.l],
|
|
833
|
+
[voice.pan.r, g.r],
|
|
834
|
+
] as const) {
|
|
835
|
+
side.gain.cancelScheduledValues(t0);
|
|
836
|
+
side.gain.setValueAtTime(side.gain.value, t0);
|
|
837
|
+
side.gain.linearRampToValueAtTime(value, t0 + RAMP_SECONDS);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
voice.shape = shape;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Begin an audible session from `fromFrame`: on the transport's `origin`
|
|
845
|
+
* when the play edge brought a new one (its frame, at the audio time of its
|
|
846
|
+
* wall instant), otherwise on a reading of the clock now.
|
|
847
|
+
*/
|
|
848
|
+
function startSession(fromFrame: number, origin: TransportOrigin | null = null): void {
|
|
235
849
|
const doc = last.doc;
|
|
236
|
-
if (doc === null ||
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
850
|
+
if (doc === null || !wake()) return;
|
|
851
|
+
session = {
|
|
852
|
+
anchorFrame: origin?.frame ?? fromFrame,
|
|
853
|
+
anchorTime: origin === null ? now() : now() - (wallNow() - origin.at) / 1000,
|
|
854
|
+
endFrame: endFrameOf(doc, last.scene),
|
|
855
|
+
voices: new Map(),
|
|
856
|
+
};
|
|
857
|
+
reconcile(fromFrame);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Bring the session's voices in line with the document, from `fromFrame`.
|
|
862
|
+
*
|
|
863
|
+
* Starting a session and absorbing an edit are the same question asked at
|
|
864
|
+
* different moments: which clips should be sounding, and are the nodes we
|
|
865
|
+
* hold the right ones for them.
|
|
866
|
+
*
|
|
867
|
+
* THROUGH THE FLATTEN, never `doc.audio`: since schema v13 that record is
|
|
868
|
+
* the bed only, and a shot's own clips are scene-local — so scheduling from
|
|
869
|
+
* it would play them at the wrong master time, or (for a shot that is not
|
|
870
|
+
* first) not at all. `masterAudioClips` is also what the export mixes from,
|
|
871
|
+
* and preview === export is the rule this lane is built on.
|
|
872
|
+
*/
|
|
873
|
+
function reconcile(fromFrame: number): void {
|
|
874
|
+
const s = session;
|
|
875
|
+
const doc = last.doc;
|
|
876
|
+
if (s === null || doc === null) return;
|
|
877
|
+
const wanted = new Set<string>();
|
|
878
|
+
// Ask for anything a duck needs and does not have yet. Cheap: the list is
|
|
879
|
+
// empty for a document with no duck, and each path is fetched once.
|
|
880
|
+
for (const asset of duckSourceAssets(doc)) {
|
|
881
|
+
const entry = analyses.get(asset.path);
|
|
882
|
+
if (entry === undefined || entry.status === "failed") requestAnalysis(asset.path);
|
|
883
|
+
}
|
|
884
|
+
const derived = derivedTracks(doc);
|
|
885
|
+
/** Every file a clip of this document plays, heard now or not. */
|
|
886
|
+
const plays = new Set<string>();
|
|
887
|
+
// A track added mid-session gets its bus (and a strip its tap) here.
|
|
888
|
+
if (addBuses(doc)) publishMonitor({ ctx: ctx!, node: master!, tracks: buses });
|
|
889
|
+
for (const m of masterAudioClips(doc)) {
|
|
890
|
+
const clip = m.clip;
|
|
245
891
|
const asset = doc.assets[clip.asset];
|
|
246
|
-
if (asset
|
|
247
|
-
|
|
892
|
+
if (asset !== undefined) plays.add(audioPlayPath(clip, asset));
|
|
893
|
+
// Muted (gain would be 0) or not soloed: skip the node entirely.
|
|
894
|
+
if (!heard(m, last.solo)) continue;
|
|
895
|
+
if (asset === undefined) {
|
|
896
|
+
// Both mixes THROW on this document and validateShotDoc refuses it, so
|
|
897
|
+
// silence with no explanation would be the preview quietly disagreeing
|
|
898
|
+
// with a file that cannot be produced at all.
|
|
899
|
+
console.error(
|
|
900
|
+
`[audio] clip ${clip.id} references unknown asset ${clip.asset}; ` +
|
|
901
|
+
`an export of this document will refuse it`,
|
|
902
|
+
);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
wanted.add(clip.id);
|
|
906
|
+
const path = audioPlayPath(clip, asset);
|
|
907
|
+
const timing = timingOf(clip, path, m.trackId);
|
|
908
|
+
const track = derived.get(clip.id) ?? clip.gainTrack;
|
|
909
|
+
const shape = shapeOf(clip, track, m.pan ?? 0);
|
|
910
|
+
const voice = s.voices.get(clip.id);
|
|
911
|
+
const entry = buffers.get(path);
|
|
912
|
+
if (voice !== undefined) {
|
|
913
|
+
if (voice.timing === timing) {
|
|
914
|
+
if (voice.shape !== shape) reautomate(voice, clip, shape, track, m.pan ?? 0);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
// NEW EFFECTS, SAME WINDOW: the processed file is being made. The old
|
|
918
|
+
// voice keeps sounding until it decodes (`onBufferReady` swaps it), so
|
|
919
|
+
// a knob turned during playback never drops the clip out.
|
|
920
|
+
if (entry?.status !== "ready" && voice.timing === timingOf(clip, voice.path, m.trackId)) {
|
|
921
|
+
if (entry === undefined || entry.status === "failed") requestDecode(path);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
release(voice); // the source window moved: this node is the wrong one
|
|
925
|
+
s.voices.delete(clip.id);
|
|
926
|
+
}
|
|
248
927
|
if (entry?.status === "ready") {
|
|
249
|
-
scheduleClip(
|
|
250
|
-
} else if (entry === undefined) {
|
|
251
|
-
requestDecode(
|
|
928
|
+
scheduleClip(m, entry.buffer, fromFrame, timing, shape, track);
|
|
929
|
+
} else if (entry === undefined || entry.status === "failed") {
|
|
930
|
+
void requestDecode(path);
|
|
252
931
|
}
|
|
253
|
-
// pending: scheduled
|
|
932
|
+
// pending: scheduled by onBufferReady when it lands
|
|
933
|
+
}
|
|
934
|
+
for (const [id, voice] of [...s.voices]) {
|
|
935
|
+
if (wanted.has(id)) continue;
|
|
936
|
+
release(voice); // deleted, muted, or moved out of this shot
|
|
937
|
+
s.voices.delete(id);
|
|
938
|
+
}
|
|
939
|
+
// Processed files the document no longer plays. A voice still sounding
|
|
940
|
+
// one holds its own buffer; one still decoding lands and goes next time.
|
|
941
|
+
for (const [path, entry] of buffers) {
|
|
942
|
+
if (entry.status !== "pending" && !plays.has(path) && isAudioFxFileName(path)) buffers.delete(path);
|
|
254
943
|
}
|
|
255
944
|
}
|
|
256
945
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
946
|
+
/** Fetch and decode one asset into the cache. Settles when it has landed or
|
|
947
|
+
* failed, and never rejects. */
|
|
948
|
+
function requestDecode(path: string, priority?: "low"): Promise<void> {
|
|
260
949
|
buffers.set(path, { status: "pending" });
|
|
261
|
-
|
|
262
|
-
.fetchBytes(`/studio/assets/${encodeURIComponent(path)}
|
|
263
|
-
.then((bytes) => deps.decode(
|
|
950
|
+
return deps
|
|
951
|
+
.fetchBytes(`/studio/assets/${encodeURIComponent(path)}`, priority)
|
|
952
|
+
.then((bytes) => deps.decode(bytes))
|
|
264
953
|
.then(
|
|
265
954
|
(buffer) => {
|
|
266
955
|
buffers.set(path, { status: "ready", buffer });
|
|
267
956
|
onBufferReady(path, buffer);
|
|
268
957
|
},
|
|
269
958
|
(e: unknown) => {
|
|
270
|
-
|
|
271
|
-
|
|
959
|
+
// Not terminal: the next PLAY EDGE tries again. A terminal failure
|
|
960
|
+
// made one transient error silence a clip for the page's life while
|
|
961
|
+
// the export — which fetches independently — still had it.
|
|
962
|
+
buffers.set(path, { status: "failed" });
|
|
963
|
+
console.error(`[audio] asset ${path} failed to load; retrying on the next play`, e);
|
|
272
964
|
},
|
|
273
965
|
);
|
|
274
966
|
}
|
|
275
967
|
|
|
276
|
-
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
968
|
+
/**
|
|
969
|
+
* DECODE AHEAD: the heard clip nearest the playhead whose asset nothing has
|
|
970
|
+
* asked for yet, then the next once that one lands.
|
|
279
971
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
972
|
+
* Only while the stage has presented THIS document whole, and at low fetch
|
|
973
|
+
* priority, because these are canonical WAVs, tens of megabytes, and the
|
|
974
|
+
* stage's own loads come first. A failure waits for the next play edge, as
|
|
975
|
+
* any other does.
|
|
976
|
+
*/
|
|
977
|
+
function decodeAhead(): void {
|
|
978
|
+
const doc = last.doc;
|
|
979
|
+
if (disposed || ahead || doc === null || last.presented !== true || doc.id !== readyDoc) return;
|
|
980
|
+
const frame = last.frame;
|
|
981
|
+
let nearest: { path: string; distance: number } | null = null;
|
|
982
|
+
for (const m of masterAudioClips(doc)) {
|
|
983
|
+
// The file playback asks for (`audioPlayPath`): a clip with effects
|
|
984
|
+
// plays its processed file, so warming the raw asset would be a large
|
|
985
|
+
// fetch nobody uses while the processed one still decodes cold on Play.
|
|
986
|
+
const asset = doc.assets[m.clip.asset];
|
|
987
|
+
const path = asset === undefined ? undefined : audioPlayPath(m.clip, asset);
|
|
988
|
+
if (path === undefined || buffers.has(path) || !heard(m, last.solo)) continue;
|
|
989
|
+
const start = m.clip.frame;
|
|
990
|
+
const end = start + m.clip.durationFrames;
|
|
991
|
+
const distance = frame < start ? start - frame : frame < end ? 0 : frame - end + 1;
|
|
992
|
+
if (nearest === null || distance < nearest.distance) nearest = { path, distance };
|
|
993
|
+
}
|
|
994
|
+
if (nearest === null) return;
|
|
995
|
+
ahead = true;
|
|
996
|
+
void requestDecode(nearest.path, "low").then(() => {
|
|
997
|
+
ahead = false;
|
|
998
|
+
decodeAhead();
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Fetch one analysis sidecar. The document's own duck is what asks for it,
|
|
1004
|
+
* so a project with no ducking never touches this path.
|
|
1005
|
+
*
|
|
1006
|
+
* On landing it RECONCILES rather than scheduling directly: the clip whose
|
|
1007
|
+
* level changed is the bed, not the voice the sidecar describes, and
|
|
1008
|
+
* reconcile is the one place that knows which is which.
|
|
1009
|
+
*/
|
|
1010
|
+
function requestAnalysis(path: string): void {
|
|
1011
|
+
analyses.set(path, { status: "pending" });
|
|
1012
|
+
void deps
|
|
1013
|
+
.fetchBytes(`/studio/assets/${encodeURIComponent(audioAnalysisFileName(path))}`)
|
|
1014
|
+
.then((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as AudioAnalysis)
|
|
1015
|
+
.then(
|
|
1016
|
+
(analysis) => {
|
|
1017
|
+
analyses.set(path, { status: "ready", analysis });
|
|
1018
|
+
analysisGeneration++;
|
|
1019
|
+
if (!disposed && session !== null && last.playing) reconcile(frameNow());
|
|
1020
|
+
},
|
|
1021
|
+
(e: unknown) => {
|
|
1022
|
+
analyses.set(path, { status: "failed" });
|
|
1023
|
+
console.error(`[audio] analysis for ${path} failed to load; a duck on it stays flat`, e);
|
|
1024
|
+
},
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* A late decode joins the CURRENT session on ITS OWN ANCHOR.
|
|
1030
|
+
*
|
|
1031
|
+
* Not on a fresh reading of the clock: the pair (anchorTime, anchorFrame) has
|
|
1032
|
+
* to be sampled at one instant, or the voice lands offset from the ones
|
|
1033
|
+
* already playing. This used to read `now()` here and pair it with the frame
|
|
1034
|
+
* recorded at the last store notification — two different moments, up to a
|
|
1035
|
+
* whole main-thread stall apart — which is a flam on a hit and phasing on two
|
|
1036
|
+
* beds. A first Play takes this path for every clip the decode ahead has not
|
|
1037
|
+
* reached yet.
|
|
1038
|
+
*/
|
|
283
1039
|
function onBufferReady(path: string, buffer: AudioBufferLike): void {
|
|
284
1040
|
if (disposed || session === null || !last.playing) return;
|
|
285
1041
|
const doc = last.doc;
|
|
286
1042
|
if (doc === null) return;
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
for (const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
1043
|
+
const from = frameNow();
|
|
1044
|
+
const derived = derivedTracks(doc);
|
|
1045
|
+
for (const m of masterAudioClips(doc)) {
|
|
1046
|
+
const clip = m.clip;
|
|
1047
|
+
const asset = doc.assets[clip.asset];
|
|
1048
|
+
if (asset === undefined || audioPlayPath(clip, asset) !== path) continue;
|
|
1049
|
+
if (!heard(m, last.solo)) continue;
|
|
1050
|
+
const timing = timingOf(clip, path, m.trackId);
|
|
1051
|
+
// A voice still playing this clip's previous file (its effects changed
|
|
1052
|
+
// while the new one was made) is swapped here; one already on it stays.
|
|
1053
|
+
const held = session.voices.get(clip.id);
|
|
1054
|
+
if (held !== undefined) {
|
|
1055
|
+
if (held.timing === timing) continue;
|
|
1056
|
+
release(held);
|
|
1057
|
+
session.voices.delete(clip.id);
|
|
1058
|
+
}
|
|
1059
|
+
const track = derived.get(clip.id) ?? clip.gainTrack;
|
|
1060
|
+
scheduleClip(m, buffer, from, timing, shapeOf(clip, track, m.pan ?? 0), track);
|
|
293
1061
|
}
|
|
294
1062
|
}
|
|
295
1063
|
|
|
296
1064
|
function teardown(): void {
|
|
297
1065
|
if (session === null) return;
|
|
298
|
-
for (const
|
|
299
|
-
try {
|
|
300
|
-
source.stop();
|
|
301
|
-
} catch {
|
|
302
|
-
// already ended — stopping is best-effort
|
|
303
|
-
}
|
|
304
|
-
source.disconnect();
|
|
305
|
-
gain.disconnect();
|
|
306
|
-
}
|
|
1066
|
+
for (const voice of session.voices.values()) release(voice);
|
|
307
1067
|
session = null;
|
|
308
1068
|
}
|
|
309
1069
|
|
|
@@ -312,30 +1072,72 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
|
|
|
312
1072
|
if (disposed) return;
|
|
313
1073
|
const prev = last;
|
|
314
1074
|
last = snap;
|
|
315
|
-
|
|
316
|
-
|
|
1075
|
+
// THE ARM. A queued Play is the press, and the start it waits for is not
|
|
1076
|
+
// one, so the realtime context is created or resumed here: the store
|
|
1077
|
+
// notifies inside the `set` that arms, which runs in the press itself.
|
|
1078
|
+
if (snap.armed === true && prev.armed !== true) wake();
|
|
1079
|
+
// THE READY EDGE, keyed to its document. A Project switch reaches this
|
|
1080
|
+
// before the stage has said it owes the new one a picture, and the
|
|
1081
|
+
// decode ahead must not start under a scene that is still loading.
|
|
1082
|
+
const presented = snap.presented === true;
|
|
1083
|
+
if (presented && prev.presented !== true) readyDoc = snap.doc?.id ?? null;
|
|
1084
|
+
// Only on what can change its answer, never on a tick. After this push,
|
|
1085
|
+
// so a play edge's own requests go first, at full priority.
|
|
1086
|
+
if (presented && (prev.presented !== true || snap.doc !== prev.doc || !sameSolo(prev.solo, snap.solo))) {
|
|
1087
|
+
queueMicrotask(decodeAhead);
|
|
1088
|
+
}
|
|
1089
|
+
const running = snap.playing && snap.audible;
|
|
1090
|
+
if (!running) {
|
|
1091
|
+
if (session !== null) teardown();
|
|
317
1092
|
return;
|
|
318
1093
|
}
|
|
319
|
-
if (
|
|
320
|
-
// Play edge
|
|
321
|
-
|
|
1094
|
+
if (session === null) {
|
|
1095
|
+
// Play edge. A new origin is this edge's alone: its session anchors on
|
|
1096
|
+
// it, and any later one in the same run takes its own reading.
|
|
1097
|
+
const origin = snap.origin ?? null;
|
|
1098
|
+
startSession(snap.frame, origin === (prev.origin ?? null) ? null : origin);
|
|
322
1099
|
return;
|
|
323
1100
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1101
|
+
const doc = snap.doc;
|
|
1102
|
+
if (doc === null) {
|
|
1103
|
+
teardown();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// The WINDOW moved under the transport (the entered shot was trimmed, or
|
|
1107
|
+
// the author entered one): every voice's stop time is wrong, so this is a
|
|
1108
|
+
// restart and not a reconcile.
|
|
1109
|
+
if (endFrameOf(doc, snap.scene) !== session.endFrame) {
|
|
329
1110
|
teardown();
|
|
330
1111
|
startSession(snap.frame);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
// THE DRIFT CHECK, which replaced a jump threshold. A scrub or a loop
|
|
1115
|
+
// wrap moves the picture without moving the audio clock, so the two
|
|
1116
|
+
// disagree by the whole distance; a monotone tick does not, and neither
|
|
1117
|
+
// does a stall — the transport's clock is absolute, so the picture
|
|
1118
|
+
// catches up to where the audio already is. On a FRAME CHANGE only: any
|
|
1119
|
+
// other notification carries the last tick's frame, which the audio has
|
|
1120
|
+
// run past by up to a frame of its own. The audio runs ahead of the
|
|
1121
|
+
// picture's frame by up to a refresh more (`DRIFT_REFRESH_S`).
|
|
1122
|
+
if (snap.frame !== prev.frame) {
|
|
1123
|
+
const ahead = now() - t(snap.frame);
|
|
1124
|
+
const allowed = framesToSeconds(DRIFT_FRAMES, doc.fps);
|
|
1125
|
+
if (ahead < -allowed || ahead > allowed + DRIFT_REFRESH_S) {
|
|
1126
|
+
teardown();
|
|
1127
|
+
startSession(snap.frame);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
331
1130
|
}
|
|
1131
|
+
// A DOCUMENT EDIT, absorbed per clip rather than as a restart.
|
|
1132
|
+
// A solo change is the same question: which voices should be sounding.
|
|
1133
|
+
if (doc !== prev.doc || !sameSolo(prev.solo, snap.solo)) reconcile(frameNow());
|
|
332
1134
|
},
|
|
333
1135
|
|
|
334
1136
|
setMasterMute(muted) {
|
|
335
1137
|
if (disposed || muted === masterMuted) return;
|
|
336
1138
|
masterMuted = muted;
|
|
337
1139
|
if (muted) teardown();
|
|
338
|
-
else if (last.playing) startSession(last.frame);
|
|
1140
|
+
else if (last.playing && last.audible) startSession(last.frame);
|
|
339
1141
|
},
|
|
340
1142
|
|
|
341
1143
|
setMasterVolume(volume) {
|
|
@@ -351,6 +1153,14 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
|
|
|
351
1153
|
if (disposed) return;
|
|
352
1154
|
disposed = true;
|
|
353
1155
|
teardown();
|
|
1156
|
+
// ITS OWN PUBLICATION AND NOT WHATEVER IS THERE. See the ceiling on the
|
|
1157
|
+
// slot: one bridge owns it in practice, and a second preview disposing
|
|
1158
|
+
// must not blank the surviving one's meter. A face's detach lands on the
|
|
1159
|
+
// next commit, after this node is already unwired, which `release`
|
|
1160
|
+
// already tolerates — its `disconnect` is wrapped for exactly that.
|
|
1161
|
+
if (monitor !== null && monitor.node === master) publishMonitor(null);
|
|
1162
|
+
for (const bus of buses.values()) bus.disconnect();
|
|
1163
|
+
buses = new Map();
|
|
354
1164
|
master?.disconnect();
|
|
355
1165
|
master = null;
|
|
356
1166
|
try {
|