@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
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// `rows[].keyframes`, `toggleKeyframe(rowId, frame)`, `setEase(refs:
|
|
7
7
|
// KeyframeAddr[])`, `focusedCurve`. Every member is about INSTANTS.
|
|
8
8
|
//
|
|
9
|
-
// A
|
|
10
|
-
// reorder and retime. Forcing one through the other gives either a
|
|
9
|
+
// A lifespan, a clip and a camera move are INTERVALS, and their verbs are
|
|
10
|
+
// trim-in, trim-out, split, reorder and retime. Forcing one through the other gives either a
|
|
11
11
|
// discriminated union half the shared components must switch on, or a
|
|
12
12
|
// `TimelineModel` whose `keyframes` array is permanently empty — and the second
|
|
13
13
|
// is worse, because it looks like it works.
|
|
@@ -18,22 +18,61 @@
|
|
|
18
18
|
// The one geometry helper this lane does NOT reuse is `pxToFrame` — see
|
|
19
19
|
// `frameDelta` below for why a signed delta must not go through it.
|
|
20
20
|
//
|
|
21
|
-
// ponytail: BlockRow and
|
|
22
|
-
// @dailies-studio/timeline when a SECOND chrome needs
|
|
23
|
-
// editor already has overlapping `Transition`s, so it plausibly will.
|
|
21
|
+
// ponytail: BlockRow and ShotRow are local. Promote them into
|
|
22
|
+
// @dailies-studio/timeline when a SECOND chrome needs an interval lane — the
|
|
23
|
+
// motion editor already has overlapping `Transition`s, so it plausibly will.
|
|
24
24
|
import {
|
|
25
|
-
|
|
25
|
+
defDurationFrames,
|
|
26
26
|
docDurationFrames,
|
|
27
|
-
|
|
27
|
+
audioHomeOf,
|
|
28
|
+
firstKeyframeFrame,
|
|
29
|
+
homeOf,
|
|
30
|
+
isDefId,
|
|
31
|
+
isOpenEndedOut,
|
|
28
32
|
layerChildren,
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
masterAudioClips,
|
|
34
|
+
framingsChain,
|
|
35
|
+
isZoomRigBreak,
|
|
36
|
+
patchHome,
|
|
37
|
+
sceneStart,
|
|
31
38
|
sceneWindows,
|
|
39
|
+
sceneTrackablePath,
|
|
40
|
+
layerLifespan,
|
|
41
|
+
layerTrackablePath,
|
|
42
|
+
updateAudioClip,
|
|
43
|
+
// ── the clip ops, which are what a VIDEO row's gestures write through ──
|
|
44
|
+
//
|
|
45
|
+
// Every one of them refuses by returning the document it was handed, which
|
|
46
|
+
// is the property `writeLifespan` below is built on: a gesture that cannot
|
|
47
|
+
// land writes nothing and the scrub's base survives untouched.
|
|
48
|
+
rollLayers,
|
|
49
|
+
rollSeam,
|
|
50
|
+
shiftLayer,
|
|
51
|
+
shiftTracks,
|
|
52
|
+
slipLayer,
|
|
53
|
+
tracksPatch,
|
|
54
|
+
trimLayer,
|
|
55
|
+
videoClipOf,
|
|
56
|
+
videoSoundClip,
|
|
57
|
+
} from "@dailies-studio/shot/doc";
|
|
58
|
+
import type {
|
|
59
|
+
AudioClipRef,
|
|
60
|
+
ComponentDef3D,
|
|
61
|
+
Doc3D,
|
|
62
|
+
LayerHome,
|
|
63
|
+
Layer3D,
|
|
64
|
+
PathShot,
|
|
65
|
+
Scene3D,
|
|
66
|
+
Shot,
|
|
67
|
+
ShotAsset,
|
|
68
|
+
ShotTrack,
|
|
69
|
+
VideoClipInfo,
|
|
70
|
+
VideoLayer3D,
|
|
32
71
|
} from "@dailies-studio/shot/doc";
|
|
33
|
-
import
|
|
34
|
-
import
|
|
35
|
-
import {
|
|
36
|
-
import
|
|
72
|
+
import { formatValue } from "@dailies-studio/ui";
|
|
73
|
+
import { clipProgress01 } from "@dailies-studio/engine";
|
|
74
|
+
import type { Ease, Rational, TrackKind, Value } from "@dailies-studio/engine";
|
|
75
|
+
import { TRANSFORM_CHANNELS } from "../../channels.js";
|
|
37
76
|
|
|
38
77
|
/** One scene's bar on the SCENE STRIP. */
|
|
39
78
|
export interface SceneBar {
|
|
@@ -44,12 +83,12 @@ export interface SceneBar {
|
|
|
44
83
|
end: number;
|
|
45
84
|
}
|
|
46
85
|
|
|
47
|
-
/** One
|
|
86
|
+
/** One camera move's bar on the camera row. */
|
|
48
87
|
export interface BlockBar {
|
|
88
|
+
/** The move's own id (`shot.id`). */
|
|
49
89
|
id: string;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
preset: string;
|
|
90
|
+
/** The move's kind (`dolly`, `orbit`), the bar's name. */
|
|
91
|
+
label: string;
|
|
53
92
|
/** SCENE-LOCAL frames, half-open. */
|
|
54
93
|
frame: number;
|
|
55
94
|
durationFrames: number;
|
|
@@ -63,11 +102,6 @@ export interface TrackRowModel {
|
|
|
63
102
|
layerId: string;
|
|
64
103
|
label: string;
|
|
65
104
|
depth: number;
|
|
66
|
-
blocks: BlockBar[];
|
|
67
|
-
/** A layer with no blocks still gets a row: it is where the Animate tool
|
|
68
|
-
* will put the first one, and an empty lane the author cannot see is a
|
|
69
|
-
* feature they cannot find. */
|
|
70
|
-
empty: boolean;
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
export function sceneBars(doc: Doc3D): SceneBar[] {
|
|
@@ -84,24 +118,42 @@ export function masterDuration(doc: Doc3D): number {
|
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
/**
|
|
87
|
-
*
|
|
121
|
+
* A sibling list in DISPLAY order: front first, each id with its index in the
|
|
122
|
+
* STORED list.
|
|
88
123
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
124
|
+
* Every layer list is stored back to front (index 0 paints first, schema v35,
|
|
125
|
+
* Figma's `children`), and Figma's layers panel shows it reversed, the front
|
|
126
|
+
* layer as the top row. A walker that emits a parent before it recurses then
|
|
127
|
+
* puts the parent row above its children, which is the other half of that
|
|
128
|
+
* reading. The outline and the dock both walk through this one function, so
|
|
129
|
+
* they never disagree about which row is on top, and a row keeps its ARRAY
|
|
130
|
+
* index, which is what a drop and `aria-posinset` are computed from.
|
|
92
131
|
*/
|
|
93
|
-
export function
|
|
132
|
+
export function frontFirst(ids: readonly string[]): { id: string; index: number }[] {
|
|
133
|
+
const out: { id: string; index: number }[] = [];
|
|
134
|
+
for (let index = ids.length - 1; index >= 0; index -= 1) out.push({ id: ids[index]!, index });
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One scene's rows, nested, FRONT FIRST (`frontFirst`), exactly as the layers
|
|
140
|
+
* panel lists them: the dock sits under that panel, and two lists of the same
|
|
141
|
+
* layers in different orders is a UI nobody can follow. Upper rows cover lower
|
|
142
|
+
* ones, which is also an NLE's track order.
|
|
143
|
+
*/
|
|
144
|
+
export function trackRows(
|
|
145
|
+
scene: LayerHome,
|
|
146
|
+
isolation: string | null = null,
|
|
147
|
+
): TrackRowModel[] {
|
|
94
148
|
const out: TrackRowModel[] = [];
|
|
95
149
|
const walk = (ids: readonly string[], depth: number): void => {
|
|
96
|
-
for (const id of ids) {
|
|
150
|
+
for (const { id } of frontFirst(ids)) {
|
|
97
151
|
const layer = scene.layersById[id];
|
|
98
152
|
if (layer === undefined) continue;
|
|
99
153
|
out.push({
|
|
100
154
|
layerId: id,
|
|
101
155
|
label: layer.label,
|
|
102
156
|
depth,
|
|
103
|
-
blocks: layer.blocks.map((b) => barOf(b, id)),
|
|
104
|
-
empty: layer.blocks.length === 0,
|
|
105
157
|
});
|
|
106
158
|
walk(layerChildren(layer), depth + 1);
|
|
107
159
|
}
|
|
@@ -110,11 +162,11 @@ export function trackRows(scene: Scene3D, isolation: string | null = null): Trac
|
|
|
110
162
|
//
|
|
111
163
|
// Going inside a card dims the rest of the scene, makes it unpickable and
|
|
112
164
|
// narrows the outline to the subtree — and the dock kept listing every layer
|
|
113
|
-
// in the scene, so the one panel an author uses to
|
|
165
|
+
// in the scene, so the one panel an author uses to retime a layer still showed
|
|
114
166
|
// dozens of rows for layers they could not click.
|
|
115
167
|
//
|
|
116
168
|
// The subtree's ROOT is included and its ancestors are not, so the card's own
|
|
117
|
-
//
|
|
169
|
+
// keys stay reachable: entering a card to retime it and then not finding it
|
|
118
170
|
// is the reason to enter at all.
|
|
119
171
|
const root = isolation === null ? null : scene.layersById[isolation];
|
|
120
172
|
if (root !== undefined && root !== null) walk([root.id], 0);
|
|
@@ -122,114 +174,126 @@ export function trackRows(scene: Scene3D, isolation: string | null = null): Trac
|
|
|
122
174
|
return out;
|
|
123
175
|
}
|
|
124
176
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
preset: block.preset,
|
|
131
|
-
frame: block.frame,
|
|
132
|
-
durationFrames: Math.max(1, Math.trunc(block.durationFrames)),
|
|
133
|
-
spring: isSpringBlock(block),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ── CHANNEL ROWS: THE LOTTIE READ OF A BLOCK LANE ───────────────────────────
|
|
138
|
-
//
|
|
139
|
-
// Lottielab's timeline expands a layer into one row per ANIMATED PROPERTY —
|
|
140
|
-
// Position, Scale, Opacity — with keyframe diamonds on each. This lane
|
|
141
|
-
// animates with blocks, not stored keyframe tracks, and that does not change
|
|
142
|
-
// here: a channel row is a DERIVED VIEW over the blocks. `deltaFor` already
|
|
143
|
-
// says which channels a block writes, so the rows come straight out of the
|
|
144
|
-
// document with nothing new stored in it.
|
|
145
|
-
//
|
|
146
|
-
// The span's endpoint values come from `resolveLayerAt` — the same fold the
|
|
147
|
-
// stage draws and the renderer samples — at the block's two edge frames. A
|
|
148
|
-
// second fold here would be a second answer to a question with exactly one.
|
|
149
|
-
//
|
|
150
|
-
// Dragging anything on a channel row drags the BLOCK. The block is still the
|
|
151
|
-
// atom: "move the opacity keyframes" moves the Fade everywhere it writes,
|
|
152
|
-
// which is what keeps a preset one legible thing instead of six anonymous
|
|
153
|
-
// tracks.
|
|
154
|
-
|
|
155
|
-
/** One block's footprint on one channel row. Frames are SCENE-LOCAL, like
|
|
156
|
-
* every bar in this file. */
|
|
157
|
-
export interface ChannelSpan {
|
|
158
|
-
blockId: string;
|
|
159
|
-
slot: AnimBlock["slot"];
|
|
160
|
-
/** The preset's display label, for the span's tooltip. */
|
|
161
|
-
preset: string;
|
|
177
|
+
/** One keyframe on a track. SCENE-LOCAL frames, like every bar in this file. */
|
|
178
|
+
export interface TrackKey {
|
|
179
|
+
/** The keyframe's own id. Carried for React's key and for nothing else — the
|
|
180
|
+
* ops address a keyframe by (track, frame), see `selectedKeyframe`. */
|
|
181
|
+
id: string;
|
|
162
182
|
frame: number;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
/** The ease LEAVING this key — `easeOut`, which is why the menu that sets it
|
|
184
|
+
* hangs off the key rather than off the segment between two. The whole object
|
|
185
|
+
* rather than its `type`, so the row can name the PRESET ("Ease Out") through
|
|
186
|
+
* the shared `matchPreset` instead of the shape ("Bezier"). */
|
|
187
|
+
ease: Ease;
|
|
188
|
+
/** The value, formatted for the title. */
|
|
189
|
+
value: string;
|
|
190
|
+
/** The OWNING TRACK's kind. Carried so the menu that sets `ease` can offer
|
|
191
|
+
* only what the engine will sample: a spring on a non-springable kind
|
|
192
|
+
* throws inside `evaluateTrack`, and the discrete kinds hold structurally
|
|
193
|
+
* so every tween on them is a no-op dressed as a choice. */
|
|
194
|
+
kind: TrackKind;
|
|
195
|
+
/** True for the track's final keyframe, whose `easeOut` governs no segment.
|
|
196
|
+
* The menu disables its ease entries rather than writing a value that is
|
|
197
|
+
* never read — this lane refuses rather than going silently inert. */
|
|
198
|
+
last: boolean;
|
|
199
|
+
/** The track curves its path (`spatialInterp`): no spring ease, the
|
|
200
|
+
* engine's rule, so the menu leaves the spring presets out. */
|
|
201
|
+
curved: boolean;
|
|
168
202
|
}
|
|
169
203
|
|
|
170
204
|
export interface ChannelRowModel {
|
|
171
|
-
|
|
205
|
+
/** The tracked path this row's diamonds edit, and the row's identity. */
|
|
206
|
+
path: string;
|
|
172
207
|
label: string;
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** The parts of a bar that fall outside the layer's lifespan, in SCENE frames.
|
|
177
|
-
* Both ends, because a lifespan can be trimmed from either side and a short
|
|
178
|
-
* enough one clips a block at both. Shared by the camera's block bars and the
|
|
179
|
-
* channel spans, so "drawn dead" means one thing. */
|
|
180
|
-
export function deadSpans(
|
|
181
|
-
bar: { frame: number; durationFrames: number },
|
|
182
|
-
lifespan: Lifespan | undefined,
|
|
183
|
-
): { from: number; to: number }[] {
|
|
184
|
-
if (lifespan === undefined) return [];
|
|
185
|
-
const end = bar.frame + bar.durationFrames;
|
|
186
|
-
const out: { from: number; to: number }[] = [];
|
|
187
|
-
if (bar.frame < lifespan.in) out.push({ from: bar.frame, to: Math.min(end, lifespan.in) });
|
|
188
|
-
if (end > lifespan.out) out.push({ from: Math.max(bar.frame, lifespan.out), to: end });
|
|
189
|
-
return out;
|
|
208
|
+
keys: TrackKey[];
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const at = spansByKey.get(key);
|
|
225
|
-
if (at === undefined) spansByKey.set(key, [span]);
|
|
226
|
-
else at.push(span);
|
|
227
|
-
}
|
|
212
|
+
* `channelRows`, replayed per layer object.
|
|
213
|
+
*
|
|
214
|
+
* Layers are immutable on edit, so identity is value here. The dock computed
|
|
215
|
+
* the rows of every visible expanded layer on every document change just to
|
|
216
|
+
* read their `.length` for a row height, while `ChannelRows` computed the same
|
|
217
|
+
* rows again to draw them. One entry per layer.
|
|
218
|
+
*/
|
|
219
|
+
const CHANNEL_ROWS = new WeakMap<Layer3D, ChannelRowModel[]>();
|
|
220
|
+
export function channelRowsCached(layer: Layer3D): ChannelRowModel[] {
|
|
221
|
+
const cached = CHANNEL_ROWS.get(layer);
|
|
222
|
+
if (cached !== undefined) return cached;
|
|
223
|
+
const rows = channelRows(layer);
|
|
224
|
+
CHANNEL_ROWS.set(layer, rows);
|
|
225
|
+
return rows;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One row per tracked path on this layer. A path nothing animates gets no row:
|
|
230
|
+
* Lottie lists animated properties, not every property, and a property lane
|
|
231
|
+
* appears when it carries animation and not before.
|
|
232
|
+
*
|
|
233
|
+
* The transform paths lead, in `TRANSFORM_CHANNELS`' order (which is
|
|
234
|
+
* resolve.ts's), then every other tracked path sorted, so two layers with the
|
|
235
|
+
* same tracks list them the same way; a `Record`'s own order is not one.
|
|
236
|
+
*/
|
|
237
|
+
export function channelRows(layer: Layer3D): ChannelRowModel[] {
|
|
238
|
+
const tracks = layer.tracks ?? {};
|
|
239
|
+
const out: ChannelRowModel[] = [];
|
|
240
|
+
for (const [path, spec] of TRANSFORM_CHANNELS) {
|
|
241
|
+
const track = tracks[path];
|
|
242
|
+
if (track !== undefined) out.push({ path, label: spec.label, keys: trackKeys(track) });
|
|
228
243
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
for (const path of Object.keys(tracks).sort()) {
|
|
245
|
+
if (TRANSFORM_CHANNELS.has(path)) continue;
|
|
246
|
+
out.push({
|
|
247
|
+
path,
|
|
248
|
+
// The LAYER'S lookup, so a tracked `instance.props.<key>` row reads the
|
|
249
|
+
// contract's label ("Time") instead of its path. Falls back to the path
|
|
250
|
+
// when the layer carries no snapshot and the registry is not in reach
|
|
251
|
+
// here — a dock row is a readout, and this module takes no store.
|
|
252
|
+
label: layerTrackablePath(layer, path)?.label ?? path,
|
|
253
|
+
keys: trackKeys(tracks[path]!),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The SHOT's own tracked properties as channel rows (P5): its background colour
|
|
261
|
+
* and its post stack.
|
|
262
|
+
*
|
|
263
|
+
* Sorted by path for `channelRows`' reason: a `Record`'s key order is not one.
|
|
264
|
+
*/
|
|
265
|
+
export function sceneChannelRows(scene: Scene3D): ChannelRowModel[] {
|
|
266
|
+
const tracks = scene.tracks ?? {};
|
|
267
|
+
return Object.keys(tracks)
|
|
268
|
+
.sort()
|
|
269
|
+
.map((path) => ({
|
|
270
|
+
path,
|
|
271
|
+
label: sceneTrackablePath(path)?.label ?? path,
|
|
272
|
+
keys: trackKeys(tracks[path]!),
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function trackKeys(track: ShotTrack): TrackKey[] {
|
|
277
|
+
const lastIndex = track.keyframes.length - 1;
|
|
278
|
+
return track.keyframes.map((k, i) => ({
|
|
279
|
+
id: k.id,
|
|
280
|
+
frame: k.frame,
|
|
281
|
+
ease: k.easeOut,
|
|
282
|
+
value: formatTrackValue(k.value),
|
|
283
|
+
kind: track.kind,
|
|
284
|
+
last: i === lastIndex,
|
|
285
|
+
curved: track.spatialInterp !== undefined,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A keyframe's value as one short string, for the diamond's title. Not a
|
|
290
|
+
* channel formatter: a track's value is the AUTHORED type at its path, which is
|
|
291
|
+
* a number, a vector or a hex colour, and `TRANSFORM_CHANNELS`' readers all
|
|
292
|
+
* take a resolved `LayerState`. */
|
|
293
|
+
function formatTrackValue(value: Value): string {
|
|
294
|
+
if (typeof value === "number") return formatValue(value, 0.01);
|
|
295
|
+
if (Array.isArray(value)) return value.map((v) => formatValue(v, 0.1)).join(", ");
|
|
296
|
+
return String(value);
|
|
233
297
|
}
|
|
234
298
|
|
|
235
299
|
/** The camera's own track: one bar per shot. It is a row like any other so the
|
|
@@ -238,15 +302,160 @@ export function channelRows(layer: Layer3D, fps: Rational): ChannelRowModel[] {
|
|
|
238
302
|
export function cameraBars(scene: Scene3D): BlockBar[] {
|
|
239
303
|
return scene.shots.map((shot) => ({
|
|
240
304
|
id: shot.id,
|
|
241
|
-
|
|
242
|
-
slot: "during" as const,
|
|
243
|
-
preset: shot.kind,
|
|
305
|
+
label: shot.kind,
|
|
244
306
|
frame: shot.frame,
|
|
245
307
|
durationFrames: Math.max(1, Math.trunc(shot.durationFrames)),
|
|
246
308
|
spring: shot.ease?.type === "spring",
|
|
247
309
|
}));
|
|
248
310
|
}
|
|
249
311
|
|
|
312
|
+
// ── CAMERA KEYFRAMES: PRESENTATION, ZERO SCHEMA (P4) ────────────────────────
|
|
313
|
+
//
|
|
314
|
+
// The camera move system already IS a keyframe track — `cameraAt` walks
|
|
315
|
+
// non-overlapping windows with hold-forever semantics, which is
|
|
316
|
+
// last-writer-by-time. What it lacked was a picture of its own poses.
|
|
317
|
+
//
|
|
318
|
+
// ── AND WHERE THE PICTURE STOPS ─────────────────────────────────────────
|
|
319
|
+
//
|
|
320
|
+
// A camera endpoint is NOT a property-lane keyframe, so it does not get that
|
|
321
|
+
// diamond. A move carries ONE ease for its whole window and the gap after it is
|
|
322
|
+
// a HOLD rather than an interpolation, so drawing them alike would teach an
|
|
323
|
+
// author that every diamond behaves the same — and the first time they
|
|
324
|
+
// right-clicked a camera mark expecting an ease menu they would be right to be
|
|
325
|
+
// annoyed. Camera marks are HOLLOW and read-only; ease stays per-move in the
|
|
326
|
+
// inspector, where a move's ease belongs.
|
|
327
|
+
|
|
328
|
+
export type CameraMarkKind = "pose" | "chained" | "break" | "waypoint";
|
|
329
|
+
|
|
330
|
+
/** One camera pose on the camera row. SCENE-LOCAL frames, like every bar. */
|
|
331
|
+
export interface CameraMark {
|
|
332
|
+
/** Stable across renders: the move it belongs to plus which end. */
|
|
333
|
+
id: string;
|
|
334
|
+
frame: number;
|
|
335
|
+
kind: CameraMarkKind;
|
|
336
|
+
title: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every camera pose in a scene, in frame order.
|
|
341
|
+
*
|
|
342
|
+
* Three rules, and each is a claim the drawing then makes:
|
|
343
|
+
*
|
|
344
|
+
* CHAINED — a move that opens on exactly the pose the previous one closed on
|
|
345
|
+
* is ONE keyframe, not two stacked on a pixel. Compared through
|
|
346
|
+
* `framingsChain`, which walks `shotWaypoints` because a `PathShot` has no
|
|
347
|
+
* `from`/`to` at all. A GAP between the two is still a chain: the camera
|
|
348
|
+
* holds that pose across it, so it really is continuous.
|
|
349
|
+
* BREAK — the end of a zoom move that changes the zoom. `cameraRigAt` pins
|
|
350
|
+
* the distance only INSIDE that window, so the rig jumps one frame later
|
|
351
|
+
* and every layer at a non-zero depth pops. Drawn as a split mark rather
|
|
352
|
+
* than as an unbroken diamond, because an unbroken diamond would promise a
|
|
353
|
+
* continuity that is not there.
|
|
354
|
+
* WAYPOINT — a path move's interior poses, READ-ONLY. The chrome refuses to
|
|
355
|
+
* half-edit a path today (`setShotFraming` no-ops on one and the inspector
|
|
356
|
+
* says so), and the only waypoint writer in the repo replaces the whole
|
|
357
|
+
* array, so a draggable mark here would be a control with no op behind it.
|
|
358
|
+
*/
|
|
359
|
+
export function cameraMarks(scene: Scene3D, fps: Rational): CameraMark[] {
|
|
360
|
+
const out: CameraMark[] = [];
|
|
361
|
+
const shots = [...scene.shots].sort((a, b) => a.frame - b.frame);
|
|
362
|
+
|
|
363
|
+
for (const [index, shot] of shots.entries()) {
|
|
364
|
+
const duration = Math.max(1, Math.trunc(shot.durationFrames));
|
|
365
|
+
const end = shot.frame + duration;
|
|
366
|
+
const previous = shots[index - 1];
|
|
367
|
+
const chained = previous !== undefined && framingsChain(previous, shot);
|
|
368
|
+
|
|
369
|
+
// The OPENING pose. Suppressed when it chains, because the previous move's
|
|
370
|
+
// closing mark already stands for it — two marks for one pose is the thing
|
|
371
|
+
// the chain predicate exists to prevent.
|
|
372
|
+
if (!chained) {
|
|
373
|
+
out.push({
|
|
374
|
+
id: `${shot.id}:in`,
|
|
375
|
+
frame: shot.frame,
|
|
376
|
+
kind: "pose",
|
|
377
|
+
title: `${shot.label} starts here`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// A path's interior poses, read-only. Endpoints are excluded: index 0 is
|
|
382
|
+
// the opening mark above and the last is the closing mark below.
|
|
383
|
+
if (shot.kind === "path") {
|
|
384
|
+
for (const [at, frame] of waypointFrames(shot, fps).entries()) {
|
|
385
|
+
if (at === 0 || at === shot.waypoints.length - 1) continue;
|
|
386
|
+
out.push({
|
|
387
|
+
id: `${shot.id}:wp${at}`,
|
|
388
|
+
frame,
|
|
389
|
+
kind: "waypoint",
|
|
390
|
+
// READ-ONLY IN TIME, editable in POSE. A mark sits at the frame the
|
|
391
|
+
// eased progress reaches this waypoint, which is derived — there is
|
|
392
|
+
// nothing under it for a drag to write to. The pose itself is edited
|
|
393
|
+
// in the inspector's waypoint rows.
|
|
394
|
+
title:
|
|
395
|
+
`${shot.label} waypoint ${at + 1} of ${shot.waypoints.length}: its time is derived ` +
|
|
396
|
+
`from the move's ease; edit its pose in the inspector`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The CLOSING pose, which on a LEGACY scene is also where a zoom's rig pin
|
|
402
|
+
// ends. v18 removed the pin, so a modern scene draws a plain pose here and
|
|
403
|
+
// the split mark survives only for documents still on the old rig.
|
|
404
|
+
const broken = scene.legacyZoomRig === true && isZoomRigBreak(shot);
|
|
405
|
+
out.push({
|
|
406
|
+
id: `${shot.id}:out`,
|
|
407
|
+
frame: end,
|
|
408
|
+
kind: broken ? "break" : chainsForward(shots, index) ? "chained" : "pose",
|
|
409
|
+
title: broken
|
|
410
|
+
? `${shot.label} ends here, and the camera RIG jumps: on this scene's old rig a zoom move ` +
|
|
411
|
+
`pins the distance only inside its own window, so every layer at a non-zero depth pops ` +
|
|
412
|
+
`one frame later. Upgrade the camera in the inspector to remove the step.`
|
|
413
|
+
: `${shot.label} ends here`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return out.sort((a, b) => a.frame - b.frame);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Whether the NEXT move opens on this one's closing pose. */
|
|
420
|
+
function chainsForward(shots: readonly Shot[], index: number): boolean {
|
|
421
|
+
const here = shots[index];
|
|
422
|
+
const next = shots[index + 1];
|
|
423
|
+
return here !== undefined && next !== undefined && framingsChain(here, next);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The frame each waypoint of a path move is first REACHED at, on the integer
|
|
428
|
+
* grid the whole document is built on.
|
|
429
|
+
*
|
|
430
|
+
* ── SCANNED, NOT SOLVED ─────────────────────────────────────────────────
|
|
431
|
+
*
|
|
432
|
+
* A waypoint sits at `i / (n - 1)` in the spline's own parameter, and the ease
|
|
433
|
+
* maps time onto that parameter — so placing a mark needs the ease INVERTED. A
|
|
434
|
+
* bezier ease has no closed-form inverse in the direction wanted, and a hold
|
|
435
|
+
* ease has no inverse at all (its progress is a step).
|
|
436
|
+
*
|
|
437
|
+
* So this walks the move's frames and takes the first one whose eased progress
|
|
438
|
+
* has reached the waypoint. That is exact ON THE GRID, which is the only
|
|
439
|
+
* resolution the dock can draw at anyway, and it is total for every ease
|
|
440
|
+
* including hold. The cost is one `clipProgress01` per frame of one move, once
|
|
441
|
+
* per render of the camera row.
|
|
442
|
+
*/
|
|
443
|
+
function waypointFrames(shot: PathShot, fps: Rational): number[] {
|
|
444
|
+
const duration = Math.max(1, Math.trunc(shot.durationFrames));
|
|
445
|
+
const count = shot.waypoints.length;
|
|
446
|
+
if (count < 2) return shot.waypoints.map(() => shot.frame);
|
|
447
|
+
const out: number[] = [];
|
|
448
|
+
let at = 0;
|
|
449
|
+
for (let i = 0; i < count; i += 1) {
|
|
450
|
+
const target = i / (count - 1);
|
|
451
|
+
while (at < duration && clipProgress01({ ...shot, durationFrames: duration }, shot.frame + at, fps) < target) {
|
|
452
|
+
at += 1;
|
|
453
|
+
}
|
|
454
|
+
out.push(shot.frame + at);
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
250
459
|
/**
|
|
251
460
|
* A DELTA in px as a delta in frames.
|
|
252
461
|
*
|
|
@@ -256,33 +465,493 @@ export function cameraBars(scene: Scene3D): BlockBar[] {
|
|
|
256
465
|
* A drag delta is signed, so running it through the clamp silently drops every
|
|
257
466
|
* leftward drag — a left-trim that never moves, which looks like a dead handle.
|
|
258
467
|
*/
|
|
468
|
+
// ── AND THE SAME RULE FOR THE POSES ───────────────────────────────────────
|
|
469
|
+
//
|
|
470
|
+
// A camera mark is an 8px square turned 45 degrees, so it occupies about 11.3px
|
|
471
|
+
// of the lane whatever the zoom, while the distance between two of them is the
|
|
472
|
+
// distance between two moves. On a row of one-frame moves that distance is one
|
|
473
|
+
// frame, and at the zoom where a 71-frame shot fills the dock that is 9.9px:
|
|
474
|
+
// the marks overlapped each other by more than a pixel and eight pairs of them
|
|
475
|
+
// fused into a chain of lozenges. A mark that cannot be told from its neighbour
|
|
476
|
+
// has stopped being a mark and become a texture.
|
|
477
|
+
//
|
|
478
|
+
// So the ones that will not fit are not drawn, EXCEPT a break. That exception
|
|
479
|
+
// is the same one the drawing itself makes: a pose, a chain and a waypoint each
|
|
480
|
+
// restate something the bars beside them already say, which is why they are
|
|
481
|
+
// hidden from assistive technology, while a break is a rig discontinuity that
|
|
482
|
+
// exists nowhere else on the row. Dropping the redundant ones costs a reader
|
|
483
|
+
// nothing; dropping a break would remove the only place that fact appears. Two
|
|
484
|
+
// breaks close enough to touch will therefore still touch, and that is the
|
|
485
|
+
// right trade: rare, and information over tidiness.
|
|
486
|
+
//
|
|
487
|
+
// 13 and not 12, because a break is drawn a pixel larger than the rest (a 9px
|
|
488
|
+
// square, so 12.73px across its diagonal) and 12 left it fusing with the pose
|
|
489
|
+
// beside it. The gap has to clear the WIDEST mark, not the common one.
|
|
490
|
+
export const CAMERA_MARK_MIN_GAP_PX = 13;
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The marks that can be drawn without fusing into their neighbours, in frame
|
|
494
|
+
* order. `ppf` is the lane's current zoom, so this thins as the author zooms
|
|
495
|
+
* out and restores every mark as they zoom back in.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* Whether a mark carries something no other part of the row does.
|
|
499
|
+
*
|
|
500
|
+
* ONE definition, because two things read it and they must agree: this file
|
|
501
|
+
* decides whether a crowded mark may be dropped, and BlockRow decides whether
|
|
502
|
+
* it is announced to assistive technology. Those were the same condition
|
|
503
|
+
* written out twice, so a second load-bearing kind would have been exposed in
|
|
504
|
+
* the markup and silently thinned away out of the picture.
|
|
505
|
+
*/
|
|
506
|
+
export function isLoadBearingMark(mark: CameraMark): boolean {
|
|
507
|
+
return mark.kind === "break";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function thinCameraMarks(marks: readonly CameraMark[], ppf: number): CameraMark[] {
|
|
511
|
+
const kept: CameraMark[] = [];
|
|
512
|
+
let lastFrame: number | null = null;
|
|
513
|
+
for (const mark of marks) {
|
|
514
|
+
const room = lastFrame === null || (mark.frame - lastFrame) * ppf >= CAMERA_MARK_MIN_GAP_PX;
|
|
515
|
+
if (!room && !isLoadBearingMark(mark)) continue;
|
|
516
|
+
kept.push(mark);
|
|
517
|
+
lastFrame = mark.frame;
|
|
518
|
+
}
|
|
519
|
+
return kept;
|
|
520
|
+
}
|
|
521
|
+
|
|
259
522
|
export function frameDelta(px: number, ppf: number): number {
|
|
260
523
|
return Math.round(px / Math.max(1e-6, ppf));
|
|
261
524
|
}
|
|
262
525
|
|
|
526
|
+
// ── THE ZOOM, AS A SLIDER POSITION ────────────────────────────────────────
|
|
527
|
+
//
|
|
528
|
+
// `dockPpf` runs 0.2 to 60, which is a 300x range, and a linear slider over it
|
|
529
|
+
// is unusable: every zoom an author actually works at lives in the first 3% of
|
|
530
|
+
// the track, and the remaining 97% is the difference between "very close" and
|
|
531
|
+
// "closer". Half a track of nothing is not a control.
|
|
532
|
+
//
|
|
533
|
+
// LOGARITHMIC, so equal travel is equal RATIO. A slider that doubles the zoom
|
|
534
|
+
// for every fifth of the track behaves the way the wheel already does
|
|
535
|
+
// (`useAnchoredZoom` multiplies by `exp(-deltaY * 0.002)`, which is the same
|
|
536
|
+
// geometric family), so the two gestures agree about what "a bit more" means
|
|
537
|
+
// and the thumb tracks a wheel zoom at a constant rate.
|
|
538
|
+
//
|
|
539
|
+
// The pair is exact in both directions (`sliderToPpf(ppfToSlider(x)) === x`
|
|
540
|
+
// within float error), which is what lets the slider be a readout of the wheel
|
|
541
|
+
// and the wheel keep working while the slider is on screen.
|
|
542
|
+
|
|
543
|
+
/** Where `ppf` sits on a 0..1 track. Clamped, so a value the store has not seen
|
|
544
|
+
* yet (or a NaN out of a broken caller) still paints somewhere real. */
|
|
545
|
+
export function ppfToSlider(ppf: number, min: number, max: number): number {
|
|
546
|
+
if (!Number.isFinite(ppf)) return 0;
|
|
547
|
+
const clamped = Math.min(max, Math.max(min, ppf));
|
|
548
|
+
return Math.log(clamped / min) / Math.log(max / min);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The zoom a 0..1 track position means. */
|
|
552
|
+
export function sliderToPpf(t: number, min: number, max: number): number {
|
|
553
|
+
if (!Number.isFinite(t)) return min;
|
|
554
|
+
const clamped = Math.min(1, Math.max(0, t));
|
|
555
|
+
return min * Math.pow(max / min, clamped);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── WHETHER A BAR CAN CARRY ITS OWN NAME ──────────────────────────────────
|
|
559
|
+
//
|
|
560
|
+
// A bar is as wide as its window is long, so on a dense row most bars are
|
|
561
|
+
// narrower than the word inside them. They were drawn anyway: the label kept
|
|
562
|
+
// its `text-overflow: ellipsis` and the browser gave it whatever was left after
|
|
563
|
+
// the inset, which on a one-frame move was between zero and three pixels. A row
|
|
564
|
+
// of twenty-two camera moves therefore read as "d d d dolly d d dolly", where
|
|
565
|
+
// every "d" is a name that has been compressed past the point of being one.
|
|
566
|
+
//
|
|
567
|
+
// The rule is the inspector's, one lane over: a label that cannot be read in
|
|
568
|
+
// full is worse than no label, because the reader cannot tell a truncation from
|
|
569
|
+
// a short word. So the name is drawn only when the whole name fits, and the
|
|
570
|
+
// bar's `title` carries the name and the frame range for every bar either
|
|
571
|
+
// way, so nothing becomes unreachable.
|
|
572
|
+
//
|
|
573
|
+
// ── WHY AN ESTIMATE AND NOT A MEASUREMENT ─────────────────────────────────
|
|
574
|
+
//
|
|
575
|
+
// The honest measurement is the text's own advance width, which needs a layout
|
|
576
|
+
// pass or a canvas `measureText` PER BAR, on a row that re-renders on every
|
|
577
|
+
// frame of a zoom drag. This is one multiply against a per-character average,
|
|
578
|
+
// and the average is measured rather than guessed: against the face the label
|
|
579
|
+
// actually resolves to (`500 12px "Inter Variable"`), "in" runs 5.12px per
|
|
580
|
+
// character, "orbit" 5.32, "dolly" 5.54 and "crossfade" 6.35. 6.4 sits just
|
|
581
|
+
// above the widest of them, so every real move name is biased toward hiding:
|
|
582
|
+
// erring that way costs a name that would have just fitted, while erring the
|
|
583
|
+
// other way puts back the sliver this exists to remove.
|
|
584
|
+
//
|
|
585
|
+
// A label of all-wide glyphs would beat it (a lone "w" is 9.95px), which is
|
|
586
|
+
// why `.block-label` keeps `text-overflow: ellipsis` underneath as a backstop
|
|
587
|
+
// rather than relying on this being exact.
|
|
588
|
+
export const BLOCK_LABEL_INSET_PX = 18;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* About how many pixels `label` needs at the dock's own face.
|
|
592
|
+
*
|
|
593
|
+
* Three buckets, because a name is NOT always a lowercase English keyword.
|
|
594
|
+
* The camera row's is (`dolly`, `orbit`), but a clip's (`clipNamePlace`) is a
|
|
595
|
+
* file or layer name, so this sees whatever the author typed. Measured against `500 12px "Inter Variable"`, the face the label
|
|
596
|
+
* actually inherits: lowercase runs 5.1 to 6.4 per character across `in`,
|
|
597
|
+
* `orbit`, `dolly` and `crossfade`, capitals about 8, and a full-width glyph
|
|
598
|
+
* takes the whole 12px em. One flat average tuned on lowercase would pass an
|
|
599
|
+
* all-caps or CJK name and then ellipsise it, which is the exact sliver this
|
|
600
|
+
* exists to remove.
|
|
601
|
+
*
|
|
602
|
+
* Iterated with `for...of` so a surrogate pair counts once: `label.length` is
|
|
603
|
+
* UTF-16 code units and would score an emoji as two narrow characters.
|
|
604
|
+
*/
|
|
605
|
+
export function estimateLabelPx(label: string): number {
|
|
606
|
+
let px = 0;
|
|
607
|
+
for (const ch of label) {
|
|
608
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
609
|
+
// Everything from CJK radicals up is full-width in practice, as are the
|
|
610
|
+
// fullwidth forms and emoji above the BMP.
|
|
611
|
+
if (code >= 0x2e80) px += 12;
|
|
612
|
+
// A cheap test for "this glyph is a capital": a character that changes
|
|
613
|
+
// under toLowerCase. It catches Latin, Greek and Cyrillic capitals without
|
|
614
|
+
// a table, and scores digits and punctuation as lowercase, which is right.
|
|
615
|
+
else if (ch !== ch.toLowerCase()) px += 8;
|
|
616
|
+
else px += 6.4;
|
|
617
|
+
}
|
|
618
|
+
return px;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Does `label` fit inside a bar `widthPx` wide, inset and all?
|
|
623
|
+
*
|
|
624
|
+
* `widthPx` is the bar's BORDER-box width, which is what BlockBarView computes
|
|
625
|
+
* and what CSS consumes, so the inset above is the label's 16px of padding plus
|
|
626
|
+
* the bar's own 1px border on each side. Getting that wrong by two pixels would
|
|
627
|
+
* bias the answer toward showing a label that then clips, which is the one
|
|
628
|
+
* direction this must not err in.
|
|
629
|
+
*/
|
|
630
|
+
export function fitsBlockLabel(widthPx: number, label: string): boolean {
|
|
631
|
+
return widthPx >= BLOCK_LABEL_INSET_PX + estimateLabelPx(label);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The gap between a clip and a name drawn beside it, the boards' 6px. */
|
|
635
|
+
export const CLIP_NAME_GAP_PX = 6;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* WHERE A CLIP'S NAME GOES. A name is never cut short: inside the clip when the
|
|
639
|
+
* whole of it fits, else just AFTER the clip, else (at the track's end, where
|
|
640
|
+
* after would run off the lane) just BEFORE it, right-aligned. `laneW` is the
|
|
641
|
+
* lane's drawable width in px, measured from the lane's own 0 like `leftPx`.
|
|
642
|
+
*/
|
|
643
|
+
export function clipNamePlace(
|
|
644
|
+
leftPx: number,
|
|
645
|
+
widthPx: number,
|
|
646
|
+
label: string,
|
|
647
|
+
laneW: number,
|
|
648
|
+
): "inside" | "after" | "before" {
|
|
649
|
+
const text = estimateLabelPx(label);
|
|
650
|
+
if (fitsBlockLabel(widthPx, label)) return "inside";
|
|
651
|
+
return leftPx + widthPx + CLIP_NAME_GAP_PX + text <= laneW || leftPx - CLIP_NAME_GAP_PX - text < 0
|
|
652
|
+
? "after"
|
|
653
|
+
: "before";
|
|
654
|
+
}
|
|
655
|
+
|
|
263
656
|
/** The camera row's stand-in layer id. Not a real layer, and deliberately not a
|
|
264
657
|
* valid `l3_` id, so a code path that treated it as one fails loudly. */
|
|
265
658
|
export const CAMERA_ROW_ID = "camera";
|
|
266
659
|
|
|
267
|
-
// ── THE
|
|
660
|
+
// ── THE PLAYHEAD IN LANE COORDINATES, AND THE LANE THAT FOLLOWS IT ─────────
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* WHERE THE PLAYHEAD IS, in the frames this lane is drawn in. ONE function,
|
|
664
|
+
* because two things read it and they must never disagree: the line that draws
|
|
665
|
+
* it, and the scroller that follows it.
|
|
666
|
+
*
|
|
667
|
+
* The fork is unify P2's one-time-base rule. A DEFINITION runs on `defFrame`, a
|
|
668
|
+
* clock of its own with no master position at all. Everything else runs on
|
|
669
|
+
* master time, re-zeroed by `origin` — which is DRAW-ONLY (see this file's
|
|
670
|
+
* master-strip header): the shot context subtracts the shot's start here and
|
|
671
|
+
* `seek` adds it straight back, so what reaches the store is the master frame
|
|
672
|
+
* it has always been.
|
|
673
|
+
*
|
|
674
|
+
* Split out of `DockPlayhead` when the lane learned to follow. Left inline it
|
|
675
|
+
* would have been copied into the follow effect, and a lane that scrolled to a
|
|
676
|
+
* different frame than it drew is the exact shape of bug this codebase has paid
|
|
677
|
+
* for before — two spellings of one mapping, agreeing on every input anyone
|
|
678
|
+
* happened to try.
|
|
679
|
+
*/
|
|
680
|
+
export function laneFrame(
|
|
681
|
+
state: { frame: number; defFrame: number; editingDef: string | null },
|
|
682
|
+
origin: number,
|
|
683
|
+
): number {
|
|
684
|
+
return state.editingDef === null ? state.frame - origin : state.defFrame;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* How much of the visible lane the playhead may cross before it drags the view
|
|
689
|
+
* along. Outside this band the scroll moves; inside it nothing happens.
|
|
690
|
+
*
|
|
691
|
+
* A DEADZONE and not a centred playhead. Centring writes a scroll on every
|
|
692
|
+
* frame, so the whole lane slides under the pointer at all times and there is
|
|
693
|
+
* never a still moment in which to click a bar. With a band, a scrub inside the
|
|
694
|
+
* visible window costs nothing at all, and only playback that runs off the edge
|
|
695
|
+
* pays.
|
|
696
|
+
*
|
|
697
|
+
* The forward number is the interesting one. Once playback pushes past it, each
|
|
698
|
+
* later frame scrolls by exactly one frame's width, so the playhead stays PINNED
|
|
699
|
+
* at 0.75 and the content slides under it — which is the "shots passing by" read
|
|
700
|
+
* this was asked for, and it falls out of the clamp rather than needing its own
|
|
701
|
+
* mode. 0.75 rather than the far edge leaves a quarter of a lane of lookahead:
|
|
702
|
+
* an author watching a cut approach can see the next shot's bars before they
|
|
703
|
+
* arrive.
|
|
704
|
+
*/
|
|
705
|
+
export const FOLLOW_BAND = { back: 0.25, forward: 0.75 } as const;
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The `scrollLeft` that brings the playhead back inside the band, or NULL when
|
|
709
|
+
* it is already inside and nothing should move.
|
|
710
|
+
*
|
|
711
|
+
* Null rather than "the current value" on purpose: the caller writes
|
|
712
|
+
* `el.scrollLeft` only when this answers, so the common case (a playhead in
|
|
713
|
+
* view) touches no DOM at all — and a write of the same number would still
|
|
714
|
+
* fire a scroll event and still fight a momentum scroll mid-flight.
|
|
715
|
+
*
|
|
716
|
+
* Pure, and that is not tidiness either: jsdom reports every `clientWidth` as 0,
|
|
717
|
+
* so a lane's geometry can only be exercised from a test by stubbing it. The
|
|
718
|
+
* arithmetic is the part worth pinning, and it is testable here with no layout
|
|
719
|
+
* at all.
|
|
720
|
+
*/
|
|
721
|
+
export function followScrollLeft(view: {
|
|
722
|
+
/** The playhead's x in CONTENT pixels — label column included, because the
|
|
723
|
+
* label cell is part of the scrolled content and `left:` is measured from
|
|
724
|
+
* the same origin. */
|
|
725
|
+
playheadPx: number;
|
|
726
|
+
scrollLeft: number;
|
|
727
|
+
/** The scroll container's `clientWidth`. */
|
|
728
|
+
laneW: number;
|
|
729
|
+
/** `scrollWidth`, so the answer is clamped to what the browser would clamp it
|
|
730
|
+
* to anyway. Without this the caller writes a number, the browser stores a
|
|
731
|
+
* different one, and every later comparison is against a value that was
|
|
732
|
+
* never there. */
|
|
733
|
+
contentW: number;
|
|
734
|
+
}): number | null {
|
|
735
|
+
const { playheadPx, scrollLeft, laneW, contentW } = view;
|
|
736
|
+
if (!(laneW > 0) || !Number.isFinite(playheadPx)) return null;
|
|
737
|
+
const max = Math.max(0, contentW - laneW);
|
|
738
|
+
const back = scrollLeft + laneW * FOLLOW_BAND.back;
|
|
739
|
+
const forward = scrollLeft + laneW * FOLLOW_BAND.forward;
|
|
740
|
+
let next = scrollLeft;
|
|
741
|
+
if (playheadPx > forward) next = playheadPx - laneW * FOLLOW_BAND.forward;
|
|
742
|
+
else if (playheadPx < back) next = playheadPx - laneW * FOLLOW_BAND.back;
|
|
743
|
+
else return null;
|
|
744
|
+
next = Math.max(0, Math.min(max, next));
|
|
745
|
+
// The clamp can land back on where we already are — a playhead past the end
|
|
746
|
+
// of a document already scrolled to its limit is the ordinary case, and it
|
|
747
|
+
// must not write a scroll on every frame of the tail.
|
|
748
|
+
return next === scrollLeft ? null : next;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** `.dock-ruler`'s height, and the sticky strip the rows scroll under. Paired
|
|
752
|
+
* with the CSS rule of the same name: the ruler is the one piece of the
|
|
753
|
+
* scroller that does not move vertically, so it is also the top of the usable
|
|
754
|
+
* viewport for anything being revealed below it. 28 in every context, the
|
|
755
|
+
* boards' ruler: the playhead's 18px pill hangs 5px down with the ticks
|
|
756
|
+
* standing up off the floor beside it. */
|
|
757
|
+
export const DOCK_RULER_H = 28;
|
|
758
|
+
|
|
759
|
+
/** Breathing room around a revealed row, so it lands just inside the edge
|
|
760
|
+
* rather than flush against it (or half under the sticky ruler). */
|
|
761
|
+
export const FOLLOW_MARGIN = 8;
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The `scrollTop` that brings a shot's row into view, or NULL when it already
|
|
765
|
+
* is and nothing should move.
|
|
766
|
+
*
|
|
767
|
+
* NEAREST, not a band, and that is the difference from `followScrollLeft`
|
|
768
|
+
* above. The horizontal follow tracks a playhead that moves every frame, so it
|
|
769
|
+
* pins the head at 0.75 of the lane and lets the content slide under it. This
|
|
770
|
+
* one is driven by `sceneId`, which moves about once per SHOT — roughly seven
|
|
771
|
+
* times in a minute of film. Recentring the lane on each of those would be the
|
|
772
|
+
* panel jumping under an author who is reading it, for no lookahead worth
|
|
773
|
+
* having. So it moves the minimum distance, and only when the row is actually
|
|
774
|
+
* outside: it is `scrollIntoView({ block: "nearest" })` written as arithmetic.
|
|
775
|
+
*
|
|
776
|
+
* Arithmetic and not the DOM call for two reasons. Past 40 shots the dock
|
|
777
|
+
* virtualizes and the row may not be MOUNTED, so there is nothing to call the
|
|
778
|
+
* method on, while the prefix sum over `groupHeights` that positions the
|
|
779
|
+
* spacers still knows exactly where it would be. And jsdom lays nothing out,
|
|
780
|
+
* so a DOM-measured follow could not be tested at all.
|
|
781
|
+
*/
|
|
782
|
+
export function followScrollTop(view: {
|
|
783
|
+
/** The row's y in CONTENT pixels, ruler included: it is inside the scroller
|
|
784
|
+
* and above every group, so it is part of the same origin. */
|
|
785
|
+
rowTop: number;
|
|
786
|
+
rowH: number;
|
|
787
|
+
scrollTop: number;
|
|
788
|
+
/** The scroll container's `clientHeight`. */
|
|
789
|
+
viewH: number;
|
|
790
|
+
/** `scrollHeight`, so the answer is clamped where the browser would clamp
|
|
791
|
+
* it, for `followScrollLeft`'s reason. */
|
|
792
|
+
contentH: number;
|
|
793
|
+
/** The sticky ruler's height: content under it is covered, not visible. */
|
|
794
|
+
stickyH: number;
|
|
795
|
+
}): number | null {
|
|
796
|
+
const { rowTop, rowH, scrollTop, viewH, contentH, stickyH } = view;
|
|
797
|
+
if (!(viewH > 0) || !Number.isFinite(rowTop)) return null;
|
|
798
|
+
const max = Math.max(0, contentH - viewH);
|
|
799
|
+
// The visible band, in content pixels: under the ruler, above the bottom.
|
|
800
|
+
const top = scrollTop + stickyH + FOLLOW_MARGIN;
|
|
801
|
+
const bottom = scrollTop + viewH - FOLLOW_MARGIN;
|
|
802
|
+
let next = scrollTop;
|
|
803
|
+
if (rowTop < top) next = rowTop - stickyH - FOLLOW_MARGIN;
|
|
804
|
+
else if (rowTop + rowH > bottom) next = rowTop + rowH - viewH + FOLLOW_MARGIN;
|
|
805
|
+
else return null;
|
|
806
|
+
next = Math.max(0, Math.min(max, next));
|
|
807
|
+
return next === scrollTop ? null : next;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Where a layer's row sits in the dock's scrolled content, ruler included (the
|
|
812
|
+
* origin `followScrollTop` takes), or NULL when no shown group lists it.
|
|
813
|
+
*
|
|
814
|
+
* From the same heights the virtual windows place their spacers by, not from
|
|
815
|
+
* the DOM, for `followScrollTop`'s reason: past the window thresholds the row
|
|
816
|
+
* being revealed is exactly the one that may not be mounted.
|
|
817
|
+
*
|
|
818
|
+
* `leads[i]` is the height of group i above its first layer row: the separator,
|
|
819
|
+
* the shot row, and the camera rows of a shown shot. `height` is the row with
|
|
820
|
+
* its open lanes, the entry the layer window holds for it.
|
|
821
|
+
*
|
|
822
|
+
* A layer id names a row of one container, but the scene context lists every
|
|
823
|
+
* shot, so `prefer` (the shot the stage shows) wins a tie before document order.
|
|
824
|
+
*/
|
|
825
|
+
export function rowOffsetOf(
|
|
826
|
+
groups: readonly { id: string; rows: readonly { layerId: string }[] }[],
|
|
827
|
+
groupHeights: readonly number[],
|
|
828
|
+
leads: readonly number[],
|
|
829
|
+
layerHeights: ReadonlyMap<string, readonly number[]>,
|
|
830
|
+
layerId: string,
|
|
831
|
+
prefer: string | null = null,
|
|
832
|
+
): { groupId: string; top: number; height: number } | null {
|
|
833
|
+
const holds = (index: number): number => {
|
|
834
|
+
const heights = layerHeights.get(groups[index]!.id) ?? [];
|
|
835
|
+
const row = groups[index]!.rows.findIndex((r) => r.layerId === layerId);
|
|
836
|
+
// A closed group lists no heights: its rows are not drawn.
|
|
837
|
+
return row < heights.length ? row : -1;
|
|
838
|
+
};
|
|
839
|
+
let index = groups.findIndex((g) => g.id === prefer);
|
|
840
|
+
if (index < 0 || holds(index) < 0) index = groups.findIndex((_, i) => holds(i) >= 0);
|
|
841
|
+
if (index < 0) return null;
|
|
842
|
+
const heights = layerHeights.get(groups[index]!.id)!;
|
|
843
|
+
const row = holds(index);
|
|
844
|
+
let top = DOCK_RULER_H + (leads[index] ?? 0);
|
|
845
|
+
for (let i = 0; i < index; i += 1) top += groupHeights[i] ?? 0;
|
|
846
|
+
for (let i = 0; i < row; i += 1) top += heights[i] ?? 0;
|
|
847
|
+
return { groupId: groups[index]!.id, top, height: heights[row] ?? 0 };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A layer row as the in-container follow reads it: its span in LANE frames,
|
|
852
|
+
* where it sits in the dock's content (ruler included, `rowOffsetOf`'s
|
|
853
|
+
* origin) with its open lanes, its depth in the pre-order walk, and whether
|
|
854
|
+
* its eye is off.
|
|
855
|
+
*/
|
|
856
|
+
export interface FollowRow {
|
|
857
|
+
in: number;
|
|
858
|
+
out: number;
|
|
859
|
+
top: number;
|
|
860
|
+
height: number;
|
|
861
|
+
depth: number;
|
|
862
|
+
hidden: boolean;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Which rows are ON SCREEN at a lane frame, by the runtime's rule and not the
|
|
867
|
+
* bar's (`useLive` lights a bar by its span alone). `layerAliveAt` drops a
|
|
868
|
+
* hidden layer, and the render walk stops at a layer that is not alive, so a
|
|
869
|
+
* child inside its own span is still not drawn while its parent is not. A
|
|
870
|
+
* row's parent is the nearest row above it one level up: `trackRows` walks
|
|
871
|
+
* pre-order and `masterRows`' time order keeps children under their parent.
|
|
872
|
+
*/
|
|
873
|
+
export function rowsOnScreenAt(rows: readonly FollowRow[], frame: number): boolean[] {
|
|
874
|
+
const out: boolean[] = [];
|
|
875
|
+
// The latest row seen at each depth: the open ancestor of the next one.
|
|
876
|
+
const chain: boolean[] = [];
|
|
877
|
+
for (const row of rows) {
|
|
878
|
+
// An isolated subtree can start below depth 0: its root has no parent row.
|
|
879
|
+
const parentOn = row.depth === 0 ? true : (chain[row.depth - 1] ?? true);
|
|
880
|
+
const on = parentOn && !row.hidden && row.in <= frame && frame < row.out;
|
|
881
|
+
chain[row.depth] = on;
|
|
882
|
+
chain.length = row.depth + 1;
|
|
883
|
+
out.push(on);
|
|
884
|
+
}
|
|
885
|
+
return out;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* THE ROWS THAT CAME ON SCREEN as the playhead moved from `from` to `to`, as
|
|
890
|
+
* one band from the topmost to the lowest of them, or NULL when none did.
|
|
891
|
+
*
|
|
892
|
+
* A difference of two sets, not a crossing of edges, so one answer covers a
|
|
893
|
+
* playback tick that skipped frames, the reverse shuttle (a row comes on as
|
|
894
|
+
* the playhead backs into its end), a scrub, and a loop's wrap to the start
|
|
895
|
+
* (the rows the shot opens with come on again). A row on screen for the whole
|
|
896
|
+
* run never appears, so a full-length background never pins the view.
|
|
897
|
+
*
|
|
898
|
+
* What the band is for is `followScrollTop`: the shot reveal's "nearest", one
|
|
899
|
+
* scroll per appearance and only when it is out of view, never a scroll per
|
|
900
|
+
* frame. Rows run in time order, so playback walks the dock down the list the
|
|
901
|
+
* way the Project's reveal walks it down the shots.
|
|
902
|
+
*/
|
|
903
|
+
export function appearedBand(
|
|
904
|
+
rows: readonly FollowRow[],
|
|
905
|
+
from: number,
|
|
906
|
+
to: number,
|
|
907
|
+
): { top: number; height: number } | null {
|
|
908
|
+
if (from === to || rows.length === 0) return null;
|
|
909
|
+
const before = rowsOnScreenAt(rows, from);
|
|
910
|
+
const after = rowsOnScreenAt(rows, to);
|
|
911
|
+
let top = Infinity;
|
|
912
|
+
let bottom = -Infinity;
|
|
913
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
914
|
+
if (!after[i] || before[i]) continue;
|
|
915
|
+
const row = rows[i]!;
|
|
916
|
+
top = Math.min(top, row.top);
|
|
917
|
+
bottom = Math.max(bottom, row.top + row.height);
|
|
918
|
+
}
|
|
919
|
+
return top === Infinity ? null : { top, height: bottom - top };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── THE MASTER STRIP, AND THE TWO CONTEXTS OVER IT ──────────────────────────
|
|
923
|
+
//
|
|
924
|
+
// The dock was two levels once, keyed on `state.sceneId` — which follows the
|
|
925
|
+
// PLAYHEAD, so the lower half changed under the author every time they scrubbed
|
|
926
|
+
// across a cut. That is the thing that was deleted, and it stays deleted.
|
|
268
927
|
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// decided what the lower half showed.
|
|
928
|
+
// What replaced it is a real context bit, `state.enteredScene`, written only by
|
|
929
|
+
// the enter/leave verbs:
|
|
272
930
|
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
931
|
+
// SCENE context — every shot's rows at their MASTER positions, in master
|
|
932
|
+
// order, one continuous strip: the sequence read, which is what answers
|
|
933
|
+
// "what happens between the logo landing and the price appearing" when
|
|
934
|
+
// those two things are in different shots. The per-element rows are opt-in
|
|
935
|
+
// here (the summary toggle) because the subject is the arrangement.
|
|
936
|
+
// SHOT context — one group, re-zeroed on the shot the author OPENED, plus
|
|
937
|
+
// the master-timed marker and audio lanes clipped to its window. Not the
|
|
938
|
+
// old scene-follows-playhead mode: nothing but the enter verb can change
|
|
939
|
+
// what is on screen, so a scrub cannot move the author.
|
|
279
940
|
//
|
|
280
941
|
// BARS STAY SCENE-LOCAL and the GROUP carries the offset. The alternative —
|
|
281
942
|
// baking `start` into every bar's `frame` — turns each bar into a master frame
|
|
282
943
|
// that every gesture has to convert back before it can call a document op, and
|
|
283
|
-
// one missed conversion moves a
|
|
944
|
+
// one missed conversion moves a bar a whole scene. A drag DELTA is
|
|
284
945
|
// offset-invariant, so keeping the offset out of the bar makes the conversion a
|
|
285
946
|
// rendering detail and never a document one.
|
|
947
|
+
//
|
|
948
|
+
// The shot context's re-zero rides that same split, and this is the sharp edge
|
|
949
|
+
// in it: `offsetFrames` STAYS the scene's true master start, because SNAPPING
|
|
950
|
+
// converts through it (`beginSnap` freezes MASTER targets) and `ChannelRow`
|
|
951
|
+
// round-trips the master playhead through it. The re-zero is a second,
|
|
952
|
+
// DRAW-ONLY number — `originFrames` — subtracted in the `left` expressions and
|
|
953
|
+
// nowhere else. Folding the two into one is how a snap in the shot context
|
|
954
|
+
// would land on a frame the same drag reaches in the scene context.
|
|
286
955
|
|
|
287
956
|
/** A layer's half-open SCENE-LOCAL lifespan, with the default filled in. */
|
|
288
957
|
export interface Lifespan {
|
|
@@ -291,7 +960,8 @@ export interface Lifespan {
|
|
|
291
960
|
}
|
|
292
961
|
|
|
293
962
|
/**
|
|
294
|
-
* The layer's bar, with an ABSENT `lifetime` resolved to the whole scene
|
|
963
|
+
* The layer's bar, with an ABSENT `lifetime` resolved to the whole scene, and
|
|
964
|
+
* an OPEN `out` resolved to the container's end.
|
|
295
965
|
*
|
|
296
966
|
* The document's field is optional and absent means "always" (`layerAliveAt`).
|
|
297
967
|
* A bar needs two ends, so the default is materialised HERE, in one place,
|
|
@@ -299,19 +969,323 @@ export interface Lifespan {
|
|
|
299
969
|
* their own. Nothing is written by reading it: the first edge drag is what puts
|
|
300
970
|
* a `lifetime` in the document.
|
|
301
971
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
972
|
+
* An open end (`isOpenEndedOut`: a component placed by an agent with only a
|
|
973
|
+
* start frame) is the same default at one end: "until the container ends". It
|
|
974
|
+
* resolves here for the same reason, and every reader downstream gets it for
|
|
975
|
+
* free. Read raw it was a bar nine quadrillion frames long: the content extent
|
|
976
|
+
* followed it, and the ruler pushed a tick every few frames of that until V8
|
|
977
|
+
* refused the array and the dock died with the tab frozen.
|
|
978
|
+
*
|
|
979
|
+
* A stored lifespan is otherwise returned AS IT IS, even when it runs past the
|
|
980
|
+
* end of the scene. Clamping it to the scene here would silently rewrite a
|
|
981
|
+
* document the first time the dock drew it, and the part past the end is
|
|
982
|
+
* exactly the thing the author needs to see in order to pull it back. The open
|
|
983
|
+
* end is not that clamp: the marker has no position to show or pull back, and
|
|
984
|
+
* nothing is written by resolving it either. A drag that moves the out edge
|
|
985
|
+
* starts from the resolved end and writes a finite one, as it does for an
|
|
986
|
+
* absent lifetime; one that moves only the in edge keeps the marker
|
|
987
|
+
* (`writeLifespan`).
|
|
988
|
+
*
|
|
989
|
+
* NEVER BEFORE `in + MIN_LIFESPAN_FRAMES`. A Shot shortened under a placement
|
|
990
|
+
* (a later `update_scene`, a razor cut before its entrance) leaves `in` at or
|
|
991
|
+
* past the end, and the end alone is an inverted bar: a negative duration in
|
|
992
|
+
* the inspector, an extent that stops short of the bar, and an in edge that
|
|
993
|
+
* jumps back to `out - 1` on the first nudge. A finite window there keeps its
|
|
994
|
+
* real `out` and the extent follows it; the open one is drawn as its shortest
|
|
995
|
+
* bar, at its start, and the extent follows that.
|
|
996
|
+
*/
|
|
997
|
+
export function lifespanOf(layer: Layer3D, containerDurationFrames: number): Lifespan {
|
|
998
|
+
// The rule itself lives in shot (`layerLifespan`), shared with the clip ops
|
|
999
|
+
// and the motion presets, so the dock cannot draw a window they disagree on.
|
|
1000
|
+
return layerLifespan(layer, containerDurationFrames);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ── HOW TALL A LIFESPAN ROW IS, AND WHY THE NUMBER LIVES HERE ───────────────
|
|
1004
|
+
//
|
|
1005
|
+
// `TimelineDock`'s virtual window builds PREFIX SUMS out of `expandedLayerHeight`
|
|
1006
|
+
// and positions spacers with them, so a row whose CSS height disagrees with the
|
|
1007
|
+
// number the sum used scrolls to the wrong place and the mismatch grows one row
|
|
1008
|
+
// at a time. The heights are therefore constants both sides read, not a literal
|
|
1009
|
+
// in the sheet and a literal in the sum.
|
|
1010
|
+
//
|
|
1011
|
+
// A VIDEO row is a CLIP row: a filmstrip over the recording's own waveform. It
|
|
1012
|
+
// is 56 tall against an ordinary row's 28, and that budget is spent exactly:
|
|
1013
|
+
//
|
|
1014
|
+
// 32 picture
|
|
1015
|
+
// 1 EDGE between the two, and an edge is what reads. In the light theme
|
|
1016
|
+
// `--panel-raised` IS `--panel`, so a "surface step up" between the strip
|
|
1017
|
+
// and the wave would be invisible; a line is not.
|
|
1018
|
+
// 22 waveform
|
|
1019
|
+
// ──
|
|
1020
|
+
// 55 the bar, inside the row's own 1px bottom border (`box-sizing` is
|
|
1021
|
+
// border-box globally, so 55 + 1 = `VIDEO_ROW_H`).
|
|
1022
|
+
|
|
1023
|
+
/** An ordinary lifespan row, paired with `.lifespan-row`'s CSS height, and the
|
|
1024
|
+
* left column's outline row (`.layer-row`, var(--row-h)): one row height. */
|
|
1025
|
+
export const LAYER_ROW_H = 28;
|
|
1026
|
+
/** One `ShotRow`, paired with `.shot-row`'s CSS height. The vertical follow
|
|
1027
|
+
* reveals exactly this much: a shot's own row, not its expanded contents,
|
|
1028
|
+
* which can be taller than the dock. 28, the row ruling: the 20px clip sits
|
|
1029
|
+
* 4px under the row's top inside the 1px bottom border. */
|
|
1030
|
+
export const SHOT_ROW_H = 28;
|
|
1031
|
+
/** A video row: the two strips plus their edge, plus the row's own border. */
|
|
1032
|
+
export const VIDEO_ROW_H = 56;
|
|
1033
|
+
export const VIDEO_PICTURE_H = 32;
|
|
1034
|
+
export const VIDEO_EDGE_H = 1;
|
|
1035
|
+
export const VIDEO_WAVE_H = 22;
|
|
1036
|
+
/** One `.channel-row`: an animated property, the "No animation" hint, or the
|
|
1037
|
+
* "+ property" row that ends every expansion. Paired with the CSS height, and
|
|
1038
|
+
* the unit `expandedLayerHeight` and `groupHeights` count channel rows in.
|
|
1039
|
+
* 28, the row ruling: a channel row is a row like any other, not a sub-row. */
|
|
1040
|
+
export const CHANNEL_ROW_H = 28;
|
|
1041
|
+
/** One `.block-row`: the shot's camera. Paired with the CSS height, and
|
|
1042
|
+
* counted by the same two sums. */
|
|
1043
|
+
export const BLOCK_ROW_H = 28;
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* How wide a bar's trim handles are, in px, measured INSIDE its two edges.
|
|
1047
|
+
*
|
|
1048
|
+
* A fixed 8px reads fine on a two-second bar and is absurd on a six-frame one:
|
|
1049
|
+
* at 16px wide the two handles ARE the bar, and the body gesture — the one an
|
|
1050
|
+
* author reaches for first — has nowhere left to be pressed. So: a third of the
|
|
1051
|
+
* bar, never below 3px (smaller is not a pointer target), never above half the
|
|
1052
|
+
* bar (the body has to survive), and never above 10px.
|
|
306
1053
|
*/
|
|
307
|
-
export function
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
1054
|
+
export function trimHandlePx(widthPx: number): number {
|
|
1055
|
+
const w = Number.isFinite(widthPx) ? Math.max(0, widthPx) : 0;
|
|
1056
|
+
return Math.min(Math.max(w / 3, 3), w / 2, 10);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** A stretch of SCENE-LOCAL frames, half-open, where the picture is frozen. */
|
|
1060
|
+
export interface HeldSpan {
|
|
1061
|
+
from: number;
|
|
1062
|
+
to: number;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* The clip facts a VIDEO row draws with, derived ONCE per document beside the
|
|
1067
|
+
* row rather than per render inside it.
|
|
1068
|
+
*
|
|
1069
|
+
* `masterRows` is memoised on the document's identity and the dock re-renders
|
|
1070
|
+
* for every zoom tick, so a row that re-derived its own clip arithmetic would
|
|
1071
|
+
* re-run `videoClipOf` and re-resolve two assets sixty times a second during a
|
|
1072
|
+
* zoom drag. It also keeps `LifespanRow` free of `doc`: the row reads its own
|
|
1073
|
+
* model and the selection, which is the rule the rest of this lane follows.
|
|
1074
|
+
*/
|
|
1075
|
+
export interface VideoRow {
|
|
1076
|
+
/** The PICTURE's asset row, which is what `Filmstrip` keys its sheet on (its
|
|
1077
|
+
* content-addressed `path`). Undefined when the document carries no row for
|
|
1078
|
+
* `video.asset`, which draws a bar with no strip rather than refusing. */
|
|
1079
|
+
pictureAsset: ShotAsset | undefined;
|
|
1080
|
+
/** The inline soundtrack's asset, handed to the waveform as it is. The OBJECT
|
|
1081
|
+
* and not its path, because `AudioWaveform` fetches on the identity of this
|
|
1082
|
+
* prop and a fresh `{ path }` per render would refetch forever. */
|
|
1083
|
+
soundAsset: ShotAsset | undefined;
|
|
1084
|
+
/**
|
|
1085
|
+
* The inline sound's slice of its source, or null when this recording
|
|
1086
|
+
* contributes none to the mix (`videoSoundClip`'s three gates: hidden,
|
|
1087
|
+
* retimed or looping, or a window that never opens).
|
|
1088
|
+
*
|
|
1089
|
+
* Only the two fields the waveform cuts on, with `offsetFrames`' absent-is-0
|
|
1090
|
+
* already resolved: the bar's placement is the LAYER's window, which the row
|
|
1091
|
+
* is drawing anyway, so carrying a whole `AudioClipRef` here would be a second
|
|
1092
|
+
* copy of the frames the lifespan already holds.
|
|
1093
|
+
*/
|
|
1094
|
+
sound: { offsetFrames: number; durationFrames: number } | null;
|
|
1095
|
+
/** `video.sound.muted`. Read off the LAYER, not off `sound`, because the
|
|
1096
|
+
* button must still say what it would toggle when the clip above is null. */
|
|
1097
|
+
muted: boolean;
|
|
1098
|
+
/**
|
|
1099
|
+
* `video.offsetFrames`, absent resolved to 0: the SCENE frame the source's
|
|
1100
|
+
* t=0 lands on, and the thing a SLIP moves.
|
|
1101
|
+
*
|
|
1102
|
+
* Carried because it is a slip's snap anchor. A slip leaves the window where
|
|
1103
|
+
* it is, so snapping the window's `in` would snap a number that is not
|
|
1104
|
+
* moving: the delta would land the FOOTAGE wherever the constant gap between
|
|
1105
|
+
* `in` and the offset happened to put it. `clip.leadFrom` is the same
|
|
1106
|
+
* crossing but is null whenever the window does not sit on the frozen side of
|
|
1107
|
+
* it, which is most of the time and exactly when a slip is being dragged.
|
|
1108
|
+
*/
|
|
1109
|
+
offsetFrames: number;
|
|
1110
|
+
/** Present only when the layer declares a soundtrack at all. Nothing to mute
|
|
1111
|
+
* is not a muted thing, and a dead button is worse than no button. */
|
|
1112
|
+
hasSound: boolean;
|
|
1113
|
+
clip: VideoClipInfo;
|
|
1114
|
+
/** Where the FIRST picture is frozen because the footage has not started,
|
|
1115
|
+
* clipped to the window. Null when no frame of the window is. */
|
|
1116
|
+
lead: HeldSpan | null;
|
|
1117
|
+
/** Where the LAST picture is frozen because the footage ran out. */
|
|
1118
|
+
hold: HeldSpan | null;
|
|
1119
|
+
/** The layer whose window BEGINS where this one's ends, so the pair has a
|
|
1120
|
+
* seam a roll can move. `rollSeam` is the authority on whether it does. */
|
|
1121
|
+
rollPartner: string | null;
|
|
311
1122
|
}
|
|
312
1123
|
|
|
313
1124
|
export interface MasterRow extends TrackRowModel {
|
|
314
1125
|
lifespan: Lifespan;
|
|
1126
|
+
/** Set for a `kind: "video"` layer in a SCENE. Absent inside a component
|
|
1127
|
+
* definition, where a video has no sound and no seam to roll: its frames are
|
|
1128
|
+
* def-local and the placing instance decides where they land. */
|
|
1129
|
+
video?: VideoRow;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* The two frozen stretches of a clip's window, on the scene clock.
|
|
1134
|
+
*
|
|
1135
|
+
* `videoClipOf` reports the two CROSSINGS and leaves the rounding here. What
|
|
1136
|
+
* this function wants out of a crossing is ONE number either way: the boundary
|
|
1137
|
+
* frame between the live stretch and the frozen one, which both halves of a
|
|
1138
|
+
* half-open span are then written from. That number falls out of the clamp in
|
|
1139
|
+
* `sourceTimeAt`, not out of a rounding preference.
|
|
1140
|
+
*
|
|
1141
|
+
* ── THE ROUNDING IS THE RATE'S SIGN, NOT THE SIDE ────────────────────────
|
|
1142
|
+
*
|
|
1143
|
+
* Source time at a scene frame is `(f - offsetFrames) * timeScale`, read at the
|
|
1144
|
+
* frame's LEFT EDGE, which is `VideoClipInfo`'s stated convention. Work out
|
|
1145
|
+
* which frames the clamp is holding, on each end and each sign:
|
|
1146
|
+
*
|
|
1147
|
+
* FORWARD the lead clamps below t=0, so a frame is frozen while `f < cross`,
|
|
1148
|
+
* and the run-out clamps above the duration, so a frame is frozen once
|
|
1149
|
+
* `f >= cross`. Both boundaries are the smallest integer at or past the
|
|
1150
|
+
* crossing, which is `ceil(cross)`.
|
|
1151
|
+
*
|
|
1152
|
+
* BACKWARD source time runs DOWN as the scene runs on, so both inequalities
|
|
1153
|
+
* flip: the lead freezes while `f > cross` and the run-out while `f <= cross`.
|
|
1154
|
+
* Both boundaries are now the smallest integer STRICTLY past the crossing,
|
|
1155
|
+
* which is `floor(cross) + 1`.
|
|
1156
|
+
*
|
|
1157
|
+
* The two agree on a fractional crossing and differ by exactly one on a whole
|
|
1158
|
+
* one, which is the case that actually ships: `leadFrom` IS `offsetFrames`, a
|
|
1159
|
+
* whole frame in every document that validates. Ceiling both signs therefore
|
|
1160
|
+
* shaded the last LIVE frame of every reversed clip as frozen, on every one of
|
|
1161
|
+
* them, and the fractional rows that would have caught it agree by luck.
|
|
1162
|
+
*
|
|
1163
|
+
* WHICH END IS WHICH is the sign too, and `VideoClipInfo`'s header states it:
|
|
1164
|
+
* the frozen first picture is the window's HEAD forward and its TAIL backward,
|
|
1165
|
+
* and the run-out is whichever one is left. Derived here beside the rounding
|
|
1166
|
+
* rather than at the call site, so the one branch the sign owns is one branch.
|
|
1167
|
+
*/
|
|
1168
|
+
function heldSpan(
|
|
1169
|
+
win: Lifespan,
|
|
1170
|
+
cross: number | null,
|
|
1171
|
+
end: "lead" | "hold",
|
|
1172
|
+
forward: boolean,
|
|
1173
|
+
): HeldSpan | null {
|
|
1174
|
+
if (cross === null || !Number.isFinite(cross)) return null;
|
|
1175
|
+
const at = forward ? Math.ceil(cross) : Math.floor(cross) + 1;
|
|
1176
|
+
const head = end === "lead" ? forward : !forward;
|
|
1177
|
+
if (head) {
|
|
1178
|
+
const to = Math.min(at, win.out);
|
|
1179
|
+
return to > win.in ? { from: win.in, to } : null;
|
|
1180
|
+
}
|
|
1181
|
+
const from = Math.max(at, win.in);
|
|
1182
|
+
return from < win.out ? { from, to: win.out } : null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* One video layer's row model.
|
|
1187
|
+
*
|
|
1188
|
+
* `byIn` maps a scene-local `in` frame to the CLIP that starts there, built
|
|
1189
|
+
* once per scene so finding an abutting neighbour is a lookup rather than a
|
|
1190
|
+
* scan per row; `null` there is a frame two clips contest, which names no
|
|
1191
|
+
* partner at all (see `clipsByIn`). A named one is still only a CANDIDATE:
|
|
1192
|
+
* `rollSeam` decides whether the pair actually has a seam, because that verdict
|
|
1193
|
+
* has exactly one home (a collapsed `legalRollRange` reads the same as a
|
|
1194
|
+
* genuine one-frame range, which is the confusion `rollSeam` exists to end).
|
|
1195
|
+
*/
|
|
1196
|
+
function videoRowOf(
|
|
1197
|
+
doc: Doc3D,
|
|
1198
|
+
scene: Scene3D,
|
|
1199
|
+
layer: VideoLayer3D,
|
|
1200
|
+
byIn: ReadonlyMap<number, string | null>,
|
|
1201
|
+
): VideoRow {
|
|
1202
|
+
const clip = videoClipOf(layer, scene.durationFrames, doc.fps, doc.assets[layer.video.asset]);
|
|
1203
|
+
const sound = videoSoundClip(layer, scene.durationFrames);
|
|
1204
|
+
const forward = (layer.video.timeScale ?? 1) > 0;
|
|
1205
|
+
const win: Lifespan = { in: clip.in, out: clip.out };
|
|
1206
|
+
const candidate = byIn.get(clip.out);
|
|
1207
|
+
return {
|
|
1208
|
+
pictureAsset: doc.assets[layer.video.asset],
|
|
1209
|
+
soundAsset: sound === null ? undefined : doc.assets[sound.asset],
|
|
1210
|
+
sound: sound === null ? null : { offsetFrames: sound.offsetFrames ?? 0, durationFrames: sound.durationFrames },
|
|
1211
|
+
muted: layer.video.sound?.muted === true,
|
|
1212
|
+
hasSound: layer.video.sound !== undefined,
|
|
1213
|
+
offsetFrames: layer.video.offsetFrames ?? 0,
|
|
1214
|
+
clip,
|
|
1215
|
+
lead: heldSpan(win, clip.leadFrom, "lead", forward),
|
|
1216
|
+
hold: heldSpan(win, clip.holdFrom, "hold", forward),
|
|
1217
|
+
// `typeof` rather than a nullish test, because the map has THREE answers
|
|
1218
|
+
// and two of them mean "no partner": absent (nothing starts there) and null
|
|
1219
|
+
// (two clips do, so which one is not the index's to pick).
|
|
1220
|
+
rollPartner:
|
|
1221
|
+
typeof candidate !== "string" || rollSeam(doc, scene.id, layer.id, candidate) === null ? null : candidate,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* IS THIS A CLIP ROW? The answer is the row's clip facts, or nothing.
|
|
1227
|
+
*
|
|
1228
|
+
* ONE PREDICATE, AND IT HAS TWO READERS, because those two are a pair:
|
|
1229
|
+
* `LifespanRow` draws a clip bar from it, and `expandedLayerHeight` in
|
|
1230
|
+
* TimelineDock sizes the row from it. The dock's virtual window builds PREFIX
|
|
1231
|
+
* SUMS out of that height and positions every spacer beneath this row with
|
|
1232
|
+
* them, so the two answers are one fact or the list shears.
|
|
1233
|
+
*
|
|
1234
|
+
* THEY DISAGREED INSIDE A COMPONENT DEFINITION, which is the case that needs
|
|
1235
|
+
* both halves and not just the layer's kind. `defGroup` carries no `video` on
|
|
1236
|
+
* its rows on purpose: a definition's frames are def-local, so its video has no
|
|
1237
|
+
* sound and no seam, and the placing instance decides where they land. The
|
|
1238
|
+
* height keyed on `layer.kind === "video"` alone, so the dock reserved 56px,
|
|
1239
|
+
* the row drew 28, and every row below it sat 28px out.
|
|
1240
|
+
*
|
|
1241
|
+
* It also answers the stale-row case the render has always needed: a row whose
|
|
1242
|
+
* layer has just been deleted under a render still carries `row.video`, and a
|
|
1243
|
+
* 56px bar drawn where the prefix sum said 28 shears the same list the same
|
|
1244
|
+
* way.
|
|
1245
|
+
*
|
|
1246
|
+
* It lives HERE, beside `videoRowOf` which mints the thing it reads, and not in
|
|
1247
|
+
* the component that draws it: a predicate two panels answer with is a model
|
|
1248
|
+
* fact, and importing one leaf component from another to get at it is the
|
|
1249
|
+
* shape that made the two disagree in the first place.
|
|
1250
|
+
*/
|
|
1251
|
+
export function clipRowOf(
|
|
1252
|
+
layer: Layer3D | undefined,
|
|
1253
|
+
row: { video?: VideoRow | undefined },
|
|
1254
|
+
): VideoRow | undefined {
|
|
1255
|
+
return layer?.kind === "video" ? row.video : undefined;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Scene-local `in` frame -> the CLIP that starts there, or null when more than
|
|
1260
|
+
* one does.
|
|
1261
|
+
*
|
|
1262
|
+
* ── A ROLL IS A CLIP GESTURE, AND THIS INDEX IS WHERE THAT IS TRUE ──────
|
|
1263
|
+
*
|
|
1264
|
+
* The header's own sentence for a roll is "this clip's tail and the next clip's
|
|
1265
|
+
* head trade frames", the handle renders on clip rows alone, and the range the
|
|
1266
|
+
* seam is bounded by (`legalRollRange`) is built out of both partners' FOOTAGE
|
|
1267
|
+
* bounds. Indexing every layer kind made a liar of all three: a rect that
|
|
1268
|
+
* happened to abut a clip became its partner, and the drag then moved a seam
|
|
1269
|
+
* between a recording and a rectangle. So only `kind: "video"` layers are
|
|
1270
|
+
* candidates, and a rect abutting a clip simply is not one.
|
|
1271
|
+
*
|
|
1272
|
+
* ── AND NULL IS A REFUSAL, NOT A MISS ───────────────────────────────────
|
|
1273
|
+
*
|
|
1274
|
+
* This used to be first-writer-wins in `layersById` order, which is an order no
|
|
1275
|
+
* author can see and no document promises: two clips starting on the same frame
|
|
1276
|
+
* meant the partner you rolled against depended on key order, and the same
|
|
1277
|
+
* gesture on the same picture could write to either one. Refusal over
|
|
1278
|
+
* corruption: a contested frame has no unambiguous "next clip", so it names
|
|
1279
|
+
* none, the handle does not render, and the author trims an edge instead.
|
|
1280
|
+
*/
|
|
1281
|
+
function clipsByIn(scene: Scene3D): Map<number, string | null> {
|
|
1282
|
+
const out = new Map<number, string | null>();
|
|
1283
|
+
for (const layer of Object.values(scene.layersById)) {
|
|
1284
|
+
if (layer.kind !== "video") continue;
|
|
1285
|
+
const at = lifespanOf(layer, scene.durationFrames).in;
|
|
1286
|
+
out.set(at, out.has(at) ? null : layer.id);
|
|
1287
|
+
}
|
|
1288
|
+
return out;
|
|
315
1289
|
}
|
|
316
1290
|
|
|
317
1291
|
/** One scene's whole block of rows, placed on the master timeline. */
|
|
@@ -323,15 +1297,54 @@ export interface MasterSceneGroup {
|
|
|
323
1297
|
end: number;
|
|
324
1298
|
/** The camera's shots. SCENE-LOCAL, like every other bar here. */
|
|
325
1299
|
camera: BlockBar[];
|
|
1300
|
+
/** The camera's POSES (P4), derived once with the group rather than per
|
|
1301
|
+
* render of the row: `cameraMarks` scans a path move's frames to invert its
|
|
1302
|
+
* ease, and the dock re-renders on every ppf change — so a zoom drag would
|
|
1303
|
+
* otherwise re-run that scan sixty times a second. */
|
|
1304
|
+
cameraKeys: CameraMark[];
|
|
326
1305
|
rows: MasterRow[];
|
|
327
1306
|
}
|
|
328
1307
|
|
|
1308
|
+
/**
|
|
1309
|
+
* THE DOCK READS IN TIME ORDER: siblings sorted by where their lifespan starts,
|
|
1310
|
+
* earliest on top. `rows` is a pre-order walk (`trackRows`), front first, so a
|
|
1311
|
+
* tie keeps the stacking order. Children stay under their parent and are sorted
|
|
1312
|
+
* among themselves the same way. The Layers panel keeps the stacking order;
|
|
1313
|
+
* this is the dock's reading only, since a timeline is read left to right and
|
|
1314
|
+
* top to bottom.
|
|
1315
|
+
*/
|
|
1316
|
+
export function timeOrder<R extends { depth: number; lifespan: Lifespan }>(rows: readonly R[]): R[] {
|
|
1317
|
+
type Node = { row: R; kids: Node[] };
|
|
1318
|
+
const roots: Node[] = [];
|
|
1319
|
+
const path: Node[] = [];
|
|
1320
|
+
for (const row of rows) {
|
|
1321
|
+
const node: Node = { row, kids: [] };
|
|
1322
|
+
path.length = Math.min(path.length, row.depth);
|
|
1323
|
+
(path[path.length - 1]?.kids ?? roots).push(node);
|
|
1324
|
+
path.push(node);
|
|
1325
|
+
}
|
|
1326
|
+
const out: R[] = [];
|
|
1327
|
+
const emit = (nodes: Node[]): void => {
|
|
1328
|
+
// `sort` is stable, so equal starts keep the walk's front-first order.
|
|
1329
|
+
for (const n of [...nodes].sort((a, b) => a.row.lifespan.in - b.row.lifespan.in)) {
|
|
1330
|
+
out.push(n.row);
|
|
1331
|
+
emit(n.kids);
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
emit(roots);
|
|
1335
|
+
return out;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
329
1338
|
/** Every scene's rows in MASTER order, each group carrying the offset its bars
|
|
330
1339
|
* are drawn at. */
|
|
331
1340
|
export function masterRows(doc: Doc3D, isolation: string | null = null): MasterSceneGroup[] {
|
|
332
1341
|
return sceneWindows(doc).flatMap((w) => {
|
|
333
1342
|
const scene = doc.scenesById[w.id];
|
|
334
1343
|
if (scene === undefined) return [];
|
|
1344
|
+
// Built from the WHOLE scene and not from the rows: an isolation narrows
|
|
1345
|
+
// what is drawn, and a clip still abuts the neighbour it abuts whether or
|
|
1346
|
+
// not that neighbour has a row on screen.
|
|
1347
|
+
const byIn = clipsByIn(scene);
|
|
335
1348
|
return [
|
|
336
1349
|
{
|
|
337
1350
|
id: w.id,
|
|
@@ -339,13 +1352,22 @@ export function masterRows(doc: Doc3D, isolation: string | null = null): MasterS
|
|
|
339
1352
|
start: w.start,
|
|
340
1353
|
end: w.end,
|
|
341
1354
|
camera: cameraBars(scene),
|
|
1355
|
+
cameraKeys: cameraMarks(scene, doc.fps),
|
|
342
1356
|
// The isolation only narrows the scene it LIVES IN. A master timeline
|
|
343
1357
|
// shows every scene, and the subtree exists in exactly one of them —
|
|
344
1358
|
// scoping the others by an id they do not contain would empty them.
|
|
345
|
-
rows:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
1359
|
+
rows: timeOrder(
|
|
1360
|
+
trackRows(scene, scene.layersById[isolation ?? ""] === undefined ? null : isolation).map((row) => {
|
|
1361
|
+
const layer = scene.layersById[row.layerId]!;
|
|
1362
|
+
const lifespan = lifespanOf(layer, scene.durationFrames);
|
|
1363
|
+
// Spread-or-omit rather than `video: undefined`, because
|
|
1364
|
+
// `exactOptionalPropertyTypes` is on and an explicitly undefined
|
|
1365
|
+
// optional is not the same thing as an absent one.
|
|
1366
|
+
return layer.kind === "video"
|
|
1367
|
+
? { ...row, lifespan, video: videoRowOf(doc, scene, layer, byIn) }
|
|
1368
|
+
: { ...row, lifespan };
|
|
1369
|
+
}),
|
|
1370
|
+
),
|
|
349
1371
|
},
|
|
350
1372
|
];
|
|
351
1373
|
});
|
|
@@ -354,7 +1376,7 @@ export function masterRows(doc: Doc3D, isolation: string | null = null): MasterS
|
|
|
354
1376
|
/**
|
|
355
1377
|
* How wide the time area has to be, in MASTER frames.
|
|
356
1378
|
*
|
|
357
|
-
* Not `docDurationFrames`: a lifespan or a
|
|
1379
|
+
* Not `docDurationFrames`: a lifespan or a camera move may sit past the end of its
|
|
358
1380
|
* scene (nothing forbids it, and the runtime simply never reaches it), and a
|
|
359
1381
|
* bar the author cannot scroll to is a bar they cannot drag back.
|
|
360
1382
|
*/
|
|
@@ -363,13 +1385,97 @@ export function masterContentFrames(doc: Doc3D, groups: readonly MasterSceneGrou
|
|
|
363
1385
|
for (const group of groups) {
|
|
364
1386
|
for (const row of group.rows) {
|
|
365
1387
|
end = Math.max(end, group.start + row.lifespan.out);
|
|
366
|
-
for (const bar of row.blocks) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
|
|
367
1388
|
}
|
|
368
1389
|
for (const bar of group.camera) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
|
|
369
1390
|
}
|
|
1391
|
+
// The two MASTER-timed lanes, on the same argument the rows make: a clip
|
|
1392
|
+
// dragged past the last shot has to stay scrollable-to, or it cannot be
|
|
1393
|
+
// dragged back. They were left out while the lanes drew unclipped and it did
|
|
1394
|
+
// not show; the shot context clips them to its window, so an extent that
|
|
1395
|
+
// stopped short would now hide the tail of a bed rather than let it overhang.
|
|
1396
|
+
for (const { clip } of masterAudioClips(doc)) {
|
|
1397
|
+
end = Math.max(end, Math.trunc(clip.frame) + Math.max(1, Math.trunc(clip.durationFrames)));
|
|
1398
|
+
}
|
|
1399
|
+
for (const marker of doc.markers ?? []) end = Math.max(end, Math.trunc(marker.frame) + 1);
|
|
370
1400
|
return end;
|
|
371
1401
|
}
|
|
372
1402
|
|
|
1403
|
+
/**
|
|
1404
|
+
* ONE SHOT's content extent, in SHOT-LOCAL frames — `masterContentFrames`' twin
|
|
1405
|
+
* for the shot context, the way `defContentFrames` is for the component one.
|
|
1406
|
+
*
|
|
1407
|
+
* Never `masterContentFrames` with one group: that floors at the whole
|
|
1408
|
+
* document's duration, so an eight-frame shot inside a fifty-second video would
|
|
1409
|
+
* open with a ruler fifty seconds wide and its own bars in the first pixel.
|
|
1410
|
+
*/
|
|
1411
|
+
export function shotContentFrames(group: MasterSceneGroup): number {
|
|
1412
|
+
let end = group.end - group.start;
|
|
1413
|
+
for (const row of group.rows) {
|
|
1414
|
+
end = Math.max(end, row.lifespan.out);
|
|
1415
|
+
}
|
|
1416
|
+
for (const bar of group.camera) end = Math.max(end, bar.frame + bar.durationFrames);
|
|
1417
|
+
return end;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* THE ISOLATED SUBTREE'S LIFESPAN UNION, in SCENE-LOCAL frames: the earliest
|
|
1422
|
+
* `in` and the latest `out` over the root and every layer under it, the same
|
|
1423
|
+
* rows `trackRows` lists for that isolation. Null when the scene does not hold
|
|
1424
|
+
* the id, or the union is empty.
|
|
1425
|
+
*
|
|
1426
|
+
* What the dock fits on entering isolation, and the span its follow holds to,
|
|
1427
|
+
* so an object that lives for one second of a ten-second shot is not drawn as
|
|
1428
|
+
* a sliver nor scrolled away from while the rest of the shot plays.
|
|
1429
|
+
*/
|
|
1430
|
+
export function isolationSpan(scene: Scene3D, isolation: string): Lifespan | null {
|
|
1431
|
+
if (scene.layersById[isolation] === undefined) return null;
|
|
1432
|
+
let from = Infinity;
|
|
1433
|
+
let to = -Infinity;
|
|
1434
|
+
for (const row of trackRows(scene, isolation)) {
|
|
1435
|
+
const layer = scene.layersById[row.layerId];
|
|
1436
|
+
if (layer === undefined) continue;
|
|
1437
|
+
const life = lifespanOf(layer, scene.durationFrames);
|
|
1438
|
+
from = Math.min(from, life.in);
|
|
1439
|
+
to = Math.max(to, life.out);
|
|
1440
|
+
}
|
|
1441
|
+
return Number.isFinite(from) && to > from ? { in: from, out: to } : null;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** One audio row, the master bed's lane and a shot's own Sound row alike.
|
|
1445
|
+
* Paired with `.audio-lane` and `.scene-audio-row`'s CSS height. */
|
|
1446
|
+
export const AUDIO_ROW_H = 32;
|
|
1447
|
+
/** `.marker-lane`'s CSS height, its 1px top border included. */
|
|
1448
|
+
export const MARKER_LANE_H = 20;
|
|
1449
|
+
/** `.dock-scroll`'s padding-bottom (`--space-3`). */
|
|
1450
|
+
const DOCK_SCROLL_PAD_H = 6;
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* THE DOCK BODY'S HEIGHT WITH EVERY SHOT FOLDED, in px: the ruler plus the rows
|
|
1454
|
+
* a first look shows. `fitShell` sizes an untouched dock to this, so the stage
|
|
1455
|
+
* gets every pixel the timeline does not draw.
|
|
1456
|
+
*
|
|
1457
|
+
* Inside a shot: Shot, Camera and Sound, plus the master bed when one overlaps
|
|
1458
|
+
* the shot (`AudioLane`'s own early return). Over the Project: one row per shot
|
|
1459
|
+
* and the separators between them, plus the master bed, which is always drawn
|
|
1460
|
+
* there. The marker lane in both, once the document has a marker.
|
|
1461
|
+
*/
|
|
1462
|
+
export function foldedDockContentH(doc: Doc3D, enteredScene: string | null): number {
|
|
1463
|
+
const windows = sceneWindows(doc);
|
|
1464
|
+
const shot = enteredScene === null ? undefined : windows.find((w) => w.id === enteredScene);
|
|
1465
|
+
let rows: number;
|
|
1466
|
+
if (shot !== undefined) {
|
|
1467
|
+
const bed = Object.values(doc.audio).some((clip) => {
|
|
1468
|
+
const start = Math.trunc(clip.frame);
|
|
1469
|
+
return start + Math.max(1, Math.trunc(clip.durationFrames)) > shot.start && start < shot.end;
|
|
1470
|
+
});
|
|
1471
|
+
rows = SHOT_ROW_H + BLOCK_ROW_H + AUDIO_ROW_H + (bed ? AUDIO_ROW_H : 0);
|
|
1472
|
+
} else {
|
|
1473
|
+
rows = windows.length * SHOT_ROW_H + Math.max(0, windows.length - 1) + AUDIO_ROW_H;
|
|
1474
|
+
}
|
|
1475
|
+
const markers = (doc.markers ?? []).length > 0 ? MARKER_LANE_H : 0;
|
|
1476
|
+
return DOCK_RULER_H + rows + markers + DOCK_SCROLL_PAD_H;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
373
1479
|
/**
|
|
374
1480
|
* The dock's one time string.
|
|
375
1481
|
*
|
|
@@ -395,11 +1501,202 @@ export function timecode(frame: number, fps: Rational): string {
|
|
|
395
1501
|
return minutes === 0 ? fixed : `${minutes}:${fixed.padStart(5, "0")}`;
|
|
396
1502
|
}
|
|
397
1503
|
|
|
398
|
-
|
|
1504
|
+
/**
|
|
1505
|
+
* THE CHROME'S ONE DURATION STRING: seconds, two decimals, no space ("18.00s",
|
|
1506
|
+
* "2.50s", "125.50s"). Paper's format, and the founder's ruling: a LENGTH
|
|
1507
|
+
* reads this way everywhere it is printed (a shot's row, the Tool footer, the
|
|
1508
|
+
* inspector head, the storyboard, a sound's trailing value), so one shot is
|
|
1509
|
+
* one string wherever the eye meets it. POSITIONS keep the clock (`timecode`,
|
|
1510
|
+
* `clock`, the rulers) and frame counts keep "60f": a clock says
|
|
1511
|
+
* where, this says how long, and the two never share a form.
|
|
1512
|
+
*
|
|
1513
|
+
* Never a minute prefix: a length is compared against other lengths, and
|
|
1514
|
+
* "1:05" beside "58.00s" would make the eye convert. A length is never
|
|
1515
|
+
* negative and never NaN, so either reads as nothing at all.
|
|
1516
|
+
*/
|
|
1517
|
+
export function formatDuration(seconds: number): string {
|
|
1518
|
+
return `${(Number.isFinite(seconds) && seconds > 0 ? seconds : 0).toFixed(2)}s`;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* THE TRANSPORT CHIP'S CLOCK: always "MM:SS.hh" ("00:02.20").
|
|
1525
|
+
*
|
|
1526
|
+
* Padded, unlike `timecode`, because the chip is a fixed slot in the head: a
|
|
1527
|
+
* readout whose width changes at every whole second shoves the suffix beside
|
|
1528
|
+
* it sideways. Rounded to the hundredth in INTEGER hundredths, so 59.996s
|
|
1529
|
+
* carries into "01:00.00" rather than printing "00:60.00". Minutes keep
|
|
1530
|
+
* counting past an hour ("75:00.00").
|
|
1531
|
+
*/
|
|
1532
|
+
export function clock(frame: number, fps: Rational): string {
|
|
1533
|
+
const h = Math.round((frame * fps.den * 100) / fps.num);
|
|
1534
|
+
const abs = Math.abs(h);
|
|
1535
|
+
return `${h < 0 ? "-" : ""}${pad2(Math.floor(abs / 6000))}:${pad2(Math.floor(abs / 100) % 60)}.${pad2(abs % 100)}`;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* THE PLAYHEAD PILL'S READING: decimal seconds, always two places ("2.20",
|
|
1540
|
+
* "8.00"), and a minute prefix only past a minute ("1:07.50"). Unlike
|
|
1541
|
+
* `timecode` a whole second keeps its decimals, so the pill holds one width
|
|
1542
|
+
* while it moves. Rounded in integer hundredths, like `clock`.
|
|
1543
|
+
*/
|
|
1544
|
+
export function playheadLabel(frame: number, fps: Rational): string {
|
|
1545
|
+
const h = Math.round((frame * fps.den * 100) / fps.num);
|
|
1546
|
+
const abs = Math.abs(h);
|
|
1547
|
+
const minutes = Math.floor(abs / 6000);
|
|
1548
|
+
const rest = `${Math.floor(abs / 100) % 60}.${pad2(abs % 100)}`;
|
|
1549
|
+
return `${h < 0 ? "-" : ""}${minutes === 0 ? rest : `${minutes}:${rest.padStart(5, "0")}`}`;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ── THE DOCK RULER'S TICKS: THREE TIERS ON A SECONDS LADDER ────────────────
|
|
399
1553
|
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
1554
|
+
// The package `Ruler` steps one tier of labelled ticks over a FRAME ladder,
|
|
1555
|
+
// which is what a keyframe lane wants. The dock spans a whole video, so it
|
|
1556
|
+
// counts SECONDS, and three tiers (labelled majors, mids, minors) say where in
|
|
1557
|
+
// a second the playhead is without a label on every tick.
|
|
1558
|
+
//
|
|
1559
|
+
// Each row is [major, mid, minor] in seconds. Every ratio inside a row is an
|
|
1560
|
+
// integer, so all three tiers are read off ONE index over the finest step and
|
|
1561
|
+
// a tick is never drawn twice.
|
|
1562
|
+
|
|
1563
|
+
/** [major, mid, minor] seconds; `null` is no minor tier at this zoom. */
|
|
1564
|
+
const RULER_LADDER: readonly (readonly [number, number, number | null])[] = [
|
|
1565
|
+
[0.1, 0.05, null],
|
|
1566
|
+
[0.2, 0.1, null],
|
|
1567
|
+
[0.5, 0.25, 0.05],
|
|
1568
|
+
[1, 0.5, 0.25],
|
|
1569
|
+
[2, 1, 0.25],
|
|
1570
|
+
[5, 1, 0.5],
|
|
1571
|
+
[10, 5, 1],
|
|
1572
|
+
[30, 10, 5],
|
|
1573
|
+
[60, 30, 10],
|
|
1574
|
+
[120, 60, 10],
|
|
1575
|
+
[300, 60, 30],
|
|
1576
|
+
[600, 300, 60],
|
|
1577
|
+
[1800, 600, 300],
|
|
1578
|
+
[3600, 1800, 600],
|
|
1579
|
+
];
|
|
1580
|
+
|
|
1581
|
+
/** The narrowest spacing a labelled major may have: room for "1:30" and air. */
|
|
1582
|
+
export const RULER_LABEL_PX = 64;
|
|
1583
|
+
/** A tier closer than this is noise, not a scale, and is not drawn. */
|
|
1584
|
+
const RULER_TIER_MIN_PX = 6;
|
|
1585
|
+
/** The same ceiling `rulerTicks` holds, for the same reason: a stored extent
|
|
1586
|
+
* far past its Shot must not become millions of spans. */
|
|
1587
|
+
const RULER_MAX_TICKS = 20000;
|
|
1588
|
+
|
|
1589
|
+
export interface DockRulerTick {
|
|
1590
|
+
/** Lane frame (fractional: a quarter second at 30 fps is 7.5 frames). */
|
|
1591
|
+
frame: number;
|
|
1592
|
+
/** Exact seconds, for the label. */
|
|
1593
|
+
seconds: number;
|
|
1594
|
+
/** 0 major (labelled), 1 mid, 2 minor. */
|
|
1595
|
+
tier: 0 | 1 | 2;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* The ticks from 0 to `extentF` lane frames at `ppf` pixels per frame: the
|
|
1600
|
+
* first ladder row whose major spacing clears `RULER_LABEL_PX`, less any tier
|
|
1601
|
+
* under `RULER_TIER_MIN_PX`, and the row coarsened until the count is bounded.
|
|
1602
|
+
*/
|
|
1603
|
+
export function dockRulerTicks(extentF: number, ppf: number, fps: Rational): DockRulerTick[] {
|
|
1604
|
+
const pxPerSecond = (ppf * fps.num) / fps.den;
|
|
1605
|
+
const endS = Number.isFinite(extentF) ? Math.max(0, (extentF * fps.den) / fps.num) : 0;
|
|
1606
|
+
if (!(pxPerSecond > 0)) return [{ frame: 0, seconds: 0, tier: 0 }];
|
|
1607
|
+
let row = RULER_LADDER.findIndex(([major]) => major * pxPerSecond >= RULER_LABEL_PX);
|
|
1608
|
+
if (row === -1) row = RULER_LADDER.length - 1;
|
|
1609
|
+
for (;;) {
|
|
1610
|
+
const [major, mid, minor] = RULER_LADDER[row]!;
|
|
1611
|
+
const steps = [major, mid, minor].filter(
|
|
1612
|
+
(s): s is number => s !== null && s * pxPerSecond >= RULER_TIER_MIN_PX,
|
|
1613
|
+
);
|
|
1614
|
+
const finest = steps[steps.length - 1]!;
|
|
1615
|
+
if (endS / finest <= RULER_MAX_TICKS || row === RULER_LADDER.length - 1) {
|
|
1616
|
+
const majorEvery = Math.round(major / finest);
|
|
1617
|
+
const midEvery = Math.round(mid / finest);
|
|
1618
|
+
const count = Math.min(Math.floor(endS / finest + 1e-9), RULER_MAX_TICKS);
|
|
1619
|
+
const ticks: DockRulerTick[] = [];
|
|
1620
|
+
for (let i = 0; i <= count; i++) {
|
|
1621
|
+
// Rounded to the millisecond so 0.05 * 3 labels "0.15", not 0.15000000000000002.
|
|
1622
|
+
const seconds = Math.round(i * finest * 1000) / 1000;
|
|
1623
|
+
ticks.push({
|
|
1624
|
+
frame: (seconds * fps.num) / fps.den,
|
|
1625
|
+
seconds,
|
|
1626
|
+
tier: i % majorEvery === 0 ? 0 : i % midEvery === 0 ? 1 : 2,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return ticks;
|
|
1630
|
+
}
|
|
1631
|
+
row++;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** A ruler label's width per character at `.dock-tick-label`'s 12px numeric
|
|
1636
|
+
* face. An estimate on purpose: reading layout for it would force a reflow per
|
|
1637
|
+
* label on every zoom step, and the face's digits are tabular, so a count of
|
|
1638
|
+
* characters is close to the drawn width. Slightly generous, so an estimate
|
|
1639
|
+
* that is off errs toward hiding a label rather than overlapping two. */
|
|
1640
|
+
const RULER_LABEL_CHAR_PX = 7.4;
|
|
1641
|
+
/** `.dock-tick-label`'s inset from its tick, on either side (var(--space-2)). */
|
|
1642
|
+
const RULER_LABEL_INSET_PX = 4;
|
|
1643
|
+
/** The least air between two labels' boxes. */
|
|
1644
|
+
const RULER_LABEL_GAP_PX = 4;
|
|
1645
|
+
|
|
1646
|
+
export type RulerLabelPlace = "start" | "end" | "hidden";
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* WHERE EACH MAJOR'S LABEL GOES: right of its tick ("start"), left of it
|
|
1650
|
+
* ("end", `.is-end`) when a start label would run off the lane, or nowhere.
|
|
1651
|
+
*
|
|
1652
|
+
* The end label reads back INTO the lane, which puts it on top of the previous
|
|
1653
|
+
* major's label whenever the two ticks are closer than both labels' widths:
|
|
1654
|
+
* `dockRulerTicks` only promises `RULER_LABEL_PX` between majors, and "1:30"
|
|
1655
|
+
* beside "1:40" needs more than that once one of them is flipped. So, walking
|
|
1656
|
+
* from the end, a label whose box would reach the next shown one is dropped.
|
|
1657
|
+
* The END label wins, because it is the one that says how long the lane is.
|
|
1658
|
+
*/
|
|
1659
|
+
export function rulerLabelPlaces(
|
|
1660
|
+
majors: readonly { x: number; text: string }[],
|
|
1661
|
+
timeW: number,
|
|
1662
|
+
): RulerLabelPlace[] {
|
|
1663
|
+
const width = (text: string): number => text.length * RULER_LABEL_CHAR_PX;
|
|
1664
|
+
const places = majors.map(({ x, text }): RulerLabelPlace =>
|
|
1665
|
+
x > 0 && x + RULER_LABEL_INSET_PX + width(text) > timeW ? "end" : "start",
|
|
1666
|
+
);
|
|
1667
|
+
let limit = Infinity; // the left edge of the next label that is shown
|
|
1668
|
+
for (let i = majors.length - 1; i >= 0; i--) {
|
|
1669
|
+
const { x, text } = majors[i]!;
|
|
1670
|
+
const left = places[i] === "end" ? x - RULER_LABEL_INSET_PX - width(text) : x + RULER_LABEL_INSET_PX;
|
|
1671
|
+
if (left + width(text) + RULER_LABEL_GAP_PX > limit) {
|
|
1672
|
+
places[i] = "hidden";
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
limit = left;
|
|
1676
|
+
}
|
|
1677
|
+
return places;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** Seconds rounded to the hundredth, as a number, so `${s}` prints "0.5". */
|
|
1681
|
+
const centi = (s: number): number => Math.round(s * 100) / 100;
|
|
1682
|
+
|
|
1683
|
+
/** THE RULER'S LABEL, in every context: a clock, "0:00", "0:02", "1:15", and
|
|
1684
|
+
* "0:00.5" between seconds. The same position form as `timecode`, so the
|
|
1685
|
+
* ruler, the playhead chip and the marker tooltips read one time. */
|
|
1686
|
+
export function rulerLabel(seconds: number): string {
|
|
1687
|
+
const s = centi(seconds);
|
|
1688
|
+
const m = Math.floor(s / 60);
|
|
1689
|
+
const rest = centi(s - m * 60);
|
|
1690
|
+
const whole = Math.floor(rest);
|
|
1691
|
+
const frac = String(centi(rest - whole)).slice(1); // "" or ".5" / ".25"
|
|
1692
|
+
return `${m}:${pad2(whole)}${frac}`;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// ── THE LIFESPAN'S GESTURES: THREE ON ANY BAR ───────────────────────────────
|
|
1696
|
+
//
|
|
1697
|
+
// BODY moves the bar AND every keyframe on the layer by the same delta.
|
|
1698
|
+
// SLIP moves every keyframe and leaves the bar where it is.
|
|
1699
|
+
// IN / OUT move one end and touch no keyframe at all.
|
|
403
1700
|
//
|
|
404
1701
|
// Body-versus-edge is After Effects verbatim, and it is not configurable in any
|
|
405
1702
|
// tool that ships it: dragging a layer's duration bar carries its keyframes,
|
|
@@ -407,13 +1704,38 @@ export function timecode(frame: number, fps: Rational): string {
|
|
|
407
1704
|
// Premiere's slip tool, Cavalry's offset command — and a timeline that ships
|
|
408
1705
|
// only the first two gets filed against for it.
|
|
409
1706
|
//
|
|
410
|
-
// EVERY MODE CLAMPS ON THE LEFT ONLY.
|
|
1707
|
+
// EVERY MODE CLAMPS ON THE LEFT ONLY. A keyframe's frame is validated non-negative
|
|
411
1708
|
// and a lifespan starting before its scene is meaningless, so zero is a real
|
|
412
1709
|
// floor; the right has no floor, because a bar hanging past the end of its scene
|
|
413
1710
|
// is a legible state the author can drag back and a clamp there would freeze the
|
|
414
1711
|
// default lifespan (which is exactly the whole scene) in place.
|
|
415
1712
|
|
|
416
|
-
|
|
1713
|
+
// ── AND THE TWO A VIDEO CLIP ADDS ───────────────────────────────────────────
|
|
1714
|
+
//
|
|
1715
|
+
// A word names a gesture on ONE bar, which is all four above ever needed. Two
|
|
1716
|
+
// of the clip gestures need an ID the word cannot hold — which OTHER bars a
|
|
1717
|
+
// body drag is carrying, and which neighbour a roll is rolling against — so
|
|
1718
|
+
// those two are objects.
|
|
1719
|
+
//
|
|
1720
|
+
// THEY ARE OBJECTS RATHER THAN EXTRA ARGUMENTS on purpose: `dragLifespan` on
|
|
1721
|
+
// the store takes `(sceneId, layerId, mode, delta)` and hands `mode` straight
|
|
1722
|
+
// to this function, so a gesture that carries its own payload in the mode needs
|
|
1723
|
+
// no change to the store's signature and cannot be called with the payload
|
|
1724
|
+
// missing.
|
|
1725
|
+
|
|
1726
|
+
/** A body drag carrying every bar in the list, not just the pressed one. */
|
|
1727
|
+
export interface LifespanMove {
|
|
1728
|
+
move: readonly string[];
|
|
1729
|
+
}
|
|
1730
|
+
/** A roll of the seam this bar's `out` shares with `roll`'s `in`. */
|
|
1731
|
+
export interface LifespanRoll {
|
|
1732
|
+
roll: string;
|
|
1733
|
+
}
|
|
1734
|
+
/** A body drag that FANS the bars out: rank i of `staggerOrder` moves i steps. */
|
|
1735
|
+
export interface LifespanStagger {
|
|
1736
|
+
stagger: readonly string[];
|
|
1737
|
+
}
|
|
1738
|
+
export type LifespanDrag = "body" | "in" | "out" | "slip" | "shift" | LifespanMove | LifespanRoll | LifespanStagger;
|
|
417
1739
|
|
|
418
1740
|
/** The shortest lifespan an edge drag may leave behind. `validate.ts` demands
|
|
419
1741
|
* `out > in`; this is that rule with the integer grid applied. */
|
|
@@ -431,34 +1753,91 @@ export function writeLifespan(
|
|
|
431
1753
|
mode: LifespanDrag,
|
|
432
1754
|
delta: number,
|
|
433
1755
|
): Doc3D {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
1756
|
+
// Any CONTAINER (unify P2): the def-local dock drags an interior layer's
|
|
1757
|
+
// lifespan with the definition's id in the sceneId slot.
|
|
1758
|
+
const home = homeOf(doc, sceneId);
|
|
1759
|
+
const layer = home?.layersById[layerId];
|
|
1760
|
+
if (home === null || layer === undefined) return doc;
|
|
1761
|
+
const containerDuration = isDefId(sceneId)
|
|
1762
|
+
? defDurationFrames(doc.components![sceneId]!)
|
|
1763
|
+
: Math.trunc(doc.scenesById[sceneId]!.durationFrames);
|
|
1764
|
+
const life = lifespanOf(layer, containerDuration);
|
|
438
1765
|
const d = Math.round(delta);
|
|
1766
|
+
if (!Number.isFinite(d)) return doc;
|
|
1767
|
+
|
|
1768
|
+
// ── THE TWO GESTURES THAT NAME A SECOND LAYER ─────────────────────────
|
|
1769
|
+
//
|
|
1770
|
+
// Both are pair or group edits, so neither can be expressed as a patch of the
|
|
1771
|
+
// pressed layer the way the four word gestures below are, and both go
|
|
1772
|
+
// straight to the op that owns the arithmetic. A roll asks `rollSeam` first
|
|
1773
|
+
// because that is the ONE place a pair's touching is decided: `rollLayers`
|
|
1774
|
+
// would refuse a pair with no seam anyway, and asking here is what turns the
|
|
1775
|
+
// delta into the absolute frame the op wants.
|
|
1776
|
+
if (typeof mode !== "string") {
|
|
1777
|
+
if ("roll" in mode) {
|
|
1778
|
+
const seam = rollSeam(doc, sceneId, layerId, mode.roll);
|
|
1779
|
+
return seam === null ? doc : rollLayers(doc, sceneId, layerId, mode.roll, seam + d);
|
|
1780
|
+
}
|
|
1781
|
+
if ("stagger" in mode) return staggerLayers(doc, sceneId, staggerOrder(home, mode.stagger, containerDuration), d);
|
|
1782
|
+
return moveLayers(doc, sceneId, mode.move, d);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// The one-bar slide. `shiftLayer` carries the window, the tracks
|
|
1786
|
+
// and the content clock together, so the picture at the new `in` is the
|
|
1787
|
+
// picture that was at the old one — and it REFUSES rather than clamps, which
|
|
1788
|
+
// is why it is not folded into the clamped `body` case below.
|
|
1789
|
+
if (mode === "shift") return shiftLayer(doc, sceneId, layerId, d);
|
|
1790
|
+
|
|
1791
|
+
// ── THE VIDEO FORK ────────────────────────────────────────────────────
|
|
1792
|
+
//
|
|
1793
|
+
// A clip's edges are bounded by its FOOTAGE as well as by its scene, and its
|
|
1794
|
+
// body carries the recording under it. Writing `lifetime` here would produce
|
|
1795
|
+
// a window over frames the recording does not have (an edge) or slide the
|
|
1796
|
+
// footage under a bar that only moved (a body) — the two states the clip ops
|
|
1797
|
+
// were written to refuse. So a video layer in a SCENE writes through them.
|
|
1798
|
+
//
|
|
1799
|
+
// NOT INSIDE A DEFINITION. The clip ops resolve their layer through
|
|
1800
|
+
// `doc.scenesById`, and a `cmp_` container is not there: they would refuse
|
|
1801
|
+
// every gesture silently. A definition's video has no sound and no seam
|
|
1802
|
+
// either (its frames are def-local; the placing instance decides where they
|
|
1803
|
+
// land), so the ordinary body below is the honest behaviour there.
|
|
1804
|
+
if (layer.kind === "video" && !isDefId(sceneId)) {
|
|
1805
|
+
switch (mode) {
|
|
1806
|
+
case "in":
|
|
1807
|
+
return trimLayer(doc, sceneId, layerId, "in", life.in + d);
|
|
1808
|
+
case "out":
|
|
1809
|
+
return trimLayer(doc, sceneId, layerId, "out", life.out + d);
|
|
1810
|
+
case "slip":
|
|
1811
|
+
return slipLayer(doc, sceneId, layerId, d);
|
|
1812
|
+
case "body":
|
|
1813
|
+
return moveLayers(doc, sceneId, [layerId], d);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
439
1817
|
let next: Layer3D;
|
|
440
1818
|
|
|
441
1819
|
switch (mode) {
|
|
442
1820
|
case "body": {
|
|
443
|
-
// ONE clamped delta for the bar and the
|
|
1821
|
+
// ONE clamped delta for the bar and the keys together. Clamping them
|
|
444
1822
|
// apart is the bug this shape exists to prevent: the bar would stop at
|
|
445
|
-
// zero while its
|
|
446
|
-
// the animation by dragging the layer.
|
|
447
|
-
|
|
1823
|
+
// zero while its keys kept sliding, and the author would have retimed
|
|
1824
|
+
// the animation by dragging the layer. `firstKeyframeFrame` is Infinity
|
|
1825
|
+
// with no keys, so the `Math.min` against it is a no-op.
|
|
1826
|
+
const moved = Math.max(d, -Math.min(life.in, firstKeyframeFrame(layer.tracks)));
|
|
448
1827
|
if (moved === 0) return doc;
|
|
449
1828
|
next = {
|
|
450
1829
|
...layer,
|
|
451
1830
|
lifetime: { in: life.in + moved, out: life.out + moved },
|
|
452
|
-
|
|
453
|
-
};
|
|
1831
|
+
...shiftedKeys(layer, moved),
|
|
1832
|
+
} as Layer3D;
|
|
454
1833
|
break;
|
|
455
1834
|
}
|
|
456
1835
|
case "slip": {
|
|
457
|
-
const earliest =
|
|
1836
|
+
const earliest = firstKeyframeFrame(layer.tracks);
|
|
458
1837
|
if (!Number.isFinite(earliest)) return doc;
|
|
459
1838
|
const moved = Math.max(d, -earliest);
|
|
460
1839
|
if (moved === 0) return doc;
|
|
461
|
-
next = { ...layer,
|
|
1840
|
+
next = { ...layer, ...shiftedKeys(layer, moved) } as Layer3D;
|
|
462
1841
|
break;
|
|
463
1842
|
}
|
|
464
1843
|
// An edge that lands where it already was returns the document BY
|
|
@@ -468,7 +1847,11 @@ export function writeLifespan(
|
|
|
468
1847
|
case "in": {
|
|
469
1848
|
const at = Math.max(0, Math.min(life.out - MIN_LIFESPAN_FRAMES, life.in + d));
|
|
470
1849
|
if (at === life.in) return doc;
|
|
471
|
-
|
|
1850
|
+
// An OPEN end stays open: this gesture moved the head, not the tail, and
|
|
1851
|
+
// closing it would end the placement short of a Shot lengthened later.
|
|
1852
|
+
// `splitLayer` and `trimLayer` keep it for the same reason.
|
|
1853
|
+
const out = layer.lifetime !== undefined && isOpenEndedOut(layer.lifetime.out) ? layer.lifetime.out : life.out;
|
|
1854
|
+
next = { ...layer, lifetime: { in: at, out } };
|
|
472
1855
|
break;
|
|
473
1856
|
}
|
|
474
1857
|
case "out": {
|
|
@@ -479,19 +1862,156 @@ export function writeLifespan(
|
|
|
479
1862
|
}
|
|
480
1863
|
}
|
|
481
1864
|
|
|
1865
|
+
return patchHome(doc, sceneId, {
|
|
1866
|
+
layers: home.layers,
|
|
1867
|
+
layersById: { ...home.layersById, [layerId]: next },
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Slide these layers whole, ALL OR NOTHING.
|
|
1873
|
+
*
|
|
1874
|
+
* ── WHY THE WHOLE SELECTION AND NOT THE PRESSED BAR ─────────────────────
|
|
1875
|
+
*
|
|
1876
|
+
* Selecting three clips and dragging one of them moves all three in every NLE,
|
|
1877
|
+
* and the dock already selects the pressed bar before the drag starts. Moving
|
|
1878
|
+
* only the one the pointer is on would silently break the arrangement the
|
|
1879
|
+
* author selected in order to preserve.
|
|
1880
|
+
*
|
|
1881
|
+
* ── WHY IT REFUSES INSTEAD OF MOVING WHAT IT CAN ────────────────────────
|
|
1882
|
+
*
|
|
1883
|
+
* `shiftLayer` refuses — by returning the document it was handed — when a
|
|
1884
|
+
* window would leave the scene or a keyframe would pass zero. Applying the rest
|
|
1885
|
+
* anyway would move part of a selection and leave the rest, which is a
|
|
1886
|
+
* rearrangement the author did not ask for, cannot see the shape of, and undoes
|
|
1887
|
+
* as one entry. So the first refusal refuses the gesture: nothing moves, the
|
|
1888
|
+
* bars stay where they were, and the next pixel of travel is tried against the
|
|
1889
|
+
* same base.
|
|
1890
|
+
*
|
|
1891
|
+
* IDS THAT ARE NOT IN THIS CONTAINER refuse for the same reason, because
|
|
1892
|
+
* `shiftLayer` answers an unknown layer with the same unchanged document. A
|
|
1893
|
+
* selection that spans two scenes therefore does not move, which is honest: a
|
|
1894
|
+
* signed delta means the same thing in both clocks, but the two windows are
|
|
1895
|
+
* bounded by different scenes and half of them landing is the state above.
|
|
1896
|
+
*/
|
|
1897
|
+
export function moveLayers(
|
|
1898
|
+
doc: Doc3D,
|
|
1899
|
+
sceneId: string,
|
|
1900
|
+
layerIds: readonly string[],
|
|
1901
|
+
byFrames: number,
|
|
1902
|
+
): Doc3D {
|
|
1903
|
+
const by = Math.round(byFrames);
|
|
1904
|
+
if (by === 0 || !Number.isFinite(by) || layerIds.length === 0) return doc;
|
|
1905
|
+
let next = doc;
|
|
1906
|
+
for (const id of layerIds) {
|
|
1907
|
+
const moved = shiftLayer(next, sceneId, id, by);
|
|
1908
|
+
if (moved === next) return doc;
|
|
1909
|
+
next = moved;
|
|
1910
|
+
}
|
|
1911
|
+
return next;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* The order a stagger fans `ids` out in: by each bar's `in`, earliest first,
|
|
1916
|
+
* and a tie by OUTLINE order (the dock's rows, top to bottom), so two bars that
|
|
1917
|
+
* start together fan out the way the author reads them. Ids not in `home` are
|
|
1918
|
+
* kept at the end, so `staggerLayers` can refuse them rather than drop them.
|
|
1919
|
+
*/
|
|
1920
|
+
export function staggerOrder(
|
|
1921
|
+
home: LayerHome,
|
|
1922
|
+
ids: readonly string[],
|
|
1923
|
+
containerDurationFrames: number,
|
|
1924
|
+
): string[] {
|
|
1925
|
+
const outline = new Map(trackRows(home).map((r, i) => [r.layerId, i] as const));
|
|
1926
|
+
const start = (id: string): number => {
|
|
1927
|
+
const layer = home.layersById[id];
|
|
1928
|
+
return layer === undefined ? Infinity : lifespanOf(layer, containerDurationFrames).in;
|
|
1929
|
+
};
|
|
1930
|
+
const rank = (id: string): number => outline.get(id) ?? Infinity;
|
|
1931
|
+
return [...ids].sort((a, b) => start(a) - start(b) || rank(a) - rank(b));
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Move `ordered[i]` by `i * step`: the first bar stays, each next one a step
|
|
1936
|
+
* further. ALL OR NOTHING, `moveLayers`' rule and for its reason: a fan with one
|
|
1937
|
+
* bar refused is a rearrangement nobody asked for.
|
|
1938
|
+
*/
|
|
1939
|
+
export function staggerLayers(doc: Doc3D, sceneId: string, ordered: readonly string[], step: number): Doc3D {
|
|
1940
|
+
const by = Math.round(step);
|
|
1941
|
+
if (by === 0 || !Number.isFinite(by) || ordered.length < 2) return doc;
|
|
1942
|
+
let next = doc;
|
|
1943
|
+
for (const [i, id] of ordered.entries()) {
|
|
1944
|
+
if (i === 0) {
|
|
1945
|
+
// Rank 0 does not move, but an id outside this container still refuses.
|
|
1946
|
+
if (homeOf(doc, sceneId)?.layersById[id] === undefined) return doc;
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
const moved = shiftLayer(next, sceneId, id, i * by);
|
|
1950
|
+
if (moved === next) return doc;
|
|
1951
|
+
next = moved;
|
|
1952
|
+
}
|
|
1953
|
+
return next;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/** Every keyframe on `layer` moved by `delta`, as a patch. No per-key clamp,
|
|
1957
|
+
* deliberately: the caller has already clamped the DELTA against the earliest
|
|
1958
|
+
* key, so none can go negative, and a floor here would break the one thing a
|
|
1959
|
+
* body drag and a slip promise, that every key moved by the same amount.
|
|
1960
|
+
* `shiftTracks` subtracts, so a move LATER passes a negative. */
|
|
1961
|
+
function shiftedKeys(layer: Layer3D, delta: number): ReturnType<typeof tracksPatch> {
|
|
1962
|
+
return tracksPatch(shiftTracks(layer.tracks, -delta));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// ── THE DEF-LOCAL STRIP (the component context's dock, unify P2) ────────────
|
|
1966
|
+
//
|
|
1967
|
+
// A definition's timeline is ONE group shaped exactly like a scene's — same
|
|
1968
|
+
// MasterRow, same gestures — with no camera row (a definition
|
|
1969
|
+
// has no camera), no scene strip and no master offsets (start = 0). Reusing
|
|
1970
|
+
// MasterSceneGroup rather than minting a sibling type is what lets the dock
|
|
1971
|
+
// render both through one path.
|
|
1972
|
+
|
|
1973
|
+
/** The open definition's rows as one master-shaped group at offset 0. */
|
|
1974
|
+
export function defGroup(def: ComponentDef3D): MasterSceneGroup {
|
|
1975
|
+
const duration = defDurationFrames(def);
|
|
1976
|
+
const home: Scene3D = defAsScene(def, duration);
|
|
1977
|
+
return {
|
|
1978
|
+
id: def.id,
|
|
1979
|
+
name: def.name,
|
|
1980
|
+
start: 0,
|
|
1981
|
+
end: duration,
|
|
1982
|
+
// A DEFINITION HAS NO CAMERA, so it has no poses either. The dock skips the
|
|
1983
|
+
// camera row entirely inside one; this keeps the shape total anyway.
|
|
1984
|
+
camera: [],
|
|
1985
|
+
cameraKeys: [],
|
|
1986
|
+
rows: trackRows(home, null).map((row) => ({
|
|
1987
|
+
...row,
|
|
1988
|
+
lifespan: lifespanOf(home.layersById[row.layerId]!, duration),
|
|
1989
|
+
})),
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** A definition wearing a Scene3D's shape, for the row derivations that take
|
|
1994
|
+
* one. Only `layers`/`layersById`/`durationFrames` are ever read by them. */
|
|
1995
|
+
function defAsScene(def: ComponentDef3D, duration: number): Scene3D {
|
|
482
1996
|
return {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1997
|
+
id: def.id,
|
|
1998
|
+
name: def.name,
|
|
1999
|
+
durationFrames: duration,
|
|
2000
|
+
background: { type: "solid", color: "#000000" },
|
|
2001
|
+
fovDeg: 35,
|
|
2002
|
+
camera: { x: 0, y: 0, zoom: 1, rollDeg: 0, focusZ: 0 },
|
|
2003
|
+
shots: [],
|
|
2004
|
+
layers: def.roots,
|
|
2005
|
+
layersById: def.layersById,
|
|
2006
|
+
effects: {},
|
|
488
2007
|
};
|
|
489
2008
|
}
|
|
490
2009
|
|
|
491
|
-
/**
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
2010
|
+
/** The def strip's content extent, `masterContentFrames`' twin. */
|
|
2011
|
+
export function defContentFrames(group: MasterSceneGroup): number {
|
|
2012
|
+
let end = group.end;
|
|
2013
|
+
for (const row of group.rows) end = Math.max(end, row.lifespan.out);
|
|
2014
|
+
return end;
|
|
495
2015
|
}
|
|
496
2016
|
|
|
497
2017
|
// ── THE AUDIO CLIP'S GESTURES ───────────────────────────────────────────────
|
|
@@ -555,10 +2075,26 @@ export function audioClipView(clip: AudioClipRef): AudioClipView {
|
|
|
555
2075
|
* a live drag can never build a document the save would refuse: `frame` and
|
|
556
2076
|
* `offsetFrames` stay non-negative integers, `durationFrames` stays positive,
|
|
557
2077
|
* and `fadeIn + fadeOut` never exceeds the duration.
|
|
2078
|
+
*
|
|
2079
|
+
* ── THE GESTURE IS BASE-AGNOSTIC, THE MARKERS ARE NOT ──────────────────
|
|
2080
|
+
*
|
|
2081
|
+
* Every clamp below works on the clip's OWN frames, which since schema v13 are
|
|
2082
|
+
* master frames for the bed and scene-local ones for a clip its shot owns. That
|
|
2083
|
+
* needs no branch: a delta is a delta in either base, and "not before zero" is
|
|
2084
|
+
* "not before the video" for one and "not before this shot" for the other,
|
|
2085
|
+
* which is exactly what each schema requires.
|
|
2086
|
+
*
|
|
2087
|
+
* The MARKER sweep is the one thing that cannot be base-agnostic, because
|
|
2088
|
+
* markers are master-timed whatever the clip is. `home` supplies the shift, and
|
|
2089
|
+
* getting it wrong would drag a beat mark off a bed in a document where the
|
|
2090
|
+
* clip and the marks are numbered in two different bases.
|
|
558
2091
|
*/
|
|
559
2092
|
export function writeAudioDrag(doc: Doc3D, clipId: string, mode: AudioDrag, delta: number): Doc3D {
|
|
560
|
-
const
|
|
2093
|
+
const home = audioHomeOf(doc, clipId);
|
|
2094
|
+
if (home === null) return doc;
|
|
2095
|
+
const clip = home.sceneId === null ? doc.audio[clipId] : doc.scenesById[home.sceneId]?.audio?.[clipId];
|
|
561
2096
|
if (clip === undefined) return doc;
|
|
2097
|
+
const base = home.sceneId === null ? 0 : (sceneStart(doc, home.sceneId) ?? 0);
|
|
562
2098
|
const c = audioClipView(clip);
|
|
563
2099
|
const d = Math.round(delta);
|
|
564
2100
|
/** The shortest a trim may leave the clip: one frame, and never shorter than
|
|
@@ -620,22 +2156,147 @@ export function writeAudioDrag(doc: Doc3D, clipId: string, mode: AudioDrag, delt
|
|
|
620
2156
|
}
|
|
621
2157
|
}
|
|
622
2158
|
|
|
623
|
-
const next
|
|
2159
|
+
const next = updateAudioClip(doc, clipId, patch);
|
|
624
2160
|
if (markerDelta === 0) return next;
|
|
625
|
-
|
|
2161
|
+
// MASTER frames, so the clip's own window is shifted into that base first.
|
|
2162
|
+
const from = base + c.frame;
|
|
2163
|
+
const end = from + c.durationFrames;
|
|
626
2164
|
const markers = (doc.markers ?? []).map((m) =>
|
|
627
|
-
m.frame >=
|
|
2165
|
+
m.frame >= from && m.frame < end ? { ...m, frame: Math.max(0, m.frame + markerDelta) } : m,
|
|
628
2166
|
);
|
|
629
2167
|
// Re-sorted because a moved subset can cross a marker that did not move, and
|
|
630
2168
|
// `validate.ts` enforces the sort the strip draws in.
|
|
631
2169
|
return { ...next, markers: [...markers].sort((a, b) => a.frame - b.frame) };
|
|
632
2170
|
}
|
|
633
2171
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
2172
|
+
// ── THE FILMSTRIP'S COLUMNS ───────────────────────────────────────────────
|
|
2173
|
+
//
|
|
2174
|
+
// Where a video row's thumbnails go, and it is the only part of the strip that
|
|
2175
|
+
// is arithmetic rather than pixels, which is why it lives here beside the rest
|
|
2176
|
+
// of the dock's geometry and not in `Filmstrip.tsx`.
|
|
2177
|
+
//
|
|
2178
|
+
// TILES ARE PLACED IN SOURCE TIME. That is the whole design, and everything
|
|
2179
|
+
// below is a consequence of it. A column is not "the nth slot of this bar", it
|
|
2180
|
+
// is "the picture at source frame s", and s is derived from the same three
|
|
2181
|
+
// fields the decode clock reads (`lifetime.in`, `video.offsetFrames`,
|
|
2182
|
+
// `video.timeScale`) through the same arithmetic `videoClipOf` states:
|
|
2183
|
+
//
|
|
2184
|
+
// sourceFrame(sceneFrame) = (sceneFrame - offsetFrames) * timeScale
|
|
2185
|
+
//
|
|
2186
|
+
// ── WHY THAT MATTERS: A TRIM MUST NOT RE-CUT THE STRIP ────────────────────
|
|
2187
|
+
//
|
|
2188
|
+
// `trimLayer` writes `lifetime` and NOTHING ELSE (`video.offsetFrames` is
|
|
2189
|
+
// untouched), so a head trim of d frames adds d to `lifetime.in`, and the
|
|
2190
|
+
// derived `sourceInFrames` moves by d * timeScale on its own. The bar's left
|
|
2191
|
+
// edge therefore moves right by d * ppf while `x0` (below) moves left by
|
|
2192
|
+
// exactly the same d * ppf, and every column's ABSOLUTE position on the lane
|
|
2193
|
+
// is unchanged. The footage slides under the window instead of being re-cut,
|
|
2194
|
+
// which is what a trim looks like in every NLE and what an author is checking
|
|
2195
|
+
// when they drag the edge: the frame they were aiming at must not move.
|
|
2196
|
+
//
|
|
2197
|
+
// A strip laid out from the BAR's left edge would do the opposite. Each trim
|
|
2198
|
+
// step would re-phase every thumbnail, the row would shimmer through the whole
|
|
2199
|
+
// gesture, and the one thing the author is reading off the strip, which
|
|
2200
|
+
// moment of the take this edge lands on, would be the thing that moves.
|
|
2201
|
+
//
|
|
2202
|
+
// ── THE COLUMN PITCH IS EXACTLY `tileWidth`, AND THAT IS NOT A ROUNDING ───
|
|
2203
|
+
//
|
|
2204
|
+
// Column i sits at `x0 + (i * intervalFrames / rate) * ppf`, and
|
|
2205
|
+
// `intervalFrames / rate` is `tileWidth / ppf` by construction, so the whole
|
|
2206
|
+
// expression collapses to `x0 + i * tileWidth`. Screen pitch is constant at
|
|
2207
|
+
// every rate and every zoom; the rate only changes how much SOURCE each column
|
|
2208
|
+
// covers, and the sign of the rate only changes which direction the source
|
|
2209
|
+
// runs as the columns march right. `intervalFrames` is therefore signed and
|
|
2210
|
+
// never rounded: rounding it would put a drift of up to half a tile into every
|
|
2211
|
+
// column index, growing along the bar.
|
|
2212
|
+
|
|
2213
|
+
/** How wide one thumbnail is drawn, in CSS pixels. Wide enough that a column
|
|
2214
|
+
* is a picture rather than a smear, narrow enough that an ordinary clip bar
|
|
2215
|
+
* carries several. */
|
|
2216
|
+
export const FILMSTRIP_TILE_PX = 48;
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* A hard ceiling on columns per plan, so a degenerate input cannot turn a
|
|
2220
|
+
* decoration into a hang.
|
|
2221
|
+
*
|
|
2222
|
+
* Not a budget the caller is expected to hit: `Filmstrip.tsx` asks only for the
|
|
2223
|
+
* visible slice plus the viewport's margin, which is a few thousand pixels and
|
|
2224
|
+
* so a few dozen columns. This catches the case where a caller asks for a whole
|
|
2225
|
+
* bar at a deep zoom.
|
|
2226
|
+
*/
|
|
2227
|
+
export const FILMSTRIP_MAX_COLUMNS = 512;
|
|
2228
|
+
|
|
2229
|
+
/** One thumbnail's placement. */
|
|
2230
|
+
export interface FilmstripColumn {
|
|
2231
|
+
/** The column's LEFT edge in BAR-LOCAL pixels. May be negative, or run past
|
|
2232
|
+
* the bar's right edge: a column that straddles an edge is still drawn, and
|
|
2233
|
+
* the bar's own `overflow: hidden` is what cuts it. */
|
|
2234
|
+
x: number;
|
|
2235
|
+
/**
|
|
2236
|
+
* The source frame this column shows, at the DOCUMENT's rate.
|
|
2237
|
+
*
|
|
2238
|
+
* Fractional and signed on purpose. Negative means the window opens before
|
|
2239
|
+
* the recording's t=0, which is the lead `videoClipOf.leadFrom` names, and
|
|
2240
|
+
* the loader clamps it to the first tile, so the frozen first picture draws
|
|
2241
|
+
* itself with no branch here. The run-out end clamps the same way.
|
|
2242
|
+
*/
|
|
2243
|
+
sourceFrame: number;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
export interface FilmstripPlan {
|
|
2247
|
+
/** Source frames one column covers. SIGNED (a negative `timeScale` runs the
|
|
2248
|
+
* source down as the scene runs on) and never rounded. */
|
|
2249
|
+
intervalFrames: number;
|
|
2250
|
+
/** Bar-local pixels where source frame 0 sits. */
|
|
2251
|
+
x0: number;
|
|
2252
|
+
columns: FilmstripColumn[];
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* The columns covering `[fromPx, toPx)` of a video clip's bar.
|
|
2257
|
+
*
|
|
2258
|
+
* PURE, and the whole reason the maths is here rather than inside the canvas
|
|
2259
|
+
* effect: the invariant above is a property of these numbers, so a test can pin
|
|
2260
|
+
* it without a DOM, a sheet, or a device pixel ratio.
|
|
2261
|
+
*
|
|
2262
|
+
* `fromPx`/`toPx` are BAR-LOCAL and are a clip, not a clamp: they decide which
|
|
2263
|
+
* columns are worth producing, never where a column lands.
|
|
2264
|
+
*/
|
|
2265
|
+
export function filmstripLayout(input: {
|
|
2266
|
+
/** `lifetime.in`, scene-local. */
|
|
2267
|
+
lifetimeIn: number;
|
|
2268
|
+
/** `video.offsetFrames`. */
|
|
2269
|
+
offsetFrames: number;
|
|
2270
|
+
/** `video.timeScale`. A zero or non-finite rate reads as 1, matching
|
|
2271
|
+
* `videoClipOf`'s own defaulting, so the strip and the decode clock cannot
|
|
2272
|
+
* disagree about what a broken field meant. */
|
|
2273
|
+
timeScale: number;
|
|
2274
|
+
ppf: number;
|
|
2275
|
+
tileWidth: number;
|
|
2276
|
+
fromPx: number;
|
|
2277
|
+
toPx: number;
|
|
2278
|
+
}): FilmstripPlan {
|
|
2279
|
+
const { lifetimeIn, offsetFrames, timeScale, ppf, tileWidth, fromPx, toPx } = input;
|
|
2280
|
+
const rate = Number.isFinite(timeScale) && timeScale !== 0 ? timeScale : 1;
|
|
2281
|
+
const empty: FilmstripPlan = { intervalFrames: 0, x0: 0, columns: [] };
|
|
2282
|
+
if (!(ppf > 0) || !(tileWidth > 0)) return empty;
|
|
2283
|
+
if (!Number.isFinite(lifetimeIn) || !Number.isFinite(offsetFrames)) return empty;
|
|
2284
|
+
if (!Number.isFinite(fromPx) || !Number.isFinite(toPx) || toPx <= fromPx) return empty;
|
|
2285
|
+
|
|
2286
|
+
const intervalFrames = (tileWidth / ppf) * rate;
|
|
2287
|
+
const x0 = (offsetFrames - lifetimeIn) * ppf;
|
|
2288
|
+
|
|
2289
|
+
// The pitch identity, spelled as `i * tileWidth` because that is what it is.
|
|
2290
|
+
// Indices may be negative, and must be: a bar whose window opens before the
|
|
2291
|
+
// source's t=0 has its first visible column at a negative source frame.
|
|
2292
|
+
const first = Math.floor((fromPx - x0) / tileWidth);
|
|
2293
|
+
const last = Math.ceil((toPx - x0) / tileWidth);
|
|
2294
|
+
if (!Number.isFinite(first) || !Number.isFinite(last)) return empty;
|
|
2295
|
+
|
|
2296
|
+
const columns: FilmstripColumn[] = [];
|
|
2297
|
+
const stop = Math.min(last, first + FILMSTRIP_MAX_COLUMNS);
|
|
2298
|
+
for (let i = first; i < stop; i++) {
|
|
2299
|
+
columns.push({ x: x0 + i * tileWidth, sourceFrame: i * intervalFrames });
|
|
2300
|
+
}
|
|
2301
|
+
return { intervalFrames, x0, columns };
|
|
641
2302
|
}
|