@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,76 +6,269 @@
|
|
|
6
6
|
// `iframe.contentWindow.__shotStudio` on load and must never observe a
|
|
7
7
|
// rendered-but-bridgeless stage; if the handle lived in a component it could
|
|
8
8
|
// only appear after the first commit, and the poll would race it.
|
|
9
|
-
import { clearRasterCache } from "@dailies-studio/shot";
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
9
|
+
import { clearLayerMaterialPool, clearPlaneCache, clearRasterCache, resetRasterPressure } from "@dailies-studio/shot";
|
|
10
|
+
import type { CoverageProbe, ShotPresentedFrame } from "@dailies-studio/shot";
|
|
11
|
+
import { createShotClock, setStagePhase } from "@dailies-studio/shot/doc";
|
|
12
|
+
import type { ShotClock } from "@dailies-studio/shot/doc";
|
|
13
|
+
import {
|
|
14
|
+
describePieceFailure,
|
|
15
|
+
docDurationFrames,
|
|
16
|
+
instanceComponentIdsOf,
|
|
17
|
+
instanceModuleOf,
|
|
18
|
+
masterToSceneCached,
|
|
19
|
+
pieceFailuresAt,
|
|
20
|
+
pieceLayerLabel,
|
|
21
|
+
posterFrame,
|
|
22
|
+
recordStageError,
|
|
23
|
+
sceneWindows,
|
|
24
|
+
sliceShotToScene,
|
|
25
|
+
} from "@dailies-studio/shot/doc";
|
|
26
|
+
import type { StageErrorSource, StageHealthSnapshot, StageRendered, StageRuntimeError } from "@dailies-studio/shot/doc";
|
|
27
|
+
import { stageReadiness } from "@dailies-studio/runtime";
|
|
28
|
+
import { PERF_HUD_ENABLED, markPerf } from "./PerfHud.js";
|
|
29
|
+
import { stageDensity } from "./adaptivePresentation.js";
|
|
30
|
+
import { RenderScaleController } from "./renderScaleController.js";
|
|
31
|
+
import { StagePerformanceTelemetry } from "./stagePerformanceTelemetry.js";
|
|
32
|
+
import { createShotStageExporter } from "./tier1Export.js";
|
|
33
|
+
import type { ShotStageExporter } from "./tier1Export.js";
|
|
34
|
+
import type { CameraGate, Doc3D, Scene3D } from "@dailies-studio/shot/doc";
|
|
12
35
|
import type {
|
|
36
|
+
EditorChrome,
|
|
37
|
+
MaterialPreviewTarget,
|
|
38
|
+
NodePreviewBatch,
|
|
39
|
+
NodePreviewRequest,
|
|
13
40
|
ShadingMode,
|
|
41
|
+
ShotBoardPin,
|
|
14
42
|
ShotBridgeHandlers,
|
|
43
|
+
ShotComponentContext,
|
|
44
|
+
ShotPeerSelection,
|
|
45
|
+
ShotPreparePace,
|
|
46
|
+
ShotPreparePlaybackResult,
|
|
47
|
+
ShotPreparePlaybackYield,
|
|
48
|
+
ShotPreparedOutcome,
|
|
15
49
|
ShotScreenQuad,
|
|
50
|
+
ShotSketchGuide,
|
|
51
|
+
ShotMotionPreview,
|
|
52
|
+
ShotSketchGuideArrow,
|
|
53
|
+
ShotSketchGuideRect,
|
|
54
|
+
ShotStudioComments,
|
|
55
|
+
ShotStudioPin,
|
|
16
56
|
ShotStageBridge,
|
|
17
57
|
ShotTool,
|
|
18
58
|
ShotView,
|
|
59
|
+
SplineBasis,
|
|
60
|
+
SplineEditContext,
|
|
19
61
|
SnapGuide,
|
|
20
62
|
StageInset,
|
|
21
63
|
StageLayout,
|
|
22
64
|
StagePane,
|
|
65
|
+
StagePieceFailure,
|
|
23
66
|
WorkingView,
|
|
67
|
+
WorkPlaneTarget,
|
|
68
|
+
} from "../bridgeTypes.js";
|
|
69
|
+
import {
|
|
70
|
+
MAX_LOOP_FRAMES,
|
|
71
|
+
MAX_NODE_PREVIEWS,
|
|
72
|
+
MAX_SKETCH_GUIDE_SHAPES,
|
|
73
|
+
SHOT_BRIDGE_VERSION,
|
|
74
|
+
componentContextSubject,
|
|
75
|
+
isPlaybackResolution,
|
|
76
|
+
validPreviewProps,
|
|
24
77
|
} from "../bridgeTypes.js";
|
|
25
|
-
import { SHOT_BRIDGE_VERSION } from "../bridgeTypes.js";
|
|
26
78
|
import { clampSplitRatio } from "./panes.js";
|
|
27
79
|
import { clampShading } from "./shading.js";
|
|
80
|
+
import { TOOL_SPECS } from "../tools.js";
|
|
28
81
|
import { INITIAL_WORKING_VIEW } from "./workingView.js";
|
|
82
|
+
import { createFontPreviewFaces, fontPreviewDoc, sanitizeFontPreview } from "./fontPreview.js";
|
|
83
|
+
import { motionPreviewDoc, sanitizeMotionPreview } from "./motionPreview.js";
|
|
84
|
+
import type { StageFontPreview } from "./fontPreview.js";
|
|
85
|
+
import type { FontPlatform } from "@dailies-studio/shot";
|
|
29
86
|
|
|
30
87
|
export interface StageClientState {
|
|
31
88
|
/** A chrome has registered its handlers. Nothing renders before this. */
|
|
32
89
|
connected: boolean;
|
|
33
90
|
doc: Doc3D | null;
|
|
34
|
-
/**
|
|
91
|
+
/** The saved revision `doc` is (v34 `loadDoc`), or null for an unsaved edit
|
|
92
|
+
* or a host that did not say. Stamped on a piece failure first seen while
|
|
93
|
+
* this document is on stage; never read by anything that draws. */
|
|
94
|
+
docRev: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* MASTER frame, for the one mode that renders it through React: a Tier-1
|
|
97
|
+
* export, whose commit stamps the marker the capture requires, written by
|
|
98
|
+
* the capture loop (`exportHost.seek`). Isolation and a component context
|
|
99
|
+
* are not that mode: isolation's head-on frame is held still for the
|
|
100
|
+
* subtree's whole life (`isolationAnchor`), and a component context's
|
|
101
|
+
* def-local frames ride the clock like the scene's. Everywhere else the
|
|
102
|
+
* transport publishes into `StageClient.clock` with its presentation token,
|
|
103
|
+
* and this field holds the frame of the last export commit: read
|
|
104
|
+
* `clock.get().frame` for the playhead.
|
|
105
|
+
*/
|
|
35
106
|
frame: number;
|
|
107
|
+
/** The scene under the playhead, or null off the timeline, with no
|
|
108
|
+
* document, or under a component context. The coarse half of the playhead:
|
|
109
|
+
* it moves at cuts, so the stage re-renders at cuts and not per frame. */
|
|
110
|
+
sceneId: string | null;
|
|
111
|
+
/** The chrome's transport is running. SCHEDULING ONLY — see the bridge's
|
|
112
|
+
* `setPlaying`. It must never reach a cache key or a sampled value. */
|
|
113
|
+
playing: boolean;
|
|
36
114
|
selection: readonly string[];
|
|
115
|
+
/** Other people's selections (bridge v43), sanitized on the way in. */
|
|
116
|
+
peerSelections: readonly ShotPeerSelection[];
|
|
37
117
|
view: ShotView;
|
|
38
118
|
/** Live only while a gesture is snapping. */
|
|
39
119
|
guides: readonly SnapGuide[];
|
|
40
120
|
tool: ShotTool;
|
|
121
|
+
/** Editor-only construction plane and spline point-mode subject. */
|
|
122
|
+
workPlane: WorkPlaneTarget;
|
|
123
|
+
splineBasis: SplineBasis;
|
|
124
|
+
splineEdit: SplineEditContext | null;
|
|
41
125
|
/** How many panes are on screen. Pushed by the chrome; never derived here,
|
|
42
126
|
* so there is one owner of "what is the author looking at". */
|
|
43
127
|
layout: StageLayout;
|
|
128
|
+
/** THE AUTHOR IS WATCHING: the picture, and nothing drawn over it. Pushed by
|
|
129
|
+
* the chrome like the layout, and orthogonal to it: it does not replace
|
|
130
|
+
* `layout`, it hides it (see `setVideo` in bridgeTypes and `watching` in
|
|
131
|
+
* StageApp). */
|
|
132
|
+
video: boolean;
|
|
133
|
+
/** VIEW ONLY (bridge v41): the person may look but not edit, so the stage
|
|
134
|
+
* draws no handles and starts no edit gesture. See `setReadOnly` in
|
|
135
|
+
* bridgeTypes and `readOnly` in StageApp. */
|
|
136
|
+
readOnly: boolean;
|
|
137
|
+
/** THE BOARD IS WATCHING (bridge v30): the same no-furniture picture as
|
|
138
|
+
* `video`, plus a derived fit into the chrome's hole. Separate from
|
|
139
|
+
* `video` because the two are different author intents that happen to want
|
|
140
|
+
* one picture; see `setStoryboardPreview` in bridgeTypes. */
|
|
141
|
+
storyboardPreview: boolean;
|
|
142
|
+
/** THE STUDIO'S COMMENT LAYER (bridge v45): every Studio pin, drawn on its
|
|
143
|
+
* anchor's frame, and whether the tool is armed. Null when the chrome has
|
|
144
|
+
* none. Sanitized on the way in; see `setStudioComments` in bridgeTypes. */
|
|
145
|
+
studioComments: ShotStudioComments | null;
|
|
146
|
+
/** THE SKETCH GUIDE (bridge v47): one storyboard sketch as plain shapes in
|
|
147
|
+
* design px, or null. Sanitized on the way in; see `setSketchGuide`. */
|
|
148
|
+
sketchGuide: ShotSketchGuide | null;
|
|
149
|
+
/** THE FONT PREVIEW (bridge v50), sanitized, with the private family its
|
|
150
|
+
* faces landed under (`face`), or null. Only the runtime's picture reads
|
|
151
|
+
* it (`fontPreviewDoc` in StageApp); see `setFontPreview`. */
|
|
152
|
+
fontPreview: StageFontPreview | null;
|
|
153
|
+
/** THE MOTION PREVIEW (bridge v53), sanitized, or null. Only the runtime's
|
|
154
|
+
* picture reads it (`motionPreviewDoc` in StageApp); see `setMotionPreview`. */
|
|
155
|
+
motionPreview: ShotMotionPreview | null;
|
|
156
|
+
/** The text layer the inline editor is open on (bridge v38), or null. */
|
|
157
|
+
textEdit: string | null;
|
|
44
158
|
workingView: WorkingView;
|
|
45
|
-
/**
|
|
46
|
-
* framing gesture lane
|
|
47
|
-
|
|
159
|
+
/** PILOTING (bridge v29): the Camera pane's navigation writes the shot
|
|
160
|
+
* camera through the framing gesture lane, and the pane wears the badge
|
|
161
|
+
* that says so. `pilotText` is the badge's words, composed by the chrome
|
|
162
|
+
* (it knows what holds the camera); null means the plain badge. */
|
|
163
|
+
pilot: boolean;
|
|
164
|
+
pilotText: string | null;
|
|
165
|
+
/** What a camera write would do at the playhead (bridge v29), so the gizmo
|
|
166
|
+
* can dim a held camera and the pointer lane can say why BEFORE a drag.
|
|
167
|
+
* Null when no document is open. */
|
|
168
|
+
cameraGate: CameraGate | null;
|
|
48
169
|
/** The isolated subtree's root layer id, or null for the whole scene. The
|
|
49
170
|
* stage derives the members, the camera and the dimming from the document
|
|
50
171
|
* it already has — see `setIsolation`. */
|
|
51
172
|
isolation: string | null;
|
|
173
|
+
/** Mesh SURFACE whose material graph owns the working-pane interaction
|
|
174
|
+
* surface. The layer still renders; only scene-edit gestures are suppressed.
|
|
175
|
+
* `slot` names which of a model glTF materials the workspace is editing, or
|
|
176
|
+
* null for the layer-wide binding. */
|
|
177
|
+
materialPreview: MaterialPreviewTarget | null;
|
|
52
178
|
/** Inside isolation, has the author taken the working camera? False means the
|
|
53
179
|
* derived orthographic head-on rig draws the pane; true means the ordinary
|
|
54
180
|
* orbit rig does. Meaningless while `isolation` is null. */
|
|
55
181
|
isolationOrbit: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* The master frame the isolated subtree is framed from: where the playhead
|
|
184
|
+
* stood when the author entered it, or pressed Home inside it. A new object
|
|
185
|
+
* on each of those and on nothing else, so it is the one key the stage's
|
|
186
|
+
* frozen head-on frame is derived on. Null while `isolation` is.
|
|
187
|
+
*/
|
|
188
|
+
isolationAnchor: { frame: number } | null;
|
|
189
|
+
/** The component context (v14): render ONE definition standalone, def-local
|
|
190
|
+
* clock, whole viewport. Null = the ordinary scene render. Mutually
|
|
191
|
+
* exclusive with isolation — the chrome owns the mode and clears isolation
|
|
192
|
+
* first. `zoom` scales the derived head-on frame; absent is 1. */
|
|
193
|
+
componentContext: ShotComponentContext | null;
|
|
56
194
|
/** The working pane's shading. Workspace state, mirrored from the chrome. */
|
|
57
195
|
shading: ShadingMode;
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
196
|
+
/** The working pane's grid floor (bridge v22). Workspace state too: how the
|
|
197
|
+
* author looks at the scene, never something about the shot. */
|
|
198
|
+
grid: boolean;
|
|
199
|
+
/** Rulers and smart guides (bridge v44), workspace state on `grid`'s rule.
|
|
200
|
+
* The playback resolution the same push carries (v52) goes to
|
|
201
|
+
* `StageClient.renderScale` and not here: it moves nothing drawn paused,
|
|
202
|
+
* so it re-renders nothing and ends no preparing pass. */
|
|
203
|
+
editorChrome: Pick<EditorChrome, "rulers" | "smartGuides">;
|
|
204
|
+
/** Motion paths for the selection (bridge v44), workspace state too. */
|
|
205
|
+
motionPaths: boolean;
|
|
206
|
+
/** How far the chrome's docked panels reach into the stage, per side. It
|
|
207
|
+
* places what has to be SEEN — the overlay's furniture, the derived head-on
|
|
208
|
+
* framing, the camera pane's fit, and where the split seam falls — and moves
|
|
209
|
+
* neither the scissor nor the hit test: `paneRects` takes it for the seam
|
|
210
|
+
* alone and still returns rects that partition the whole viewport. See
|
|
211
|
+
* `setStageInset`. Zero until the chrome says otherwise. */
|
|
61
212
|
inset: StageInset;
|
|
213
|
+
/** A Tier-1 export is running: StageApp renders the export framing (design
|
|
214
|
+
* viewport, no view offset, dpr 1, no overlays) and the capture loop owns
|
|
215
|
+
* `frame`. Chrome `seek`/`loadDoc` pushes are DEFERRED until this clears —
|
|
216
|
+
* a mid-capture re-render would let the encoder stamp one frame's
|
|
217
|
+
* timestamp on another frame's pixels. Null = normal editing. */
|
|
218
|
+
exporting: { scene?: string } | null;
|
|
219
|
+
/** THE FROZEN PICTURE (v51): the frame a preparing pass froze on screen,
|
|
220
|
+
* from the freeze until it lifts, or null. While it stands, StageApp keeps
|
|
221
|
+
* the overlays it drew for that frame and starts no transform on the
|
|
222
|
+
* scene graph under it, which holds another frame. */
|
|
223
|
+
held: number | null;
|
|
224
|
+
/** A preparing pass is drawing its own frames under the freeze (v51). Set
|
|
225
|
+
* by its first publication and cleared by an author seek, the restore, the
|
|
226
|
+
* hand-over and a cancel. StageTools projects nothing while it is set, so
|
|
227
|
+
* the furniture keeps the frozen frame's outlines. */
|
|
228
|
+
passDrawing: boolean;
|
|
62
229
|
}
|
|
63
230
|
|
|
64
231
|
const INITIAL: StageClientState = {
|
|
65
232
|
connected: false,
|
|
66
233
|
doc: null,
|
|
234
|
+
docRev: null,
|
|
67
235
|
frame: 0,
|
|
236
|
+
sceneId: null,
|
|
237
|
+
playing: false,
|
|
68
238
|
selection: [],
|
|
239
|
+
peerSelections: [],
|
|
69
240
|
view: { zoom: 1, panX: 0, panY: 0 },
|
|
70
241
|
guides: [],
|
|
71
242
|
tool: "select",
|
|
243
|
+
workPlane: { kind: "xy" },
|
|
244
|
+
splineBasis: "bezier",
|
|
245
|
+
splineEdit: null,
|
|
72
246
|
layout: { mode: "camera", splitRatio: 0.5 },
|
|
247
|
+
video: false,
|
|
248
|
+
readOnly: false,
|
|
249
|
+
storyboardPreview: false,
|
|
250
|
+
studioComments: null,
|
|
251
|
+
sketchGuide: null,
|
|
252
|
+
fontPreview: null,
|
|
253
|
+
motionPreview: null,
|
|
254
|
+
textEdit: null,
|
|
73
255
|
workingView: INITIAL_WORKING_VIEW,
|
|
74
|
-
|
|
256
|
+
pilot: false,
|
|
257
|
+
pilotText: null,
|
|
258
|
+
cameraGate: null,
|
|
75
259
|
isolation: null,
|
|
260
|
+
materialPreview: null,
|
|
76
261
|
isolationOrbit: false,
|
|
262
|
+
isolationAnchor: null,
|
|
263
|
+
componentContext: null,
|
|
77
264
|
shading: "solid",
|
|
265
|
+
grid: true,
|
|
266
|
+
editorChrome: { rulers: false, smartGuides: true },
|
|
267
|
+
motionPaths: true,
|
|
78
268
|
inset: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
269
|
+
exporting: null,
|
|
270
|
+
held: null,
|
|
271
|
+
passDrawing: false,
|
|
79
272
|
};
|
|
80
273
|
|
|
81
274
|
/** Finite and non-negative on every side. A boundary, like `clampSplitRatio`:
|
|
@@ -86,8 +279,168 @@ function clampInset(inset: StageInset): StageInset {
|
|
|
86
279
|
return { top: one(inset.top), right: one(inset.right), bottom: one(inset.bottom), left: one(inset.left) };
|
|
87
280
|
}
|
|
88
281
|
|
|
282
|
+
function sanitizeWorkPlane(target: WorkPlaneTarget): WorkPlaneTarget {
|
|
283
|
+
if (target.kind === "selection") {
|
|
284
|
+
return target.layerId.length > 0 ? { kind: "selection", layerId: target.layerId } : { kind: "xy" };
|
|
285
|
+
}
|
|
286
|
+
return target.kind === "xy" || target.kind === "xz" || target.kind === "yz" || target.kind === "view"
|
|
287
|
+
? { kind: target.kind }
|
|
288
|
+
: { kind: "xy" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
292
|
+
|
|
293
|
+
/** At most this many peer outlines, and ids per peer: past that the outlines
|
|
294
|
+
* are noise, and the stage projects every id each frame. */
|
|
295
|
+
export const MAX_PEER_SELECTIONS = 16;
|
|
296
|
+
export const MAX_PEER_IDS = 50;
|
|
297
|
+
|
|
298
|
+
/** A boundary like `sanitizePins`: each name becomes a `<text>` and each
|
|
299
|
+
* colour a `stroke`. A peer with no ids or no plain hex colour is dropped;
|
|
300
|
+
* a name is cut to 60 characters. */
|
|
301
|
+
export function sanitizePeerSelections(peers: unknown): ShotPeerSelection[] {
|
|
302
|
+
if (!Array.isArray(peers)) return [];
|
|
303
|
+
const out: ShotPeerSelection[] = [];
|
|
304
|
+
for (const peer of peers as unknown[]) {
|
|
305
|
+
if (out.length >= MAX_PEER_SELECTIONS) break;
|
|
306
|
+
if (typeof peer !== "object" || peer === null) continue;
|
|
307
|
+
const { ids, name, color } = peer as Record<string, unknown>;
|
|
308
|
+
if (!Array.isArray(ids) || typeof color !== "string" || !HEX_COLOR_RE.test(color)) continue;
|
|
309
|
+
const kept = (ids as unknown[]).filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, MAX_PEER_IDS);
|
|
310
|
+
if (kept.length === 0) continue;
|
|
311
|
+
out.push({ ids: kept, name: typeof name === "string" ? name.slice(0, 60) : "", color });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Pins become positioned SVG with a label: a pin with no thread id or a
|
|
317
|
+
* point off the frame is dropped, and a colour that is not plain hex falls
|
|
318
|
+
* back to the accent rather than reaching a `fill` attribute. */
|
|
319
|
+
function sanitizePins(pins: unknown): ShotBoardPin[] {
|
|
320
|
+
if (!Array.isArray(pins)) return [];
|
|
321
|
+
const unit = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
322
|
+
return pins.flatMap((pin: Partial<ShotBoardPin> | null) => {
|
|
323
|
+
if (pin === null || typeof pin !== "object") return [];
|
|
324
|
+
if (typeof pin.threadId !== "string" || pin.threadId.length === 0 || !unit(pin.u) || !unit(pin.v)) return [];
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
threadId: pin.threadId,
|
|
328
|
+
u: pin.u,
|
|
329
|
+
v: pin.v,
|
|
330
|
+
initials: typeof pin.initials === "string" ? pin.initials.slice(0, 2) : "",
|
|
331
|
+
color: typeof pin.color === "string" && HEX_COLOR_RE.test(pin.color) ? pin.color : "#0099FF",
|
|
332
|
+
active: pin.active === true,
|
|
333
|
+
label: typeof pin.label === "string" ? pin.label : "Comment",
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Studio pins (bridge v45) on `sanitizePins`'s rule, plus the anchor: a pin
|
|
340
|
+
* whose anchor names no Shot, a frame that is not a whole frame at or after
|
|
341
|
+
* the Shot's start, or a layer that is not an id or null, is dropped. */
|
|
342
|
+
export function sanitizeStudioComments(comments: ShotStudioComments | null): ShotStudioComments | null {
|
|
343
|
+
if (comments === null || typeof comments !== "object") return null;
|
|
344
|
+
const raw: unknown[] = Array.isArray(comments.pins) ? comments.pins : [];
|
|
345
|
+
const pins = raw.flatMap((pin): ShotStudioPin[] => {
|
|
346
|
+
if (pin === null || typeof pin !== "object") return [];
|
|
347
|
+
const { anchor, ...rest } = pin as Partial<ShotStudioPin>;
|
|
348
|
+
if (anchor === null || typeof anchor !== "object" || anchor.kind !== "studio") return [];
|
|
349
|
+
const { sceneId, frame, layerId } = anchor;
|
|
350
|
+
if (typeof sceneId !== "string" || sceneId.length === 0) return [];
|
|
351
|
+
if (!Number.isSafeInteger(frame) || frame < 0) return [];
|
|
352
|
+
if (layerId !== null && (typeof layerId !== "string" || layerId.length === 0)) return [];
|
|
353
|
+
const [board] = sanitizePins([{ ...rest, u: anchor.u, v: anchor.v }]);
|
|
354
|
+
if (board === undefined) return [];
|
|
355
|
+
const { u, v, ...face } = board;
|
|
356
|
+
return [{ ...face, anchor: { kind: "studio", sceneId, frame, layerId, u, v } }];
|
|
357
|
+
});
|
|
358
|
+
return { pins, armed: comments.armed === true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A sketch guide (bridge v47) the overlay can draw: finite numbers, sides
|
|
362
|
+
* above zero, labels as short plain strings, at most
|
|
363
|
+
* `MAX_SKETCH_GUIDE_SHAPES` shapes. A shape that fails is dropped; a guide
|
|
364
|
+
* with nothing left is null. */
|
|
365
|
+
export function sanitizeSketchGuide(guide: ShotSketchGuide | null): ShotSketchGuide | null {
|
|
366
|
+
if (guide === null || typeof guide !== "object") return null;
|
|
367
|
+
const num = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n);
|
|
368
|
+
const pair = (p: unknown): p is [number, number] => Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]);
|
|
369
|
+
const label = (l: unknown): { label?: string } => (typeof l === "string" && l.trim() !== "" ? { label: l.trim().slice(0, 80) } : {});
|
|
370
|
+
const rects = (Array.isArray(guide.rects) ? guide.rects : []).flatMap((r: Partial<ShotSketchGuideRect> | null): ShotSketchGuideRect[] => {
|
|
371
|
+
if (r === null || typeof r !== "object" || !pair(r.at) || !pair(r.size) || r.size[0] <= 0 || r.size[1] <= 0) return [];
|
|
372
|
+
return [{ at: [r.at[0], r.at[1]], size: [r.size[0], r.size[1]], ...(num(r.radius) && r.radius > 0 ? { radius: r.radius } : {}), ...label(r.label) }];
|
|
373
|
+
});
|
|
374
|
+
const arrows = (Array.isArray(guide.arrows) ? guide.arrows : []).flatMap((a: Partial<ShotSketchGuideArrow> | null): ShotSketchGuideArrow[] => {
|
|
375
|
+
if (a === null || typeof a !== "object" || !pair(a.from) || !pair(a.to)) return [];
|
|
376
|
+
return [{ from: [a.from[0], a.from[1]], to: [a.to[0], a.to[1]], ...label(a.label) }];
|
|
377
|
+
});
|
|
378
|
+
const kept = rects.slice(0, MAX_SKETCH_GUIDE_SHAPES);
|
|
379
|
+
const shapes = { rects: kept, arrows: arrows.slice(0, MAX_SKETCH_GUIDE_SHAPES - kept.length) };
|
|
380
|
+
return shapes.rects.length + shapes.arrows.length === 0 ? null : shapes;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A DERIVED head-on rig is drawing the working pane: an isolated subtree, or an
|
|
385
|
+
* open component definition.
|
|
386
|
+
*
|
|
387
|
+
* The stage's copy of the chrome store's `headOnOwnsPane`. Two copies because
|
|
388
|
+
* the two sides hold different state shapes, and no more than two: the one
|
|
389
|
+
* thing a third head-on mode must not have to do is find six spellings of this.
|
|
390
|
+
*
|
|
391
|
+
* `componentContext` is validated against the document wherever it is written
|
|
392
|
+
* (`setComponentContext`, `loadDoc`), so a non-null one always names a
|
|
393
|
+
* definition that exists and is therefore always drawing.
|
|
394
|
+
*/
|
|
395
|
+
export function headOnOwnsPane(state: StageClientState): boolean {
|
|
396
|
+
return (state.isolation !== null || state.componentContext !== null) && !state.isolationOrbit;
|
|
397
|
+
}
|
|
398
|
+
|
|
89
399
|
export interface StageClient {
|
|
90
400
|
readonly bridge: ShotStageBridge;
|
|
401
|
+
/** The playhead as a STORE (shot's frameStore.ts). `bridge.seek` publishes
|
|
402
|
+
* every accepted frame here; `ShotRuntime` subscribes to it and the stage's
|
|
403
|
+
* frame loops read it. The one live answer to "what frame is it". */
|
|
404
|
+
readonly clock: ShotClock;
|
|
405
|
+
/** Shared by the always-on playback quality policy and, under `?perf=1`,
|
|
406
|
+
* the opt-in diagnostics. */
|
|
407
|
+
readonly performanceTelemetry: StagePerformanceTelemetry;
|
|
408
|
+
/** The playback render scale (renderScaleController.ts): StageApp hands it
|
|
409
|
+
* to the runtime as `presentationScale` and feeds it the GPU timer's
|
|
410
|
+
* frames; the client feeds it the frames the transport presented (the
|
|
411
|
+
* telemetry's `subscribePresented`), tells it the transport, the
|
|
412
|
+
* document's rate, another Project and the Preferences mode, and reports
|
|
413
|
+
* what it holds (v52). */
|
|
414
|
+
readonly renderScale: RenderScaleController;
|
|
415
|
+
/** One runtime presentation reached the GL canvas. This is the single
|
|
416
|
+
* stage-local fan-out for telemetry and an armed Tier-1 framebuffer
|
|
417
|
+
* snapshot; callers must invoke it synchronously from onPresented.
|
|
418
|
+
* `drawnPlaying` is the `playing` the runtime was rendered with for this
|
|
419
|
+
* draw: a flag flip reaches the runtime a commit after it is set here.
|
|
420
|
+
* `drawnForExport` is whether that render was in export framing: only such
|
|
421
|
+
* a draw may resolve an armed capture (see `present` below). */
|
|
422
|
+
present(info: ShotPresentedFrame, document: Doc3D | null, drawnPlaying?: boolean, drawnForExport?: boolean): void;
|
|
423
|
+
/** A presentation of the WHOLE picture for `health()` (v35): StageApp calls
|
|
424
|
+
* it from every presentation once the placed components' modules are
|
|
425
|
+
* installed, `epoch` being the module epoch they were imported under
|
|
426
|
+
* (hosted), paused or playing, with no telemetry, capture or
|
|
427
|
+
* preparing-pass work. Ignored while other work above the runtime is
|
|
428
|
+
* still outstanding (the capture gate's own question), and never recorded
|
|
429
|
+
* for a preparing pass's own frames (v51), which are not the author's.
|
|
430
|
+
*
|
|
431
|
+
* It also closes the READINESS LATCH (v51): a whole presentation of the
|
|
432
|
+
* open subject (`drawn.subject`) means this renderer owes it nothing more
|
|
433
|
+
* (`onStageBusy`). And one the transport drew in the playing presentation
|
|
434
|
+
* (`drawn.playing`) joins `takePlayed()`. Without `drawn`, the subject is
|
|
435
|
+
* the open one whenever `document` is the open document, drawn paused. */
|
|
436
|
+
noteDrawn(info: ShotPresentedFrame, document: Doc3D | null, epoch?: number | null, drawn?: StageDrawn): void;
|
|
437
|
+
/** The project's modules hot-updated under this stage (Vite's
|
|
438
|
+
* `vite:afterUpdate`, locally): the code it runs is now that update's.
|
|
439
|
+
* `checked` is each placed component's module (`/src/X.tsx`) the update
|
|
440
|
+
* touched, with null when it replaced cleanly and the failure when it did
|
|
441
|
+
* not, which leaves the stage running the previous code. `health()` says
|
|
442
|
+
* so as a component failure, and its `loadedAt` moves to now. */
|
|
443
|
+
noteHotUpdate(checked: ReadonlyMap<string, string | null>): void;
|
|
91
444
|
getState(): StageClientState;
|
|
92
445
|
subscribe(listener: () => void): () => void;
|
|
93
446
|
/** The chrome's handlers, or null before connect. The stage calls up through
|
|
@@ -98,64 +451,1444 @@ export interface StageClient {
|
|
|
98
451
|
setQuadSource(source: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null): void;
|
|
99
452
|
/** How the stage answers `fitView()`. Registered by the viewport. */
|
|
100
453
|
setFitter(fit: (() => ShotView) | null): void;
|
|
454
|
+
/** How the stage answers `designPointAt()`. Registered by the viewport;
|
|
455
|
+
* until then the bridge answers null and a drop lands centred. */
|
|
456
|
+
setDesignPointer(source: ((clientX: number, clientY: number) => readonly [number, number] | null) | null): void;
|
|
457
|
+
/** What the last instance-module resolution could not load. Registered by
|
|
458
|
+
* StageApp; the Tier-1 export REFUSES rather than shipping the empty layers
|
|
459
|
+
* those errors describe. */
|
|
460
|
+
setInstanceErrors(errors: readonly string[]): void;
|
|
461
|
+
/** Layers whose component RESOLVED and then threw while rendering — the
|
|
462
|
+
* boundary contains the crash and the layer draws nothing, which an export
|
|
463
|
+
* cannot tell from a layer meant to be empty. Same refusal, other cause. */
|
|
464
|
+
setInstanceCrashes(errors: readonly string[]): void;
|
|
465
|
+
/** A crash above the instance boundaries makes the whole stage unusable.
|
|
466
|
+
* Latch it into the export refusal path and reject an armed capture now,
|
|
467
|
+
* instead of leaving the controller to wait for its presentation timeout. */
|
|
468
|
+
setFatalError(message: string): void;
|
|
469
|
+
/** Known render-input or renderer failures whose fallback pixels are valid
|
|
470
|
+
* for an interactive preview but not an exact export. Registered by the
|
|
471
|
+
* stage and cleared when every reported source has recovered.
|
|
472
|
+
* `compileBlocked` says one of them is a standing renderer compile failure
|
|
473
|
+
* that stopped the draw (the runtime's `blocking`, never a list of failed
|
|
474
|
+
* pipelines the rest draws around): the stage cannot present, so it owes
|
|
475
|
+
* no presentation (v51). */
|
|
476
|
+
setRenderErrors(errors: readonly string[], compileBlocked?: boolean): void;
|
|
477
|
+
/** The pieces the runtime isolated (`ShotRuntimeProps.onPieceFailures`),
|
|
478
|
+
* whole. Forwarded to the chrome (v34 `onPieceFailures`), and an exact
|
|
479
|
+
* capture of any frame a listed piece belongs to REFUSES by name, since
|
|
480
|
+
* that frame would be missing it: at arm, at the matching presentation,
|
|
481
|
+
* and at once for a capture already armed. */
|
|
482
|
+
setPieceFailures(failures: readonly StagePieceFailure[]): void;
|
|
483
|
+
/** The list as last set. */
|
|
484
|
+
pieceFailures(): readonly StagePieceFailure[];
|
|
485
|
+
/** An error outside the piece log: an uncaught throw or rejection in this
|
|
486
|
+
* document, the export worker's failure. Kept, deduplicated, for
|
|
487
|
+
* `health()` (v35) and announced to the chrome (`onStageHealth`). */
|
|
488
|
+
recordError(source: StageErrorSource, message: string): void;
|
|
489
|
+
/** How the stage answers `requestNodePreviews`. Registered by the node
|
|
490
|
+
* preview component once it mounts; until then the bridge answers null,
|
|
491
|
+
* which is the same posture `setQuadSource` takes for selection shapes. */
|
|
492
|
+
setNodePreviewSource(
|
|
493
|
+
source: ((request: NodePreviewRequest) => Promise<NodePreviewBatch | null>) | null,
|
|
494
|
+
): void;
|
|
495
|
+
/** How the stage answers `coverage()` (v36): the runtime's check of the
|
|
496
|
+
* live Shot, handed over while one is mounted and null once none is. */
|
|
497
|
+
setCoverageProbe(probe: CoverageProbe | null): void;
|
|
101
498
|
/** …and `fitWorkingView()`. A separate registration rather than one fitter
|
|
102
499
|
* returning both, because the two are computed from different things: the
|
|
103
500
|
* camera fit needs the PANE SIZE and the working fit needs the DOCUMENT. */
|
|
104
501
|
setWorkingFitter(fit: (() => WorkingView) | null): void;
|
|
502
|
+
/** How `hoverLayer` reaches the overlay: an imperative sink registered by
|
|
503
|
+
* StageApp, so a chrome hover never routes through `set()` (a stage commit
|
|
504
|
+
* is a GL draw under frameloop="never"). */
|
|
505
|
+
setHoverSink(sink: ((layerId: string | null) => void) | null): void;
|
|
506
|
+
/** The stage area element, registered by StageApp — the Tier-1 exporter
|
|
507
|
+
* finds the GL canvas and the commit marker through it. Stage-internal,
|
|
508
|
+
* never on the bridge. */
|
|
509
|
+
setExportArea(el: HTMLElement | null): void;
|
|
510
|
+
/** The renderer-owned framebuffer canvas. Kept separate from the area marker
|
|
511
|
+
* so a component DOM portal cannot redirect capture by adding another canvas.
|
|
512
|
+
* The optional predicate is the renderer's already-created WebGL context,
|
|
513
|
+
* which closes the event-delivery gap around a lost context. */
|
|
514
|
+
setExportCanvas(
|
|
515
|
+
canvas: HTMLCanvasElement | null,
|
|
516
|
+
isContextLost?: () => boolean,
|
|
517
|
+
): void;
|
|
105
518
|
setDisposer(dispose: () => void): void;
|
|
519
|
+
/** A press or a wheel on the stage: aborts a dashboard poster capture that
|
|
520
|
+
* holds the stage in export framing, and answers true when there was one,
|
|
521
|
+
* so the input is swallowed rather than landing on a picture the author
|
|
522
|
+
* never framed. */
|
|
523
|
+
yieldPoster(): boolean;
|
|
524
|
+
/** `passDrawing`, as a stable synchronous getter for StageTools' frame
|
|
525
|
+
* loop, which runs inside a draw and cannot wait for a render (v51). */
|
|
526
|
+
passDrawing(): boolean;
|
|
527
|
+
/** Where the frozen picture and its shield mount (v51): StageApp's element
|
|
528
|
+
* between the runtime's canvas host and the overlays, OUTSIDE the canvas's
|
|
529
|
+
* parent, so `exportRefusal`'s portal scan never counts it. Stage-internal.
|
|
530
|
+
* Null (the stage unmounting it) ends a pass and drops a freeze. */
|
|
531
|
+
registerFreezeHost(el: HTMLElement | null): void;
|
|
532
|
+
/** Whether a stage interaction is live (v51): a G/R/S modal, a pen or
|
|
533
|
+
* spline draft, a marquee or a held pointer, a drag over the stage.
|
|
534
|
+
* Registered by StageApp, which owns them. A preparing pass neither starts
|
|
535
|
+
* nor goes on while one is (`yielded: "interaction"`); the inline text
|
|
536
|
+
* editor and a spline edit are the client's own state and count too. */
|
|
537
|
+
setInteractionProbe(probe: (() => boolean) | null): void;
|
|
538
|
+
/** One of those interactions just began: a running preparing pass ends now
|
|
539
|
+
* (`yielded: "interaction"`), not at its next hop. */
|
|
540
|
+
yieldToInteraction(): void;
|
|
541
|
+
/** The raster rung the stage draws at (the authored zoom, quantised:
|
|
542
|
+
* `editorRasterScale`), the last part of
|
|
543
|
+
* `preparationKey().base`. Registered by StageApp. */
|
|
544
|
+
setRasterScale(scale: number): void;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** What one presentation drew, beside its document (v51 `noteDrawn`). */
|
|
548
|
+
export interface StageDrawn {
|
|
549
|
+
/** Its subject (`stageSubject` of the render that drew it). */
|
|
550
|
+
subject: string | null;
|
|
551
|
+
/** Drawn in the playing presentation: the `playing` it rendered with. */
|
|
552
|
+
playing: boolean;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* WHAT THE STAGE SHOWS (v51), the subject the readiness latch is kept for:
|
|
557
|
+
* the Project, a component context on it, or a material preview of one of its
|
|
558
|
+
* meshes. The document's id leads every one, so another Project is another
|
|
559
|
+
* subject even under the same code context. Null with no document.
|
|
560
|
+
*/
|
|
561
|
+
export function stageSubject(
|
|
562
|
+
doc: Doc3D | null,
|
|
563
|
+
context: ShotComponentContext | null,
|
|
564
|
+
preview: MaterialPreviewTarget | null,
|
|
565
|
+
): string | null {
|
|
566
|
+
if (doc === null) return null;
|
|
567
|
+
if (preview !== null) return `${doc.id}\nmaterial:${preview.layerId}:${preview.slot}`;
|
|
568
|
+
return context === null ? doc.id : `${doc.id}\n${componentContextSubject(context)}`;
|
|
106
569
|
}
|
|
107
570
|
|
|
108
|
-
|
|
571
|
+
/**
|
|
572
|
+
* How long the preparing pass waits for one frame's presentation before it
|
|
573
|
+
* moves on. A cold frame can take over a second (a lit material's program is
|
|
574
|
+
* 100 to 300 ms on its own, and gemini's first cut measured 1.5 s); the
|
|
575
|
+
* deadline only has to stop one frame that never lands from holding Play.
|
|
576
|
+
*/
|
|
577
|
+
export const PREPARE_FRAME_DEADLINE_MS = 5_000;
|
|
578
|
+
|
|
579
|
+
/** The longest an `"idle"` pace waits for the browser's idle time between two
|
|
580
|
+
* frames (`requestIdleCallback`'s timeout). */
|
|
581
|
+
export const IDLE_HOP_TIMEOUT_MS = 100;
|
|
582
|
+
|
|
583
|
+
/** How long a frozen picture still stands once the author scrubs under it: a
|
|
584
|
+
* scrub shows the frames it passes, a video frame still decoding or a Shot
|
|
585
|
+
* still loading included, so it never waits out the 5 s backstop. */
|
|
586
|
+
export const SCRUB_LIFT_MS = 150;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Lay `bitmap` over `canvas`, in `host`, with a SHIELD over every overlay.
|
|
590
|
+
*
|
|
591
|
+
* The host is StageApp's, after the runtime's canvas host and before the
|
|
592
|
+
* overlays (`registerFreezeHost`): the copy covers the canvas and sits under
|
|
593
|
+
* the overlays, and `exportRefusal`'s portal scan, which counts the canvas's
|
|
594
|
+
* siblings, never sees it. The shield is transparent and stacks over the
|
|
595
|
+
* overlays. A press or a wheel on it is swallowed and calls `onPress`: the
|
|
596
|
+
* scene graph under it holds another frame, so a pick there would act on a
|
|
597
|
+
* picture the author is not looking at. A right press opens no browser menu,
|
|
598
|
+
* a move, a click and a double click stop there, so the hover and the
|
|
599
|
+
* double-click lanes stay still. It sets no cursor, so the area's shows
|
|
600
|
+
* through. Removing both is the caller's.
|
|
601
|
+
*/
|
|
602
|
+
function mountFreeze(
|
|
603
|
+
host: HTMLElement,
|
|
604
|
+
canvas: HTMLCanvasElement,
|
|
605
|
+
bitmap: ImageBitmap,
|
|
606
|
+
onPress: () => void,
|
|
607
|
+
): { remove(): void } {
|
|
608
|
+
const frozen = document.createElement("canvas");
|
|
609
|
+
frozen.width = bitmap.width;
|
|
610
|
+
frozen.height = bitmap.height;
|
|
611
|
+
frozen.getContext("2d")?.drawImage(bitmap, 0, 0);
|
|
612
|
+
frozen.dataset["preparing"] = "frozen";
|
|
613
|
+
const box = canvas.getBoundingClientRect();
|
|
614
|
+
const origin = host.getBoundingClientRect();
|
|
615
|
+
Object.assign(frozen.style, {
|
|
616
|
+
position: "absolute",
|
|
617
|
+
left: `${box.left - origin.left}px`,
|
|
618
|
+
top: `${box.top - origin.top}px`,
|
|
619
|
+
width: `${box.width}px`,
|
|
620
|
+
height: `${box.height}px`,
|
|
621
|
+
pointerEvents: "none",
|
|
622
|
+
});
|
|
623
|
+
const shield = document.createElement("div");
|
|
624
|
+
shield.dataset["preparing"] = "shield";
|
|
625
|
+
Object.assign(shield.style, {
|
|
626
|
+
position: "absolute",
|
|
627
|
+
left: "0",
|
|
628
|
+
top: "0",
|
|
629
|
+
width: "100%",
|
|
630
|
+
height: "100%",
|
|
631
|
+
zIndex: "1",
|
|
632
|
+
pointerEvents: "auto",
|
|
633
|
+
});
|
|
634
|
+
const press = (event: Event): void => {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
event.stopPropagation();
|
|
637
|
+
onPress();
|
|
638
|
+
};
|
|
639
|
+
const stop = (event: Event): void => event.stopPropagation();
|
|
640
|
+
shield.addEventListener("pointerdown", press);
|
|
641
|
+
shield.addEventListener("wheel", press, { passive: false });
|
|
642
|
+
shield.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
643
|
+
for (const type of ["pointermove", "click", "dblclick"]) shield.addEventListener(type, stop);
|
|
644
|
+
host.append(frozen, shield);
|
|
645
|
+
return {
|
|
646
|
+
remove() {
|
|
647
|
+
frozen.remove();
|
|
648
|
+
shield.remove();
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The changes a preparing pass ends on (v51 `yielded: "push"`): a stage push
|
|
654
|
+
* that changes the picture or the overlays drawn over it. */
|
|
655
|
+
const PASS_ENDS_ON_PUSH: ReadonlySet<keyof StageClientState> = new Set<keyof StageClientState>([
|
|
656
|
+
"fontPreview",
|
|
657
|
+
"motionPreview",
|
|
658
|
+
"grid",
|
|
659
|
+
"editorChrome",
|
|
660
|
+
"motionPaths",
|
|
661
|
+
"peerSelections",
|
|
662
|
+
"studioComments",
|
|
663
|
+
"sketchGuide",
|
|
664
|
+
"readOnly",
|
|
665
|
+
"textEdit",
|
|
666
|
+
"isolation",
|
|
667
|
+
"materialPreview",
|
|
668
|
+
"componentContext",
|
|
669
|
+
"layout",
|
|
670
|
+
"video",
|
|
671
|
+
"view",
|
|
672
|
+
"inset",
|
|
673
|
+
]);
|
|
674
|
+
|
|
675
|
+
export function createStageClient(
|
|
676
|
+
opts: {
|
|
677
|
+
/** Test seam: the Tier-1 exporter factory. The real one spawns a module
|
|
678
|
+
* Worker and reads a live GL canvas, neither of which exists in jsdom —
|
|
679
|
+
* a fake here is what lets the deferral rules be pinned. */
|
|
680
|
+
createExporter?: typeof createShotStageExporter;
|
|
681
|
+
/** Test seam for deterministic request-to-present timing. */
|
|
682
|
+
performanceTelemetry?: StagePerformanceTelemetry;
|
|
683
|
+
/** Test seam for the close/export ordering of the three runtime caches. */
|
|
684
|
+
clearCaches?: () => void;
|
|
685
|
+
/** Test seam: where the font preview's faces load (v50). jsdom has no
|
|
686
|
+
* CSS Font Loading API. */
|
|
687
|
+
fontPlatform?: () => FontPlatform | null;
|
|
688
|
+
} = {},
|
|
689
|
+
): StageClient {
|
|
109
690
|
let state: StageClientState = INITIAL;
|
|
691
|
+
const clock = createShotClock(0);
|
|
692
|
+
/** The last frame the transport accepted: the coalescing reference and
|
|
693
|
+
* what a mode that renders the frame through React starts from. */
|
|
694
|
+
let lastFrame = 0;
|
|
695
|
+
/** The preparing pass in flight (`preparePlayback`), or null. `end` stops
|
|
696
|
+
* it and gives the stage back (a reason, or null for a cancel); `pace` is
|
|
697
|
+
* the chrome's, asked again here; `startFrame` is the author's frame it
|
|
698
|
+
* gives back; `ended` settles once that frame has been published again. */
|
|
699
|
+
let preparing: {
|
|
700
|
+
end(reason: ShotPreparePlaybackYield | null): void;
|
|
701
|
+
pace(): ShotPreparePace;
|
|
702
|
+
startFrame: number;
|
|
703
|
+
ended: Promise<void>;
|
|
704
|
+
} | null = null;
|
|
705
|
+
/** Coverage checks running (v36): a pass under an `"idle"` pace would draw
|
|
706
|
+
* other frames under them, so none starts meanwhile. */
|
|
707
|
+
let checking = 0;
|
|
708
|
+
/** The frozen picture over the canvas (v51), or null. It belongs to the
|
|
709
|
+
* client and not to one pass: a pass that starts while it still stands
|
|
710
|
+
* adopts it. */
|
|
711
|
+
let freeze: { remove(): void } | null = null;
|
|
712
|
+
/** The element it mounts in (`registerFreezeHost`). */
|
|
713
|
+
let freezeHost: HTMLElement | null = null;
|
|
714
|
+
/** The freeze's pending lift, or null: on the first complete presentation
|
|
715
|
+
* of the frame the clock is on, from the publication `after` on (prepare
|
|
716
|
+
* tokens count down), paused, or in either presentation once the
|
|
717
|
+
* transport runs (`either`). `deadline` is the 5 s backstop, which an
|
|
718
|
+
* author's scrub has shortened once `scrubbed` (`SCRUB_LIFT_MS`). */
|
|
719
|
+
let lift: { after: number; either: boolean; scrubbed: boolean; deadline: ReturnType<typeof setTimeout> } | null = null;
|
|
720
|
+
/** StageApp's answer to "is a stage interaction live" (v51). */
|
|
721
|
+
let interactionProbe: (() => boolean) | null = null;
|
|
722
|
+
const interactionLive = (): boolean =>
|
|
723
|
+
state.textEdit !== null || state.splineEdit !== null || interactionProbe?.() === true;
|
|
724
|
+
/** The transport is running: the chrome's `setPlaying`, which `playing`
|
|
725
|
+
* is not while a pass holds the playing presentation. */
|
|
726
|
+
let transport = false;
|
|
727
|
+
/** What the transport drew whole in the playing presentation since it
|
|
728
|
+
* started (`takePlayed`), as frames per document object: a loop draws the
|
|
729
|
+
* same frames again, and a Set keeps them once. */
|
|
730
|
+
let played = new Map<Doc3D, Set<number>>();
|
|
731
|
+
/** What `preparationKey()` reads: the GPU epoch (a new renderer, the end of
|
|
732
|
+
* export framing, a cache clear, a close: `gpuStateReplaced`), the raster
|
|
733
|
+
* rung, and the module epoch and hot updates the placed code components
|
|
734
|
+
* were imported under. */
|
|
735
|
+
let gpuEpoch = 0;
|
|
736
|
+
let rasterScale = 1;
|
|
737
|
+
let moduleEpoch: number | null = null;
|
|
738
|
+
let hotUpdates = 0;
|
|
739
|
+
/** The one MessageChannel a `"play"` pace hops through, made on first
|
|
740
|
+
* use, and the steps waiting on it in order. */
|
|
741
|
+
let hopChannel: MessageChannel | null = null;
|
|
742
|
+
const hops: (() => void)[] = [];
|
|
743
|
+
/**
|
|
744
|
+
* The next frame, in a task of its own. Never `setTimeout(step, 0)`: a
|
|
745
|
+
* nested timer clamps to 4 ms. `"play"` hops through the message channel;
|
|
746
|
+
* `"idle"` waits for the browser's idle time, at most `IDLE_HOP_TIMEOUT_MS`
|
|
747
|
+
* (a browser without `requestIdleCallback` hops as `"play"` does).
|
|
748
|
+
*/
|
|
749
|
+
const hop = (pace: ShotPreparePace, run: () => void): void => {
|
|
750
|
+
if (pace === "idle" && typeof requestIdleCallback === "function") {
|
|
751
|
+
requestIdleCallback(() => run(), { timeout: IDLE_HOP_TIMEOUT_MS });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (hopChannel === null) {
|
|
755
|
+
hopChannel = new MessageChannel();
|
|
756
|
+
hopChannel.port1.onmessage = () => hops.shift()?.();
|
|
757
|
+
}
|
|
758
|
+
hops.push(run);
|
|
759
|
+
hopChannel.port2.postMessage(null);
|
|
760
|
+
};
|
|
761
|
+
/** The runtime's coverage check of the live Shot, registered while one is
|
|
762
|
+
* mounted (`ShotRuntimeProps.onCoverageProbe`). */
|
|
763
|
+
let coverageProbe: CoverageProbe | null = null;
|
|
764
|
+
/** The dashboard poster in flight (`capturePoster`), or null. It is
|
|
765
|
+
* decoration: whatever the author starts on the stage aborts it, and
|
|
766
|
+
* `settled` is the moment the stage has left its export framing. */
|
|
767
|
+
let poster: { abort: AbortController; settled: Promise<void> } | null = null;
|
|
768
|
+
/** The document whose poster capture FAILED (not yielded). It answers null
|
|
769
|
+
* until the document changes, so the chrome may ask again freely: a
|
|
770
|
+
* retry costs a comparison, never another minute in export framing. */
|
|
771
|
+
let posterFailedFor: Doc3D | null = null;
|
|
772
|
+
/** Abort the poster when it holds the stage in export framing, resolving
|
|
773
|
+
* once the stage is back in the author's; null when there is none. */
|
|
774
|
+
const yieldPoster = (): Promise<void> | null => {
|
|
775
|
+
if (poster === null || state.exporting === null) return null;
|
|
776
|
+
poster.abort.abort();
|
|
777
|
+
return poster.settled;
|
|
778
|
+
};
|
|
779
|
+
/** Publications the preparing pass is waiting to see presented, by token. */
|
|
780
|
+
const presentWaiters = new Map<number, (drawnPlaying: boolean) => void>();
|
|
781
|
+
/** The preparing pass's own tokens: NEGATIVE, so they can never be one the
|
|
782
|
+
* telemetry minted (it counts up from 1). */
|
|
783
|
+
let prepareTokens = 0;
|
|
784
|
+
const prepareToken = (): number => {
|
|
785
|
+
prepareTokens -= 1;
|
|
786
|
+
return prepareTokens;
|
|
787
|
+
};
|
|
788
|
+
const performanceTelemetry =
|
|
789
|
+
opts.performanceTelemetry ?? new StagePerformanceTelemetry({ rich: PERF_HUD_ENABLED });
|
|
790
|
+
const renderScale = new RenderScaleController();
|
|
791
|
+
/** The scene under a master frame, for `StageClientState.sceneId`. */
|
|
792
|
+
const sceneIdAt = (doc: Doc3D | null, frame: number): string | null =>
|
|
793
|
+
doc === null || state.componentContext !== null ? null : (masterToSceneCached(doc, frame)?.sceneId ?? null);
|
|
110
794
|
const listeners = new Set<() => void>();
|
|
111
795
|
let handlers: ShotBridgeHandlers | null = null;
|
|
112
796
|
let quadSource: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null = null;
|
|
113
797
|
let fitter: (() => ShotView) | null = null;
|
|
798
|
+
let designPointer: ((clientX: number, clientY: number) => readonly [number, number] | null) | null = null;
|
|
799
|
+
let nodePreviewSource:
|
|
800
|
+
| ((request: NodePreviewRequest) => Promise<NodePreviewBatch | null>)
|
|
801
|
+
| null = null;
|
|
114
802
|
let workingFitter: (() => WorkingView) | null = null;
|
|
803
|
+
/** The chrome-hover lane's imperative sink, registered by StageApp. Null
|
|
804
|
+
* before the stage mounts — a hover over a not-yet-connected stage is
|
|
805
|
+
* nothing to draw on. */
|
|
806
|
+
let hoverSink: ((layerId: string | null) => void) | null = null;
|
|
115
807
|
let dispose: (() => void) | null = null;
|
|
808
|
+
let exportArea: HTMLElement | null = null;
|
|
809
|
+
let exportCanvas: HTMLCanvasElement | null = null;
|
|
810
|
+
let exportContextLost = false;
|
|
811
|
+
let exportContextIsLost: (() => boolean) | null = null;
|
|
812
|
+
let removeExportCanvasListeners: (() => void) | null = null;
|
|
813
|
+
let exporter: ShotStageExporter | null = null;
|
|
814
|
+
/** Created on first use: the real one spawns a Worker to probe. */
|
|
815
|
+
const exporterOf = (): ShotStageExporter =>
|
|
816
|
+
(exporter ??= (opts.createExporter ?? createShotStageExporter)(exportHost, {
|
|
817
|
+
onWorkerError: (message) => recordError("export-worker", message),
|
|
818
|
+
}));
|
|
819
|
+
let pendingCapture: {
|
|
820
|
+
frame: number;
|
|
821
|
+
width: number;
|
|
822
|
+
height: number;
|
|
823
|
+
snapshotting: boolean;
|
|
824
|
+
snapshot: (canvas: HTMLCanvasElement) => Promise<ImageBitmap>;
|
|
825
|
+
resolve: (bitmap: ImageBitmap) => void;
|
|
826
|
+
reject: (error: unknown) => void;
|
|
827
|
+
} | null = null;
|
|
828
|
+
/** What the last instance-module resolution could not load; the Tier-1
|
|
829
|
+
* export refuses while it is non-empty. */
|
|
830
|
+
let instanceErrors: string[] = [];
|
|
831
|
+
/** Contained render crashes, by the same rule. */
|
|
832
|
+
let instanceCrashes: string[] = [];
|
|
833
|
+
/** Placed components' modules whose last hot update failed, by module
|
|
834
|
+
* path (`/src/X.tsx`): the stage still runs their previous code. */
|
|
835
|
+
const hotErrors = new Map<string, string>();
|
|
836
|
+
/** When the stage last took a hot update of the project's code (wall ms),
|
|
837
|
+
* or null: `health().loadedAt` is the later of this and its load. */
|
|
838
|
+
let codeAt: number | null = null;
|
|
839
|
+
/** The hot-update failures of modules the document on stage places. */
|
|
840
|
+
const placedHotErrors = (): string[] => {
|
|
841
|
+
if (hotErrors.size === 0 || state.doc === null) return [];
|
|
842
|
+
const placed = new Set(instanceComponentIdsOf(state.doc).map((id) => `/${instanceModuleOf(id)}`));
|
|
843
|
+
return [...hotErrors].filter(([module]) => placed.has(module)).map(([, message]) => message);
|
|
844
|
+
};
|
|
845
|
+
/** Last-resort StageRootBoundary failure. The boundary cannot recover without
|
|
846
|
+
* reloading the stage realm, so this remains terminal for this client. */
|
|
847
|
+
let fatalError: string | null = null;
|
|
848
|
+
/** Recoverable failures outside instance resolution. A preview may show a
|
|
849
|
+
* fallback for these, but an accepted export must not silently bake it. */
|
|
850
|
+
let renderErrors: string[] = [];
|
|
851
|
+
/** The pieces the runtime isolated, as last reported. Scoped per FRAME at
|
|
852
|
+
* the gate (`piecesAt`): a Shot warming ahead of its cut in the editor is
|
|
853
|
+
* not in the frame being captured. */
|
|
854
|
+
let pieceFailures: readonly StagePieceFailure[] = [];
|
|
855
|
+
// ── HEALTH (v35) ─────────────────────────────────────────────────────
|
|
856
|
+
//
|
|
857
|
+
// What `health()` answers: everything above, the errors outside the piece
|
|
858
|
+
// log, and what was last PRESENTED (a complete picture, readiness clear),
|
|
859
|
+
// with the saved revision and module epoch it was drawn from. The chrome
|
|
860
|
+
// hears `onStageHealth` when any of it changes, never per frame: a
|
|
861
|
+
// presentation announces itself only when its document, revision or epoch
|
|
862
|
+
// differs from the last one's.
|
|
863
|
+
let stageErrors: StageRuntimeError[] = [];
|
|
864
|
+
let rendered: { -readonly [K in keyof StageRendered]: StageRendered[K] } | null = null;
|
|
865
|
+
/** The document object `rendered` was drawn from. */
|
|
866
|
+
let presentedDoc: Doc3D | null = null;
|
|
867
|
+
/** Each pushed document's saved revision, by object: a presentation may
|
|
868
|
+
* still draw the document a newer push replaced. */
|
|
869
|
+
const revOfDoc = new WeakMap<Doc3D, string | null>();
|
|
870
|
+
/** Editor telemetry only, never read by anything that draws. */
|
|
871
|
+
const wallNow = (): number => Date.now();
|
|
872
|
+
/** What `health()` says was last presented: announced only when the
|
|
873
|
+
* document, its revision or the module epoch moved, never per frame. */
|
|
874
|
+
function notePresented(info: ShotPresentedFrame, document: Doc3D | null, epoch: number | null): void {
|
|
875
|
+
if (document === null) return;
|
|
876
|
+
const rev = revOfDoc.get(document) ?? null;
|
|
877
|
+
const same = rendered !== null && presentedDoc === document && rendered.rev === rev && rendered.epoch === epoch;
|
|
878
|
+
presentedDoc = document;
|
|
879
|
+
if (same) {
|
|
880
|
+
rendered!.frame = info.frame;
|
|
881
|
+
rendered!.at = wallNow();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
rendered = { rev, docId: document.id, frame: info.frame, epoch, at: wallNow() };
|
|
885
|
+
notifyHealth();
|
|
886
|
+
}
|
|
887
|
+
function recordError(source: StageErrorSource, message: string): void {
|
|
888
|
+
stageErrors = recordStageError(stageErrors, source, message, state.docRev, wallNow());
|
|
889
|
+
notifyHealth();
|
|
890
|
+
}
|
|
891
|
+
function notifyHealth(): void {
|
|
892
|
+
try {
|
|
893
|
+
handlers?.onStageHealth?.();
|
|
894
|
+
} catch {
|
|
895
|
+
// A handler must never throw back into the stage (bridgeTypes).
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/** The GPU state behind prepared frames was replaced (v51): a new
|
|
899
|
+
* renderer, the end of export framing, a cache clear, a close. The
|
|
900
|
+
* preparation base moves, and the chrome hears it (`onStageHealth`), so a
|
|
901
|
+
* scheduler that had nothing left to prepare looks again. */
|
|
902
|
+
function gpuStateReplaced(): void {
|
|
903
|
+
gpuEpoch += 1;
|
|
904
|
+
notifyHealth();
|
|
905
|
+
}
|
|
906
|
+
/** Chrome pushes that landed mid-export, applied when it ends. `frame` also
|
|
907
|
+
* remembers the pre-export playhead so a cancelled export puts the author
|
|
908
|
+
* back where they were. */
|
|
909
|
+
const deferred: {
|
|
910
|
+
doc: Doc3D | null;
|
|
911
|
+
/** `doc`'s revision, from the same push. */
|
|
912
|
+
rev: string | null;
|
|
913
|
+
frame: number | null;
|
|
914
|
+
componentContext: ShotComponentContext | null;
|
|
915
|
+
closed: boolean;
|
|
916
|
+
clearCaches: boolean;
|
|
917
|
+
/** The latest isolation push, boxed so "isolate nothing" (null) is not
|
|
918
|
+
* mistaken for "nothing was pushed". `orbit` is the camera mode the
|
|
919
|
+
* chrome re-asserts right after it (`setIsolationOrbit`). */
|
|
920
|
+
isolation: { layerId: string | null; orbit: boolean } | null;
|
|
921
|
+
} = {
|
|
922
|
+
doc: null,
|
|
923
|
+
rev: null,
|
|
924
|
+
frame: null,
|
|
925
|
+
componentContext: null,
|
|
926
|
+
closed: false,
|
|
927
|
+
clearCaches: false,
|
|
928
|
+
isolation: null,
|
|
929
|
+
};
|
|
116
930
|
|
|
117
931
|
function set(patch: Partial<StageClientState>): void {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
932
|
+
const keys = Object.keys(patch) as (keyof StageClientState)[];
|
|
933
|
+
if (keys.every((key) => Object.is(state[key], patch[key]))) return;
|
|
934
|
+
const was = state;
|
|
935
|
+
state = { ...state, ...patch };
|
|
936
|
+
// THE SUBJECT, and the latch with it (v51): a new one is owed its first
|
|
937
|
+
// whole presentation on this renderer. An edit of the open Project is
|
|
938
|
+
// not a new subject: the latch is kept per subject, never per document
|
|
939
|
+
// object, or every keystroke would hold Play again.
|
|
940
|
+
if (state.doc !== was.doc || state.componentContext !== was.componentContext || state.materialPreview !== was.materialPreview) {
|
|
941
|
+
const next = stageSubject(state.doc, state.componentContext, state.materialPreview);
|
|
942
|
+
if (next !== subject) {
|
|
943
|
+
subject = next;
|
|
944
|
+
latch = null;
|
|
945
|
+
queueBusy();
|
|
946
|
+
// A FROZEN PICTURE OF ONE SUBJECT never stands for another, nor do
|
|
947
|
+
// the overlays held with it: the new subject's own progressive draw is
|
|
948
|
+
// the honest picture, and its first whole one, which the lift waits
|
|
949
|
+
// for, is exactly what a load withholds. In this patch, not a second.
|
|
950
|
+
if (freeze !== null) {
|
|
951
|
+
cancelLift();
|
|
952
|
+
freeze.remove();
|
|
953
|
+
freeze = null;
|
|
954
|
+
state = { ...state, held: null, passDrawing: false };
|
|
955
|
+
}
|
|
123
956
|
}
|
|
124
957
|
}
|
|
125
|
-
|
|
126
|
-
|
|
958
|
+
// THE PHASE THE RUNTIME READS (`stagePhase`), in this task: the hand-over
|
|
959
|
+
// clears `passDrawing` and publishes the start frame in one, and that
|
|
960
|
+
// draw is the transport's first picture, never a pass's.
|
|
961
|
+
setStagePhase(state.passDrawing ? "pass" : state.playing ? "transport" : "paused");
|
|
127
962
|
for (const l of listeners) l();
|
|
963
|
+
// A PASS GIVES WAY (v51) to a stage interaction starting, and to a push
|
|
964
|
+
// that changes the picture or what is drawn over it. By value, because
|
|
965
|
+
// the chrome re-pushes some of these unchanged with every document push.
|
|
966
|
+
const pass = preparing;
|
|
967
|
+
if (pass === null) return;
|
|
968
|
+
const moved = (key: keyof StageClientState): boolean => JSON.stringify(was[key]) !== JSON.stringify(state[key]);
|
|
969
|
+
if (state.splineEdit !== null && moved("splineEdit")) pass.end("interaction");
|
|
970
|
+
else if (keys.some((key) => PASS_ENDS_ON_PUSH.has(key) && moved(key))) pass.end("push");
|
|
128
971
|
}
|
|
129
972
|
|
|
973
|
+
/** The font preview's faces (v50): loaded outside every readiness claim and
|
|
974
|
+
* every error report, and handed to the state once they land. */
|
|
975
|
+
const previewFaces = createFontPreviewFaces((face) => {
|
|
976
|
+
if (state.fontPreview !== null) set({ fontPreview: { ...state.fontPreview, face } });
|
|
977
|
+
}, opts.fontPlatform);
|
|
978
|
+
/** Whether the font preview changes what the runtime draws: one naming no
|
|
979
|
+
* text layer of the open document changes nothing, so the coverage check
|
|
980
|
+
* and the poster refuse nothing for it. */
|
|
981
|
+
const previewingFont = (): boolean =>
|
|
982
|
+
state.doc !== null && state.fontPreview !== null && fontPreviewDoc(state.doc, state.fontPreview) !== state.doc;
|
|
983
|
+
/** The same question for the motion preview (v53): a preset that lands no
|
|
984
|
+
* key on the open document changes nothing. */
|
|
985
|
+
const previewingMotion = (): boolean =>
|
|
986
|
+
state.doc !== null && state.motionPreview !== null && motionPreviewDoc(state.doc, state.motionPreview) !== state.doc;
|
|
987
|
+
|
|
988
|
+
function validComponentContext(
|
|
989
|
+
ctx: ShotComponentContext | null,
|
|
990
|
+
doc: Doc3D | null,
|
|
991
|
+
): ShotComponentContext | null {
|
|
992
|
+
if (ctx === null) return null;
|
|
993
|
+
const zoom = typeof ctx.zoom === "number" && ctx.zoom > 0 && Number.isFinite(ctx.zoom)
|
|
994
|
+
? ctx.zoom
|
|
995
|
+
: 1;
|
|
996
|
+
// v45's preview fields, each omitted unless valid so a context without
|
|
997
|
+
// them stays the v44 object.
|
|
998
|
+
const look = {
|
|
999
|
+
...(ctx.background === "dark" || ctx.background === "light" || ctx.background === "brand"
|
|
1000
|
+
? { background: ctx.background }
|
|
1001
|
+
: {}),
|
|
1002
|
+
...(Number.isSafeInteger(ctx.loopFrames) && ctx.loopFrames! > 0 && ctx.loopFrames! <= MAX_LOOP_FRAMES
|
|
1003
|
+
? { loopFrames: ctx.loopFrames! }
|
|
1004
|
+
: {}),
|
|
1005
|
+
};
|
|
1006
|
+
if ("code" in ctx) {
|
|
1007
|
+
if (ctx.code.length === 0) return null;
|
|
1008
|
+
// Rebuilt, not passed through, so junk fields a future chrome sends
|
|
1009
|
+
// cannot ride into stage state; `props` survives only in validated form
|
|
1010
|
+
// (v27) — a malformed key is dropped rather than blanking the preview.
|
|
1011
|
+
const props = validPreviewProps(ctx.props);
|
|
1012
|
+
return { code: ctx.code, zoom, ...(props === undefined ? {} : { props }), ...look };
|
|
1013
|
+
}
|
|
1014
|
+
if (doc?.components?.[ctx.def] === undefined) return null;
|
|
1015
|
+
// Tried values (v46), validated like the code arm's. The Edit tab's bit
|
|
1016
|
+
// (v48) only ever crosses as `true`.
|
|
1017
|
+
const tried = validPreviewProps(ctx.props);
|
|
1018
|
+
return {
|
|
1019
|
+
def: ctx.def,
|
|
1020
|
+
zoom,
|
|
1021
|
+
...(tried === undefined ? {} : { props: tried }),
|
|
1022
|
+
...look,
|
|
1023
|
+
...(ctx.edit === true ? { edit: true as const } : {}),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** The listed pieces that belong to the picture at `frame`, in the frame
|
|
1028
|
+
* space the capture uses: a one-Shot export's frames are its slice's. */
|
|
1029
|
+
function piecesAt(frame: number): StagePieceFailure[] {
|
|
1030
|
+
if (pieceFailures.length === 0) return [];
|
|
1031
|
+
const doc = state.doc;
|
|
1032
|
+
if (doc === null) return [...pieceFailures];
|
|
1033
|
+
const scope = state.exporting?.scene;
|
|
1034
|
+
const drawn = scope === undefined ? doc : (sliceShotToScene(doc, scope) ?? doc);
|
|
1035
|
+
return pieceFailuresAt(pieceFailures, drawn, frame);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** The renderer's context is lost: its loss event fired, or the renderer
|
|
1039
|
+
* says so now. A renderer that cannot answer counts as lost: an exact
|
|
1040
|
+
* export cannot turn that uncertainty into accepted pixels. */
|
|
1041
|
+
function contextLost(): boolean {
|
|
1042
|
+
if (exportContextLost) return true;
|
|
1043
|
+
if (exportContextIsLost === null) return false;
|
|
1044
|
+
try {
|
|
1045
|
+
return exportContextIsLost();
|
|
1046
|
+
} catch {
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** `frame` is the capture's own; without one (a begin) the frame-scoped
|
|
1052
|
+
* refusals wait for the frame. */
|
|
1053
|
+
function exportRefusal(frame?: number): Error | null {
|
|
1054
|
+
if (fatalError !== null) {
|
|
1055
|
+
return new Error(`export refused because the stage crashed: ${fatalError}`);
|
|
1056
|
+
}
|
|
1057
|
+
if (frame !== undefined) {
|
|
1058
|
+
const broken = piecesAt(frame);
|
|
1059
|
+
if (broken.length > 0) {
|
|
1060
|
+
const first = broken[0]!;
|
|
1061
|
+
return new Error(
|
|
1062
|
+
`export refused: ${broken.length} piece(s) of frame ${frame} failed to render, so the frame ` +
|
|
1063
|
+
`would be missing them: ${describePieceFailure(first, pieceLayerLabel(state.doc, first))}`,
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (contextLost()) {
|
|
1068
|
+
return new Error(
|
|
1069
|
+
"export refused because the ShotRuntime WebGL context is lost; " +
|
|
1070
|
+
"reload the stage before exporting",
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if (renderErrors.length > 0) {
|
|
1074
|
+
return new Error(
|
|
1075
|
+
`export refused: ${renderErrors.length} known render failure(s) would make ` +
|
|
1076
|
+
`the accepted export incomplete or wrong: ${renderErrors[0]!}`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const moduleErrors = [...instanceErrors, ...placedHotErrors()];
|
|
1080
|
+
if (moduleErrors.length > 0) {
|
|
1081
|
+
return new Error(
|
|
1082
|
+
`export refused: ${moduleErrors.length} component module(s) failed to load, ` +
|
|
1083
|
+
`so their layers would render empty or stale: ${moduleErrors[0]!}`,
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
if (instanceCrashes.length > 0) {
|
|
1087
|
+
return new Error(
|
|
1088
|
+
`export refused: ${instanceCrashes.length} component(s) crashed while rendering, ` +
|
|
1089
|
+
`so their layers would render empty: ${instanceCrashes[0]!}`,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
const canvasParent = exportCanvas?.parentElement ?? null;
|
|
1093
|
+
const portal =
|
|
1094
|
+
canvasParent === null
|
|
1095
|
+
? null
|
|
1096
|
+
: [...canvasParent.children].find((child) => child !== exportCanvas) ?? null;
|
|
1097
|
+
if (portal !== null) {
|
|
1098
|
+
return new Error(
|
|
1099
|
+
"export refused: component DOM output is mounted beside the ShotRuntime canvas, " +
|
|
1100
|
+
"but browser export captures only the renderer framebuffer; use the Studio server render",
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** A refusal that arrives while a capture is armed cancels it at once, for
|
|
1107
|
+
* the frame that capture is for. */
|
|
1108
|
+
function refuseArmedCapture(): void {
|
|
1109
|
+
if (state.exporting === null) return;
|
|
1110
|
+
const refusal = exportRefusal(pendingCapture?.frame);
|
|
1111
|
+
if (refusal !== null) cancelPendingCapture(refusal);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function clearStageCaches(): void {
|
|
1115
|
+
// The GPU state prepared frames stood on goes with the caches (v51).
|
|
1116
|
+
gpuStateReplaced();
|
|
1117
|
+
if (opts.clearCaches !== undefined) {
|
|
1118
|
+
opts.clearCaches();
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
clearRasterCache();
|
|
1122
|
+
clearPlaneCache();
|
|
1123
|
+
clearLayerMaterialPool();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function cancelPendingCapture(error: Error): void {
|
|
1127
|
+
const pending = pendingCapture;
|
|
1128
|
+
if (pending === null) return;
|
|
1129
|
+
pendingCapture = null;
|
|
1130
|
+
pending.reject(error);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function registerExportCanvas(
|
|
1134
|
+
canvas: HTMLCanvasElement | null,
|
|
1135
|
+
isContextLost?: () => boolean,
|
|
1136
|
+
): void {
|
|
1137
|
+
removeExportCanvasListeners?.();
|
|
1138
|
+
removeExportCanvasListeners = null;
|
|
1139
|
+
exportCanvas = canvas;
|
|
1140
|
+
exportContextLost = false;
|
|
1141
|
+
exportContextIsLost = isContextLost ?? null;
|
|
1142
|
+
if (canvas === null) return;
|
|
1143
|
+
// A NEW RENDERER (v51): it owes the subject its first whole presentation,
|
|
1144
|
+
// and nothing an older one prepared is resident on it.
|
|
1145
|
+
latch = null;
|
|
1146
|
+
gpuStateReplaced();
|
|
1147
|
+
queueBusy();
|
|
1148
|
+
|
|
1149
|
+
const lost = (event: Event): void => {
|
|
1150
|
+
// WebGL restores only when the loss event is cancelled, so preview may
|
|
1151
|
+
// recover. Exact export stays terminal until a fresh renderer canvas
|
|
1152
|
+
// mounts: ClockDriver's program watch belongs to the old context and a
|
|
1153
|
+
// restored event alone does not prove those programs were recompiled.
|
|
1154
|
+
event.preventDefault();
|
|
1155
|
+
exportContextLost = true;
|
|
1156
|
+
cancelPendingCapture(
|
|
1157
|
+
new Error(
|
|
1158
|
+
"export refused because the ShotRuntime WebGL context was lost during capture",
|
|
1159
|
+
),
|
|
1160
|
+
);
|
|
1161
|
+
// A lost context presents nothing, so nothing may wait on it (v51):
|
|
1162
|
+
// not the hold, and not a pass, whose every frame would wait out its
|
|
1163
|
+
// deadline. The chrome reads the loss from `health()` as the pass ends.
|
|
1164
|
+
preparing?.end(null);
|
|
1165
|
+
queueBusy();
|
|
1166
|
+
};
|
|
1167
|
+
// On a WebGPU-backed canvas this event simply never fires; the listener is
|
|
1168
|
+
// the WebGL path's loss signal and is harmless to leave armed.
|
|
1169
|
+
canvas.addEventListener("webglcontextlost", lost);
|
|
1170
|
+
removeExportCanvasListeners = () => {
|
|
1171
|
+
canvas.removeEventListener("webglcontextlost", lost);
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* THE CAPTURE GATE'S QUESTION: is any real work outstanding.
|
|
1177
|
+
*
|
|
1178
|
+
* `shot:draw` and `shot:repaint` are internal handoff claims that open and
|
|
1179
|
+
* close inside a single presentation, so waiting on them is waiting on
|
|
1180
|
+
* yourself. `shot:instances` is a real load (StageApp holds it across
|
|
1181
|
+
* `resolveInstanceModules`), but the capture gate needs no claim for it:
|
|
1182
|
+
* StageApp checks the resolved map's identity directly before it calls
|
|
1183
|
+
* `present`, which is the stricter test. A paused video seek (`shot:seek`)
|
|
1184
|
+
* holds every capture, because photographing a stale frame is the bug that
|
|
1185
|
+
* claim exists to prevent.
|
|
1186
|
+
*/
|
|
1187
|
+
const HANDOFF = new Set(["shot:draw", "shot:repaint", "shot:instances"]);
|
|
1188
|
+
const externallyPending = (): boolean => stageReadiness.waitingOn().some((label) => !HANDOFF.has(label));
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* THE PLAYBACK HOLD'S QUESTION (v51): does this renderer still owe the open
|
|
1192
|
+
* subject its first whole presentation.
|
|
1193
|
+
*
|
|
1194
|
+
* Not "is any load open", which the hold used to ask of the registry: then
|
|
1195
|
+
* a preparing pass's own loads greyed Play, and the button lit on renderer
|
|
1196
|
+
* creation, before any picture. A whole presentation (`noteDrawn`'s full
|
|
1197
|
+
* path) of the subject closes the latch once, and loads arriving after it
|
|
1198
|
+
* hold nothing. A paused video seek therefore holds the subject's FIRST
|
|
1199
|
+
* whole presentation and never a later one, and Pause never re-arms it. A
|
|
1200
|
+
* new subject, a new renderer or a new stage document owes one again.
|
|
1201
|
+
*
|
|
1202
|
+
* Released, so nothing waits for ever, while the stage cannot present at
|
|
1203
|
+
* all: a crash, a lost context, a standing renderer compile failure. A
|
|
1204
|
+
* standing fallback (a texture, an html, a font or a glyph failure) is not
|
|
1205
|
+
* that: the stage still presents, so the latch closes.
|
|
1206
|
+
*/
|
|
1207
|
+
let subject: string | null = null;
|
|
1208
|
+
let latch: string | null = null;
|
|
1209
|
+
let compileBlocked = false;
|
|
1210
|
+
const presentationOwed = (): boolean =>
|
|
1211
|
+
state.doc !== null && latch !== subject && fatalError === null && !compileBlocked && !contextLost();
|
|
1212
|
+
|
|
1213
|
+
/** What the chrome was last TOLD. Null before `connect`: a value computed
|
|
1214
|
+
* while nobody was listening was never sent, and must not suppress the
|
|
1215
|
+
* first real push. */
|
|
1216
|
+
let sentBusy: boolean | null = null;
|
|
1217
|
+
let busyQueued = false;
|
|
1218
|
+
const publishBusy = (): void => {
|
|
1219
|
+
if (handlers === null) return;
|
|
1220
|
+
const busy = presentationOwed();
|
|
1221
|
+
if (busy === sentBusy) return;
|
|
1222
|
+
sentBusy = busy;
|
|
1223
|
+
handlers.onStageBusy(busy);
|
|
1224
|
+
};
|
|
1225
|
+
/** COALESCED TO A MICROTASK and then deduped by value: one commit can move
|
|
1226
|
+
* the subject, the renderer and the errors together, and the chrome only
|
|
1227
|
+
* needs to know where that run landed. Never from inside a presentation:
|
|
1228
|
+
* the chrome's store write would put React between two draws. */
|
|
1229
|
+
const queueBusy = (): void => {
|
|
1230
|
+
if (busyQueued) return;
|
|
1231
|
+
busyQueued = true;
|
|
1232
|
+
queueMicrotask(() => {
|
|
1233
|
+
busyQueued = false;
|
|
1234
|
+
publishBusy();
|
|
1235
|
+
});
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* ONE SECOND OF PRESENTATION, forwarded to the chrome.
|
|
1240
|
+
*
|
|
1241
|
+
* On a microtask for `publishBusy`'s reason: the window closes inside the
|
|
1242
|
+
* runtime's `onPresented` call stack, and a chrome store write from there
|
|
1243
|
+
* would put React rendering between a draw and the next one.
|
|
1244
|
+
*/
|
|
1245
|
+
const unsubscribeWindow = performanceTelemetry.subscribeWindow((window) => {
|
|
1246
|
+
const perSecond = 1000 / window.durationMs;
|
|
1247
|
+
const report = {
|
|
1248
|
+
presentedFps: window.presentedFrames * perSecond,
|
|
1249
|
+
expectedFps: window.expectedFrames * perSecond,
|
|
1250
|
+
droppedPublications: window.droppedPublications,
|
|
1251
|
+
// The GPU's own time and the scale in force (v52): what the fps chip
|
|
1252
|
+
// says while the picture renders below the display's density.
|
|
1253
|
+
gpuMs: window.gpuP95Ms,
|
|
1254
|
+
renderScale: renderScale.inForce(),
|
|
1255
|
+
};
|
|
1256
|
+
queueMicrotask(() => handlers?.onPerformance(report));
|
|
1257
|
+
});
|
|
1258
|
+
/** THE PLAYBACK RENDER SCALE'S DEADLINES (renderScaleController.ts): every
|
|
1259
|
+
* frame of the transport that reached the canvas, with its document frame,
|
|
1260
|
+
* timed by the clock read its request took in `publishFrame`, before it
|
|
1261
|
+
* was drawn. Inside the runtime's `onPresented` call stack, and a push
|
|
1262
|
+
* only. */
|
|
1263
|
+
const unsubscribePresented = performanceTelemetry.subscribePresented((at, frame) => renderScale.presented(at, frame));
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* The transport's seek: publish `frame` to the clock and answer the token
|
|
1267
|
+
* its presentation will echo, or null when nothing was published (an export
|
|
1268
|
+
* owns the clock, or the frame is already the one on screen). `seek` and the
|
|
1269
|
+
* preparing pass share it, so a prepared frame is drawn exactly as a played
|
|
1270
|
+
* one is.
|
|
1271
|
+
*
|
|
1272
|
+
* The token is the telemetry's, which it mints only while the transport
|
|
1273
|
+
* runs or the perf HUD is on. `tokenFor` supplies one otherwise, for a
|
|
1274
|
+
* caller that has to know when THIS publication is presented.
|
|
1275
|
+
*/
|
|
1276
|
+
const publishFrame = (frame: number, tokenFor?: () => number): number | null => {
|
|
1277
|
+
// Mid-export the capture loop owns the clock — remember the ask and
|
|
1278
|
+
// land on it when the export ends.
|
|
1279
|
+
if (state.exporting !== null) {
|
|
1280
|
+
deferred.frame = Math.round(frame);
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
// Integer frames over a rational fps is the engine's invariant, and this
|
|
1284
|
+
// is the boundary a float could sneak through: a chrome that computed a
|
|
1285
|
+
// frame from a pixel position would otherwise hand the studio clock a
|
|
1286
|
+
// fractional one and break byte-identity against the export.
|
|
1287
|
+
// THE PREVIEW LOOP (v45): inside a component context with a loop length,
|
|
1288
|
+
// every frame wraps into it, so a transport that runs past the loop
|
|
1289
|
+
// shows the loop again instead of a held last frame.
|
|
1290
|
+
const loop = state.componentContext?.loopFrames;
|
|
1291
|
+
const rounded = loop === undefined ? Math.round(frame) : ((Math.round(frame) % loop) + loop) % loop;
|
|
1292
|
+
// REQUESTED here, PUBLISHED only if it actually changes the stage: a
|
|
1293
|
+
// transport tick that resolves to the frame already on screen is a
|
|
1294
|
+
// coalesced request, not a dropped one, and counting it as loss would
|
|
1295
|
+
// degrade quality on any document slower than the display.
|
|
1296
|
+
const request = performanceTelemetry.request(rounded);
|
|
1297
|
+
if (lastFrame === rounded) {
|
|
1298
|
+
performanceTelemetry.coalesceRequest(request);
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
lastFrame = rounded;
|
|
1302
|
+
const token = performanceTelemetry.publish(request) ?? tokenFor?.() ?? null;
|
|
1303
|
+
// ── THE FRAME IS A STORE WRITE, NOT A STATE WRITE ───────────────────
|
|
1304
|
+
//
|
|
1305
|
+
// The stage committed its whole tree per seek: react-dom's half of a
|
|
1306
|
+
// played frame, measured at 1.6 to 3 ms, for a tree whose structure
|
|
1307
|
+
// changes at cuts. Only the scene under the playhead is state now; the
|
|
1308
|
+
// frame and its token go to the clock, which `ShotRuntime`'s driver
|
|
1309
|
+
// subscribes to and the stage's frame loops read. A component context's
|
|
1310
|
+
// def-local frames too, so a played definition commits no stage render
|
|
1311
|
+
// per frame: the runtime's component mount reads them off the clock. The
|
|
1312
|
+
// one mode that renders the frame through React, an export, never gets
|
|
1313
|
+
// here: its capture loop writes the frame (`exportHost.seek`).
|
|
1314
|
+
const sceneId = sceneIdAt(state.doc, rounded);
|
|
1315
|
+
if (sceneId !== state.sceneId) set({ sceneId });
|
|
1316
|
+
clock.set(rounded, token);
|
|
1317
|
+
return token;
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* A preparing pass's publication: `frame` with the pass's own `token`,
|
|
1322
|
+
* minted before it so a draw inside the publication finds its waiter.
|
|
1323
|
+
* Never coalesced, so a frame already on screen is drawn again under the
|
|
1324
|
+
* new token, and never a telemetry token: a negative one tells a pass's
|
|
1325
|
+
* presentation from the transport's (`takePlayed`). A pass never runs in
|
|
1326
|
+
* a component context, and an export ends it.
|
|
1327
|
+
*/
|
|
1328
|
+
const publishPrepared = (frame: number, token: number): void => {
|
|
1329
|
+
const rounded = Math.round(frame);
|
|
1330
|
+
if (state.exporting !== null) {
|
|
1331
|
+
deferred.frame = rounded;
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
lastFrame = rounded;
|
|
1335
|
+
const sceneId = sceneIdAt(state.doc, rounded);
|
|
1336
|
+
if (sceneId !== state.sceneId) set({ sceneId });
|
|
1337
|
+
clock.set(rounded, token);
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const cancelLift = (): void => {
|
|
1341
|
+
if (lift === null) return;
|
|
1342
|
+
clearTimeout(lift.deadline);
|
|
1343
|
+
lift = null;
|
|
1344
|
+
};
|
|
1345
|
+
/** The frozen picture comes down, now, and the furniture follows the
|
|
1346
|
+
* scene graph again. */
|
|
1347
|
+
const dropFreeze = (): void => {
|
|
1348
|
+
cancelLift();
|
|
1349
|
+
freeze?.remove();
|
|
1350
|
+
freeze = null;
|
|
1351
|
+
if (state.held !== null || state.passDrawing) set({ held: null, passDrawing: false });
|
|
1352
|
+
};
|
|
1353
|
+
/** A press or a wheel on the shield (`mountFreeze`). It ends the pass
|
|
1354
|
+
* drawing under it (`yielded: "press"`), or, once that pass has given the
|
|
1355
|
+
* stage back, takes the frozen picture down at once, so a press between
|
|
1356
|
+
* the restore and the lift is never one that does nothing. Swallowed
|
|
1357
|
+
* either way: until the restore is drawn the scene graph may still hold
|
|
1358
|
+
* the pass's frame. */
|
|
1359
|
+
const shieldPress = (): void => {
|
|
1360
|
+
if (preparing !== null) preparing.end("press");
|
|
1361
|
+
else dropFreeze();
|
|
1362
|
+
};
|
|
1363
|
+
/** A standing freeze waits for the author's frame from publication `after`
|
|
1364
|
+
* on (see `lift`), and comes down at the 5 s backstop at the latest. */
|
|
1365
|
+
const armLift = (after: number): void => {
|
|
1366
|
+
if (freeze === null) return;
|
|
1367
|
+
cancelLift();
|
|
1368
|
+
lift = { after, either: transport, scrubbed: false, deadline: setTimeout(dropFreeze, PREPARE_FRAME_DEADLINE_MS) };
|
|
1369
|
+
};
|
|
1370
|
+
|
|
130
1371
|
const bridge: ShotStageBridge = {
|
|
131
1372
|
version: SHOT_BRIDGE_VERSION,
|
|
132
1373
|
connect(next) {
|
|
133
1374
|
handlers = next;
|
|
134
|
-
|
|
1375
|
+
// THE HANDSHAKE, and unconditional: the chrome starts at `stageBusy:
|
|
1376
|
+
// false`, so a presentation owed before it was listening, on a cold
|
|
1377
|
+
// open, would otherwise never be reported at all.
|
|
1378
|
+
sentBusy = null;
|
|
1379
|
+
queueBusy();
|
|
1380
|
+
// THE FONT PREVIEW IS CLEARED ON EVERY ATTACH (v50), its faces with it:
|
|
1381
|
+
// it belongs to an open picker, and a chrome that has just attached has
|
|
1382
|
+
// none open and re-pushes nothing for it.
|
|
1383
|
+
previewFaces.show(null);
|
|
1384
|
+
set({ connected: true, fontPreview: null, motionPreview: null });
|
|
1385
|
+
},
|
|
1386
|
+
health(): StageHealthSnapshot {
|
|
1387
|
+
return {
|
|
1388
|
+
crashed: fatalError,
|
|
1389
|
+
contextLost: contextLost(),
|
|
1390
|
+
pieces: pieceFailures,
|
|
1391
|
+
renderErrors: [...renderErrors],
|
|
1392
|
+
componentErrors: [...instanceErrors, ...instanceCrashes, ...placedHotErrors()],
|
|
1393
|
+
errors: stageErrors,
|
|
1394
|
+
rendered: rendered === null ? null : { ...rendered },
|
|
1395
|
+
loadedAt: Math.max(performance.timeOrigin, codeAt ?? 0),
|
|
1396
|
+
};
|
|
1397
|
+
},
|
|
1398
|
+
async coverage(request) {
|
|
1399
|
+
// THE AUTHOR'S FRAME, AS THE SHOT DRAWS IT. A poster holding export
|
|
1400
|
+
// framing steps aside first, as it does for any capture. A preparing
|
|
1401
|
+
// pass under an `"idle"` pace draws other frames under a frozen
|
|
1402
|
+
// picture: it ends (`yielded: "coverage"`), and hands the author's frame
|
|
1403
|
+
// back, before the check reads the stage, and none starts while one
|
|
1404
|
+
// runs (`checking`). A queued Play's pass is the author's and is never
|
|
1405
|
+
// ended by a read: the check refuses while it runs, as it refuses a
|
|
1406
|
+
// playing stage.
|
|
1407
|
+
//
|
|
1408
|
+
// It moves no `preparationKey()` (v51): the check draws into targets
|
|
1409
|
+
// of its own and frees only those, so what prepared frames stand on is
|
|
1410
|
+
// untouched. An agent's every write is checked, and a base that moved
|
|
1411
|
+
// with each would throw every prepared frame away after it.
|
|
1412
|
+
const yielding = yieldPoster();
|
|
1413
|
+
if (yielding !== null) await yielding;
|
|
1414
|
+
checking += 1;
|
|
1415
|
+
try {
|
|
1416
|
+
const pass = preparing;
|
|
1417
|
+
if (pass !== null && pass.pace() === "idle") {
|
|
1418
|
+
pass.end("coverage");
|
|
1419
|
+
await pass.ended;
|
|
1420
|
+
}
|
|
1421
|
+
// What the check refuses, by what the stage shows: its picture must
|
|
1422
|
+
// be the Shot's, still, at one frame.
|
|
1423
|
+
const refusal =
|
|
1424
|
+
state.doc === null
|
|
1425
|
+
? "no Project is on the stage"
|
|
1426
|
+
: state.exporting !== null
|
|
1427
|
+
? "the stage is exporting"
|
|
1428
|
+
: preparing !== null
|
|
1429
|
+
? "the stage is preparing to play, so its frame moves; the check measures a paused stage"
|
|
1430
|
+
: state.playing
|
|
1431
|
+
? "the stage is playing, so its frame moves; the check measures a paused stage"
|
|
1432
|
+
: state.componentContext !== null
|
|
1433
|
+
? "the stage shows a component on its own, not a Shot"
|
|
1434
|
+
: state.isolation !== null
|
|
1435
|
+
? "the stage is isolating a layer, which fades the rest of the Shot"
|
|
1436
|
+
: state.materialPreview !== null
|
|
1437
|
+
? "the stage is previewing a material, which hides the rest of the Shot"
|
|
1438
|
+
: previewingFont()
|
|
1439
|
+
? "the stage is previewing a font on some text layers, so its picture is not the Shot's"
|
|
1440
|
+
: previewingMotion()
|
|
1441
|
+
? "the stage is previewing a motion preset on some layers, so its picture is not the Shot's"
|
|
1442
|
+
: coverageProbe === null
|
|
1443
|
+
? "no Shot is under the playhead"
|
|
1444
|
+
: null;
|
|
1445
|
+
if (refusal !== null) throw new Error(`the stage did not check which layers draw: ${refusal}`);
|
|
1446
|
+
return await coverageProbe!({ ...(request ?? {}), frame: lastFrame });
|
|
1447
|
+
} finally {
|
|
1448
|
+
checking -= 1;
|
|
1449
|
+
}
|
|
135
1450
|
},
|
|
136
|
-
loadDoc(doc) {
|
|
1451
|
+
loadDoc(doc, rev = null) {
|
|
1452
|
+
revOfDoc.set(doc, rev);
|
|
1453
|
+
// THE SAME DOCUMENT, NOW A SAVED REVISION (v35): the chrome's own save
|
|
1454
|
+
// landed, or a re-push of what is on stage. Nothing to re-render; the
|
|
1455
|
+
// revision is all that moved, and a picture already drawn from this
|
|
1456
|
+
// object IS that revision.
|
|
1457
|
+
if (doc === state.doc && doc === (deferred.doc ?? state.doc)) {
|
|
1458
|
+
if (rev !== state.docRev) {
|
|
1459
|
+
set({ docRev: rev });
|
|
1460
|
+
if (rendered !== null && presentedDoc === doc && rendered.rev !== rev) {
|
|
1461
|
+
rendered.rev = rev;
|
|
1462
|
+
notifyHealth();
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
// Mid-export: DEFERRED, never interleaved. A doc swap re-renders every
|
|
1468
|
+
// plane, and the capture loop would encode frame N's timestamp over
|
|
1469
|
+
// whatever the new document draws. The export finishes on the document
|
|
1470
|
+
// it started with; the push lands the moment it ends.
|
|
1471
|
+
if (state.exporting !== null) {
|
|
1472
|
+
// An edit, not an echo of the document the stage already has.
|
|
1473
|
+
if (doc !== (deferred.doc ?? state.doc)) yieldPoster();
|
|
1474
|
+
deferred.doc = doc;
|
|
1475
|
+
deferred.rev = rev;
|
|
1476
|
+
deferred.closed = false;
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// The authored cadence is what every window is judged against, so it has
|
|
1480
|
+
// to land before the first bucket closes. During export this waits with
|
|
1481
|
+
// the document because the active capture still uses the old cadence.
|
|
1482
|
+
// It is the render scale's budget too.
|
|
1483
|
+
performanceTelemetry.setFrameRate(doc.fps);
|
|
1484
|
+
renderScale.setFrameRate(doc.fps);
|
|
137
1485
|
// A DIFFERENT document opening, not an edit echo: the span from here to
|
|
138
1486
|
// the next presented frame is the author's "how long until I see it".
|
|
139
1487
|
// markPerf no-ops without ?perf=1. Set BEFORE the state write, because
|
|
140
1488
|
// the commit this write triggers is the frame that settles it.
|
|
141
|
-
if (state.doc?.id !== doc.id)
|
|
142
|
-
|
|
1489
|
+
if (state.doc?.id !== doc.id) {
|
|
1490
|
+
markPerf("open→frame");
|
|
1491
|
+
// A pass's frames and what the transport played were the other
|
|
1492
|
+
// Project's (v51). An edit of this one keeps both.
|
|
1493
|
+
preparing?.end(null);
|
|
1494
|
+
played = new Map();
|
|
1495
|
+
// So was the raster cache's pressure: left standing, it would cut
|
|
1496
|
+
// this Project's first rasters at a cheaper rung, then cut them again
|
|
1497
|
+
// a rung at a time as the relief came in. And so was the render scale
|
|
1498
|
+
// the last Project's GPU time settled on.
|
|
1499
|
+
resetRasterPressure();
|
|
1500
|
+
renderScale.reset();
|
|
1501
|
+
}
|
|
1502
|
+
// A doc swap can remove the definition the context renders; a context
|
|
1503
|
+
// pointing at nothing would leave the whole viewport blank with no way
|
|
1504
|
+
// out from the stage side. A CODE subject survives the swap: it is built
|
|
1505
|
+
// from a registry id, so no document can take it away.
|
|
1506
|
+
const ctx =
|
|
1507
|
+
state.componentContext !== null &&
|
|
1508
|
+
!("code" in state.componentContext) &&
|
|
1509
|
+
doc.components?.[state.componentContext.def] === undefined
|
|
1510
|
+
? null
|
|
1511
|
+
: state.componentContext;
|
|
1512
|
+
const materialPreview = materialPreviewStillReal(doc, state.materialPreview?.layerId ?? null)
|
|
1513
|
+
? state.materialPreview
|
|
1514
|
+
: null;
|
|
1515
|
+
// Every outstanding token belonged to the OLD document. Holding them
|
|
1516
|
+
// would let a stale publication match a presentation of the new one.
|
|
1517
|
+
performanceTelemetry.resetPublications();
|
|
1518
|
+
performanceTelemetry.setDebugDocument(doc);
|
|
1519
|
+
set({
|
|
1520
|
+
doc,
|
|
1521
|
+
docRev: rev,
|
|
1522
|
+
componentContext: ctx,
|
|
1523
|
+
splineEdit: null,
|
|
1524
|
+
materialPreview,
|
|
1525
|
+
sceneId: ctx === null ? (masterToSceneCached(doc, lastFrame)?.sceneId ?? null) : null,
|
|
1526
|
+
});
|
|
143
1527
|
},
|
|
144
1528
|
seek(frame) {
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
1529
|
+
// A scrub is the author's: a poster holding the stage steps aside, and
|
|
1530
|
+
// the frame lands when its framing ends. A re-push of the frame the
|
|
1531
|
+
// stage will return to anyway is not one.
|
|
1532
|
+
if (state.exporting !== null && Math.round(frame) !== deferred.frame) yieldPoster();
|
|
1533
|
+
// Nor is a re-push of the frame a running pass gives back (v51): the
|
|
1534
|
+
// chrome restates the playhead after every document push, an ambient
|
|
1535
|
+
// one included, and a queued Play's pass runs on across those.
|
|
1536
|
+
if (preparing !== null && Math.round(frame) === preparing.startFrame) return;
|
|
1537
|
+
// A seek ends a preparing pass's picture (v51): the pass gives the
|
|
1538
|
+
// stage back first, and a freeze still standing lifts on the frame this
|
|
1539
|
+
// lands on. A SCRUB NEVER LEAVES THE PICTURE FROZEN: a frame that moves
|
|
1540
|
+
// under a freeze waiting for its lift shortens its backstop, once.
|
|
1541
|
+
preparing?.end(null);
|
|
1542
|
+
if (state.passDrawing) set({ passDrawing: false });
|
|
1543
|
+
if (lift !== null && !lift.scrubbed && Math.round(frame) !== lastFrame) {
|
|
1544
|
+
clearTimeout(lift.deadline);
|
|
1545
|
+
lift = { ...lift, scrubbed: true, deadline: setTimeout(dropFreeze, SCRUB_LIFT_MS) };
|
|
1546
|
+
}
|
|
1547
|
+
publishFrame(frame);
|
|
1548
|
+
},
|
|
1549
|
+
preparePlayback(request) {
|
|
1550
|
+
const { frames, onProgress, signal, pace, deadlineMs } = request;
|
|
1551
|
+
preparing?.end(null);
|
|
1552
|
+
const answer = (result: ShotPreparePlaybackResult): Promise<ShotPreparePlaybackResult> => Promise.resolve(result);
|
|
1553
|
+
// UNDER EXPORT FRAMING a queued Play steps a poster aside and runs once
|
|
1554
|
+
// it has. Anything else holding the framing (an export, a still, a
|
|
1555
|
+
// capture), or a poster under an `"idle"` pace, is the chrome's to
|
|
1556
|
+
// wait out.
|
|
1557
|
+
if (state.exporting !== null) {
|
|
1558
|
+
const yielding = pace() === "play" ? yieldPoster() : null;
|
|
1559
|
+
return yielding === null
|
|
1560
|
+
? answer({ completed: false, presented: 0, yielded: "export" })
|
|
1561
|
+
: yielding.then(() => bridge.preparePlayback(request));
|
|
1562
|
+
}
|
|
1563
|
+
const canvas = exportCanvas;
|
|
1564
|
+
const host = freezeHost;
|
|
1565
|
+
if (state.doc === null) return answer({ completed: false, presented: 0, refused: "no Project is on the stage" });
|
|
1566
|
+
if (state.componentContext !== null) {
|
|
1567
|
+
return answer({ completed: false, presented: 0, refused: "the stage shows a component on its own" });
|
|
1568
|
+
}
|
|
1569
|
+
if (canvas === null || host === null) {
|
|
1570
|
+
return answer({ completed: false, presented: 0, refused: "the stage has no picture to freeze" });
|
|
1571
|
+
}
|
|
1572
|
+
// What the stage is doing comes first: a live interaction (its pointer,
|
|
1573
|
+
// plane and picks read the scene graph a pass re-poses), a hidden page
|
|
1574
|
+
// (it presents nothing), a coverage check measuring the author's frame
|
|
1575
|
+
// (only an `"idle"` pace gives way to it), and a font preview, whose
|
|
1576
|
+
// frames are not the Project's.
|
|
1577
|
+
const early: ShotPreparePlaybackYield | null = interactionLive()
|
|
1578
|
+
? "interaction"
|
|
1579
|
+
: typeof document !== "undefined" && document.visibilityState === "hidden"
|
|
1580
|
+
? "hidden"
|
|
1581
|
+
: checking > 0 && pace() === "idle"
|
|
1582
|
+
? "coverage"
|
|
1583
|
+
: previewingFont() || previewingMotion()
|
|
1584
|
+
? "push"
|
|
1585
|
+
: null;
|
|
1586
|
+
if (early !== null) return answer({ completed: false, presented: 0, yielded: early });
|
|
1587
|
+
if (signal?.aborted === true) return answer({ completed: false, presented: 0 });
|
|
1588
|
+
return new Promise((resolve) => {
|
|
1589
|
+
const startFrame = lastFrame;
|
|
1590
|
+
const wasPlaying = state.playing;
|
|
1591
|
+
let presented = 0;
|
|
1592
|
+
let settled = false;
|
|
1593
|
+
/** Tears down what the pass is waiting on now: one publication's
|
|
1594
|
+
* presentation and its deadline. */
|
|
1595
|
+
let abandon: (() => void) | null = null;
|
|
1596
|
+
let ended: () => void = () => {};
|
|
1597
|
+
// The chrome aborts with `"input"` for author input under an idle
|
|
1598
|
+
// pace; any other abort is a cancel.
|
|
1599
|
+
const onAbort = (): void => restore(false, signal?.reason === "input" ? "input" : null);
|
|
1600
|
+
const onResize = (): void => restore(false, "resize");
|
|
1601
|
+
const onHidden = (): void => {
|
|
1602
|
+
if (document.visibilityState === "hidden") restore(false, "hidden");
|
|
1603
|
+
};
|
|
1604
|
+
const stop = (): void => {
|
|
1605
|
+
settled = true;
|
|
1606
|
+
abandon?.();
|
|
1607
|
+
abandon = null;
|
|
1608
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1609
|
+
window.removeEventListener("resize", onResize);
|
|
1610
|
+
document.removeEventListener("visibilitychange", onHidden);
|
|
1611
|
+
if (preparing === self) preparing = null;
|
|
1612
|
+
// After the author's frame is published, in this task.
|
|
1613
|
+
queueMicrotask(ended);
|
|
1614
|
+
};
|
|
1615
|
+
/**
|
|
1616
|
+
* BACK TO THE AUTHOR'S STAGE: an `"idle"` pace at the end of the
|
|
1617
|
+
* list, a cancel, a yield, an export. The stage gets its own playing
|
|
1618
|
+
* flag back and the start frame under a fresh token, the furniture
|
|
1619
|
+
* follows the scene graph again, and a freeze lifts on the first
|
|
1620
|
+
* complete presentation of the frame the clock is on (`lift`): the
|
|
1621
|
+
* start frame, or the newer one a later seek lands on.
|
|
1622
|
+
*
|
|
1623
|
+
* PAUSED, NOT JUST PRESENTED. `playing` reaches the runtime a commit
|
|
1624
|
+
* after it is set here, so the restored frame's first draw is usually
|
|
1625
|
+
* still the playing one, and a playing draw does not wait on a video
|
|
1626
|
+
* seek: it showed the nearest picture the decoder held, and lifted
|
|
1627
|
+
* there the author saw a softer frame for about 150 ms, then the real
|
|
1628
|
+
* one. Mid-export the frame lands in `deferred.frame`, which the
|
|
1629
|
+
* export's end restores.
|
|
1630
|
+
*/
|
|
1631
|
+
const restore = (completed: boolean, why: ShotPreparePlaybackYield | null): void => {
|
|
1632
|
+
if (settled) return;
|
|
1633
|
+
stop();
|
|
1634
|
+
set({ playing: wasPlaying, passDrawing: false });
|
|
1635
|
+
const token = prepareToken();
|
|
1636
|
+
armLift(token);
|
|
1637
|
+
publishPrepared(startFrame, token);
|
|
1638
|
+
resolve({ completed, presented, ...(why === null ? {} : { yielded: why }) });
|
|
1639
|
+
};
|
|
1640
|
+
/** Could not run at all: nothing of `frames` was drawn. */
|
|
1641
|
+
const refuse = (reason: string): void => {
|
|
1642
|
+
if (settled) return;
|
|
1643
|
+
stop();
|
|
1644
|
+
resolve({ completed: false, presented: 0, refused: reason });
|
|
1645
|
+
};
|
|
1646
|
+
const self = {
|
|
1647
|
+
end: (why: ShotPreparePlaybackYield | null): void => restore(false, why),
|
|
1648
|
+
pace,
|
|
1649
|
+
startFrame,
|
|
1650
|
+
ended: new Promise<void>((done) => {
|
|
1651
|
+
ended = done;
|
|
1652
|
+
}),
|
|
1653
|
+
};
|
|
1654
|
+
preparing = self;
|
|
1655
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1656
|
+
window.addEventListener("resize", onResize);
|
|
1657
|
+
document.addEventListener("visibilitychange", onHidden);
|
|
1658
|
+
/** Wait for publication `token` to be presented IN THE PLAYING
|
|
1659
|
+
* PRESENTATION, or for `ms` to pass. A frame drawn paused prepared a
|
|
1660
|
+
* mount playback never uses (the warm-up's playing roles), and
|
|
1661
|
+
* `playing` reaches the runtime a commit after `begin` sets it, so
|
|
1662
|
+
* the pass's first frame is usually drawn paused first; that commit
|
|
1663
|
+
* draws it again, echoing the token.
|
|
1664
|
+
* At the deadline an external claim still open (a font, a texture,
|
|
1665
|
+
* a video seek) makes it `"timeout-pending"`. */
|
|
1666
|
+
const waitFor = (token: number, ms: number, done: (outcome: ShotPreparedOutcome) => void): void => {
|
|
1667
|
+
const settle = (outcome: ShotPreparedOutcome): void => {
|
|
1668
|
+
abandon?.();
|
|
1669
|
+
abandon = null;
|
|
1670
|
+
done(outcome);
|
|
1671
|
+
};
|
|
1672
|
+
const timer = setTimeout(() => settle(externallyPending() ? "timeout-pending" : "timeout"), ms);
|
|
1673
|
+
presentWaiters.set(token, (drawnPlaying) => {
|
|
1674
|
+
if (drawnPlaying) settle("presented");
|
|
1675
|
+
});
|
|
1676
|
+
abandon = () => {
|
|
1677
|
+
clearTimeout(timer);
|
|
1678
|
+
presentWaiters.delete(token);
|
|
1679
|
+
};
|
|
1680
|
+
};
|
|
1681
|
+
/** One frame: published under a fresh token (so a frame already on
|
|
1682
|
+
* screen is drawn again, in the playing presentation), waited for,
|
|
1683
|
+
* reported, then the next one after a hop. At the end of the list
|
|
1684
|
+
* the pace decides: hand over, or restore. */
|
|
1685
|
+
const step = (index: number): void => {
|
|
1686
|
+
if (settled) return;
|
|
1687
|
+
if (interactionLive()) {
|
|
1688
|
+
restore(false, "interaction");
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (index >= frames.length) {
|
|
1692
|
+
if (pace() === "play") handOver();
|
|
1693
|
+
else restore(true, null);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const token = prepareToken();
|
|
1697
|
+
const asked = deadlineMs?.(index);
|
|
1698
|
+
const ms =
|
|
1699
|
+
typeof asked === "number" && asked > 0 ? Math.min(asked, PREPARE_FRAME_DEADLINE_MS) : PREPARE_FRAME_DEADLINE_MS;
|
|
1700
|
+
waitFor(token, ms, (outcome) => {
|
|
1701
|
+
if (outcome === "presented") presented += 1;
|
|
1702
|
+
// NOT FROM INSIDE THE PRESENTATION: `present` runs in the
|
|
1703
|
+
// runtime's `onPresented` call stack, where neither the chrome's
|
|
1704
|
+
// store write nor the next publication may run. Reported even if
|
|
1705
|
+
// the pass ended meanwhile: drawn is drawn.
|
|
1706
|
+
queueMicrotask(() => {
|
|
1707
|
+
onProgress?.(index + 1, frames.length, outcome);
|
|
1708
|
+
if (!settled) hop(pace(), () => step(index + 1));
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
publishPrepared(frames[index]!, token);
|
|
1712
|
+
};
|
|
1713
|
+
/**
|
|
1714
|
+
* THE HAND-OVER, at the end of a queued Play's list: the start frame
|
|
1715
|
+
* is presented again in the playing presentation, under a fresh token,
|
|
1716
|
+
* and the freeze lifts INSIDE that presentation, so the picture the
|
|
1717
|
+
* author sees is the one the transport starts on and never another
|
|
1718
|
+
* Shot's. The furniture follows the scene graph from this draw on, so
|
|
1719
|
+
* the lift shows outlines projected from it. At the 5 s backstop it
|
|
1720
|
+
* restores instead: no presentation of the start frame means no
|
|
1721
|
+
* hand-over, and the freeze waits for one by the lift's rules, which
|
|
1722
|
+
* the chrome's start of the transport opens to either presentation.
|
|
1723
|
+
*/
|
|
1724
|
+
const handOver = (): void => {
|
|
1725
|
+
set({ passDrawing: false });
|
|
1726
|
+
const token = prepareToken();
|
|
1727
|
+
waitFor(token, PREPARE_FRAME_DEADLINE_MS, (outcome) => {
|
|
1728
|
+
if (outcome !== "presented") {
|
|
1729
|
+
restore(true, null);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
stop();
|
|
1733
|
+
dropFreeze();
|
|
1734
|
+
resolve({ completed: true, presented, handedOver: true });
|
|
1735
|
+
});
|
|
1736
|
+
publishPrepared(startFrame, token);
|
|
1737
|
+
};
|
|
1738
|
+
/** THE PLAYING PRESENTATION, which is what playback draws with: the
|
|
1739
|
+
* warm-up's playing roles. The canvas density is the paused one
|
|
1740
|
+
* (`useStageDensity` holds it while `playing`), so the pass resizes
|
|
1741
|
+
* nothing. From the first publication on the furniture holds the
|
|
1742
|
+
* frozen frame. */
|
|
1743
|
+
const begin = (): void => {
|
|
1744
|
+
if (settled) return;
|
|
1745
|
+
if (interactionLive()) {
|
|
1746
|
+
restore(false, "interaction");
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
set({ playing: true, passDrawing: true });
|
|
1750
|
+
step(0);
|
|
1751
|
+
};
|
|
1752
|
+
// ── EVERY PASS DRAWS UNDER A FROZEN PICTURE ────────────────────────
|
|
1753
|
+
//
|
|
1754
|
+
// Nothing it draws may be seen: not the other frames, not the playing
|
|
1755
|
+
// presentation's own picture of this one (a video's nearest decoded
|
|
1756
|
+
// frame). A freeze an earlier pass left standing OF THIS FRAME is
|
|
1757
|
+
// adopted, its lift called off. Otherwise the frame on screen is
|
|
1758
|
+
// presented again and copied INSIDE that presentation
|
|
1759
|
+
// (the playback framebuffer is unpreserved, so a copy taken later is
|
|
1760
|
+
// blank), then laid over the canvas with the shield above every
|
|
1761
|
+
// overlay. A freeze of an older frame (a seek landed before its lift)
|
|
1762
|
+
// stands, lift and all, until the new copy replaces it.
|
|
1763
|
+
if (freeze !== null && state.held === startFrame) {
|
|
1764
|
+
cancelLift();
|
|
1765
|
+
begin();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
const token = prepareToken();
|
|
1769
|
+
const timer = setTimeout(() => refuse("freeze timed out"), PREPARE_FRAME_DEADLINE_MS);
|
|
1770
|
+
abandon = () => {
|
|
1771
|
+
clearTimeout(timer);
|
|
1772
|
+
presentWaiters.delete(token);
|
|
1773
|
+
};
|
|
1774
|
+
presentWaiters.set(token, () => {
|
|
1775
|
+
abandon?.();
|
|
1776
|
+
abandon = null;
|
|
1777
|
+
let copy: Promise<ImageBitmap>;
|
|
1778
|
+
try {
|
|
1779
|
+
copy = createImageBitmap(canvas);
|
|
1780
|
+
} catch {
|
|
1781
|
+
refuse("the stage could not copy its picture");
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
void copy.then(
|
|
1785
|
+
(bitmap) => {
|
|
1786
|
+
if (settled) {
|
|
1787
|
+
bitmap.close();
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (interactionLive()) {
|
|
1791
|
+
bitmap.close();
|
|
1792
|
+
restore(false, "interaction");
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
const older = freeze;
|
|
1796
|
+
freeze = mountFreeze(host, canvas, bitmap, shieldPress);
|
|
1797
|
+
bitmap.close();
|
|
1798
|
+
older?.remove();
|
|
1799
|
+
cancelLift();
|
|
1800
|
+
set({ held: startFrame });
|
|
1801
|
+
begin();
|
|
1802
|
+
},
|
|
1803
|
+
() => refuse("the stage could not copy its picture"),
|
|
1804
|
+
);
|
|
1805
|
+
});
|
|
1806
|
+
publishPrepared(startFrame, token);
|
|
1807
|
+
});
|
|
1808
|
+
},
|
|
1809
|
+
preparationKey() {
|
|
1810
|
+
const canvas = exportCanvas;
|
|
1811
|
+
// The density the stage draws at, paused and playing (`stageDensity`,
|
|
1812
|
+
// StageApp's canvas `dpr`). A change while the transport runs or a
|
|
1813
|
+
// pass draws reads here at once, while the canvas waits for the Pause
|
|
1814
|
+
// edge: the base moves, so nothing drawn at the old density is credited
|
|
1815
|
+
// under the new one.
|
|
1816
|
+
const ratio = stageDensity();
|
|
1817
|
+
return {
|
|
1818
|
+
base: `${performance.timeOrigin}:${gpuEpoch}:${canvas?.clientWidth ?? 0}x${canvas?.clientHeight ?? 0}@${ratio}:${rasterScale}`,
|
|
1819
|
+
modules: `${moduleEpoch ?? ""}:${hotUpdates}`,
|
|
1820
|
+
};
|
|
1821
|
+
},
|
|
1822
|
+
takePlayed() {
|
|
1823
|
+
const out: { frame: number; document: Doc3D }[] = [];
|
|
1824
|
+
for (const [document, frames] of played) for (const frame of frames) out.push({ frame, document });
|
|
1825
|
+
played = new Map();
|
|
1826
|
+
return out;
|
|
1827
|
+
},
|
|
1828
|
+
setPlaying(playing) {
|
|
1829
|
+
// NOT deferred mid-export, unlike `seek` and `loadDoc`. Those two would
|
|
1830
|
+
// change what the capture loop draws; this changes only what the stage
|
|
1831
|
+
// does between draws, and an export cannot be running while the transport
|
|
1832
|
+
// is (`setPlaying` refuses on the chrome side). Writing it through keeps
|
|
1833
|
+
// the flag honest if the two ever overlap.
|
|
1834
|
+
const next = playing === true;
|
|
1835
|
+
// The transport never overlaps a preparing pass (v51): one the chrome
|
|
1836
|
+
// did not hand over gives the stage back first.
|
|
1837
|
+
preparing?.end(null);
|
|
1838
|
+
// A poster is not an export: the chrome takes no lock for it, so Play
|
|
1839
|
+
// can arrive mid-capture. Aborting it cancels the armed capture at once,
|
|
1840
|
+
// so no captured frame can be drawn under the playing flag.
|
|
1841
|
+
if (next) yieldPoster();
|
|
1842
|
+
// Opens or closes the sampling windows, and releases the rung on pause.
|
|
1843
|
+
// The render scale takes the transport's frames alone as samples and
|
|
1844
|
+
// deadlines: a preparing pass sets `playing` and never this.
|
|
1845
|
+
performanceTelemetry.setPlaying(next);
|
|
1846
|
+
renderScale.setPlaying(next);
|
|
1847
|
+
// RETRACTED ON STOP, and null rather than zero: no frames are expected of
|
|
1848
|
+
// a stopped stage, so the last window's rate is stale the moment playback
|
|
1849
|
+
// ends and 0 fps would read as a stall. Queued so it lands after any
|
|
1850
|
+
// window report already in flight, and outside the chrome's own set.
|
|
1851
|
+
if (!next) queueMicrotask(() => handlers?.onPerformance(null));
|
|
1852
|
+
// What the transport draws is counted from its start (`takePlayed`). A
|
|
1853
|
+
// freeze still standing may lift in either presentation from here, but
|
|
1854
|
+
// never at once: the canvas may still hold another Shot's picture.
|
|
1855
|
+
if (next && !transport) played = new Map();
|
|
1856
|
+
transport = next;
|
|
1857
|
+
if (next && lift !== null) lift.either = true;
|
|
1858
|
+
if (state.playing === next) return;
|
|
1859
|
+
set({ playing: next });
|
|
150
1860
|
},
|
|
151
1861
|
setSelection(ids) {
|
|
152
1862
|
set({ selection: [...ids] });
|
|
153
1863
|
},
|
|
1864
|
+
setPeerSelections(peers) {
|
|
1865
|
+
const next = sanitizePeerSelections(peers);
|
|
1866
|
+
// Identity-stable when both are empty: no peers is the common case.
|
|
1867
|
+
if (next.length === 0 && state.peerSelections.length === 0) return;
|
|
1868
|
+
set({ peerSelections: next });
|
|
1869
|
+
},
|
|
154
1870
|
selectionQuads(pane = "camera") {
|
|
1871
|
+
// Under a frozen picture the feed holds that frame's quads (StageTools
|
|
1872
|
+
// projects nothing while a pass draws), so this answers for it.
|
|
155
1873
|
return quadSource === null ? null : quadSource(pane);
|
|
156
1874
|
},
|
|
157
1875
|
setTool(tool) {
|
|
158
|
-
|
|
1876
|
+
// An unknown id (the retired "move", or any stale caller) arms Select.
|
|
1877
|
+
set({ tool: Object.hasOwn(TOOL_SPECS, tool) ? tool : "select" });
|
|
1878
|
+
},
|
|
1879
|
+
setWorkPlane(target) {
|
|
1880
|
+
set({ workPlane: sanitizeWorkPlane(target) });
|
|
1881
|
+
},
|
|
1882
|
+
setSplineBasis(splineBasis) {
|
|
1883
|
+
set({ splineBasis: splineBasis === "catmull-rom" ? "catmull-rom" : "bezier" });
|
|
1884
|
+
},
|
|
1885
|
+
setSplineEditContext(splineEdit) {
|
|
1886
|
+
set({
|
|
1887
|
+
splineEdit:
|
|
1888
|
+
splineEdit === null
|
|
1889
|
+
? null
|
|
1890
|
+
: { layerId: splineEdit.layerId, selectedPointIds: [...splineEdit.selectedPointIds] },
|
|
1891
|
+
});
|
|
159
1892
|
},
|
|
160
1893
|
setSnapGuides(guides) {
|
|
161
1894
|
// Identity-stable when both are empty, so the common case (no gesture
|
|
@@ -163,6 +1896,11 @@ export function createStageClient(): StageClient {
|
|
|
163
1896
|
if (guides.length === 0 && state.guides.length === 0) return;
|
|
164
1897
|
set({ guides: [...guides] });
|
|
165
1898
|
},
|
|
1899
|
+
hoverLayer(layerId) {
|
|
1900
|
+
// NOT a `set()`: the whole point of the verb is that a chrome hover
|
|
1901
|
+
// costs one SVG leaf repaint, never a stage commit. See bridgeTypes v22.
|
|
1902
|
+
hoverSink?.(layerId);
|
|
1903
|
+
},
|
|
166
1904
|
setView(view) {
|
|
167
1905
|
set({ view });
|
|
168
1906
|
},
|
|
@@ -172,6 +1910,9 @@ export function createStageClient(): StageClient {
|
|
|
172
1910
|
set({ view });
|
|
173
1911
|
return view;
|
|
174
1912
|
},
|
|
1913
|
+
designPointAt(clientX, clientY) {
|
|
1914
|
+
return designPointer?.(clientX, clientY) ?? null;
|
|
1915
|
+
},
|
|
175
1916
|
setStageLayout(layout) {
|
|
176
1917
|
// Gaining a working pane is the compile-storm moment (SplitRenderer +
|
|
177
1918
|
// clay mount); the span to the next presented frame measures it.
|
|
@@ -181,7 +1922,75 @@ export function createStageClient(): StageClient {
|
|
|
181
1922
|
// anywhere — the stage does not get to assume its caller was careful.
|
|
182
1923
|
set({ layout: { mode: layout.mode, splitRatio: clampSplitRatio(layout.splitRatio) } });
|
|
183
1924
|
},
|
|
1925
|
+
setVideo(video) {
|
|
1926
|
+
// Gaining or losing the working pane is the compile-storm moment on this
|
|
1927
|
+
// side too: Video is drawn as the camera-only panes, so turning it OFF
|
|
1928
|
+
// over a split or a 3D-view layout mounts the same renderer the layout
|
|
1929
|
+
// push does. Marked with the same span for the same reason.
|
|
1930
|
+
if (!video && state.layout.mode !== "camera" && state.video) {
|
|
1931
|
+
markPerf("split→frame");
|
|
1932
|
+
}
|
|
1933
|
+
set({ video });
|
|
1934
|
+
},
|
|
1935
|
+
setReadOnly(on) {
|
|
1936
|
+
set({ readOnly: on === true });
|
|
1937
|
+
},
|
|
1938
|
+
setStoryboardPreview(on) {
|
|
1939
|
+
// No `split→frame` mark, unlike `setVideo` above: the board covers the
|
|
1940
|
+
// window, so gaining or losing the working pane behind it costs the
|
|
1941
|
+
// author nothing they are looking at.
|
|
1942
|
+
set({ storyboardPreview: on });
|
|
1943
|
+
},
|
|
1944
|
+
setStudioComments(comments) {
|
|
1945
|
+
const next = sanitizeStudioComments(comments);
|
|
1946
|
+
// Identity-stable when both are empty: no Studio comments is the common
|
|
1947
|
+
// case, and the chrome re-pushes on every attach.
|
|
1948
|
+
if (next === null && state.studioComments === null) return;
|
|
1949
|
+
set({ studioComments: next });
|
|
1950
|
+
},
|
|
1951
|
+
setSketchGuide(guide) {
|
|
1952
|
+
const next = sanitizeSketchGuide(guide);
|
|
1953
|
+
// Identity-stable when both are off: the chrome re-pushes on attach.
|
|
1954
|
+
if (next === null && state.sketchGuide === null) return;
|
|
1955
|
+
set({ sketchGuide: next });
|
|
1956
|
+
},
|
|
1957
|
+
setFontPreview(preview) {
|
|
1958
|
+
// NOT deferred mid-export: export framing draws the real document
|
|
1959
|
+
// whatever this holds (StageApp), and the preview shows again when the
|
|
1960
|
+
// export ends.
|
|
1961
|
+
const next = sanitizeFontPreview(preview);
|
|
1962
|
+
// Identity-stable when both are off: no preview is the common case.
|
|
1963
|
+
if (next === null && state.fontPreview === null) return;
|
|
1964
|
+
// A hover is the author's: a poster holding export framing steps aside,
|
|
1965
|
+
// and the preview shows once it has.
|
|
1966
|
+
if (next !== null) yieldPoster();
|
|
1967
|
+
const face = previewFaces.show(next);
|
|
1968
|
+
set({ fontPreview: next === null ? null : { ...next, face } });
|
|
1969
|
+
},
|
|
1970
|
+
setMotionPreview(preview) {
|
|
1971
|
+
// NOT deferred mid-export, the font preview's rule: export framing draws
|
|
1972
|
+
// the real document whatever this holds (StageApp).
|
|
1973
|
+
const next = sanitizeMotionPreview(preview);
|
|
1974
|
+
if (next === null && state.motionPreview === null) return;
|
|
1975
|
+
if (next !== null) yieldPoster();
|
|
1976
|
+
set({ motionPreview: next });
|
|
1977
|
+
},
|
|
1978
|
+
editText(layerId) {
|
|
1979
|
+
set({ textEdit: typeof layerId === "string" ? layerId : null });
|
|
1980
|
+
},
|
|
184
1981
|
setIsolation(isolation) {
|
|
1982
|
+
// DEFERRED MID-EXPORT, like every other chrome push. The chrome sends
|
|
1983
|
+
// this after every document push, and it once wrote `frame`: landing it
|
|
1984
|
+
// during a capture moved the export framing off the armed frame, so that
|
|
1985
|
+
// frame never presented and the export timed out 20 s later. The anchor
|
|
1986
|
+
// below reads `lastFrame`, which the capture loop owns, so it still waits.
|
|
1987
|
+
if (state.exporting !== null) {
|
|
1988
|
+
// Rides every document push unchanged: only a real change is the
|
|
1989
|
+
// author's, and steps a poster aside.
|
|
1990
|
+
if (isolation !== (deferred.isolation?.layerId ?? state.isolation)) yieldPoster();
|
|
1991
|
+
deferred.isolation = { layerId: isolation, orbit: false };
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
185
1994
|
// Validated against the DOCUMENT here, not trusted: the chrome's id can
|
|
186
1995
|
// be one edit behind the stage's doc, and an isolation naming a layer
|
|
187
1996
|
// that no longer exists would leave the pane pointed at nothing with no
|
|
@@ -192,10 +2001,71 @@ export function createStageClient(): StageClient {
|
|
|
192
2001
|
// perspective pose derived from somewhere else entirely, and the head-on
|
|
193
2002
|
// entry view — the whole point of isolation — would only appear the first
|
|
194
2003
|
// time.
|
|
195
|
-
|
|
2004
|
+
const next = isolation !== null && scene !== null ? isolation : null;
|
|
2005
|
+
set({
|
|
2006
|
+
isolation: next,
|
|
2007
|
+
isolationOrbit: false,
|
|
2008
|
+
// A NEW ANCHOR ONLY FOR A NEW SUBTREE. This push rides every document
|
|
2009
|
+
// push unchanged, and re-anchoring on each one re-fitted the camera on
|
|
2010
|
+
// every edit: a drag that grew the subtree zoomed the pane out from
|
|
2011
|
+
// under the pointer that was dragging.
|
|
2012
|
+
...(next === state.isolation ? {} : { isolationAnchor: next === null ? null : { frame: lastFrame } }),
|
|
2013
|
+
});
|
|
2014
|
+
},
|
|
2015
|
+
setMaterialPreview(target) {
|
|
2016
|
+
// Clamped AT THE BOUNDARY, the way `clampInset` is: a slot arrives as a
|
|
2017
|
+
// number from another document and a negative or fractional one would
|
|
2018
|
+
// otherwise reach the runtime as a key that matches nothing.
|
|
2019
|
+
const layerId = target === null ? null : target.layerId;
|
|
2020
|
+
const known = state.doc !== null && materialPreviewStillReal(state.doc, layerId);
|
|
2021
|
+
if (!known || target === null) {
|
|
2022
|
+
set({ materialPreview: null });
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const slot =
|
|
2026
|
+
typeof target.slot === "number" && Number.isSafeInteger(target.slot) && target.slot >= 0
|
|
2027
|
+
? target.slot
|
|
2028
|
+
: null;
|
|
2029
|
+
set({ materialPreview: { layerId: target.layerId, slot } });
|
|
196
2030
|
},
|
|
197
2031
|
setIsolationOrbit(taken) {
|
|
198
|
-
|
|
2032
|
+
// Riding a deferred isolation push, the orbit belongs to that push: it
|
|
2033
|
+
// is applied with it in `end()`, not to the isolation still on screen.
|
|
2034
|
+
if (state.exporting !== null && deferred.isolation !== null) {
|
|
2035
|
+
deferred.isolation.orbit = taken === true;
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
// FALSE IS HOME, and Home inside a subtree re-derives its frame from
|
|
2039
|
+
// where the playhead stands now: the frame is held still across edits,
|
|
2040
|
+
// so this is how the author re-fits after moving things out of it.
|
|
2041
|
+
set(
|
|
2042
|
+
taken === true || state.isolation === null
|
|
2043
|
+
? { isolationOrbit: taken === true }
|
|
2044
|
+
: { isolationOrbit: false, isolationAnchor: { frame: lastFrame } },
|
|
2045
|
+
);
|
|
2046
|
+
},
|
|
2047
|
+
setComponentContext(ctx) {
|
|
2048
|
+
// THE ZOOM IS CLAMPED AT THE BOUNDARY, exactly as `setMaterialPreview`
|
|
2049
|
+
// clamps its slot and `clampInset` its sides: it divides the subject's
|
|
2050
|
+
// half-extents, so a zero, a negative or a NaN is an orthographic frustum
|
|
2051
|
+
// that projects the whole subject to nothing — with no error anywhere.
|
|
2052
|
+
// The chrome owns the RANGE; this owns only "a number the camera can use".
|
|
2053
|
+
const targetDoc = state.exporting !== null
|
|
2054
|
+
? deferred.closed
|
|
2055
|
+
? null
|
|
2056
|
+
: deferred.doc ?? state.doc
|
|
2057
|
+
: state.doc;
|
|
2058
|
+
const next = validComponentContext(ctx, targetDoc);
|
|
2059
|
+
if (state.exporting !== null) {
|
|
2060
|
+
// A poster never starts inside a context, so a context arriving is
|
|
2061
|
+
// the author entering one: the poster steps aside.
|
|
2062
|
+
if (next !== null) yieldPoster();
|
|
2063
|
+
// Export framing always renders scenes. Remember a chrome context push
|
|
2064
|
+
// for the single restore commit instead of interleaving it with capture.
|
|
2065
|
+
deferred.componentContext = next;
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
set({ componentContext: next });
|
|
199
2069
|
},
|
|
200
2070
|
setStageInset(inset) {
|
|
201
2071
|
const next = clampInset(inset);
|
|
@@ -217,55 +2087,652 @@ export function createStageClient(): StageClient {
|
|
|
217
2087
|
set({ workingView });
|
|
218
2088
|
return workingView;
|
|
219
2089
|
},
|
|
2090
|
+
setGridVisible(visible) {
|
|
2091
|
+
set({ grid: visible === true });
|
|
2092
|
+
},
|
|
2093
|
+
setEditorChrome(chrome) {
|
|
2094
|
+
// Clamped at the boundary like the shading: a mode this stage does not
|
|
2095
|
+
// know is the default.
|
|
2096
|
+
renderScale.setMode(isPlaybackResolution(chrome.playbackResolution) ? chrome.playbackResolution : "auto");
|
|
2097
|
+
const next = { rulers: chrome.rulers === true, smartGuides: chrome.smartGuides !== false };
|
|
2098
|
+
if (next.rulers === state.editorChrome.rulers && next.smartGuides === state.editorChrome.smartGuides) return;
|
|
2099
|
+
set({ editorChrome: next });
|
|
2100
|
+
},
|
|
2101
|
+
setMotionPaths(on) {
|
|
2102
|
+
if ((on === true) !== state.motionPaths) set({ motionPaths: on === true });
|
|
2103
|
+
},
|
|
220
2104
|
setShadingMode(mode) {
|
|
221
2105
|
// Clamped at the boundary like the split ratio: an unknown mode from a
|
|
222
2106
|
// careless caller means the default clay, not a pane that draws nothing.
|
|
223
2107
|
set({ shading: clampShading(mode) });
|
|
224
2108
|
},
|
|
225
|
-
|
|
226
|
-
set({
|
|
2109
|
+
setPilot(on, text) {
|
|
2110
|
+
set({ pilot: on === true, pilotText: typeof text === "string" ? text : null });
|
|
2111
|
+
},
|
|
2112
|
+
setCameraGate(gate) {
|
|
2113
|
+
set({ cameraGate: gate ?? null });
|
|
227
2114
|
},
|
|
228
2115
|
closeDoc() {
|
|
229
|
-
|
|
2116
|
+
// Mid-export: deferred like loadDoc. Unmounting every plane under the
|
|
2117
|
+
// capture loop is the one thing worse than re-rendering under it.
|
|
2118
|
+
if (state.exporting !== null) {
|
|
2119
|
+
yieldPoster();
|
|
2120
|
+
deferred.closed = true;
|
|
2121
|
+
deferred.doc = null;
|
|
2122
|
+
deferred.componentContext = null;
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
// A pass draws the document that is leaving, and nothing prepared for
|
|
2126
|
+
// it stands for anything now (v51).
|
|
2127
|
+
preparing?.end(null);
|
|
2128
|
+
gpuStateReplaced();
|
|
2129
|
+
// The context names a definition in the doc that just left; a context
|
|
2130
|
+
// over no document would render nothing with no way out.
|
|
2131
|
+
performanceTelemetry.setDebugDocument(null);
|
|
2132
|
+
// A font preview belongs to the document's open picker, and leaves with it.
|
|
2133
|
+
previewFaces.show(null);
|
|
2134
|
+
set({
|
|
2135
|
+
doc: null,
|
|
2136
|
+
docRev: null,
|
|
2137
|
+
selection: [],
|
|
2138
|
+
componentContext: null,
|
|
2139
|
+
splineEdit: null,
|
|
2140
|
+
materialPreview: null,
|
|
2141
|
+
fontPreview: null,
|
|
2142
|
+
motionPreview: null,
|
|
2143
|
+
});
|
|
230
2144
|
},
|
|
231
2145
|
clearCaches() {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
2146
|
+
// `closeShot` pairs this with `closeDoc`. During export that close is
|
|
2147
|
+
// deferred, so its cache clear must be deferred with it. Disposing live
|
|
2148
|
+
// raster or plane resources inside the capture loop can invalidate the
|
|
2149
|
+
// frame in flight and force replacement allocations on later frames.
|
|
2150
|
+
if (state.exporting !== null) {
|
|
2151
|
+
deferred.clearCaches = true;
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
clearStageCaches();
|
|
2155
|
+
},
|
|
2156
|
+
exportCapability() {
|
|
2157
|
+
return exporterOf().capability();
|
|
2158
|
+
},
|
|
2159
|
+
exportMovie(request) {
|
|
2160
|
+
// An export the author asked for waits only for a poster to step aside.
|
|
2161
|
+
// Otherwise it enters export framing in this task, before any await,
|
|
2162
|
+
// which the dialog's same-task document check relies on.
|
|
2163
|
+
const yielding = yieldPoster();
|
|
2164
|
+
return yielding === null
|
|
2165
|
+
? exporterOf().exportMovie(request)
|
|
2166
|
+
: yielding.then(() => exporterOf().exportMovie(request));
|
|
2167
|
+
},
|
|
2168
|
+
requestNodePreviews(request) {
|
|
2169
|
+
// Never during an export, for `capturePoster`'s reason and one more: the
|
|
2170
|
+
// preview component is not even mounted in export framing, so this is a
|
|
2171
|
+
// belt on top of a structural guarantee rather than the only guard.
|
|
2172
|
+
// Never without a preview target either — a thumbnail needs the source
|
|
2173
|
+
// material channels of the surface being edited, and there is no such
|
|
2174
|
+
// surface when the workspace is closed.
|
|
2175
|
+
if (state.exporting !== null || state.materialPreview === null || nodePreviewSource === null) {
|
|
2176
|
+
return Promise.resolve(null);
|
|
2177
|
+
}
|
|
2178
|
+
// Clamped rather than refused. A request one node too long is a scroll
|
|
2179
|
+
// position, and a size outside the band is a device-pixel-ratio the
|
|
2180
|
+
// chrome could not have known about.
|
|
2181
|
+
const size = Math.max(32, Math.min(256, Math.round(request.size)));
|
|
2182
|
+
const nodeIds = [...new Set(request.nodeIds)].slice(0, MAX_NODE_PREVIEWS);
|
|
2183
|
+
if (nodeIds.length === 0) return Promise.resolve(null);
|
|
2184
|
+
return nodePreviewSource({ ...request, size, nodeIds });
|
|
2185
|
+
},
|
|
2186
|
+
async capturePoster(maxWidth) {
|
|
2187
|
+
// THE EXPORT'S OWN CAPTURE, never a read of the live canvas: the playback
|
|
2188
|
+
// framebuffer is unpreserved, and a late read can be valid-sized black.
|
|
2189
|
+
// The exporter flips into export framing, arms the in-draw snapshot at
|
|
2190
|
+
// the poster frame (the one the local CLI renders, `posterFrame`), and
|
|
2191
|
+
// restores the author's stage on every path.
|
|
2192
|
+
//
|
|
2193
|
+
// Only on an idle stage showing the film. A poster is decoration: it
|
|
2194
|
+
// never interrupts playback, an export, Play's preparing pass, a
|
|
2195
|
+
// component or material being edited, or a font being previewed (export
|
|
2196
|
+
// framing would replace that view with the film). And it never holds
|
|
2197
|
+
// the stage against the author: anything they start on it (Play, an
|
|
2198
|
+
// export or capture, a scrub, an edit, a press, a font preview) aborts it
|
|
2199
|
+
// (`yieldPoster`), and the chrome takes no lock for it. Null means no
|
|
2200
|
+
// new upload, so an existing poster remains and a project that never
|
|
2201
|
+
// had one keeps the gradient.
|
|
2202
|
+
const doc = state.doc;
|
|
2203
|
+
if (
|
|
2204
|
+
doc === null ||
|
|
2205
|
+
doc === posterFailedFor ||
|
|
2206
|
+
poster !== null ||
|
|
2207
|
+
state.exporting !== null ||
|
|
2208
|
+
state.playing ||
|
|
2209
|
+
preparing !== null ||
|
|
2210
|
+
state.componentContext !== null ||
|
|
2211
|
+
state.materialPreview !== null ||
|
|
2212
|
+
previewingFont() ||
|
|
2213
|
+
previewingMotion()
|
|
2214
|
+
) {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
const width = Math.min(Math.max(1, Math.floor(maxWidth)), doc.size.width);
|
|
2218
|
+
const abort = new AbortController();
|
|
2219
|
+
const capture = exporterOf().captureStill({
|
|
2220
|
+
frame: posterFrame(docDurationFrames(doc)),
|
|
2221
|
+
width,
|
|
2222
|
+
height: Math.max(1, Math.round((doc.size.height * width) / doc.size.width)),
|
|
2223
|
+
type: "image/jpeg",
|
|
2224
|
+
quality: 0.85,
|
|
2225
|
+
signal: abort.signal,
|
|
2226
|
+
});
|
|
2227
|
+
const entry = { abort, settled: capture.then(() => undefined, () => undefined) };
|
|
2228
|
+
poster = entry;
|
|
2229
|
+
try {
|
|
2230
|
+
return await capture;
|
|
2231
|
+
} catch {
|
|
2232
|
+
// Yielded is not failed: the chrome asks again once the stage is idle.
|
|
2233
|
+
if (!abort.signal.aborted) posterFailedFor = doc;
|
|
2234
|
+
return null;
|
|
2235
|
+
} finally {
|
|
2236
|
+
if (poster === entry) poster = null;
|
|
2237
|
+
}
|
|
2238
|
+
},
|
|
2239
|
+
captureFrame({ format, ...still }) {
|
|
2240
|
+
const yielding = yieldPoster();
|
|
2241
|
+
return yielding === null
|
|
2242
|
+
? exporterOf().captureStill({ ...still, type: `image/${format}` })
|
|
2243
|
+
: yielding.then(() => exporterOf().captureStill({ ...still, type: `image/${format}` }));
|
|
238
2244
|
},
|
|
239
2245
|
dispose() {
|
|
2246
|
+
preparing?.end(null);
|
|
2247
|
+
dropFreeze();
|
|
2248
|
+
hopChannel?.port1.close();
|
|
2249
|
+
hopChannel = null;
|
|
2250
|
+
hops.length = 0;
|
|
2251
|
+
previewFaces.show(null);
|
|
2252
|
+
cancelPendingCapture(new Error("the stage was disposed during capture"));
|
|
2253
|
+
registerExportCanvas(null);
|
|
2254
|
+
unsubscribeWindow();
|
|
2255
|
+
unsubscribePresented();
|
|
240
2256
|
dispose?.();
|
|
241
2257
|
dispose = null;
|
|
242
2258
|
handlers = null;
|
|
2259
|
+
// A reminted client handshakes again in `connect`; a stale "they already
|
|
2260
|
+
// know it is true" would swallow that push.
|
|
2261
|
+
sentBusy = null;
|
|
2262
|
+
},
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2265
|
+
/** The exporter's window onto this client: state for the numbers, the
|
|
2266
|
+
* registered area element for the canvas and the commit marker, and the
|
|
2267
|
+
* shared readiness latch for "the draw landed". `begin`/`end` are the only
|
|
2268
|
+
* writers of `exporting`, so the deferral in `loadDoc`/`seek` and this
|
|
2269
|
+
* block cannot disagree about when the capture loop owns the clock. */
|
|
2270
|
+
const exportHost: Parameters<typeof createShotStageExporter>[0] = {
|
|
2271
|
+
composition() {
|
|
2272
|
+
const doc = state.doc;
|
|
2273
|
+
return doc === null
|
|
2274
|
+
? null
|
|
2275
|
+
: { width: doc.size.width, height: doc.size.height, fps: doc.fps };
|
|
2276
|
+
},
|
|
2277
|
+
scopeFrames(sceneId) {
|
|
2278
|
+
const doc = state.doc;
|
|
2279
|
+
if (doc === null) return null;
|
|
2280
|
+
if (sceneId === undefined) return docDurationFrames(doc);
|
|
2281
|
+
const slice = sliceShotToScene(doc, sceneId);
|
|
2282
|
+
// sliceShotToScene falls back to the whole document for an unknown id
|
|
2283
|
+
// (a component rendering nothing is worse than one rendering the shot);
|
|
2284
|
+
// an EXPORT of the wrong scope is the opposite trade, so refuse.
|
|
2285
|
+
if (slice === null || doc.scenesById[sceneId] === undefined) return null;
|
|
2286
|
+
return docDurationFrames(slice);
|
|
2287
|
+
},
|
|
2288
|
+
sceneStartFrames(sceneId) {
|
|
2289
|
+
const doc = state.doc;
|
|
2290
|
+
// A one-scene export starts on its only scene; the whole timeline's
|
|
2291
|
+
// master frames ARE its export frames.
|
|
2292
|
+
if (doc === null || sceneId !== undefined) return [0];
|
|
2293
|
+
return sceneWindows(doc).map((window) => window.start);
|
|
2294
|
+
},
|
|
2295
|
+
begin(sceneId) {
|
|
2296
|
+
if (state.exporting !== null) throw new Error("an export is already running");
|
|
2297
|
+
// A preparing pass ends FIRST, synchronously (`yielded: "export"`): it
|
|
2298
|
+
// restores the stage's playing flag and the author's frame while
|
|
2299
|
+
// nothing is exporting, so the frame recorded below is the author's and
|
|
2300
|
+
// not the pass's current one. A frozen picture comes down with it:
|
|
2301
|
+
// export framing redraws the canvas, and nothing may stand over it.
|
|
2302
|
+
preparing?.end("export");
|
|
2303
|
+
dropFreeze();
|
|
2304
|
+
// REFUSAL OVER CORRUPTION. A component whose module failed to load or
|
|
2305
|
+
// crashed draws nothing, and export cannot tell that apart from a layer
|
|
2306
|
+
// meant to be empty. The author has already seen the stage error; this is
|
|
2307
|
+
// the exporter declining to bake it into every frame.
|
|
2308
|
+
const refusal = exportRefusal();
|
|
2309
|
+
if (refusal !== null) throw refusal;
|
|
2310
|
+
deferred.frame = lastFrame;
|
|
2311
|
+
deferred.doc = null;
|
|
2312
|
+
deferred.componentContext = state.componentContext;
|
|
2313
|
+
deferred.closed = false;
|
|
2314
|
+
deferred.clearCaches = false;
|
|
2315
|
+
deferred.isolation = null;
|
|
2316
|
+
// An export always renders SCENES: a component context left set would
|
|
2317
|
+
// put the runtime's def mount and the export framing on the canvas at
|
|
2318
|
+
// once. Cleared rather than refused — the chrome's dialog is master-side
|
|
2319
|
+
// and the author asked for the shot.
|
|
2320
|
+
set({
|
|
2321
|
+
exporting: sceneId === undefined ? {} : { scene: sceneId },
|
|
2322
|
+
// Outside every valid timeline. The first capture arms BEFORE seek(0),
|
|
2323
|
+
// so frame 0 cannot be coalesced with begin's framing commit and miss
|
|
2324
|
+
// the only in-draw snapshot opportunity.
|
|
2325
|
+
frame: -1,
|
|
2326
|
+
// Belt and braces. The chrome already refuses to start an export while
|
|
2327
|
+
// the transport runs and refuses to start the transport mid-export, so
|
|
2328
|
+
// this should always be false already — but `playing` defers work a
|
|
2329
|
+
// capture must not have deferred, and the cost of asserting it here is
|
|
2330
|
+
// one boolean against a whole class of wrong exported frame.
|
|
2331
|
+
playing: false,
|
|
2332
|
+
componentContext: null,
|
|
2333
|
+
});
|
|
2334
|
+
},
|
|
2335
|
+
seek(frame) {
|
|
2336
|
+
// Through React: the commit stamps `data-export-frame`, which the
|
|
2337
|
+
// capture requires in the same task as the draw. The clock follows so
|
|
2338
|
+
// the stage's frame loops agree.
|
|
2339
|
+
set({ frame });
|
|
2340
|
+
clock.set(frame);
|
|
2341
|
+
},
|
|
2342
|
+
end() {
|
|
2343
|
+
cancelPendingCapture(new Error("the export ended before its frame was captured"));
|
|
2344
|
+
// Deferred chrome pushes land HERE, with the export flag and the frame
|
|
2345
|
+
// in one commit — the stage never paints an in-between state.
|
|
2346
|
+
const restoredFrame = deferred.frame ?? lastFrame;
|
|
2347
|
+
lastFrame = restoredFrame;
|
|
2348
|
+
const patch: Partial<StageClientState> = {
|
|
2349
|
+
exporting: null,
|
|
2350
|
+
frame: restoredFrame,
|
|
2351
|
+
};
|
|
2352
|
+
const clearCaches = deferred.clearCaches;
|
|
2353
|
+
try {
|
|
2354
|
+
const restoredDoc = deferred.closed ? null : deferred.doc ?? state.doc;
|
|
2355
|
+
patch.componentContext = validComponentContext(deferred.componentContext, restoredDoc);
|
|
2356
|
+
if (deferred.closed) {
|
|
2357
|
+
performanceTelemetry.setDebugDocument(null);
|
|
2358
|
+
patch.doc = null;
|
|
2359
|
+
patch.docRev = null;
|
|
2360
|
+
patch.selection = [];
|
|
2361
|
+
patch.splineEdit = null;
|
|
2362
|
+
patch.materialPreview = null;
|
|
2363
|
+
previewFaces.show(null);
|
|
2364
|
+
patch.fontPreview = null;
|
|
2365
|
+
patch.motionPreview = null;
|
|
2366
|
+
} else if (deferred.doc !== null) {
|
|
2367
|
+
// Another Project, as `loadDoc` resets for one.
|
|
2368
|
+
if (deferred.doc.id !== state.doc?.id) {
|
|
2369
|
+
resetRasterPressure();
|
|
2370
|
+
renderScale.reset();
|
|
2371
|
+
}
|
|
2372
|
+
performanceTelemetry.setFrameRate(deferred.doc.fps);
|
|
2373
|
+
renderScale.setFrameRate(deferred.doc.fps);
|
|
2374
|
+
performanceTelemetry.resetPublications();
|
|
2375
|
+
performanceTelemetry.setDebugDocument(deferred.doc);
|
|
2376
|
+
patch.doc = deferred.doc;
|
|
2377
|
+
patch.docRev = deferred.rev;
|
|
2378
|
+
patch.splineEdit = null;
|
|
2379
|
+
patch.materialPreview = materialPreviewStillReal(
|
|
2380
|
+
deferred.doc,
|
|
2381
|
+
state.materialPreview?.layerId ?? null,
|
|
2382
|
+
)
|
|
2383
|
+
? state.materialPreview
|
|
2384
|
+
: null;
|
|
2385
|
+
}
|
|
2386
|
+
// The isolation push the export held back, validated against the
|
|
2387
|
+
// document that is about to be current, as `setIsolation` does.
|
|
2388
|
+
if (deferred.isolation !== null) {
|
|
2389
|
+
const layerId = deferred.isolation.layerId;
|
|
2390
|
+
patch.isolation =
|
|
2391
|
+
layerId !== null && restoredDoc !== null && sceneWithLayer(restoredDoc, layerId) !== null
|
|
2392
|
+
? layerId
|
|
2393
|
+
: null;
|
|
2394
|
+
// What the live pair would have left: `setIsolation` resets the
|
|
2395
|
+
// orbit and `setIsolationOrbit` sets it, whatever is isolated. A
|
|
2396
|
+
// component context reads it too (`headOnOwnsPane`).
|
|
2397
|
+
patch.isolationOrbit = deferred.isolation.orbit;
|
|
2398
|
+
if (patch.isolation !== state.isolation) {
|
|
2399
|
+
patch.isolationAnchor = patch.isolation === null ? null : { frame: restoredFrame };
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
patch.sceneId = sceneIdAt(restoredDoc, restoredFrame);
|
|
2403
|
+
} finally {
|
|
2404
|
+
// WHATEVER FAILED ABOVE, THE STAGE LEAVES EXPORT FRAMING. Stuck in it,
|
|
2405
|
+
// every chrome push stays deferred for the session and every later
|
|
2406
|
+
// export refuses with "an export is already running".
|
|
2407
|
+
deferred.doc = null;
|
|
2408
|
+
deferred.frame = null;
|
|
2409
|
+
deferred.componentContext = null;
|
|
2410
|
+
deferred.closed = false;
|
|
2411
|
+
deferred.clearCaches = false;
|
|
2412
|
+
deferred.isolation = null;
|
|
2413
|
+
set(patch);
|
|
2414
|
+
clock.set(restoredFrame);
|
|
2415
|
+
// Export framing dropped the resident scenes and remounted the placed
|
|
2416
|
+
// components: what prepared frames stood on is gone (v51). Said once
|
|
2417
|
+
// the stage is the author's again, a throw above included.
|
|
2418
|
+
gpuStateReplaced();
|
|
2419
|
+
}
|
|
2420
|
+
// With exporting cleared and the deferred document patch published, no
|
|
2421
|
+
// capture draw can race this release. The following React commit either
|
|
2422
|
+
// unmounts the old document or mounts the replacement from empty caches.
|
|
2423
|
+
if (clearCaches) clearStageCaches();
|
|
2424
|
+
},
|
|
2425
|
+
canvas() {
|
|
2426
|
+
return exportCanvas;
|
|
2427
|
+
},
|
|
2428
|
+
frameCommitted(frame) {
|
|
2429
|
+
return exportArea?.getAttribute("data-export-frame") === String(frame);
|
|
2430
|
+
},
|
|
2431
|
+
waitingOn() {
|
|
2432
|
+
return stageReadiness.describe();
|
|
2433
|
+
},
|
|
2434
|
+
armCapture(frame, size, snapshot) {
|
|
2435
|
+
if (state.exporting === null) {
|
|
2436
|
+
return Promise.reject(new Error("an exact framebuffer capture requires export framing"));
|
|
2437
|
+
}
|
|
2438
|
+
const refusal = exportRefusal(frame);
|
|
2439
|
+
if (refusal !== null) return Promise.reject(refusal);
|
|
2440
|
+
if (pendingCapture !== null) {
|
|
2441
|
+
return Promise.reject(new Error("another export frame capture is already armed"));
|
|
2442
|
+
}
|
|
2443
|
+
return new Promise<ImageBitmap>((resolve, reject) => {
|
|
2444
|
+
pendingCapture = {
|
|
2445
|
+
frame,
|
|
2446
|
+
width: size.width,
|
|
2447
|
+
height: size.height,
|
|
2448
|
+
snapshotting: false,
|
|
2449
|
+
snapshot,
|
|
2450
|
+
resolve,
|
|
2451
|
+
reject,
|
|
2452
|
+
};
|
|
2453
|
+
});
|
|
2454
|
+
},
|
|
2455
|
+
cancelCapture(error) {
|
|
2456
|
+
cancelPendingCapture(error);
|
|
243
2457
|
},
|
|
244
2458
|
};
|
|
245
2459
|
|
|
246
2460
|
return {
|
|
247
2461
|
bridge,
|
|
2462
|
+
clock,
|
|
2463
|
+
performanceTelemetry,
|
|
2464
|
+
renderScale,
|
|
2465
|
+
noteDrawn(info, document, epoch = null, drawn) {
|
|
2466
|
+
if (document === null) return;
|
|
2467
|
+
moduleEpoch = epoch;
|
|
2468
|
+
// A FRAME THE TRANSPORT DREW (`takePlayed`), in the playing
|
|
2469
|
+
// presentation: never a pass's (its tokens are negative), and none
|
|
2470
|
+
// while a component or a font preview stands in for the Project's
|
|
2471
|
+
// picture, which the chrome's keys do not describe.
|
|
2472
|
+
const token = info.presentationToken;
|
|
2473
|
+
const transported =
|
|
2474
|
+
transport &&
|
|
2475
|
+
drawn?.playing === true &&
|
|
2476
|
+
!(token !== undefined && token < 0) &&
|
|
2477
|
+
state.componentContext === null &&
|
|
2478
|
+
state.fontPreview === null &&
|
|
2479
|
+
state.motionPreview === null;
|
|
2480
|
+
// A PASS'S OWN FRAME is not the author's picture, so it is never what
|
|
2481
|
+
// `health()` says was drawn: an agent's check reads that as the
|
|
2482
|
+
// playhead's Shot drawn at that revision. The freeze, the restore and
|
|
2483
|
+
// the hand-over draw the author's frame with `passDrawing` clear, and
|
|
2484
|
+
// count; a late draw of a prepared frame is off the clock's frame.
|
|
2485
|
+
const passFrame = token !== undefined && token < 0 && (state.passDrawing || info.frame !== clock.get().frame);
|
|
2486
|
+
// Playing, the next frame of the picture already recorded moves only
|
|
2487
|
+
// the frame: no readiness query and no clock read per played frame,
|
|
2488
|
+
// once the latch is closed and while nothing counts what was played.
|
|
2489
|
+
if (
|
|
2490
|
+
state.playing &&
|
|
2491
|
+
!transported &&
|
|
2492
|
+
latch === subject &&
|
|
2493
|
+
rendered !== null &&
|
|
2494
|
+
presentedDoc === document &&
|
|
2495
|
+
rendered.epoch === epoch &&
|
|
2496
|
+
rendered.rev === (revOfDoc.get(document) ?? null)
|
|
2497
|
+
) {
|
|
2498
|
+
if (!passFrame) rendered.frame = info.frame;
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
if (externallyPending()) return;
|
|
2502
|
+
// A MOTION PREVIEW'S FRAME is not the Project's picture either (v53): the
|
|
2503
|
+
// chrome is seeking through a preset's frames on a stamped copy, so
|
|
2504
|
+
// `health()` reads pending until it clears and the playhead is drawn.
|
|
2505
|
+
if (!passFrame && !previewingMotion()) notePresented(info, document, epoch);
|
|
2506
|
+
if (transported) played.set(document, (played.get(document) ?? new Set<number>()).add(info.frame));
|
|
2507
|
+
// THE LATCH (v51): this renderer presented the open subject whole.
|
|
2508
|
+
const drew = drawn === undefined ? (document === state.doc ? subject : null) : drawn.subject;
|
|
2509
|
+
if (latch !== subject && drew === subject) {
|
|
2510
|
+
latch = subject;
|
|
2511
|
+
queueBusy();
|
|
2512
|
+
}
|
|
2513
|
+
},
|
|
2514
|
+
noteHotUpdate(checked) {
|
|
2515
|
+
for (const [module, error] of checked) {
|
|
2516
|
+
if (error === null) hotErrors.delete(module);
|
|
2517
|
+
else hotErrors.set(module, error);
|
|
2518
|
+
}
|
|
2519
|
+
hotUpdates += 1;
|
|
2520
|
+
codeAt = wallNow();
|
|
2521
|
+
if (hotErrors.size > 0) refuseArmedCapture();
|
|
2522
|
+
notifyHealth();
|
|
2523
|
+
},
|
|
2524
|
+
present(info, document, drawnPlaying = false, drawnForExport = false) {
|
|
2525
|
+
// The preparing pass, first: it waits on exactly this publication.
|
|
2526
|
+
const waiter = info.presentationToken === undefined ? undefined : presentWaiters.get(info.presentationToken);
|
|
2527
|
+
waiter?.(drawnPlaying);
|
|
2528
|
+
// Then a frozen picture waiting for the author's frame (v51): the frame
|
|
2529
|
+
// the clock is on now, so a seek moves the lift to the newer frame;
|
|
2530
|
+
// drawn from the restore's publication on, not a late draw of a
|
|
2531
|
+
// prepared one; complete; and paused, or in either presentation once
|
|
2532
|
+
// the transport runs (see `restore` in `preparePlayback`).
|
|
2533
|
+
const waiting = lift;
|
|
2534
|
+
if (waiting !== null) {
|
|
2535
|
+
const token = info.presentationToken;
|
|
2536
|
+
const current = info.frame === clock.get().frame && !(token !== undefined && token < 0 && token > waiting.after);
|
|
2537
|
+
if (current && (!drawnPlaying || waiting.either) && !externallyPending()) dropFreeze();
|
|
2538
|
+
}
|
|
2539
|
+
const pending = pendingCapture;
|
|
2540
|
+
// The DOCUMENT drawn, too. The r3f root commits after the DOM one, so a
|
|
2541
|
+
// draw from the previous document can land at the armed frame with the
|
|
2542
|
+
// new frame marker already stamped: the still stage reuses one stage
|
|
2543
|
+
// for every Shot of a Project, and most thumbnails are frame 0. Export
|
|
2544
|
+
// framing defers `loadDoc`, so `state.doc` is the armed document.
|
|
2545
|
+
//
|
|
2546
|
+
// And the FRAMING drawn. Until the r3f root commits the export render,
|
|
2547
|
+
// the editor's tree is still mounted, and a frame it started behind a
|
|
2548
|
+
// compile can land at the armed frame with every live check above and
|
|
2549
|
+
// below already true. Its picture is the presentation lane's: raster
|
|
2550
|
+
// rungs, analytic shapes, the GPU Canvas2D store. So only a draw from
|
|
2551
|
+
// an export-framing render resolves a capture.
|
|
2552
|
+
if (
|
|
2553
|
+
pending === null ||
|
|
2554
|
+
state.exporting === null ||
|
|
2555
|
+
!drawnForExport ||
|
|
2556
|
+
info.frame !== pending.frame ||
|
|
2557
|
+
document !== state.doc
|
|
2558
|
+
) {
|
|
2559
|
+
performanceTelemetry.present(info, document);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
if (pending.snapshotting) {
|
|
2563
|
+
// The framebuffer has already been synchronously handed to the
|
|
2564
|
+
// snapshot implementation. Keep the capture record live until that
|
|
2565
|
+
// asynchronous snapshot settles so a context loss, fatal stage error,
|
|
2566
|
+
// or known render failure can still cancel the final export frame.
|
|
2567
|
+
performanceTelemetry.present(info, document);
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
const refusal = exportRefusal(pending.frame);
|
|
2571
|
+
if (refusal !== null) {
|
|
2572
|
+
pendingCapture = null;
|
|
2573
|
+
pending.reject(refusal);
|
|
2574
|
+
performanceTelemetry.present(info, document);
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
// ClockDriver already proved its local scope clear before publishing.
|
|
2578
|
+
// The module-global scope can also contain cooperative work owned above
|
|
2579
|
+
// ShotRuntime, so it is a second exact gate. The three labels
|
|
2580
|
+
// `externallyPending` excludes are internal handoff claims: draw/repaint
|
|
2581
|
+
// belong to the presentation now executing, while instance-map identity
|
|
2582
|
+
// is checked directly by StageApp before it calls `present` and releases
|
|
2583
|
+
// in the parent layout effect after this child draw.
|
|
2584
|
+
if (externallyPending()) {
|
|
2585
|
+
performanceTelemetry.present(info, document);
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
const canvas = exportCanvas;
|
|
2589
|
+
if (
|
|
2590
|
+
canvas === null ||
|
|
2591
|
+
canvas.width !== pending.width ||
|
|
2592
|
+
canvas.height !== pending.height ||
|
|
2593
|
+
exportArea?.getAttribute("data-export-frame") !== String(pending.frame)
|
|
2594
|
+
) {
|
|
2595
|
+
// ResizeObserver and the r3f commit can land separately. Keep the arm
|
|
2596
|
+
// until a later matching presentation proves both framing and pixels.
|
|
2597
|
+
performanceTelemetry.present(info, document);
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
pending.snapshotting = true;
|
|
2601
|
+
try {
|
|
2602
|
+
// Invocation, not only resolution, stays in ShotRuntime's onPresented
|
|
2603
|
+
// call stack. createImageBitmap snapshots the unpreserved default
|
|
2604
|
+
// framebuffer before Chromium can present and retire it.
|
|
2605
|
+
void pending.snapshot(canvas).then(
|
|
2606
|
+
(bitmap) => {
|
|
2607
|
+
if (pendingCapture !== pending) {
|
|
2608
|
+
// Cancellation rejects the controller immediately, but the
|
|
2609
|
+
// browser may still finish a createImageBitmap already in
|
|
2610
|
+
// flight. It must not escape or remain live after refusal.
|
|
2611
|
+
bitmap.close();
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
pendingCapture = null;
|
|
2615
|
+
pending.resolve(bitmap);
|
|
2616
|
+
},
|
|
2617
|
+
(error) => {
|
|
2618
|
+
if (pendingCapture !== pending) return;
|
|
2619
|
+
pendingCapture = null;
|
|
2620
|
+
pending.reject(error);
|
|
2621
|
+
},
|
|
2622
|
+
);
|
|
2623
|
+
} catch (error) {
|
|
2624
|
+
if (pendingCapture === pending) {
|
|
2625
|
+
pendingCapture = null;
|
|
2626
|
+
pending.reject(error);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
// Snapshot INVOCATION comes first. Rich telemetry can fingerprint,
|
|
2630
|
+
// allocate a sample row, and notify diagnostic listeners; none of that
|
|
2631
|
+
// belongs between the GL draw and an unpreserved framebuffer read.
|
|
2632
|
+
performanceTelemetry.present(info, document);
|
|
2633
|
+
},
|
|
248
2634
|
getState: () => state,
|
|
249
2635
|
subscribe(listener) {
|
|
250
2636
|
listeners.add(listener);
|
|
251
2637
|
return () => listeners.delete(listener);
|
|
252
2638
|
},
|
|
253
2639
|
handlers: () => handlers,
|
|
2640
|
+
setInstanceErrors(errors) {
|
|
2641
|
+
instanceErrors = [...errors];
|
|
2642
|
+
refuseArmedCapture();
|
|
2643
|
+
notifyHealth();
|
|
2644
|
+
},
|
|
2645
|
+
setInstanceCrashes(errors) {
|
|
2646
|
+
instanceCrashes = [...errors];
|
|
2647
|
+
refuseArmedCapture();
|
|
2648
|
+
notifyHealth();
|
|
2649
|
+
},
|
|
2650
|
+
setFatalError(message) {
|
|
2651
|
+
fatalError = message.trim() === "" ? "unknown stage error" : message;
|
|
2652
|
+
refuseArmedCapture();
|
|
2653
|
+
notifyHealth();
|
|
2654
|
+
queueBusy();
|
|
2655
|
+
},
|
|
2656
|
+
setRenderErrors(errors, blocked = false) {
|
|
2657
|
+
renderErrors = [...errors];
|
|
2658
|
+
compileBlocked = blocked;
|
|
2659
|
+
refuseArmedCapture();
|
|
2660
|
+
notifyHealth();
|
|
2661
|
+
queueBusy();
|
|
2662
|
+
},
|
|
2663
|
+
setPieceFailures(failures) {
|
|
2664
|
+
pieceFailures = [...failures];
|
|
2665
|
+
// Before the chrome hears it: its handler can start other work.
|
|
2666
|
+
refuseArmedCapture();
|
|
2667
|
+
handlers?.onPieceFailures(pieceFailures);
|
|
2668
|
+
notifyHealth();
|
|
2669
|
+
},
|
|
2670
|
+
pieceFailures: () => pieceFailures,
|
|
2671
|
+
recordError,
|
|
2672
|
+
setHoverSink(sink) {
|
|
2673
|
+
hoverSink = sink;
|
|
2674
|
+
},
|
|
254
2675
|
setQuadSource(source) {
|
|
255
2676
|
quadSource = source;
|
|
256
2677
|
},
|
|
2678
|
+
setNodePreviewSource(source) {
|
|
2679
|
+
nodePreviewSource = source;
|
|
2680
|
+
},
|
|
2681
|
+
setCoverageProbe(probe) {
|
|
2682
|
+
coverageProbe = probe;
|
|
2683
|
+
},
|
|
257
2684
|
setFitter(fit) {
|
|
258
2685
|
fitter = fit;
|
|
259
2686
|
},
|
|
2687
|
+
setDesignPointer(source) {
|
|
2688
|
+
designPointer = source;
|
|
2689
|
+
},
|
|
260
2690
|
setWorkingFitter(fit) {
|
|
261
2691
|
workingFitter = fit;
|
|
262
2692
|
},
|
|
2693
|
+
setExportArea(el) {
|
|
2694
|
+
exportArea = el;
|
|
2695
|
+
},
|
|
2696
|
+
setExportCanvas(canvas, isContextLost) {
|
|
2697
|
+
if (isContextLost === undefined) registerExportCanvas(canvas);
|
|
2698
|
+
else registerExportCanvas(canvas, isContextLost);
|
|
2699
|
+
},
|
|
263
2700
|
setDisposer(fn) {
|
|
264
2701
|
dispose = fn;
|
|
265
2702
|
},
|
|
2703
|
+
yieldPoster() {
|
|
2704
|
+
return yieldPoster() !== null;
|
|
2705
|
+
},
|
|
2706
|
+
passDrawing: () => state.passDrawing,
|
|
2707
|
+
registerFreezeHost(el) {
|
|
2708
|
+
if (el === freezeHost) return;
|
|
2709
|
+
// The frozen picture lived in the old host and went with it.
|
|
2710
|
+
if (freezeHost !== null) {
|
|
2711
|
+
preparing?.end(null);
|
|
2712
|
+
dropFreeze();
|
|
2713
|
+
}
|
|
2714
|
+
freezeHost = el;
|
|
2715
|
+
},
|
|
2716
|
+
setInteractionProbe(probe) {
|
|
2717
|
+
interactionProbe = probe;
|
|
2718
|
+
},
|
|
2719
|
+
yieldToInteraction() {
|
|
2720
|
+
preparing?.end("interaction");
|
|
2721
|
+
},
|
|
2722
|
+
setRasterScale(scale) {
|
|
2723
|
+
rasterScale = scale;
|
|
2724
|
+
},
|
|
266
2725
|
};
|
|
267
2726
|
}
|
|
268
2727
|
|
|
2728
|
+
/** One fatal-stage path shared by the root boundary and its integration test.
|
|
2729
|
+
* The export refusal must be installed before chrome reporting, because the
|
|
2730
|
+
* handler can synchronously start other work in response to the error. */
|
|
2731
|
+
export function reportFatalStage(client: StageClient, message: string): void {
|
|
2732
|
+
client.setFatalError(message);
|
|
2733
|
+
client.handlers()?.onError(`stage crashed: ${message}`);
|
|
2734
|
+
}
|
|
2735
|
+
|
|
269
2736
|
/** The scene holding a layer, or null. Exported for the isolation guard and for
|
|
270
2737
|
* the stage's own scoping — "is this id still real" is the question both ask,
|
|
271
2738
|
* and a second implementation of it is how the two drift. */
|
|
@@ -276,3 +2743,17 @@ export function sceneWithLayer(doc: Doc3D, layerId: string | null): Scene3D | nu
|
|
|
276
2743
|
}
|
|
277
2744
|
return null;
|
|
278
2745
|
}
|
|
2746
|
+
|
|
2747
|
+
/** Material preview also supports a mesh inside an opened component
|
|
2748
|
+
* definition. Layer ids are document-global, so a linear container walk is a
|
|
2749
|
+
* complete lookup and avoids teaching the stage bridge about chrome context. */
|
|
2750
|
+
function materialPreviewStillReal(doc: Doc3D, layerId: string | null): layerId is string {
|
|
2751
|
+
if (layerId === null) return false;
|
|
2752
|
+
for (const scene of Object.values(doc.scenesById)) {
|
|
2753
|
+
if (scene.layersById[layerId]?.kind === "mesh") return true;
|
|
2754
|
+
}
|
|
2755
|
+
for (const def of Object.values(doc.components ?? {})) {
|
|
2756
|
+
if (def.layersById[layerId]?.kind === "mesh") return true;
|
|
2757
|
+
}
|
|
2758
|
+
return false;
|
|
2759
|
+
}
|