@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
|
@@ -20,39 +20,191 @@
|
|
|
20
20
|
// GIF gets NO size figure at all — its size is dominated by palette and by how
|
|
21
21
|
// much of the frame changes, neither of which the bits-per-pixel model knows.
|
|
22
22
|
// It shows the frame count instead, which is true.
|
|
23
|
-
|
|
23
|
+
//
|
|
24
|
+
// ── WHEN AN AGENT ASKED FOR IT ──────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// An export question's yes (state/agentQuestion.ts, `exportAsk`) opens this
|
|
27
|
+
// dialog with the agent's format, quality and Shot, and hosted with Save to
|
|
28
|
+
// Videos on (Only me, unless the person's remembered choice already saves).
|
|
29
|
+
// What the export then saves is reported back to the question: the Videos id
|
|
30
|
+
// and page, else the downloaded file's name. A studio server render is
|
|
31
|
+
// reported by the question dialog when its job ends (AgentQuestionDialog).
|
|
32
|
+
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
|
33
|
+
import type { ReactNode } from "react";
|
|
24
34
|
import {
|
|
25
35
|
EXPORT_QUALITIES,
|
|
26
36
|
docDurationFrames,
|
|
27
37
|
estimateMp4Size,
|
|
38
|
+
exportFileBase,
|
|
28
39
|
exportQuality,
|
|
29
40
|
formatSizeEstimate,
|
|
30
41
|
formatTimeEstimate,
|
|
42
|
+
masterAudioClips,
|
|
31
43
|
predecessorTransitionOmitted,
|
|
32
44
|
sceneWindows,
|
|
45
|
+
creditsText,
|
|
46
|
+
creditsWithin,
|
|
47
|
+
sliceShotToScene,
|
|
48
|
+
soundCredits,
|
|
33
49
|
} from "@dailies-studio/shot/doc";
|
|
34
50
|
import type { ExportFormat, ExportQualityId, RenderStats } from "@dailies-studio/shot/doc";
|
|
51
|
+
import { audioPeakWarning } from "@dailies-studio/engine";
|
|
35
52
|
import { Dialog, SegmentedControl } from "@dailies-studio/ui";
|
|
36
|
-
import {
|
|
53
|
+
import { CAPABILITY_PROBE_TIMED_OUT, gifFrameStride } from "@dailies-studio/shot/tier1";
|
|
54
|
+
import type { Tier1Capability } from "@dailies-studio/shot/tier1";
|
|
55
|
+
import { ApiError, renderState, saveVideo, startRender } from "../../api.js";
|
|
56
|
+
import type { AgentQuestionVideo, VideoVisibility } from "../../api.js";
|
|
57
|
+
import type { AgentExportAsk } from "../../state/agentQuestion.js";
|
|
58
|
+
import { dailiesHost } from "../../hostApi.js";
|
|
59
|
+
import { reconcileRender } from "../../state/renderEvents.js";
|
|
37
60
|
import type { RenderJob } from "../../api.js";
|
|
61
|
+
import { mixShotAudio } from "../../audio/offlineMix.js";
|
|
38
62
|
import { useShotEditor, useStore } from "../../state/context.js";
|
|
63
|
+
import { defClockFrames, playbackWindow } from "../../state/types.js";
|
|
64
|
+
import type { ShotEditorState } from "../../state/types.js";
|
|
65
|
+
import { exportBlocker } from "../../state/workspace.js";
|
|
66
|
+
import { raceStage } from "../../state/stageWatchdog.js";
|
|
67
|
+
import { reportError } from "../../state/errorReports.js";
|
|
68
|
+
import { exportLockCount } from "./ExportScrim.js";
|
|
39
69
|
|
|
40
|
-
/** "the whole
|
|
70
|
+
/** "the whole Project", as a select value. Not a scene id, and cannot be one. */
|
|
41
71
|
const WHOLE = "";
|
|
72
|
+
/** "the work area", as a select value. Scene ids are `scn_` ids, never this. */
|
|
73
|
+
const WORK_AREA = "work-area";
|
|
74
|
+
|
|
75
|
+
/** The Project context's work area as the transport plays it (clamped by
|
|
76
|
+
* `playbackWindow`, never the raw field), or null when there is none, when
|
|
77
|
+
* a definition or an entered Shot owns the clock, or when it spans the film. */
|
|
78
|
+
function projectWorkArea(s: ShotEditorState): { start: number; frames: number } | null {
|
|
79
|
+
if (s.doc === null || s.workArea === null || defClockFrames(s) !== null || s.enteredScene !== null) return null;
|
|
80
|
+
const area = playbackWindow(s);
|
|
81
|
+
return area.frames < docDurationFrames(s.doc) ? area : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** How many times the dialog asks the stage for its encoders when a probe
|
|
85
|
+
* runs past the stage's own deadline. A late probe is not a "no": under load
|
|
86
|
+
* (the stage capturing a poster on a software GL) the first one has run out
|
|
87
|
+
* of time where the next answered, and hosted has no server to fall back to. */
|
|
88
|
+
const PROBE_ATTEMPTS = 3;
|
|
89
|
+
/** Why a probe's await ended when the stage it asked was replaced: its answer
|
|
90
|
+
* died with the old stage's realm, so the new stage is asked. */
|
|
91
|
+
const PROBE_STAGE_RELOADED = "the stage reloaded while its encoders were checked";
|
|
92
|
+
|
|
93
|
+
/** The export refusals by name, for the hosted error report: the stage's
|
|
94
|
+
* (bridgeClient.ts `exportRefusal`, in its order) and this dialog's own. A
|
|
95
|
+
* refusal carries only its sentence, so the name is read off it, and one no
|
|
96
|
+
* row matches reports with `refusal: null`. Names that must never drift
|
|
97
|
+
* would need a `refusal` field on the stage's Error. */
|
|
98
|
+
const REFUSALS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
99
|
+
["stage_crashed", /stage crashed/],
|
|
100
|
+
["piece_failed", /piece\(s\)/],
|
|
101
|
+
["context_lost", /context (is|was) lost/],
|
|
102
|
+
["render_failed", /render failure/],
|
|
103
|
+
["module_failed", /module\(s\) failed/],
|
|
104
|
+
["component_crashed", /crashed while rendering/],
|
|
105
|
+
["dom_output", /DOM output/],
|
|
106
|
+
["project_changed", /project changed/],
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/** How an in-browser export ENDED. Its progress is not here: that lives in the
|
|
110
|
+
* store's export lock, so the scrim over the stage, the header badge and this
|
|
111
|
+
* row all read one number and closing the dialog loses none of it. */
|
|
112
|
+
type BrowserJob = { phase: "done"; filename: string } | { phase: "cancelled" };
|
|
113
|
+
|
|
114
|
+
/** Hosted only: whether a finished export is also saved to the workspace's
|
|
115
|
+
* Videos, and who its page opens for (worker videos.ts). */
|
|
116
|
+
type ShareChoice = "off" | VideoVisibility;
|
|
117
|
+
const SHARE_CHOICES: readonly ShareChoice[] = ["off", "private", "workspace", "link"];
|
|
118
|
+
/** The last choice, per browser. A preference, so storage that throws (a
|
|
119
|
+
* private window, blocked site data) only means the default. */
|
|
120
|
+
const SHARE_KEY = "dailies.export.share";
|
|
121
|
+
function readShare(): ShareChoice {
|
|
122
|
+
try {
|
|
123
|
+
const saved = localStorage.getItem(SHARE_KEY);
|
|
124
|
+
return SHARE_CHOICES.find((c) => c === saved) ?? "off";
|
|
125
|
+
} catch {
|
|
126
|
+
return "off";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function writeShare(choice: ShareChoice): void {
|
|
130
|
+
try {
|
|
131
|
+
localStorage.setItem(SHARE_KEY, choice);
|
|
132
|
+
} catch {
|
|
133
|
+
// Remembering is a convenience; the export itself is unaffected.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const SHARE_HINTS: Record<ShareChoice, string> = {
|
|
137
|
+
off: "The file goes to your downloads only.",
|
|
138
|
+
private: "Also saved to Videos. Only you can open its page. You can change this later in Videos.",
|
|
139
|
+
workspace: "Also saved to Videos. Members of this workspace can open its page. You can change this later in Videos.",
|
|
140
|
+
link: "Also saved to Videos. Anyone with the link can watch it. You can change this later in Videos.",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The save to Videos that follows a hosted download. Apart from the job:
|
|
144
|
+
* the file is already in the downloads, so a failed save is not a failed
|
|
145
|
+
* export. */
|
|
146
|
+
type Shared = { phase: "saving" } | { phase: "saved"; url: string } | { phase: "failed"; message: string };
|
|
42
147
|
|
|
43
|
-
|
|
148
|
+
/** An anchor download, not showSaveFilePicker: no permission prompt, works in
|
|
149
|
+
* every browser, and the object URL is revoked after the click has consumed
|
|
150
|
+
* it (the same helper the motion editor ships). */
|
|
151
|
+
function download(blob: Blob, filename: string): void {
|
|
152
|
+
const url = URL.createObjectURL(blob);
|
|
153
|
+
const a = document.createElement("a");
|
|
154
|
+
a.href = url;
|
|
155
|
+
a.download = filename;
|
|
156
|
+
document.body.appendChild(a);
|
|
157
|
+
a.click();
|
|
158
|
+
a.remove();
|
|
159
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function ExportDialog(props: {
|
|
163
|
+
onClose(): void;
|
|
164
|
+
/** Test seam for holding the asynchronous mix across a document edit. */
|
|
165
|
+
mixAudio?: typeof mixShotAudio;
|
|
166
|
+
}) {
|
|
44
167
|
const store = useStore();
|
|
45
168
|
const doc = useShotEditor((s) => s.doc);
|
|
46
169
|
const name = useShotEditor((s) => s.name);
|
|
47
170
|
const currentScene = useShotEditor((s) => s.sceneId);
|
|
48
171
|
const job = useShotEditor((s) => s.renderJob);
|
|
172
|
+
// ONE answer to "is an export running", whichever path started it.
|
|
173
|
+
const exportLock = useShotEditor((s) => s.exportLock);
|
|
174
|
+
// The agent's export ask for THIS Project, if the person said yes to one.
|
|
175
|
+
const exportAsk = useSyncExternalStore(store.agentQuestion.subscribe, () => store.agentQuestion.getState().exportAsk);
|
|
176
|
+
const asked = exportAsk !== null && exportAsk.projectId === doc?.id ? exportAsk : null;
|
|
177
|
+
// Its choices, read once as the dialog opens: after that they are the
|
|
178
|
+
// person's to change.
|
|
179
|
+
const [askedAtOpen] = useState(() => asked?.export ?? null);
|
|
180
|
+
const hosted = dailiesHost() !== undefined;
|
|
49
181
|
|
|
50
|
-
const [format, setFormat] = useState<ExportFormat>("mp4");
|
|
51
|
-
const [quality, setQuality] = useState<ExportQualityId>("social");
|
|
52
|
-
// Defaults to the WHOLE
|
|
182
|
+
const [format, setFormat] = useState<ExportFormat>(askedAtOpen?.format ?? "mp4");
|
|
183
|
+
const [quality, setQuality] = useState<ExportQualityId>(askedAtOpen?.quality ?? "social");
|
|
184
|
+
// Defaults to the WHOLE Project even though a scene is open, because
|
|
53
185
|
// "export" without qualification means the deliverable. Choosing one scene is
|
|
54
|
-
// deliberate, and the select says which one.
|
|
55
|
-
|
|
186
|
+
// deliberate, and the select says which one. A work area the author drew is
|
|
187
|
+
// that deliberate choice already made, so it is the default when there is one.
|
|
188
|
+
// An agent's ask is the choice made for them: its Shot, else the whole
|
|
189
|
+
// Project it promised the agent, never a work area drawn earlier.
|
|
190
|
+
const [scopeChoice, setScope] = useState<string>(() => {
|
|
191
|
+
if (askedAtOpen !== null) {
|
|
192
|
+
const scene = askedAtOpen.scene;
|
|
193
|
+
return scene !== undefined && store.getState().doc?.scenesById[scene] !== undefined ? scene : WHOLE;
|
|
194
|
+
}
|
|
195
|
+
return projectWorkArea(store.getState()) === null ? WHOLE : WORK_AREA;
|
|
196
|
+
});
|
|
197
|
+
// Primitive selectors: a fresh object per read would never settle.
|
|
198
|
+
const areaStart = useShotEditor((s) => projectWorkArea(s)?.start ?? null);
|
|
199
|
+
const areaFrames = useShotEditor((s) => projectWorkArea(s)?.frames ?? null);
|
|
200
|
+
const area = areaStart === null || areaFrames === null ? null : { start: areaStart, frames: areaFrames };
|
|
201
|
+
// A work area cleared while the dialog is open falls back to the whole Project.
|
|
202
|
+
const scope = scopeChoice === WORK_AREA && area === null ? WHOLE : scopeChoice;
|
|
203
|
+
const sceneId = scope === WHOLE || scope === WORK_AREA ? undefined : scope;
|
|
204
|
+
const range = scope === WORK_AREA ? area : null;
|
|
205
|
+
/** Why no export can start here (a component, material or code workspace
|
|
206
|
+
* owns the stage), or null. A run already going is not refused by it. */
|
|
207
|
+
const blocked = useShotEditor(exportBlocker);
|
|
56
208
|
const [stats, setStats] = useState<RenderStats>({ runs: [] });
|
|
57
209
|
const [error, setError] = useState<string | null>(null);
|
|
58
210
|
// ON by default: the document's clips are part of the deliverable, and an
|
|
@@ -60,18 +212,149 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
60
212
|
// wrong artifact this lane refuses to produce. Unticking it is deliberate,
|
|
61
213
|
// and the row says how many clips it is dropping.
|
|
62
214
|
const [withAudio, setWithAudio] = useState(true);
|
|
215
|
+
// BROWSER by default, MP4 and GIF alike: the author's own machine renders
|
|
216
|
+
// near-instantly and costs the studio nothing (the Earth Studio model).
|
|
217
|
+
// Locally the server stays the answer for a browser that cannot encode the
|
|
218
|
+
// format, said in place. Hosted there is no server to offer.
|
|
219
|
+
const [dest, setDest] = useState<"browser" | "server">("browser");
|
|
220
|
+
// The hosted editor has no Tier-2 renderer, so the server is never offered
|
|
221
|
+
// (`hosted`, above).
|
|
222
|
+
/** What THIS browser can encode, probed through the stage. Null while the
|
|
223
|
+
* probe runs (the "checking" note); "unavailable" when the stage is not
|
|
224
|
+
* connected; "unanswered" when every probe ran out of time. */
|
|
225
|
+
const [cap, setCap] = useState<Tier1Capability | "unavailable" | "unanswered" | null>(null);
|
|
226
|
+
/** Which probe is running, from 1, for the checking note. */
|
|
227
|
+
const [probeAttempt, setProbeAttempt] = useState(1);
|
|
228
|
+
/** Bumped by "Check again", which runs the probe anew. */
|
|
229
|
+
const [probeRun, setProbeRun] = useState(0);
|
|
230
|
+
const [browserJob, setBrowserJob] = useState<BrowserJob | null>(null);
|
|
231
|
+
// An agent's ask saves to Videos: the remembered choice when it saves, else
|
|
232
|
+
// Only me. The remembered choice itself is left as it was.
|
|
233
|
+
const [share, setShare] = useState<ShareChoice>(() => {
|
|
234
|
+
const saved = readShare();
|
|
235
|
+
return askedAtOpen !== null && hosted && saved === "off" ? "private" : saved;
|
|
236
|
+
});
|
|
237
|
+
const [shared, setShared] = useState<Shared | null>(null);
|
|
238
|
+
const [audioNotice, setAudioNotice] = useState<string | null>(null);
|
|
239
|
+
/** The credits' Copy said so. */
|
|
240
|
+
const [copied, setCopied] = useState(false);
|
|
241
|
+
/** Set when the last MP4 came from a software encoder at a bitrate, whose
|
|
242
|
+
* opening frames start soft. Only the finished export knows which encoder
|
|
243
|
+
* ran (the stage decides it when the encode starts), so it is said after. */
|
|
244
|
+
const [encoderNotice, setEncoderNotice] = useState<string | null>(null);
|
|
245
|
+
/** Ties the Audio checkbox to the hint that says why it is ticked or not. */
|
|
246
|
+
const audioHintId = useId();
|
|
247
|
+
// An export outlives this dialog, so a failure that lands after Close has to
|
|
248
|
+
// reach the shell's error strip instead of a setState nobody is mounted for.
|
|
249
|
+
const mounted = useRef(true);
|
|
250
|
+
useEffect(() => () => { mounted.current = false; }, []);
|
|
251
|
+
|
|
252
|
+
// ── FOCUS FOLLOWS THE ONE ACTION THAT IS LIVE ──────────────────────────
|
|
253
|
+
// Export sits in the footer and Cancel export sits in the Rendering card,
|
|
254
|
+
// so starting or ending a run UNMOUNTS the focused button and drops focus
|
|
255
|
+
// on <body>. The Dialog's Escape and Tab trap listen on the dialog itself,
|
|
256
|
+
// so from <body> the keyboard would have no way back in. When focus has
|
|
257
|
+
// fallen out like that, it moves to the action that replaced the one that
|
|
258
|
+
// went, or to the dialog itself when that action is disabled. A focus
|
|
259
|
+
// anywhere else (the author tabbed to Quality while a run from another tab
|
|
260
|
+
// arrived) is left alone: that is not ours to steal.
|
|
261
|
+
const running = exportLock !== null;
|
|
262
|
+
const exportButton = useRef<HTMLButtonElement>(null);
|
|
263
|
+
const cancelButton = useRef<HTMLButtonElement>(null);
|
|
264
|
+
const body = useRef<HTMLDivElement>(null);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const at = document.activeElement;
|
|
267
|
+
if (at !== null && at !== document.body) return;
|
|
268
|
+
const next = running ? cancelButton.current : exportButton.current;
|
|
269
|
+
(next !== null && !next.disabled ? next : body.current?.closest<HTMLElement>('[role="dialog"]'))?.focus();
|
|
270
|
+
}, [running]);
|
|
271
|
+
|
|
272
|
+
// ── A SERVER RUN RETIRES THE LAST FAILURE ──────────────────────────────
|
|
273
|
+
// The outcome slot ranks a failure above the last job, so a 409 (or any
|
|
274
|
+
// refused POST) left its Failed card standing after a server run that
|
|
275
|
+
// started elsewhere had gone on to finish: the dialog said "Failed" over a
|
|
276
|
+
// render that had just written its file. A server lock is a NEW run, so
|
|
277
|
+
// whatever went wrong before it is history. Keyed on `where`, not on the
|
|
278
|
+
// lock object, which is fresh on every progress tick. A browser lock does
|
|
279
|
+
// not clear it: runBrowser clears its own error before it takes the lock,
|
|
280
|
+
// and a browser failure lands WHILE that lock is still held.
|
|
281
|
+
const lockWhere = exportLock?.where;
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (lockWhere === "server") setError(null);
|
|
284
|
+
}, [lockWhere]);
|
|
285
|
+
|
|
286
|
+
// Capability probe on open: a dead stage degrades to the server path with a
|
|
287
|
+
// reason rather than a dead button. A probe the stage timed out is asked
|
|
288
|
+
// again, up to PROBE_ATTEMPTS, and each await races the stage watchdog: a
|
|
289
|
+
// stage that reloads mid-probe takes its answer with it, so the new stage
|
|
290
|
+
// is asked rather than the dialog waiting forever.
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (store.stage() === null) {
|
|
293
|
+
setCap("unavailable");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const closed = new AbortController();
|
|
297
|
+
void (async () => {
|
|
298
|
+
for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt++) {
|
|
299
|
+
const bridge = store.stage();
|
|
300
|
+
if (bridge === null) break;
|
|
301
|
+
setProbeAttempt(attempt);
|
|
302
|
+
let answer: Tier1Capability;
|
|
303
|
+
try {
|
|
304
|
+
answer = await raceStage(bridge.exportCapability(), {
|
|
305
|
+
stage: () => store.stage(),
|
|
306
|
+
bridgeAtStart: bridge,
|
|
307
|
+
signal: closed.signal,
|
|
308
|
+
reloaded: PROBE_STAGE_RELOADED,
|
|
309
|
+
});
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (closed.signal.aborted) return;
|
|
312
|
+
if (e instanceof Error && e.message === PROBE_STAGE_RELOADED) continue;
|
|
313
|
+
setCap("unavailable");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (closed.signal.aborted) return;
|
|
317
|
+
if (!(answer.mp4.ok === false && answer.mp4.reason === CAPABILITY_PROBE_TIMED_OUT)) {
|
|
318
|
+
setCap(answer);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!closed.signal.aborted) setCap(store.stage() === null ? "unavailable" : "unanswered");
|
|
323
|
+
})();
|
|
324
|
+
return () => closed.abort();
|
|
325
|
+
}, [store, probeRun]);
|
|
326
|
+
const checkAgain = (): void => {
|
|
327
|
+
setCap(null);
|
|
328
|
+
setProbeAttempt(1);
|
|
329
|
+
setProbeRun((run) => run + 1);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// The browser path needs a connected stage and an encoder for the format:
|
|
333
|
+
// H.264 for an MP4, the GIF kind for a GIF (absent on a stage older than
|
|
334
|
+
// it). When the probe says no, the choice STEPS ASIDE to the server rather
|
|
335
|
+
// than holding a dead Export button — the note beside the row says why, and
|
|
336
|
+
// re-choosing "This browser" snaps back with the same note still on screen.
|
|
337
|
+
// Hosted there is nowhere to step to: the note says why and Export waits.
|
|
338
|
+
const encoder = cap === null || typeof cap === "string" ? undefined : format === "gif" ? cap.gif : cap.mp4;
|
|
339
|
+
const browserReady = encoder?.ok === true;
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!hosted && dest === "browser" && cap !== null && !browserReady) setDest("server");
|
|
342
|
+
}, [hosted, dest, cap, browserReady]);
|
|
63
343
|
|
|
64
344
|
// The dialog can open MID-RENDER (the author closed it and came back), so it
|
|
65
345
|
// asks rather than assuming idle — and picks up this project's measured speed
|
|
66
|
-
// in the same round trip.
|
|
346
|
+
// in the same round trip. Hosted has no server render to ask about.
|
|
67
347
|
useEffect(() => {
|
|
68
|
-
if (name === null) return;
|
|
348
|
+
if (name === null || hosted) return;
|
|
69
349
|
let live = true;
|
|
350
|
+
const asked = store.getState().renderJob;
|
|
70
351
|
void renderState(name)
|
|
71
352
|
.then((state) => {
|
|
72
353
|
if (!live) return;
|
|
73
354
|
setStats(state.stats);
|
|
74
|
-
|
|
355
|
+
// The live job, or the last result after a reload (the route reports
|
|
356
|
+
// this shot's renders only), or a render the server has lost.
|
|
357
|
+
reconcileRender(store, name, state, asked);
|
|
75
358
|
})
|
|
76
359
|
.catch(() => {
|
|
77
360
|
// An estimate is a nicety. A server without the render lane still lets
|
|
@@ -80,20 +363,56 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
80
363
|
return () => {
|
|
81
364
|
live = false;
|
|
82
365
|
};
|
|
83
|
-
}, [name, store, job?.phase]);
|
|
366
|
+
}, [name, store, job?.phase, hosted]);
|
|
84
367
|
|
|
85
368
|
if (doc === null || name === null) return null;
|
|
86
369
|
|
|
87
370
|
const windows = sceneWindows(doc);
|
|
88
371
|
const frames =
|
|
89
|
-
|
|
90
|
-
?
|
|
91
|
-
:
|
|
372
|
+
range !== null
|
|
373
|
+
? range.frames
|
|
374
|
+
: sceneId === undefined
|
|
375
|
+
? docDurationFrames(doc)
|
|
376
|
+
: (windows.find((w) => w.id === sceneId)?.end ?? 0) - (windows.find((w) => w.id === sceneId)?.start ?? 0);
|
|
377
|
+
/** Whether master frames [from, from + length) reach into the range. */
|
|
378
|
+
const inRange = (from: number, length: number): boolean =>
|
|
379
|
+
range === null || (from < range.start + range.frames && from + length > range.start);
|
|
92
380
|
const megapixels = (doc.size.width * doc.size.height) / 1_000_000;
|
|
93
381
|
// GIF has no audio stream at all, so the checkbox is not a choice there and
|
|
94
382
|
// the size model must not budget for one either.
|
|
95
|
-
|
|
96
|
-
|
|
383
|
+
// BOTH HOMES. `doc.audio` is only the bed since schema v13, so counting it
|
|
384
|
+
// alone would budget no audio bitrate — and, worse, drop `audible` to false
|
|
385
|
+
// on a document whose whole soundtrack is shot-owned, silently exporting a
|
|
386
|
+
// video with no sound and a checkbox that looked ticked.
|
|
387
|
+
// THE SCOPE'S clips, not the document's. Counting the whole timeline while
|
|
388
|
+
// the range is one scene promised sound for a scene that owns none: the mix
|
|
389
|
+
// then returned null, no track was added, and nothing said why.
|
|
390
|
+
// Only the UNMUTED clips, as the mixers count them: a muted clip adds no
|
|
391
|
+
// sound, and counting it promised a track the file would not carry.
|
|
392
|
+
const scoped = sceneId === undefined ? doc : (sliceShotToScene(doc, sceneId) ?? doc);
|
|
393
|
+
const rangeClips = masterAudioClips(scoped).filter((m) => inRange(m.clip.frame, m.clip.durationFrames));
|
|
394
|
+
const clipCount = rangeClips.filter((m) => m.clip.muted !== true).length;
|
|
395
|
+
const effectiveDest = hosted ? "browser" : dest;
|
|
396
|
+
// A browser whose encoder cannot take audio ships the video silent, so the
|
|
397
|
+
// row says so before the export rather than after the download.
|
|
398
|
+
const browserAudioReason =
|
|
399
|
+
effectiveDest === "browser" && cap !== null && typeof cap !== "string" && !cap.audio.ok
|
|
400
|
+
? cap.audio.reason
|
|
401
|
+
: undefined;
|
|
402
|
+
const audible = format === "mp4" && withAudio && clipCount > 0 && browserAudioReason === undefined;
|
|
403
|
+
// THE CREDITS THIS FILE OWES (v49): the Openverse sounds it plays, in the
|
|
404
|
+
// export's own scope. Only when the file carries sound: a video exported
|
|
405
|
+
// silent plays no sound and owes no credit.
|
|
406
|
+
const credits = audible ? soundCredits(doc, range ?? sceneId) : { required: [], courtesy: [] };
|
|
407
|
+
const creditBlock = creditsText(credits);
|
|
408
|
+
/** "Project" or "Shot", whichever the range exports from. */
|
|
409
|
+
const unit = range !== null ? "work area" : sceneId === undefined ? "Project" : "Shot";
|
|
410
|
+
// The same "off" rule the server render reads (`shotMotionBlurAt`).
|
|
411
|
+
const blurred = scoped.scenes.some((id) => {
|
|
412
|
+
const mb = scoped.scenesById[id]?.effects?.motionBlur;
|
|
413
|
+
const w = windows.find((x) => x.id === id);
|
|
414
|
+
return mb !== undefined && mb.samples > 1 && (w === undefined || inRange(w.start, w.end - w.start));
|
|
415
|
+
});
|
|
97
416
|
const size = estimateMp4Size({
|
|
98
417
|
width: doc.size.width,
|
|
99
418
|
height: doc.size.height,
|
|
@@ -102,59 +421,265 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
102
421
|
quality: exportQuality(quality),
|
|
103
422
|
audio: audible,
|
|
104
423
|
});
|
|
105
|
-
const running = job?.phase === "running";
|
|
106
424
|
|
|
107
|
-
|
|
425
|
+
/** Tell the agent's question where the export went. A refused report says
|
|
426
|
+
* so in the shell's strip: the file itself is fine. */
|
|
427
|
+
const report = (ask: AgentExportAsk | null, video: AgentQuestionVideo, what: string): void => {
|
|
428
|
+
if (ask === null) return;
|
|
429
|
+
store.agentQuestion.reportVideo(ask, name, video).catch((e: unknown) => {
|
|
430
|
+
store.reportError(`${what} was exported, but telling the agent failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const runServer = async (): Promise<void> => {
|
|
435
|
+
// Export is hidden while the lock is held; this is the refusal behind it,
|
|
436
|
+
// for a keyboard submit that raced the re-render.
|
|
437
|
+
if (exportLock !== null) return;
|
|
438
|
+
const reportTo = asked;
|
|
108
439
|
setError(null);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
440
|
+
try {
|
|
441
|
+
// The server renders the saved document, so what is on screen is saved
|
|
442
|
+
// first: an edit inside the autosave debounce, or behind a failed save,
|
|
443
|
+
// would otherwise export an older revision. `flush` throws on the latter.
|
|
444
|
+
await store.flush();
|
|
445
|
+
// A browser export may have taken the lock while the save ran.
|
|
446
|
+
if (store.getState().exportLock !== null) return;
|
|
447
|
+
const started = await startRender(name, {
|
|
448
|
+
format,
|
|
449
|
+
quality,
|
|
450
|
+
...(sceneId === undefined ? {} : { sceneId }),
|
|
451
|
+
...(range === null ? {} : { frameRange: { start: range.start, end: range.start + range.frames } }),
|
|
452
|
+
// Only ever sent as `false`. Absent means the server's default, which is
|
|
453
|
+
// to mux whatever `doc.audio` carries, and saying so explicitly would
|
|
454
|
+
// make this chrome the thing that decides it.
|
|
455
|
+
...(withAudio ? {} : { audio: false }),
|
|
123
456
|
});
|
|
457
|
+
store.setRenderJob(started);
|
|
458
|
+
// Reported when the job ends, by the question dialog, which outlives
|
|
459
|
+
// this one.
|
|
460
|
+
if (reportTo !== null) store.agentQuestion.exportOnServer(started.id);
|
|
461
|
+
} catch (e) {
|
|
462
|
+
// A 409 means a server run is already going. When the bus has already
|
|
463
|
+
// delivered it, the Rendering card says so and outlives this refusal;
|
|
464
|
+
// writing the error then would resurface it the moment that run ends.
|
|
465
|
+
if (e instanceof ApiError && e.conflict) {
|
|
466
|
+
if (store.getState().exportLock?.where !== "server") setError(e.message);
|
|
467
|
+
} else setError(e instanceof Error ? e.message : String(e));
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const runBrowser = async (): Promise<void> => {
|
|
472
|
+
const reportTo = asked;
|
|
473
|
+
const bridgeAtStart = store.stage();
|
|
474
|
+
if (bridgeAtStart === null) {
|
|
475
|
+
setError("the stage is not connected: reload and try again");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// THE LOCK, which stops the transport, refuses Space, veils the stage and
|
|
479
|
+
// survives this dialog unmounting. It throws when one is already running,
|
|
480
|
+
// which is the same refusal hiding the Export button expresses. Said by
|
|
481
|
+
// nothing more: the lock it lost to already shows as the Rendering card,
|
|
482
|
+
// Cancel included, and a refusal kept in `error` would outlive that run
|
|
483
|
+
// and paint a Failed card over the run's real outcome.
|
|
484
|
+
let signal: AbortSignal;
|
|
485
|
+
try {
|
|
486
|
+
signal = store.beginBrowserExport();
|
|
487
|
+
} catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
setError(null);
|
|
491
|
+
setAudioNotice(null);
|
|
492
|
+
setEncoderNotice(null);
|
|
493
|
+
setBrowserJob(null);
|
|
494
|
+
setShared(null);
|
|
495
|
+
// The export promise lives in the IFRAME's realm, and dies unsettled with
|
|
496
|
+
// it if the stage reloads mid-export, so every await races the bridge's
|
|
497
|
+
// replacement and a local abort (`raceStage`).
|
|
498
|
+
const watch = { stage: () => store.stage(), bridgeAtStart, signal, reloaded: "the stage reloaded: export aborted" };
|
|
499
|
+
try {
|
|
500
|
+
// The mix renders BEFORE the capture starts — a missing or undecodable
|
|
501
|
+
// audio asset must reject the export up front, not after two minutes
|
|
502
|
+
// of frames (the motion lane's posture, refusal over corruption).
|
|
503
|
+
const audio = audible
|
|
504
|
+
? await raceStage((props.mixAudio ?? mixShotAudio)(doc, range ?? sceneId), watch)
|
|
505
|
+
: null;
|
|
506
|
+
// Audio mixing is asynchronous, while document edits can still arrive
|
|
507
|
+
// from another tab or an agent behind the export scrim. The stage does
|
|
508
|
+
// not freeze its document until exportMovie enters `begin`, below. Refuse
|
|
509
|
+
// if the immutable document object changed during the mix, otherwise the
|
|
510
|
+
// accepted file could pair audio from the old revision with video from
|
|
511
|
+
// the new one. The check and exportMovie call are in the same task, and
|
|
512
|
+
// exportMovie enters stage export framing synchronously before its first
|
|
513
|
+
// await, so no document push can interleave between them.
|
|
514
|
+
if (store.getState().doc !== doc) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
"the project changed while its audio was prepared; export refused so sound and video cannot come from different revisions",
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
// The peak is measured on the FLOAT mix, which is the last moment it has
|
|
520
|
+
// its true amplitude — the encoder flattens everything past full scale.
|
|
521
|
+
// Same sentence the server render prints, from the same engine advisory,
|
|
522
|
+
// and on screen while the frames render rather than after the download.
|
|
523
|
+
if (audio !== null) setAudioNotice(audioPeakWarning(audio.peak));
|
|
524
|
+
const q = exportQuality(quality);
|
|
525
|
+
const result = await raceStage(
|
|
526
|
+
bridgeAtStart.exportMovie({
|
|
527
|
+
format,
|
|
528
|
+
...(sceneId === undefined ? {} : { sceneId }),
|
|
529
|
+
...(range === null ? {} : { window: range }),
|
|
530
|
+
// The same quality table the server path reads, expressed the way
|
|
531
|
+
// WebCodecs takes it: the CRF as the fixed QP the stage encodes at
|
|
532
|
+
// where a hardware encoder honours one (bridge v49), and bits/s from
|
|
533
|
+
// bits-per-pixel at this document's dimensions and rate for every
|
|
534
|
+
// other encoder. A GIF's size is its palette's, as on the server,
|
|
535
|
+
// which gives it no quality number either.
|
|
536
|
+
...(format === "gif"
|
|
537
|
+
? {}
|
|
538
|
+
: {
|
|
539
|
+
quantizer: q.crf,
|
|
540
|
+
videoBitrate: Math.round(
|
|
541
|
+
q.bitsPerPixel * doc.size.width * doc.size.height * (doc.fps.num / doc.fps.den),
|
|
542
|
+
),
|
|
543
|
+
}),
|
|
544
|
+
...(audio !== null ? { audio } : {}),
|
|
545
|
+
signal,
|
|
546
|
+
onProgress: (done, total) => store.exportProgress(done, total),
|
|
547
|
+
}),
|
|
548
|
+
watch,
|
|
549
|
+
);
|
|
550
|
+
// Named by the server render's rule, so both backends write one name.
|
|
551
|
+
const filename = `${exportFileBase(doc, sceneId, range !== null)}${result.extension}`;
|
|
552
|
+
download(result.blob, filename);
|
|
553
|
+
setBrowserJob({ phase: "done", filename });
|
|
554
|
+
if (hosted && share !== "off") {
|
|
555
|
+
setShared({ phase: "saving" });
|
|
556
|
+
// Not awaited: the lock (and the stage's veil) ends with the download,
|
|
557
|
+
// and the upload needs neither.
|
|
558
|
+
saveVideo(result.blob, {
|
|
559
|
+
project: name,
|
|
560
|
+
title: doc.title ?? name,
|
|
561
|
+
filename,
|
|
562
|
+
visibility: share,
|
|
563
|
+
// Within what a saved video keeps: the dialog below still shows and
|
|
564
|
+
// copies the whole block.
|
|
565
|
+
...(creditBlock === "" ? {} : { credits: creditsWithin(credits) }),
|
|
566
|
+
durationMs: Math.round((frames * 1000 * doc.fps.den) / doc.fps.num),
|
|
567
|
+
width: doc.size.width,
|
|
568
|
+
height: doc.size.height,
|
|
569
|
+
}).then(
|
|
570
|
+
(saved) => {
|
|
571
|
+
if (mounted.current) setShared({ phase: "saved", url: saved.url });
|
|
572
|
+
report(reportTo, { where: "videos", id: saved.id, url: saved.url }, filename);
|
|
573
|
+
},
|
|
574
|
+
(e: unknown) => {
|
|
575
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
576
|
+
if (mounted.current) setShared({ phase: "failed", message });
|
|
577
|
+
else store.reportError(`${filename} downloaded, but saving it to Videos failed: ${message}`);
|
|
578
|
+
// Only the person's downloads hold it: the agent hears that.
|
|
579
|
+
report(reportTo, { where: "downloads", filename }, filename);
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
} else report(reportTo, { where: "downloads", filename }, filename);
|
|
583
|
+
// A software encoder at a bitrate (OpenH264 in Chrome) starts every film
|
|
584
|
+
// at a coarse QP and walks it down, so fine grain and a faint tint arrive
|
|
585
|
+
// late (docs/exporting.md, "Shot Tier 1"). Said beside the download
|
|
586
|
+
// rather than refused, because it is still the best encode this browser
|
|
587
|
+
// can make.
|
|
588
|
+
if (result.encoding?.rateControl === "bitrate" && result.encoding.encoder === "software") {
|
|
589
|
+
setEncoderNotice(
|
|
590
|
+
"This browser encoded the video in software, so its first second may look softer than the rest." +
|
|
591
|
+
(hosted ? "" : " The studio server render does not have this problem."),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
if (result.audioSkippedReason !== undefined) {
|
|
595
|
+
// The movie downloaded but shipped silent — say why instead of
|
|
596
|
+
// letting the author discover it on playback. Overwrites the peak
|
|
597
|
+
// advisory, which has nothing to warn about in a file with no sound.
|
|
598
|
+
setAudioNotice(
|
|
599
|
+
`The video saved without sound: ${result.audioSkippedReason}.` +
|
|
600
|
+
(hosted ? "" : " The studio server render can include it."),
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
} catch (e) {
|
|
604
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
605
|
+
if (message === "export cancelled") {
|
|
606
|
+
// The author pressed Cancel; it must not read as a failure.
|
|
607
|
+
setBrowserJob({ phase: "cancelled" });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Hosted, the admin panel hears of it too. A piece's refusal names the
|
|
611
|
+
// layer by its label, which is the author's words, so it goes without
|
|
612
|
+
// its sentence: the piece's own report (`stage.piece_failure`) carries
|
|
613
|
+
// its id and what it threw.
|
|
614
|
+
const refusal = /export refused/.test(message) ? (REFUSALS.find(([, said]) => said.test(message))?.[0] ?? null) : undefined;
|
|
615
|
+
reportError({
|
|
616
|
+
code: refusal === undefined ? "export.failed" : "export.refused",
|
|
617
|
+
...(refusal === "piece_failed" ? {} : { message }),
|
|
618
|
+
projectId: doc.id,
|
|
619
|
+
...(sceneId === undefined ? {} : { shot: sceneId }),
|
|
620
|
+
detail: { format, ...(refusal === undefined ? {} : { refusal }) },
|
|
621
|
+
});
|
|
622
|
+
if (mounted.current) {
|
|
623
|
+
setBrowserJob(null);
|
|
624
|
+
setError(message);
|
|
625
|
+
} else {
|
|
626
|
+
// Closed mid-export and it then failed: the shell's strip is the only
|
|
627
|
+
// surface left, and losing the reason entirely is the alternative.
|
|
628
|
+
store.reportError(message);
|
|
629
|
+
}
|
|
630
|
+
} finally {
|
|
631
|
+
store.endBrowserExport();
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const submit = (): void => {
|
|
636
|
+
if (blocked !== null) return;
|
|
637
|
+
if (effectiveDest === "browser") void runBrowser();
|
|
638
|
+
else void runServer();
|
|
124
639
|
};
|
|
125
640
|
|
|
126
641
|
return (
|
|
127
642
|
<Dialog
|
|
128
643
|
open
|
|
129
644
|
title="Export"
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
className="shot-export-dialog"
|
|
645
|
+
// Paper's 648px form dialog. The shared 320px card was too narrow for a
|
|
646
|
+
// label column plus a size RANGE: the estimate clipped mid-range, and
|
|
647
|
+
// "911 KB–2" reads as a finished number rather than a truncated one.
|
|
648
|
+
size="wide"
|
|
135
649
|
onClose={props.onClose}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
650
|
+
// No Export while one runs, and no second Cancel here: the Rendering
|
|
651
|
+
// card below carries the one Cancel export, beside the count it stops.
|
|
652
|
+
// The title bar's X is the one way to close, so a run leaves no footer.
|
|
653
|
+
{...(running
|
|
654
|
+
? {}
|
|
655
|
+
: {
|
|
656
|
+
actions: (
|
|
657
|
+
<button
|
|
658
|
+
ref={exportButton}
|
|
659
|
+
type="button"
|
|
660
|
+
className="btn is-primary"
|
|
661
|
+
// Held while the probe is still running on the browser path.
|
|
662
|
+
// Locally a known-bad capability never reaches here: the
|
|
663
|
+
// snap-back effect has already moved the destination to the
|
|
664
|
+
// server. Hosted it holds, with the note saying why. Held too
|
|
665
|
+
// while the stage shows one component or material, not the film.
|
|
666
|
+
disabled={blocked !== null || (effectiveDest === "browser" && (cap === null || !browserReady))}
|
|
667
|
+
onClick={submit}
|
|
668
|
+
>
|
|
669
|
+
Export
|
|
670
|
+
</button>
|
|
671
|
+
),
|
|
672
|
+
})}
|
|
152
673
|
>
|
|
153
|
-
<div className="export-dialog" data-testid="export-dialog">
|
|
154
|
-
<label
|
|
155
|
-
|
|
674
|
+
<div ref={body} className="export-dialog" data-testid="export-dialog">
|
|
675
|
+
{/* A <div>, not a <label>, around a segmented control: a label
|
|
676
|
+
forwards a click on its text to its FIRST labelable descendant, so
|
|
677
|
+
clicking "Format" chose MP4. The control names itself. */}
|
|
678
|
+
<div className="export-row">
|
|
679
|
+
<span className="export-label">Format</span>
|
|
156
680
|
<SegmentedControl
|
|
157
681
|
ariaLabel="Export format"
|
|
682
|
+
className="is-accent is-lg export-switch"
|
|
158
683
|
value={format}
|
|
159
684
|
options={[
|
|
160
685
|
{ value: "mp4", label: "MP4" },
|
|
@@ -162,12 +687,101 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
162
687
|
]}
|
|
163
688
|
onChange={(next) => setFormat(next as ExportFormat)}
|
|
164
689
|
/>
|
|
165
|
-
</
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
{/* Hosted has no server render at all, so there is no choice to show. */}
|
|
693
|
+
{hosted ? null : (
|
|
694
|
+
<div className="export-row">
|
|
695
|
+
<span className="export-label">Render on</span>
|
|
696
|
+
<SegmentedControl
|
|
697
|
+
ariaLabel="Render destination"
|
|
698
|
+
className="is-accent is-lg export-switch is-destination"
|
|
699
|
+
value={dest}
|
|
700
|
+
options={[
|
|
701
|
+
{ value: "browser", label: "This browser" },
|
|
702
|
+
{ value: "server", label: "Studio server" },
|
|
703
|
+
]}
|
|
704
|
+
onChange={(next) => setDest(next as "browser" | "server")}
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
708
|
+
|
|
709
|
+
{cap === null ? (
|
|
710
|
+
<p className="export-note" data-testid="export-capability-checking">
|
|
711
|
+
{probeAttempt === 1
|
|
712
|
+
? "Checking this browser's encoder…"
|
|
713
|
+
: `The encoder check ran out of time, so it is checking again (${probeAttempt} of ${PROBE_ATTEMPTS})…`}
|
|
714
|
+
</p>
|
|
715
|
+
) : null}
|
|
716
|
+
{cap === "unavailable" ? (
|
|
717
|
+
<p className="export-note" data-testid="export-capability-note">
|
|
718
|
+
The browser export renders through the stage, which is not
|
|
719
|
+
connected.{" "}
|
|
720
|
+
{hosted ? "Reload the editor to reconnect it." : "The studio server render still works."}
|
|
721
|
+
</p>
|
|
722
|
+
) : null}
|
|
723
|
+
{cap === "unanswered" ? (
|
|
724
|
+
<p className="export-note" data-testid="export-capability-note">
|
|
725
|
+
This browser's encoder did not answer in {PROBE_ATTEMPTS} checks.{" "}
|
|
726
|
+
<button type="button" className="link-button" onClick={checkAgain}>
|
|
727
|
+
Check again
|
|
728
|
+
</button>
|
|
729
|
+
{hosted ? "" : " The studio server render still works."}
|
|
730
|
+
</p>
|
|
731
|
+
) : null}
|
|
732
|
+
{cap !== null && typeof cap !== "string" && !browserReady ? (
|
|
733
|
+
<p className="export-note" data-testid="export-capability-note">
|
|
734
|
+
This browser can't encode the {format === "gif" ? "GIF" : "MP4"}:{" "}
|
|
735
|
+
{encoder?.reason ?? "this stage predates the in-browser GIF encoder."}
|
|
736
|
+
{hosted ? "" : " The studio server render still works."}
|
|
737
|
+
</p>
|
|
738
|
+
) : null}
|
|
739
|
+
{/* Why Export is held: the dialog stays open over a running export
|
|
740
|
+
in any workspace, so a run that ends here must not offer a start
|
|
741
|
+
that would capture the wrong stage. */}
|
|
742
|
+
{blocked !== null && !running ? (
|
|
743
|
+
<p className="export-note" data-testid="export-blocked-note">
|
|
744
|
+
{blocked}
|
|
745
|
+
</p>
|
|
746
|
+
) : null}
|
|
747
|
+
|
|
748
|
+
{asked !== null ? (
|
|
749
|
+
<p className="export-note" data-testid="export-agent-note">
|
|
750
|
+
The agent asked for this export, and is told where the video is saved.
|
|
751
|
+
</p>
|
|
752
|
+
) : null}
|
|
753
|
+
|
|
754
|
+
{/* Hosted only: the local studio has no workspace to share into. */}
|
|
755
|
+
{hosted ? (
|
|
756
|
+
<>
|
|
757
|
+
<div className="export-row">
|
|
758
|
+
<span className="export-label">Share</span>
|
|
759
|
+
<SegmentedControl
|
|
760
|
+
ariaLabel="Save to Videos"
|
|
761
|
+
className="is-accent is-lg export-switch is-share"
|
|
762
|
+
value={share}
|
|
763
|
+
options={[
|
|
764
|
+
{ value: "off", label: "Don't save" },
|
|
765
|
+
{ value: "private", label: "Only me" },
|
|
766
|
+
{ value: "workspace", label: "Workspace" },
|
|
767
|
+
{ value: "link", label: "Anyone with the link" },
|
|
768
|
+
]}
|
|
769
|
+
onChange={(next) => {
|
|
770
|
+
setShare(next as ShareChoice);
|
|
771
|
+
writeShare(next as ShareChoice);
|
|
772
|
+
}}
|
|
773
|
+
/>
|
|
774
|
+
</div>
|
|
775
|
+
<p className="export-note" data-testid="export-share-note">
|
|
776
|
+
{SHARE_HINTS[share]}
|
|
777
|
+
</p>
|
|
778
|
+
</>
|
|
779
|
+
) : null}
|
|
166
780
|
|
|
167
781
|
<label className="export-row">
|
|
168
|
-
<span>Quality</span>
|
|
782
|
+
<span className="export-label">Quality</span>
|
|
169
783
|
<select
|
|
170
|
-
className="select"
|
|
784
|
+
className="select is-field export-select"
|
|
171
785
|
aria-label="Export quality"
|
|
172
786
|
value={quality}
|
|
173
787
|
onChange={(e) => setQuality(e.target.value as ExportQualityId)}
|
|
@@ -181,14 +795,15 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
181
795
|
</label>
|
|
182
796
|
|
|
183
797
|
<label className="export-row">
|
|
184
|
-
<span>Range</span>
|
|
798
|
+
<span className="export-label">Range</span>
|
|
185
799
|
<select
|
|
186
|
-
className="select"
|
|
800
|
+
className="select is-field export-select"
|
|
187
801
|
aria-label="Export range"
|
|
188
802
|
value={scope}
|
|
189
803
|
onChange={(e) => setScope(e.target.value)}
|
|
190
804
|
>
|
|
191
|
-
<option value={WHOLE}>Whole
|
|
805
|
+
<option value={WHOLE}>Whole Project</option>
|
|
806
|
+
{area === null ? null : <option value={WORK_AREA}>Work area</option>}
|
|
192
807
|
{doc.scenes.map((id) => (
|
|
193
808
|
<option key={id} value={id}>
|
|
194
809
|
{doc.scenesById[id]?.name ?? id}
|
|
@@ -202,83 +817,291 @@ export function ExportDialog(props: { onClose(): void }) {
|
|
|
202
817
|
render does not carry it. Warned HERE, client-side off (doc, scope),
|
|
203
818
|
because the dialog already holds both and this costs the author
|
|
204
819
|
nothing — the alternative is finding out after the render. */}
|
|
205
|
-
{
|
|
820
|
+
{sceneId !== undefined && predecessorTransitionOmitted(doc, sceneId) ? (
|
|
206
821
|
<p className="export-note" data-testid="export-transition-note">
|
|
207
822
|
The transition into this shot plays from the shot before it, so a
|
|
208
|
-
single-shot render starts on a cut. Render the whole
|
|
823
|
+
single-shot render starts on a cut. Render the whole Project to
|
|
209
824
|
include it.
|
|
210
825
|
</p>
|
|
211
826
|
) : null}
|
|
827
|
+
{/* The stage previews without blur, and so does its export. Said, not
|
|
828
|
+
switched: the author may be fine dropping it. */}
|
|
829
|
+
{blurred && effectiveDest === "browser" ? (
|
|
830
|
+
<p className="export-note" data-testid="export-blur-note">
|
|
831
|
+
Motion blur is not rendered in a browser export.
|
|
832
|
+
{hosted ? "" : " The studio server render includes it."}
|
|
833
|
+
</p>
|
|
834
|
+
) : null}
|
|
212
835
|
|
|
213
836
|
<label className="export-row">
|
|
214
|
-
<span>Audio</span>
|
|
837
|
+
<span className="export-label">Audio</span>
|
|
215
838
|
<span className="export-audio" data-testid="export-audio">
|
|
839
|
+
{/* `checked` is what the FILE will carry, not the preference:
|
|
840
|
+
with no unmuted clips, as a GIF, or from a browser that cannot
|
|
841
|
+
encode audio, there is no audio track, and a ticked box over
|
|
842
|
+
"no audio clips in this Shot" claimed one. The preference
|
|
843
|
+
(`withAudio`) is kept, so picking MP4 back or a scope with
|
|
844
|
+
clips restores the author's choice. */}
|
|
216
845
|
<input
|
|
846
|
+
className="checkbox"
|
|
217
847
|
type="checkbox"
|
|
218
848
|
aria-label="Include audio"
|
|
219
|
-
|
|
220
|
-
|
|
849
|
+
aria-describedby={audioHintId}
|
|
850
|
+
checked={audible}
|
|
851
|
+
disabled={format === "gif" || clipCount === 0 || browserAudioReason !== undefined}
|
|
221
852
|
onChange={(e) => setWithAudio(e.currentTarget.checked)}
|
|
222
853
|
/>
|
|
223
|
-
<span>
|
|
854
|
+
<span className="export-audio-hint" id={audioHintId}>
|
|
224
855
|
{clipCount === 0
|
|
225
|
-
?
|
|
856
|
+
? rangeClips.length === 0
|
|
857
|
+
? `no audio clips in this ${unit}`
|
|
858
|
+
: `every audio clip in this ${unit} is muted`
|
|
226
859
|
: format === "gif"
|
|
227
|
-
? `${clipCount} clip${clipCount === 1 ? "" : "s"}
|
|
228
|
-
:
|
|
229
|
-
?
|
|
230
|
-
:
|
|
860
|
+
? `${clipCount} clip${clipCount === 1 ? "" : "s"}, but GIF has no audio track`
|
|
861
|
+
: browserAudioReason !== undefined
|
|
862
|
+
? `this browser cannot encode audio: ${browserAudioReason}.${hosted ? "" : " The studio server render includes it."}`
|
|
863
|
+
: withAudio
|
|
864
|
+
? `${clipCount} clip${clipCount === 1 ? "" : "s"} included`
|
|
865
|
+
: `${clipCount} clip${clipCount === 1 ? "" : "s"} will be dropped`}
|
|
231
866
|
</span>
|
|
232
867
|
</span>
|
|
233
868
|
</label>
|
|
234
869
|
|
|
870
|
+
{creditBlock === "" ? null : (
|
|
871
|
+
<div className="export-credits" data-testid="export-credits">
|
|
872
|
+
<div className="export-row">
|
|
873
|
+
<span className="export-label">Credits</span>
|
|
874
|
+
<button
|
|
875
|
+
type="button"
|
|
876
|
+
className="btn is-outline"
|
|
877
|
+
data-testid="export-credits-copy"
|
|
878
|
+
onClick={() => {
|
|
879
|
+
void navigator.clipboard?.writeText(creditBlock).then(
|
|
880
|
+
() => {
|
|
881
|
+
if (mounted.current) setCopied(true);
|
|
882
|
+
},
|
|
883
|
+
() => {},
|
|
884
|
+
);
|
|
885
|
+
}}
|
|
886
|
+
>
|
|
887
|
+
{copied ? "Copied" : "Copy credits"}
|
|
888
|
+
</button>
|
|
889
|
+
</div>
|
|
890
|
+
<p className="export-note" data-testid="export-credits-note">
|
|
891
|
+
{credits.required.length > 0
|
|
892
|
+
? "Required by CC BY: put these lines in the video's description or end card."
|
|
893
|
+
: "No credit is required. These lines thank the people whose sounds you used."}
|
|
894
|
+
</p>
|
|
895
|
+
<pre className="export-credits-text" data-testid="export-credits-text">
|
|
896
|
+
{creditBlock}
|
|
897
|
+
</pre>
|
|
898
|
+
</div>
|
|
899
|
+
)}
|
|
900
|
+
|
|
235
901
|
<dl className="export-estimate" data-testid="export-estimate">
|
|
236
902
|
<dt>Frames</dt>
|
|
237
|
-
|
|
903
|
+
{/* The file's own count: a GIF over 50 fps keeps every k-th frame
|
|
904
|
+
(`gifFrameStride`), because browsers slow a delay under 2 cs. */}
|
|
905
|
+
<dd>{format === "gif" ? Math.ceil(frames / gifFrameStride(doc.fps)) : frames}</dd>
|
|
238
906
|
<dt>Size</dt>
|
|
239
907
|
{/* See the header: a GIF's size is a palette question, and the
|
|
240
908
|
bits-per-pixel model does not know the answer. */}
|
|
241
909
|
<dd>{format === "gif" ? "varies with palette" : formatSizeEstimate(size)}</dd>
|
|
242
|
-
|
|
243
|
-
|
|
910
|
+
{/* The time figure is a median of the SERVER's own past renders —
|
|
911
|
+
it knows nothing about this browser's machine, so the browser
|
|
912
|
+
path shows no number rather than a wrong one. */}
|
|
913
|
+
{effectiveDest === "server" ? (
|
|
914
|
+
<>
|
|
915
|
+
<dt>Time</dt>
|
|
916
|
+
<dd>{formatTimeEstimate(stats, megapixels, frames)}</dd>
|
|
917
|
+
</>
|
|
918
|
+
) : null}
|
|
244
919
|
</dl>
|
|
245
920
|
|
|
246
|
-
{
|
|
247
|
-
|
|
921
|
+
{/* ONE outcome card at a time, newest first: a run in progress, then
|
|
922
|
+
a failure this dialog saw, then how the last run ended.
|
|
923
|
+
The slot is a polite live region, so a screen reader hears
|
|
924
|
+
"Rendering", "Saved", "Failed" as the card changes; it stays in the
|
|
925
|
+
DOM while empty because a region inserted with its content is not
|
|
926
|
+
reliably announced. The per-frame count inside Rendering opts out
|
|
927
|
+
(aria-live="off") or every progress tick would be read aloud. */}
|
|
928
|
+
<div className="export-outcome-slot" role="status">
|
|
929
|
+
{exportLock !== null ? (
|
|
930
|
+
<Outcome
|
|
931
|
+
kind="rendering"
|
|
932
|
+
title="Rendering"
|
|
933
|
+
action={
|
|
934
|
+
// The run's one Cancel, for either path: one lock is behind it.
|
|
935
|
+
// A `.btn` at the card's 28px (`export-outcome-cancel`).
|
|
936
|
+
<button
|
|
937
|
+
ref={cancelButton}
|
|
938
|
+
type="button"
|
|
939
|
+
className="btn export-outcome-cancel"
|
|
940
|
+
onClick={() => store.cancelExport()}
|
|
941
|
+
>
|
|
942
|
+
Cancel export
|
|
943
|
+
</button>
|
|
944
|
+
}
|
|
945
|
+
>
|
|
946
|
+
<p className="export-outcome-copy" data-testid="export-progress" aria-live="off">
|
|
947
|
+
{exportLockCount(exportLock)}
|
|
948
|
+
{exportLock.where === "browser" ? " in this browser" : ""}
|
|
949
|
+
</p>
|
|
950
|
+
</Outcome>
|
|
951
|
+
) : error !== null ? (
|
|
952
|
+
<Outcome kind="failed" title="Failed">
|
|
953
|
+
<p className="export-outcome-copy" data-testid="export-error">
|
|
954
|
+
{error}
|
|
955
|
+
</p>
|
|
956
|
+
</Outcome>
|
|
957
|
+
) : browserJob !== null ? (
|
|
958
|
+
<BrowserJobRow job={browserJob} shared={shared} />
|
|
959
|
+
) : job !== null ? (
|
|
960
|
+
<JobRow job={job} />
|
|
961
|
+
) : null}
|
|
962
|
+
</div>
|
|
963
|
+
{/* What the render had to say about the document, beside whatever card
|
|
964
|
+
is showing: an overloaded mix, a material that stalls under software
|
|
965
|
+
raster, a pool smaller than asked for. The server used to write
|
|
966
|
+
these to a log callback the job never passed. */}
|
|
967
|
+
{(job?.warnings ?? []).map((w) => (
|
|
968
|
+
<p className="export-note" data-testid="export-warning" key={w}>
|
|
969
|
+
{w}
|
|
970
|
+
</p>
|
|
971
|
+
))}
|
|
972
|
+
{audioNotice !== null && (
|
|
973
|
+
<p className="export-note" data-testid="export-audio-notice">
|
|
974
|
+
{audioNotice}
|
|
975
|
+
</p>
|
|
976
|
+
)}
|
|
977
|
+
{encoderNotice !== null && (
|
|
978
|
+
<p className="export-note" data-testid="export-encoder-notice">
|
|
979
|
+
{encoderNotice}
|
|
980
|
+
</p>
|
|
981
|
+
)}
|
|
248
982
|
</div>
|
|
249
983
|
</Dialog>
|
|
250
984
|
);
|
|
251
985
|
}
|
|
252
986
|
|
|
987
|
+
type OutcomeKind = "rendering" | "done" | "cancelled" | "failed";
|
|
988
|
+
|
|
989
|
+
/** Paper's outcome card: a sunken tile with a title row (and the Rendering
|
|
990
|
+
* card's Cancel on it) over one line of copy. Only Rendering takes the accent
|
|
991
|
+
* ink: blue marks the state that is live, and a finished run is not. A
|
|
992
|
+
* failure is ink too, with its reason in the copy, so the card states what
|
|
993
|
+
* happened rather than shouting it. `data-outcome` is the state's hook. */
|
|
994
|
+
function Outcome(props: { kind: OutcomeKind; title: string; action?: ReactNode; children: ReactNode }) {
|
|
995
|
+
return (
|
|
996
|
+
<section className={`export-outcome is-${props.kind}`} data-outcome={props.kind}>
|
|
997
|
+
<div className="export-outcome-head">
|
|
998
|
+
<span className="export-outcome-title">{props.title}</span>
|
|
999
|
+
{props.action}
|
|
1000
|
+
</div>
|
|
1001
|
+
{props.children}
|
|
1002
|
+
</section>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** How an in-browser export ENDED — answering the same question the server's
|
|
1007
|
+
* JobRow does ("where did it go") for a file that went to the DOWNLOADS
|
|
1008
|
+
* folder rather than the project. Progress is the lock's, above. */
|
|
1009
|
+
function BrowserJobRow(props: { job: BrowserJob; shared: Shared | null }) {
|
|
1010
|
+
const { job, shared } = props;
|
|
1011
|
+
const [copied, setCopied] = useState(false);
|
|
1012
|
+
if (job.phase === "done") {
|
|
1013
|
+
return (
|
|
1014
|
+
<Outcome kind="done" title="Saved">
|
|
1015
|
+
<p className="export-outcome-copy" data-testid="export-progress">
|
|
1016
|
+
{/* Plain copy, as Paper sets it: the name is part of the sentence. */}
|
|
1017
|
+
Saved {job.filename} to your downloads.
|
|
1018
|
+
</p>
|
|
1019
|
+
{shared === null ? null : shared.phase === "saving" ? (
|
|
1020
|
+
<p className="export-outcome-copy" data-testid="export-share">
|
|
1021
|
+
Saving to Videos…
|
|
1022
|
+
</p>
|
|
1023
|
+
) : shared.phase === "failed" ? (
|
|
1024
|
+
<p className="export-outcome-copy" data-testid="export-share">
|
|
1025
|
+
Saving to Videos failed: {shared.message}. The download still stands.
|
|
1026
|
+
</p>
|
|
1027
|
+
) : (
|
|
1028
|
+
<p className="export-outcome-copy export-share-links" data-testid="export-share">
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
className="link-button"
|
|
1032
|
+
onClick={() => {
|
|
1033
|
+
void navigator.clipboard?.writeText(shared.url).then(() => setCopied(true), () => {});
|
|
1034
|
+
}}
|
|
1035
|
+
>
|
|
1036
|
+
{copied ? "Link copied" : "Copy link"}
|
|
1037
|
+
</button>
|
|
1038
|
+
<a className="link-button" href={shared.url} target="_blank" rel="noopener">
|
|
1039
|
+
Open page ↗
|
|
1040
|
+
</a>
|
|
1041
|
+
</p>
|
|
1042
|
+
)}
|
|
1043
|
+
</Outcome>
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
// Not an error, and it must not look like one: the author pressed Cancel.
|
|
1047
|
+
return (
|
|
1048
|
+
<Outcome kind="cancelled" title="Cancelled">
|
|
1049
|
+
<p className="export-outcome-copy" data-testid="export-progress">
|
|
1050
|
+
Export cancelled. Nothing was saved.
|
|
1051
|
+
</p>
|
|
1052
|
+
</Outcome>
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
253
1056
|
/** The live job, or the last one this session. Shown after a "done" too: the
|
|
254
1057
|
* author's next question is always "where did it go". */
|
|
255
1058
|
function JobRow(props: { job: RenderJob }) {
|
|
256
1059
|
const { job } = props;
|
|
257
|
-
if (job.phase === "running") {
|
|
258
|
-
return (
|
|
259
|
-
<p className="export-progress" data-testid="export-progress">
|
|
260
|
-
Rendering frame {job.done}/{job.total || "?"}
|
|
261
|
-
</p>
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
1060
|
if (job.phase === "done") {
|
|
265
1061
|
return (
|
|
266
|
-
<
|
|
267
|
-
|
|
268
|
-
|
|
1062
|
+
<Outcome kind="done" title="Saved">
|
|
1063
|
+
<p className="export-outcome-copy" data-testid="export-progress">
|
|
1064
|
+
Written to {job.out}
|
|
1065
|
+
</p>
|
|
1066
|
+
</Outcome>
|
|
269
1067
|
);
|
|
270
1068
|
}
|
|
271
1069
|
if (job.phase === "cancelled") {
|
|
272
1070
|
// Not an error, and it must not look like one: the author pressed Cancel.
|
|
273
1071
|
return (
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
1072
|
+
<Outcome kind="cancelled" title="Cancelled">
|
|
1073
|
+
<p className="export-outcome-copy" data-testid="export-progress">
|
|
1074
|
+
Render cancelled. Nothing was written.
|
|
1075
|
+
</p>
|
|
1076
|
+
</Outcome>
|
|
277
1077
|
);
|
|
278
1078
|
}
|
|
279
1079
|
return (
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
1080
|
+
<Outcome kind="failed" title="Failed">
|
|
1081
|
+
<p className="export-outcome-copy" data-testid="export-progress">
|
|
1082
|
+
{job.error ?? "The render failed."}
|
|
1083
|
+
</p>
|
|
1084
|
+
{/* Where the failing frame's forensics went, and the render's last
|
|
1085
|
+
output: the page's own errors used to stop at a server log. */}
|
|
1086
|
+
{job.diagnostics !== undefined && (
|
|
1087
|
+
<p className="export-outcome-copy" data-testid="export-diagnostics">
|
|
1088
|
+
Diagnostics for the failing frame: <code>{job.diagnostics}</code>
|
|
1089
|
+
</p>
|
|
1090
|
+
)}
|
|
1091
|
+
{job.log !== undefined && job.log.length > 0 && (
|
|
1092
|
+
<details className="export-log" data-testid="export-log">
|
|
1093
|
+
<summary>
|
|
1094
|
+
{/* Paper's 12px chevron in place of the UA's marker triangle,
|
|
1095
|
+
which takes neither a token colour nor this size. It turns
|
|
1096
|
+
over when the log is open (dialogs.css). */}
|
|
1097
|
+
<svg className="export-log-chevron" viewBox="0 0 12 12" aria-hidden="true">
|
|
1098
|
+
<path d="m3 4.5 3 3 3-3" />
|
|
1099
|
+
</svg>
|
|
1100
|
+
Render output
|
|
1101
|
+
</summary>
|
|
1102
|
+
<pre>{job.log.join("\n")}</pre>
|
|
1103
|
+
</details>
|
|
1104
|
+
)}
|
|
1105
|
+
</Outcome>
|
|
283
1106
|
);
|
|
284
1107
|
}
|