auricui-skill 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +29 -2
- package/bin/install.mjs +66 -0
- package/package.json +29 -4
- package/skill/SKILL.md +64 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/assets/examples/ApprovalCard.tsx +8 -0
- package/skill/assets/examples/BasicApp.tsx +38 -0
- package/skill/assets/examples/BriefingCard.tsx +8 -0
- package/skill/assets/examples/ChatControls.tsx +49 -0
- package/skill/assets/examples/ChatPanel.tsx +73 -0
- package/skill/assets/examples/Checkbox.tsx +13 -0
- package/skill/assets/examples/Combobox.tsx +14 -0
- package/skill/assets/examples/Drawer.tsx +10 -0
- package/skill/assets/examples/MemoryCard.tsx +9 -0
- package/skill/assets/examples/MessageList.tsx +8 -0
- package/skill/assets/examples/Theme.tsx +14 -0
- package/skill/assets/examples/ToolCall.tsx +8 -0
- package/skill/assets/examples/Tree.tsx +15 -0
- package/skill/assets/examples/examples.css +11 -0
- package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
- package/skill/assets/upstream/LICENSE +21 -0
- package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
- package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
- package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
- package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
- package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
- package/skill/assets/upstream/apps/desktop/package.json +51 -0
- package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
- package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
- package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
- package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
- package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
- package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
- package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
- package/skill/assets/upstream/packages/openfilm/package.json +88 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
- package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
- package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
- package/skill/assets/upstream/packages/shared/package.json +45 -0
- package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
- package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
- package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
- package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
- package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
- package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
- package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
- package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
- package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
- package/skill/references/agent-components.md +33 -0
- package/skill/references/api-index.json +2693 -0
- package/skill/references/chat-components.md +65 -0
- package/skill/references/components.md +23 -0
- package/skill/references/core-components.md +44 -0
- package/skill/references/dependencies.md +22 -0
- package/skill/references/esm-package.md +48 -0
- package/skill/references/integration.md +43 -0
- package/skill/references/library-manifest.json +12 -0
- package/skill/references/source-manifest.json +325 -0
- package/skill/references/studio-components.md +24 -0
- package/skill/references/theme-motion.md +49 -0
- package/skill/references/troubleshooting.md +27 -0
- package/skill/references/verification.md +13 -0
- package/skill/scripts/check_dependencies.py +109 -0
- package/skill/scripts/inspect_api.py +60 -0
|
@@ -0,0 +1,3222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project in Studio: the top bar, then the assets, the picture and the inspector side by side, and the timeline
|
|
3
|
+
* across the bottom. Every pane floats on the darker board; the seams between them are the board showing through and
|
|
4
|
+
* can be dragged.
|
|
5
|
+
*
|
|
6
|
+
* The film is film.html in the project folder. Edits made here show at once (applied with the server's own code) and
|
|
7
|
+
* are written one at a time (see editor/use-film.ts); the agent's writes come in through the folder's events.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { BookmarkPlus, ClipboardCopy, ClipboardPaste, GalleryVerticalEnd, Layers, LibraryBig, MessageSquarePlus, MonitorDown, Settings2, TriangleAlert, Upload } from 'lucide-react';
|
|
11
|
+
import { applyOps } from '../../../server/ops.mjs';
|
|
12
|
+
import { api, ApiError, PREFS_EVENT, projectEvents, type Clip, type Film, type Listing, type Op, type ProjectFilm, type ProjectSettings } from '@/api';
|
|
13
|
+
import { useT } from '@/i18n';
|
|
14
|
+
import { useFilm, type Step } from '@/editor/use-film';
|
|
15
|
+
import { Player } from '@/editor/player';
|
|
16
|
+
import { Preview, type FilmInfo, type PlayedReport, type PreviewHandle } from '@/editor/Preview';
|
|
17
|
+
import { usePreviewRenders } from '@/editor/use-preview-renders';
|
|
18
|
+
import { filmShape, type MediaFacts } from '@/lib/film-shape';
|
|
19
|
+
import { changesPicture } from '@/lib/picture-files';
|
|
20
|
+
import type { ReplaceFamily } from '@/lib/clip-replace';
|
|
21
|
+
import { layoutTracks, type TimelineBlock, type TimelineTrack } from '@/lib/timeline-layout';
|
|
22
|
+
import { filmClipKind, filmSrcIsPage, filmSrcKind, filmTrackBadges, parseFilmDocLoc, type FilmBox, type FilmDoc, type FilmOverride } from '@/lib/film';
|
|
23
|
+
import { exactRate, footageFps, setTimecodeFps, snapToFrame, stepFrames } from '@/lib/timecode';
|
|
24
|
+
import { JUMP_FRAMES, loopBack, playStart, shuttleRate } from '@/lib/timeline-keys';
|
|
25
|
+
import { comboOf, parseCombo } from '@/lib/keymap';
|
|
26
|
+
import { currentKeymap, keyAction, useKeyHint } from '@/lib/keymap-store';
|
|
27
|
+
import { attrsOf, pasteAttrs, type AttrGroup, type CopiedAttrs } from '@/lib/clip-attrs';
|
|
28
|
+
import { cutPoints, namedTrack, nextCut, trackNamesOf } from '@/lib/timeline-nav';
|
|
29
|
+
import { anyLocked, lockedEdits } from '@/lib/track-lock';
|
|
30
|
+
import { placeMedia, type SourceRange } from '@/lib/media-place';
|
|
31
|
+
import { apartOnTracks, type ClipEdit } from '@/lib/film-clip-edits';
|
|
32
|
+
import { mediaFetch, useMediaImages } from '@/lib/media-queue';
|
|
33
|
+
import { clipboardItemsOf, clipboardNow, pasteEdit, type TimelineClipboardItem } from '@/lib/timeline-clipboard';
|
|
34
|
+
import { editModelOf, freshLink, linkedIds, magnetOn, mainTrackOf, planMove, planRippleTrim, planRoom, planSpeed, soundTrackFor, type EditMode } from '@/lib/timeline-edit';
|
|
35
|
+
import { readTimelinePrefs, switchesOf } from '@/lib/timeline-prefs';
|
|
36
|
+
import { fadeMsOf, type FadeMs } from '@/lib/clip-fade';
|
|
37
|
+
import {
|
|
38
|
+
findTransitions, planRemoveTransition, planResizeTransition, planTransition, TRANSITION_MS,
|
|
39
|
+
type ClipFacts, type TransitionAsk, type TransitionPlan,
|
|
40
|
+
} from '@/lib/transitions';
|
|
41
|
+
import { frameMs } from '@/lib/timecode';
|
|
42
|
+
import { splitHalves } from '@/lib/timeline-split';
|
|
43
|
+
import { assetDropInsert, assetRunInsert, assetRunMs, pendingDropOf, soundLaneHit, withPendingBlocks, type AssetDropTarget, type AssetLaneHit, type AssetRunInsert, type PendingDrop } from '@/lib/asset-timeline';
|
|
44
|
+
import { isComputerFileDrag, type ResourceDragItem } from '@/lib/resource-drag';
|
|
45
|
+
import { ladderStepMs, THUMB_CELL_PX, thumbAnchorTimes } from '@/lib/thumb-cache';
|
|
46
|
+
import { clipThumbId, overridesToken } from '@/lib/thumb-frame';
|
|
47
|
+
import { useLoadedThumbs } from '@/lib/use-loaded-thumbs';
|
|
48
|
+
import { droppedOverrides, overrideTarget, undoneFields, type DroppedEdits, type PersonEdit } from '@/lib/dropped-overrides';
|
|
49
|
+
import { filmFrameBox, type FilmFrameId } from '@/lib/film-frame';
|
|
50
|
+
import { canvasFor, firstPictureSize, fitToCanvas } from '@/lib/canvas-fit';
|
|
51
|
+
import { factsOfProbe, mediaListingOf, resourceKindOf, srcHitsAsset, warmProjectMedia, type MediaListing, type MediaProxy, type MediaResult, type MediaSources, type WorkspaceResource } from '@/lib/workspace-resources';
|
|
52
|
+
import { BrandLoadingScreen } from './BrandLoadingScreen';
|
|
53
|
+
import { BrandLogo } from './BrandLogo';
|
|
54
|
+
import { SettingsModal } from './settings/SettingsModal';
|
|
55
|
+
import { useSettings } from './settings/use-settings';
|
|
56
|
+
import { DesktopDialog } from './DesktopDialog';
|
|
57
|
+
import type { ContextMenuEntry } from './ContextMenu';
|
|
58
|
+
import { studioHost, useHostPanel, type CommunityItem, type HostPanel, type StageBox, type StudioCommand, type StudioRef } from '../lib/host';
|
|
59
|
+
import { clipRef, fileRef, layerRef, markRange, momentRef, rangeRef, regionRef, stillUrl, subtitleRef, trackRef, type ClipFrameSource, type FilmRange } from '@/lib/studio-refs';
|
|
60
|
+
import { boxNow, chatPointOf, draftBoxesAt, draftMarksOf, sameClip, NO_DRAFT, type DraftMarks, type PictureMarks } from '@/lib/chat-marks';
|
|
61
|
+
import { usePageChanges } from './use-page-changes';
|
|
62
|
+
import { SubtitleOverlay } from './SubtitleOverlay';
|
|
63
|
+
import { SubtitleInspector, subtitleLineKey, type SubtitleLineAt, type SubtitleLineRefer } from './SubtitleInspector';
|
|
64
|
+
import { useFilmSubtitles } from '@/lib/use-film-subtitles';
|
|
65
|
+
import { speakerAt, useSubtitleEdits } from '@/lib/use-subtitle-edits';
|
|
66
|
+
import { cueAt, filmSubtitleStyleFor, filmSubtitleWords, sameFilmSubtitleLanguage } from '@/lib/subtitles';
|
|
67
|
+
import { DEFAULT_INSPECTOR, DOCK_GAP, DockPaneDivider, MIN_DOCK_H, MIN_INSPECTOR, MIN_STAGE_H, MIN_VIEWER, PaneDivider, usePaneSize } from './PaneDivider';
|
|
68
|
+
import { PANE_BAR, PANE_BTN_ON, PANE_TOGGLE, PANE_TOGGLE_ICON } from './dock-pane-bar';
|
|
69
|
+
import { Tooltip } from './Tooltip';
|
|
70
|
+
import { TabStrip } from './TabStrip';
|
|
71
|
+
import { ExamplesDialog } from './ExamplesGallery';
|
|
72
|
+
import { UpdateButton, WebUpdateButton } from './UpdateButton';
|
|
73
|
+
import { MediaPool, type ImportProgress } from './MediaPool';
|
|
74
|
+
import type { SubtitleRowSpec } from './SubtitleRow';
|
|
75
|
+
import { LayersPanel } from './LayersPanel';
|
|
76
|
+
import type { LayerRow } from '@/lib/layers-tree';
|
|
77
|
+
import { SourcePreview } from './SourcePreview';
|
|
78
|
+
import { ResourceInspector } from './ResourceInspector';
|
|
79
|
+
import { FilmInspector } from './FilmInspector';
|
|
80
|
+
import { Timeline, timelineFitHeight, type TimelineCommands, type TimelineHistory } from './Timeline';
|
|
81
|
+
import { useEditorTools } from './use-editor-tools';
|
|
82
|
+
import { AudioMeters, METERS_W } from './AudioMeters';
|
|
83
|
+
import { trackFlagStep, type SessionTimeline } from './OperationHistory';
|
|
84
|
+
import { ProjectLayoutMenu } from './ProjectLayoutMenu';
|
|
85
|
+
import { ProjectMenu } from './ProjectMenu';
|
|
86
|
+
import { ProjectHistoryButton, ProjectHistoryControls } from './ProjectHistoryControls';
|
|
87
|
+
import { useProjectHistory } from './use-project-history';
|
|
88
|
+
import { ProjectsPopover } from './ProjectsLibrary';
|
|
89
|
+
import { FilmFullscreenBar, useFullscreenKeys } from './FilmFullscreenBar';
|
|
90
|
+
import { ViewerBar } from './ViewerBar';
|
|
91
|
+
import { LiveTime, type PlaybackClock } from './LiveTime';
|
|
92
|
+
import { Toast, useToast } from './Toast';
|
|
93
|
+
import { SpendAsks } from './SpendAsks';
|
|
94
|
+
import { ExportCenter } from './export/ExportCenter';
|
|
95
|
+
import { PasteAttrsDialog } from './PasteAttrsDialog';
|
|
96
|
+
import { ExportTray, type ExportTrayHandle } from './export/ExportTray';
|
|
97
|
+
import { FilmStageSelect, type FilmStageControl, type LayerGeometry, type StageClipEdit, type StagePick } from './FilmStageSelect';
|
|
98
|
+
import { openSection } from './inspector-fields';
|
|
99
|
+
import { FontScopeContext, type FontScope } from '@/lib/use-fonts';
|
|
100
|
+
import { stageMenu, stageRecoveryMenu, type StageArrangeResult, type StageMenuContext, type StagePictureActions } from './stage-menu';
|
|
101
|
+
import type { StageEditFailure, StageTextEdit } from './FilmInspector';
|
|
102
|
+
import { layerTarget, pageClipOf, recoverLayer } from '@/lib/stage-layers';
|
|
103
|
+
import { pictureStyle, relativeTo, type TemplateControls } from '@/lib/template-controls';
|
|
104
|
+
import { propsAttr, propsOf, type ComponentProp } from '@/lib/component-props';
|
|
105
|
+
import type { PropsHost } from './PropertiesSection';
|
|
106
|
+
import type { LayerPropsActions } from './FilmInspector';
|
|
107
|
+
import { SAVED, refreshSaved, savedActions, savedSources, useSavedListing } from '@/lib/saved';
|
|
108
|
+
import { batchMap } from '@/lib/batch-map';
|
|
109
|
+
import { groupInsertAt, groupInsertOps } from '@/lib/saved-insert';
|
|
110
|
+
import { pageClips, pageMayHave, similarEdits } from '@/lib/similar-layers';
|
|
111
|
+
import type { MaskSession } from '@/lib/stage-mask';
|
|
112
|
+
import type { MaskShape } from '@/lib/clip-mask';
|
|
113
|
+
import { nextOverrides, overrideOf, type OverrideGeometry } from '@/lib/film-overrides';
|
|
114
|
+
import { compactScale, type MgTransform, type NodeOverride, type OverridePivot, type StageElement, type StageElementStyle, type StageGeometry } from '@/lib/stage-types';
|
|
115
|
+
import { usePreviewPrefs } from '@/lib/preview-prefs';
|
|
116
|
+
import { isTypingTarget } from './typing-target';
|
|
117
|
+
|
|
118
|
+
const PANE = 'relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[7px]';
|
|
119
|
+
/** The files a picture inside a page can be replaced with. */
|
|
120
|
+
const PICTURE_FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
|
|
121
|
+
/* ⌘ is the modifier on a Mac, Ctrl elsewhere (the editor's keys, lib/timeline-keys) */
|
|
122
|
+
const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
123
|
+
/** Which the media pane shows, the project's media or Saved: kept while Studio is open, across projects. */
|
|
124
|
+
const MEDIA_SCOPE: { current: 'project' | 'saved' } = { current: 'project' };
|
|
125
|
+
/** where the app's panel starts: under Studio's top bar (its h-8) */
|
|
126
|
+
const HOST_PANEL_TOP = 32;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A panel shown or hidden, as the person last left it (kept in this browser; open when nothing is kept): the value,
|
|
130
|
+
* the way to change and keep it (the person's own toggle), and the way to change it for now only (shown on its own).
|
|
131
|
+
*/
|
|
132
|
+
function useKeptFlag(key: string, initial = true): [boolean, (open: boolean) => void, React.Dispatch<React.SetStateAction<boolean>>] {
|
|
133
|
+
const [open, setOpen] = React.useState(() => {
|
|
134
|
+
try { const kept = localStorage.getItem(key); return kept == null ? initial : kept !== '0'; } catch { return initial; }
|
|
135
|
+
});
|
|
136
|
+
const keep = React.useCallback((next: boolean) => {
|
|
137
|
+
setOpen(next);
|
|
138
|
+
try { localStorage.setItem(key, next ? '1' : '0'); } catch { /* not kept */ }
|
|
139
|
+
}, [key]);
|
|
140
|
+
return [open, keep, setOpen];
|
|
141
|
+
}
|
|
142
|
+
const TIMELINE_H = 200;
|
|
143
|
+
const TIMELINE_SHARE = 0.45;
|
|
144
|
+
const TIMELINE_SPARE = 48;
|
|
145
|
+
const VIEWER_BESIDE_ASSETS = 440;
|
|
146
|
+
const TITLE_MAX_CHARS = 56;
|
|
147
|
+
const TOP_BTN =
|
|
148
|
+
'flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-md text-[var(--text-dim)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]';
|
|
149
|
+
|
|
150
|
+
const fitBox = (w: number, h: number): React.CSSProperties => ({
|
|
151
|
+
width: `min(100cqw, 100cqh * ${w} / ${h})`,
|
|
152
|
+
height: `min(100cqh, 100cqw * ${h} / ${w})`,
|
|
153
|
+
});
|
|
154
|
+
const clampTitle = (title: string) => (title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS)}…` : title);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Which edit-history label a batch of clip edits gets: by its most telling property; a track's switch by its badge; a
|
|
158
|
+
* move or a trim of one or two clips by their names ("Moved title2").
|
|
159
|
+
*/
|
|
160
|
+
function editStepLabel(edits: readonly ClipEdit[], tracks: readonly TimelineTrack[]): string {
|
|
161
|
+
const props = new Set(edits.map((e) => e.prop));
|
|
162
|
+
const [first] = edits;
|
|
163
|
+
if (first && props.size === 1 && (first.prop === 'locked' || first.prop === 'hidden' || first.prop === 'muted')) {
|
|
164
|
+
const badge = tracks.find((tr) => tr.docIndex === parseFilmDocLoc(first.loc)?.track)?.badge ?? '';
|
|
165
|
+
return trackFlagStep(first.prop, first.value === true, badge);
|
|
166
|
+
}
|
|
167
|
+
if (props.has('box')) return 'history.stepTransform';
|
|
168
|
+
if (props.has('fade')) return 'history.stepFade';
|
|
169
|
+
if (props.has('volume') || props.has('muted')) return 'history.stepVolume';
|
|
170
|
+
if (props.has('trackOrder')) return 'history.stepTrackOrder';
|
|
171
|
+
if (props.has('locked') || props.has('hidden')) return 'history.stepTrackFlag';
|
|
172
|
+
if (['at', 'start', 'end', 'track'].some((p) => props.has(p))) {
|
|
173
|
+
const blocks = tracks.flatMap((tr) => tr.blocks);
|
|
174
|
+
const names = [...new Set(edits.map((e) => blocks.find((b) => b.loc === e.loc)?.clipId ?? ''))];
|
|
175
|
+
if (names.some((n) => !n) || names.length > 2) return 'history.stepMove';
|
|
176
|
+
return clipStep(props.has('start') || props.has('end') ? 'history.stepTrimmed' : 'history.stepMoved', names);
|
|
177
|
+
}
|
|
178
|
+
return 'history.stepStyle';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The white page under a dip to white (studio/server/transition-pages.mjs keeps it once). */
|
|
182
|
+
const WHITE_SRC = 'transitions/white.html';
|
|
183
|
+
|
|
184
|
+
/** An edit-history label naming the clips it was done to (see OperationHistory's stepText): "Deleted slow". */
|
|
185
|
+
const clipStep = (key: string, ids: readonly string[]) => `${key}|${ids.join(', ')}`;
|
|
186
|
+
|
|
187
|
+
/** The project's id-named clips by their place in film.html (`film.html#t.c`). */
|
|
188
|
+
function clipIdAt(film: Film | null, loc: string): string | null {
|
|
189
|
+
const at = parseFilmDocLoc(loc);
|
|
190
|
+
return at ? film?.tracks[at.track]?.clips[at.clip]?.id ?? null : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function ProjectView({ projectId, onProject, onLeave }: {
|
|
194
|
+
projectId: string;
|
|
195
|
+
/** go to another project */
|
|
196
|
+
onProject: (id: string) => void;
|
|
197
|
+
/** this project was taken off the list (or is gone): leave it */
|
|
198
|
+
onLeave: () => void;
|
|
199
|
+
}) {
|
|
200
|
+
const t = useT();
|
|
201
|
+
const toast = useToast();
|
|
202
|
+
const keyHint = useKeyHint();
|
|
203
|
+
/* the latest onLeave, for the folder's events (subscribed once per project) */
|
|
204
|
+
const leaveRef = React.useRef(onLeave);
|
|
205
|
+
leaveRef.current = onLeave;
|
|
206
|
+
/* how many times the project's files changed since it opened (see the folder events below) */
|
|
207
|
+
const [filesTick, setFilesTick] = React.useState(0);
|
|
208
|
+
const [loaded, setLoaded] = React.useState<ProjectFilm | null>(null);
|
|
209
|
+
const [failure, setFailure] = React.useState<string | null>(null);
|
|
210
|
+
/** why the picture could not draw the film (shown on the stage; the timeline then places clips by film.html alone) */
|
|
211
|
+
const [previewError, setPreviewError] = React.useState<string | null>(null);
|
|
212
|
+
const previewErrorRef = React.useRef(previewError);
|
|
213
|
+
previewErrorRef.current = previewError;
|
|
214
|
+
const [rawListing, setRawListing] = React.useState<Listing | null>(null);
|
|
215
|
+
const [facts, setFacts] = React.useState<Map<string, ReturnType<typeof factsOfProbe> & { audio?: boolean }>>(new Map());
|
|
216
|
+
const [info, setInfo] = React.useState<FilmInfo | null>(null);
|
|
217
|
+
const preview = React.useRef<PreviewHandle>(null);
|
|
218
|
+
|
|
219
|
+
/* ── the project, its film and its files ── */
|
|
220
|
+
React.useEffect(() => {
|
|
221
|
+
/* a project the list no longer has (deleted meanwhile, a tab of it opened again): gone, so this page leaves it */
|
|
222
|
+
api.film(projectId).then(setLoaded, (e: Error) => { if (e instanceof ApiError && e.status === 404) leaveRef.current(); else setFailure(e.message); });
|
|
223
|
+
void api.opened(projectId).catch(() => {});
|
|
224
|
+
const loadFiles = () => api.files(projectId).then(setRawListing, () => {});
|
|
225
|
+
void loadFiles();
|
|
226
|
+
return projectEvents(projectId, (event) => {
|
|
227
|
+
/* a picture that could not be drawn is tried again whenever the folder changes: the missing file arrived, or
|
|
228
|
+
the clip that named it is gone from film.html now that the edit is written */
|
|
229
|
+
if (previewErrorRef.current && (event.type === 'film' || event.type === 'files')) { preview.current?.reload?.(); }
|
|
230
|
+
/* deleted (here, in another tab, by an agent), or its folder gone: this page leaves it (with tabs, its tab closes) */
|
|
231
|
+
if (event.type === 'gone') { leaveRef.current(); return; }
|
|
232
|
+
/* renamed from another tab, or the projects list */
|
|
233
|
+
if (event.type === 'project') { setLoaded((l) => (l ? { ...l, project: event.project } : l)); return; }
|
|
234
|
+
if (event.type !== 'files') return;
|
|
235
|
+
void loadFiles();
|
|
236
|
+
/* a page's picture depends on the files it loads (its styles, code, pictures): its thumbnails are asked again
|
|
237
|
+
(the server answers an unchanged one in a word) */
|
|
238
|
+
setFilesTick((n) => n + 1);
|
|
239
|
+
/* a page, or a file pages use, changed on disk: the picture is drawn from them again. So is a media file a clip
|
|
240
|
+
plays, made again in place (a render written over the last one): its element would go on playing what it
|
|
241
|
+
had loaded of the old file. Other media under assets/ are loaded as they are used, and what no page loads
|
|
242
|
+
(an agent's notes and scripts) leaves the picture as it is (lib/picture-files) */
|
|
243
|
+
const used = new Set(blocksRef.current.map((b) => b.src).filter(Boolean));
|
|
244
|
+
if (changesPicture(event.paths, used)) preview.current?.reload?.();
|
|
245
|
+
});
|
|
246
|
+
}, [projectId]);
|
|
247
|
+
|
|
248
|
+
/* the last film the folder had, to tell what an outside write left out (see droppedOverrides) */
|
|
249
|
+
const lastServerDoc = React.useRef<Film | null>(null);
|
|
250
|
+
const [dropped, setDropped] = React.useState<DroppedEdits[] | null>(null);
|
|
251
|
+
/* the person's field edits an outside write changed (see undoneFields), and the steps they were made in. They are
|
|
252
|
+
watched as long as their values are in the film, across any number of outside writes; `settledEdits` are the ones
|
|
253
|
+
the person already put back or left out, and those whose clip went */
|
|
254
|
+
const [undone, setUndone] = React.useState<PersonEdit[]>([]);
|
|
255
|
+
const personSteps = React.useRef<readonly Step[]>([]);
|
|
256
|
+
const settledEdits = React.useRef(new Set<PersonEdit>());
|
|
257
|
+
/* the film as loaded: a new project name (a rename, or the snapshot a reconnecting page is sent) leaves it as it is */
|
|
258
|
+
const initial = React.useMemo(() => (loaded?.doc && loaded.value ? { value: loaded.value, rev: loaded.rev } : null), [loaded?.doc, loaded?.value, loaded?.rev]);
|
|
259
|
+
React.useEffect(() => { if (initial) lastServerDoc.current = initial.value; }, [initial]);
|
|
260
|
+
const film = useFilm(projectId, initial, React.useCallback((value: Film, kind: 'edit' | 'server', head?: string) => {
|
|
261
|
+
preview.current?.update(value, head);
|
|
262
|
+
if (kind === 'server') {
|
|
263
|
+
const found = droppedOverrides(lastServerDoc.current as unknown as FilmDoc, value as unknown as FilmDoc);
|
|
264
|
+
if (found.length) setDropped(found);
|
|
265
|
+
/* what the person set (and is still in the film), that this write changed */
|
|
266
|
+
const edits = personSteps.current.flatMap((s) => s.redo.flatMap((op) => (op.op === 'props' ? op.edits : [])));
|
|
267
|
+
/* compared with the film shown here (the person's edits in it), not the folder's last: an edit of theirs being
|
|
268
|
+
written is no change */
|
|
269
|
+
const fields = undoneFields(filmRef.current as unknown as FilmDoc, value as unknown as FilmDoc, edits, settledEdits.current);
|
|
270
|
+
for (const e of fields.gone) settledEdits.current.add(e);
|
|
271
|
+
if (fields.undone.length) {
|
|
272
|
+
const key = (e: PersonEdit) => `${e.clip}\u0000${e.prop}`;
|
|
273
|
+
const fresh = new Set(fields.undone.map(key));
|
|
274
|
+
setUndone((cur) => [...cur.filter((e) => !fresh.has(key(e))), ...fields.undone]);
|
|
275
|
+
}
|
|
276
|
+
lastServerDoc.current = value;
|
|
277
|
+
}
|
|
278
|
+
}, []));
|
|
279
|
+
personSteps.current = film.steps.done;
|
|
280
|
+
const filmRef = React.useRef(film.film);
|
|
281
|
+
filmRef.current = film.film;
|
|
282
|
+
/* on `showError`, which stays the same: `toast` is new with every message, so showing it would run this again, and
|
|
283
|
+
again (the editor gone) */
|
|
284
|
+
const { showError } = toast;
|
|
285
|
+
React.useEffect(() => { if (film.error) showError(film.error); }, [film.error, showError]);
|
|
286
|
+
|
|
287
|
+
/* media facts (length, size, sound) of the project's videos, stills and sounds and its web pages, probed once each: a
|
|
288
|
+
page without a duration has no end, and is dropped on the timeline with a length of its own; a picture's size
|
|
289
|
+
gives its clip a box in its own shape */
|
|
290
|
+
const probed = React.useRef(new Set<string>());
|
|
291
|
+
/* the answers handed on a few times a second, not one by one (lib/batch-map) */
|
|
292
|
+
const factsIn = React.useMemo(() => batchMap<string, ReturnType<typeof factsOfProbe> & { audio?: boolean }>((batch) => {
|
|
293
|
+
setFacts((m) => { const next = new Map(m); for (const [path, f] of batch) next.set(path, f); return next; });
|
|
294
|
+
}, 250), []);
|
|
295
|
+
React.useEffect(() => () => factsIn.cancel(), [factsIn]);
|
|
296
|
+
React.useEffect(() => {
|
|
297
|
+
if (!rawListing) return;
|
|
298
|
+
const wanted = [
|
|
299
|
+
...rawListing.files.filter((f) => f.kind === 'video' || f.kind === 'image' || f.kind === 'audio').map((f) => ({ f, page: false })),
|
|
300
|
+
/* pages: the project's, and any put in a folder of the media pane */
|
|
301
|
+
...[...rawListing.pages, ...rawListing.files.filter((f) => f.kind === 'page')].map((f) => ({ f, page: true })),
|
|
302
|
+
];
|
|
303
|
+
for (const { f, page } of wanted) {
|
|
304
|
+
const key = `${f.path}@${f.mtime}`;
|
|
305
|
+
if (probed.current.has(key)) continue;
|
|
306
|
+
probed.current.add(key);
|
|
307
|
+
void mediaFetch(`/api/projects/${encodeURIComponent(projectId)}/media?what=probe&path=${encodeURIComponent(f.path)}&v=${f.mtime}`)
|
|
308
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
309
|
+
.then((probe) => {
|
|
310
|
+
if (!probe) return;
|
|
311
|
+
const facts = {
|
|
312
|
+
...factsOfProbe(probe), audio: Boolean(probe.audio),
|
|
313
|
+
...(page && !(probe.duration > 0) ? { endless: true } : {}),
|
|
314
|
+
...(page && probe.reflows ? { reflows: true } : {}),
|
|
315
|
+
};
|
|
316
|
+
factsIn.set(f.path, facts);
|
|
317
|
+
}, () => {});
|
|
318
|
+
}
|
|
319
|
+
}, [rawListing, projectId, factsIn]);
|
|
320
|
+
|
|
321
|
+
const folder = loaded?.folder ?? '';
|
|
322
|
+
const mediaSources = React.useMemo<MediaSources>(() => {
|
|
323
|
+
const media = (what: string, path: string, mtime: number | string) => `/api/projects/${encodeURIComponent(projectId)}/media?what=${what}&path=${encodeURIComponent(path)}&v=${mtime}`;
|
|
324
|
+
return {
|
|
325
|
+
file: (f) => `${folder}${f.path.split('/').map(encodeURIComponent).join('/')}?v=${Math.round(f.mtimeMs)}`,
|
|
326
|
+
poster: (f) => (f.kind === 'video' ? media('poster', f.path, Math.round(f.mtimeMs)) : undefined),
|
|
327
|
+
wave: (f) => media('wave', f.path, Math.round(f.mtimeMs)),
|
|
328
|
+
pagePoster: (f) => media('poster', f.path, `${Math.round(f.mtimeMs)}.${filesTick}`),
|
|
329
|
+
pageSurface: (f) => `${folder}${f.path.split('/').map(encodeURIComponent).join('/')}`,
|
|
330
|
+
};
|
|
331
|
+
}, [projectId, folder, filesTick]);
|
|
332
|
+
/* for the pictures' thumbnails in a menu built after the render that made it */
|
|
333
|
+
const mediaSourcesRef = React.useRef(mediaSources);
|
|
334
|
+
mediaSourcesRef.current = mediaSources;
|
|
335
|
+
/*
|
|
336
|
+
* proxies (studio/server/proxies.mjs): asked for the project's heavy footage whenever its files change or the
|
|
337
|
+
* preferences do, followed while they are made. One ready for footage on the film, or other preferences, load the
|
|
338
|
+
* preview again behind the picture, so it plays what it should.
|
|
339
|
+
*/
|
|
340
|
+
const [proxies, setProxies] = React.useState<Record<string, MediaProxy>>({});
|
|
341
|
+
const [prefsTick, setPrefsTick] = React.useState(0);
|
|
342
|
+
React.useEffect(() => {
|
|
343
|
+
const changed = () => setPrefsTick((n) => n + 1);
|
|
344
|
+
window.addEventListener(PREFS_EVENT, changed);
|
|
345
|
+
return () => window.removeEventListener(PREFS_EVENT, changed);
|
|
346
|
+
}, []);
|
|
347
|
+
React.useEffect(() => { if (prefsTick) preview.current?.reload?.(); }, [prefsTick]);
|
|
348
|
+
React.useEffect(() => {
|
|
349
|
+
if (!rawListing) return undefined;
|
|
350
|
+
let alive = true;
|
|
351
|
+
let timer = 0;
|
|
352
|
+
let before: Record<string, MediaProxy> = {};
|
|
353
|
+
const ask = async () => {
|
|
354
|
+
const answer = await fetch(`/api/projects/${encodeURIComponent(projectId)}/proxies`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
355
|
+
.then((r) => (r.ok ? r.json() : null), () => null) as { states?: Record<string, MediaProxy> } | null;
|
|
356
|
+
if (!alive || !answer) return;
|
|
357
|
+
const states = answer.states ?? {};
|
|
358
|
+
setProxies(states);
|
|
359
|
+
const now = Object.keys(states).filter((path) => states[path]!.state === 'ready' && before[path] && before[path]!.state !== 'ready');
|
|
360
|
+
if (now.some((path) => blocksRef.current.some((b) => b.src && srcHitsAsset(b.src, path)))) preview.current?.reload?.();
|
|
361
|
+
before = states;
|
|
362
|
+
if (Object.values(states).some((s) => s.state === 'waiting' || s.state === 'making')) timer = window.setTimeout(() => { void ask(); }, 1500);
|
|
363
|
+
};
|
|
364
|
+
void ask();
|
|
365
|
+
return () => { alive = false; window.clearTimeout(timer); };
|
|
366
|
+
}, [rawListing, projectId, prefsTick]);
|
|
367
|
+
const listing = React.useMemo<MediaListing | null>(
|
|
368
|
+
() => (rawListing ? mediaListingOf(rawListing, (path) => ({ ...facts.get(path), ...(proxies[path] ? { proxy: proxies[path] } : {}) }), (rawListing as { made?: string[] }).made) : null),
|
|
369
|
+
[rawListing, facts, proxies],
|
|
370
|
+
);
|
|
371
|
+
React.useEffect(() => { if (listing) warmProjectMedia(projectId, listing, mediaSources); }, [projectId, listing, mediaSources]);
|
|
372
|
+
/* a file of the media pane as a reference: shown by its poster (a video, a page) or itself (a still, on Studio's
|
|
373
|
+
origin: see stillUrl) */
|
|
374
|
+
const refOfFile = React.useCallback((file: WorkspaceResource) => fileRef(file, file.kind === 'mg' ? mediaSources.pagePoster?.(file)
|
|
375
|
+
: file.kind === 'image' ? stillUrl(projectId, file.path, Math.round(file.mtimeMs)) : file.kind === 'video' ? mediaSources.poster?.(file) : undefined), [mediaSources, projectId]);
|
|
376
|
+
|
|
377
|
+
/* ── transport: the sound is the clock; the picture follows it, drawn in the preview from the player's line ── */
|
|
378
|
+
const player = React.useRef(new Player());
|
|
379
|
+
React.useEffect(() => {
|
|
380
|
+
const p = player.current;
|
|
381
|
+
p.onAnchor = (anchor) => preview.current?.play?.(anchor);
|
|
382
|
+
return () => { p.onAnchor = null; p.close(); };
|
|
383
|
+
}, []);
|
|
384
|
+
const [playing, setPlaying] = React.useState(false);
|
|
385
|
+
const [timeMs, setTimeMs] = React.useState(0);
|
|
386
|
+
const timeRef = React.useRef(0);
|
|
387
|
+
timeRef.current = timeMs;
|
|
388
|
+
const listeners = React.useRef(new Set<() => void>());
|
|
389
|
+
/* where playback stops by itself (Play range: the range's end), ms; null: the film's end */
|
|
390
|
+
const stopAtRef = React.useRef<number | null>(null);
|
|
391
|
+
/* loop playback (⇧L, the viewer's button): the range marked, else the whole film, over and over */
|
|
392
|
+
const [loop, setLoop] = React.useState(false);
|
|
393
|
+
const loopRef = React.useRef(loop);
|
|
394
|
+
loopRef.current = loop;
|
|
395
|
+
/* the speed picked in the viewer's playback settings: what plays once a J/K/L shuttle is let go */
|
|
396
|
+
const speedRef = React.useRef(1);
|
|
397
|
+
/* the range marked (set below, with the timeline's): what loops */
|
|
398
|
+
const rangeNowRef = React.useRef<FilmRange | null>(null);
|
|
399
|
+
const clock = React.useMemo<PlaybackClock>(() => ({
|
|
400
|
+
get: () => player.current.time() * 1000,
|
|
401
|
+
subscribe: (fn) => { listeners.current.add(fn); return () => listeners.current.delete(fn); },
|
|
402
|
+
line: () => { const a = player.current.anchor(); return a ? { ms: a.t * 1000, at: a.at, rate: a.rate } : null; },
|
|
403
|
+
}), []);
|
|
404
|
+
/* the player never says 0 (it divides by its length): an empty film is 0.001 s long, here 0 */
|
|
405
|
+
const drawnMs = (info?.duration ?? 0) > 0.001 ? Math.round(info!.duration * 1000) : 0;
|
|
406
|
+
/* the picture at t: at the film's very end (where nothing plays any more) its last frame, not black */
|
|
407
|
+
const pictureAt = (t: number) => { const d = player.current.duration; return d > 0 ? Math.min(t, d - 1e-3) : t; };
|
|
408
|
+
|
|
409
|
+
React.useEffect(() => {
|
|
410
|
+
if (!playing) return undefined;
|
|
411
|
+
let frame = 0;
|
|
412
|
+
let lastTick = 0;
|
|
413
|
+
const tick = (now: number) => {
|
|
414
|
+
const t = player.current.time();
|
|
415
|
+
/* looping: back to the start of what loops once its end is reached */
|
|
416
|
+
const back = loopRef.current ? loopBack(t * 1000, rangeNowRef.current, player.current.duration * 1000) : null;
|
|
417
|
+
if (back != null) {
|
|
418
|
+
stopAtRef.current = null;
|
|
419
|
+
/* the player plays on from there: the preview is told its new line */
|
|
420
|
+
player.current.seek(back / 1000);
|
|
421
|
+
setTimeMs(back);
|
|
422
|
+
for (const fn of listeners.current) fn();
|
|
423
|
+
frame = requestAnimationFrame(tick);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const stopAt = stopAtRef.current;
|
|
427
|
+
if (stopAt != null && t * 1000 >= stopAt) {
|
|
428
|
+
stopAtRef.current = null;
|
|
429
|
+
player.current.pause();
|
|
430
|
+
player.current.seek(stopAt / 1000);
|
|
431
|
+
setPlaying(false);
|
|
432
|
+
setTimeMs(stopAt);
|
|
433
|
+
preview.current?.seek(pictureAt(stopAt / 1000));
|
|
434
|
+
for (const fn of listeners.current) fn();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
/* the picture draws itself from the player's line (Preview's play); the playhead, timecode and subtitles read the
|
|
438
|
+
clock; the rest of the editor hears the time a few times a second (each time, all of it renders again) */
|
|
439
|
+
for (const fn of listeners.current) fn();
|
|
440
|
+
if (now - lastTick > 250) { lastTick = now; setTimeMs(t * 1000); }
|
|
441
|
+
if (!player.current.playing || t >= player.current.duration - 1e-3) {
|
|
442
|
+
player.current.pause();
|
|
443
|
+
setPlaying(false);
|
|
444
|
+
setTimeMs(player.current.time() * 1000);
|
|
445
|
+
preview.current?.seek(pictureAt(player.current.time()));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
frame = requestAnimationFrame(tick);
|
|
449
|
+
};
|
|
450
|
+
frame = requestAnimationFrame(tick);
|
|
451
|
+
return () => cancelAnimationFrame(frame);
|
|
452
|
+
}, [playing]);
|
|
453
|
+
|
|
454
|
+
const seek = React.useCallback((ms: number) => {
|
|
455
|
+
const t = Math.max(0, Math.min(ms / 1000, player.current.duration || ms / 1000));
|
|
456
|
+
/* playing, it plays on from there (the preview is told the player's new line); still, the picture shows t */
|
|
457
|
+
player.current.seek(t);
|
|
458
|
+
setTimeMs(t * 1000);
|
|
459
|
+
if (!player.current.playing) preview.current?.seek(pictureAt(t));
|
|
460
|
+
for (const fn of listeners.current) fn();
|
|
461
|
+
}, []);
|
|
462
|
+
/* ── J / K / L: the shuttle. 1× and 2× forward are the player's own (with sound); faster, or backwards, the picture
|
|
463
|
+
alone is sought along at that rate, silent, as a scrub is (the player plays forward only, at most 2×) ── */
|
|
464
|
+
const shuttleRateRef = React.useRef(0);
|
|
465
|
+
const shuttleFrame = React.useRef(0);
|
|
466
|
+
/** The shuttle let go: the player back at the speed picked if the shuttle changed its rate. */
|
|
467
|
+
const stopShuttle = React.useCallback(() => {
|
|
468
|
+
if (shuttleFrame.current) cancelAnimationFrame(shuttleFrame.current);
|
|
469
|
+
shuttleFrame.current = 0;
|
|
470
|
+
if (shuttleRateRef.current !== 0 && player.current.rate !== speedRef.current) {
|
|
471
|
+
player.current.setRate(speedRef.current);
|
|
472
|
+
setRateState(player.current.rate);
|
|
473
|
+
}
|
|
474
|
+
shuttleRateRef.current = 0;
|
|
475
|
+
}, []);
|
|
476
|
+
const pause = React.useCallback(() => {
|
|
477
|
+
stopAtRef.current = null;
|
|
478
|
+
/* a picture-alone shuttle stops too */
|
|
479
|
+
if (shuttleFrame.current) { cancelAnimationFrame(shuttleFrame.current); shuttleFrame.current = 0; shuttleRateRef.current = 0; }
|
|
480
|
+
if (!player.current.playing) return;
|
|
481
|
+
player.current.pause();
|
|
482
|
+
/* stopped on the frame being shown: the timecode, the picture and an edit at the playhead all mean that frame */
|
|
483
|
+
player.current.seek(snapToFrame(player.current.time() * 1000) / 1000);
|
|
484
|
+
setPlaying(false);
|
|
485
|
+
setTimeMs(player.current.time() * 1000);
|
|
486
|
+
preview.current?.seek(pictureAt(player.current.time()));
|
|
487
|
+
for (const fn of listeners.current) fn();
|
|
488
|
+
}, []);
|
|
489
|
+
const toggle = React.useCallback(() => {
|
|
490
|
+
stopShuttle();
|
|
491
|
+
if (player.current.playing) { pause(); return; }
|
|
492
|
+
if (!(player.current.duration > 0)) return;
|
|
493
|
+
stopAtRef.current = null;
|
|
494
|
+
/* looping, it starts inside what loops */
|
|
495
|
+
if (loopRef.current) {
|
|
496
|
+
const from = playStart(player.current.time() * 1000, rangeNowRef.current, player.current.duration * 1000);
|
|
497
|
+
if (Math.abs(from - player.current.time() * 1000) > 1) seek(from);
|
|
498
|
+
}
|
|
499
|
+
void player.current.play();
|
|
500
|
+
setPlaying(true);
|
|
501
|
+
}, [pause, seek]);
|
|
502
|
+
/** J, K or L pressed (see lib/timeline-keys shuttleRate). */
|
|
503
|
+
const shuttle = React.useCallback((key: 'j' | 'k' | 'l') => {
|
|
504
|
+
const was = shuttleRateRef.current || (player.current.playing ? player.current.rate : 0);
|
|
505
|
+
const rate = shuttleRate(was, key);
|
|
506
|
+
if (rate === 0) { stopShuttle(); pause(); return; }
|
|
507
|
+
if (!(player.current.duration > 0)) return;
|
|
508
|
+
if (rate > 0 && rate <= 2) {
|
|
509
|
+
if (shuttleFrame.current) { cancelAnimationFrame(shuttleFrame.current); shuttleFrame.current = 0; }
|
|
510
|
+
shuttleRateRef.current = rate;
|
|
511
|
+
player.current.setRate(rate);
|
|
512
|
+
setRateState(player.current.rate);
|
|
513
|
+
if (!player.current.playing) { stopAtRef.current = null; void player.current.play(); setPlaying(true); }
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
pause();
|
|
517
|
+
shuttleRateRef.current = rate;
|
|
518
|
+
const end = player.current.duration * 1000;
|
|
519
|
+
let at = player.current.time() * 1000;
|
|
520
|
+
let last = performance.now();
|
|
521
|
+
let told = 0;
|
|
522
|
+
const step = (now: number) => {
|
|
523
|
+
at = Math.max(0, Math.min(end, at + (now - last) * rate));
|
|
524
|
+
last = now;
|
|
525
|
+
const done = at <= 0 || at >= end;
|
|
526
|
+
/* the picture is told every 40 ms, as a scrub tells it */
|
|
527
|
+
if (now - told >= 40 || done) { told = now; seek(at); }
|
|
528
|
+
if (done) { shuttleFrame.current = 0; shuttleRateRef.current = 0; return; }
|
|
529
|
+
shuttleFrame.current = requestAnimationFrame(step);
|
|
530
|
+
};
|
|
531
|
+
shuttleFrame.current = requestAnimationFrame(step);
|
|
532
|
+
}, [pause, seek, stopShuttle]);
|
|
533
|
+
React.useEffect(() => () => { if (shuttleFrame.current) cancelAnimationFrame(shuttleFrame.current); }, []);
|
|
534
|
+
/* the selection bar's Play range: from its start, stopping at its end */
|
|
535
|
+
const playRange = React.useCallback((range: FilmRange) => {
|
|
536
|
+
pause();
|
|
537
|
+
if (!(player.current.duration > 0)) return;
|
|
538
|
+
seek(range.startMs);
|
|
539
|
+
stopAtRef.current = range.endMs;
|
|
540
|
+
void player.current.play();
|
|
541
|
+
setPlaying(true);
|
|
542
|
+
}, [pause, seek]);
|
|
543
|
+
/* a browser that held the sound back lets it start on the next click or key anywhere on the page */
|
|
544
|
+
React.useEffect(() => {
|
|
545
|
+
const wake = () => player.current.wake();
|
|
546
|
+
window.addEventListener('pointerdown', wake, true);
|
|
547
|
+
window.addEventListener('keydown', wake, true);
|
|
548
|
+
return () => { window.removeEventListener('pointerdown', wake, true); window.removeEventListener('keydown', wake, true); };
|
|
549
|
+
}, []);
|
|
550
|
+
/* scrubbing the timeline pauses, and stays paused */
|
|
551
|
+
/* while scrubbing, the picture and the timecode follow each step the timeline tells (every frame); the rest of the
|
|
552
|
+
editor, which renders whole each time, hears the time a few times a second, and once more as the scrub ends */
|
|
553
|
+
const scrubTold = React.useRef(0);
|
|
554
|
+
const scrubPreview = React.useCallback((ms: number) => {
|
|
555
|
+
pause();
|
|
556
|
+
const t = Math.max(0, Math.min(ms / 1000, player.current.duration || ms / 1000));
|
|
557
|
+
player.current.seek(t);
|
|
558
|
+
preview.current?.seek(pictureAt(t));
|
|
559
|
+
for (const fn of listeners.current) fn();
|
|
560
|
+
const now = performance.now();
|
|
561
|
+
if (now - scrubTold.current > 100) { scrubTold.current = now; setTimeMs(t * 1000); }
|
|
562
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
563
|
+
}, [pause]);
|
|
564
|
+
const scrubCommit = React.useCallback((ms: number) => { pause(); seek(ms); }, [pause, seek]);
|
|
565
|
+
/* the timeline's preview axis: the picture at the moment under the pointer, the clock and the playhead staying; null:
|
|
566
|
+
back to the playhead's picture. Not while playing (the picture is the film's then) */
|
|
567
|
+
const hoverPreview = React.useCallback((ms: number | null) => {
|
|
568
|
+
if (player.current.playing) return;
|
|
569
|
+
preview.current?.seek(pictureAt(ms == null ? player.current.time() : Math.max(0, ms / 1000)));
|
|
570
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
571
|
+
}, []);
|
|
572
|
+
|
|
573
|
+
/* a seek asked for past the film's end as it was (the end of a clip just put on): made once the film is that long */
|
|
574
|
+
const seekWhenLonger = React.useRef<number | null>(null);
|
|
575
|
+
const onInfo = React.useCallback((next: FilmInfo) => {
|
|
576
|
+
setInfo(next);
|
|
577
|
+
if (loaded) player.current.load(new URL(loaded.folder).origin, next.sounds, next.duration);
|
|
578
|
+
const want = seekWhenLonger.current;
|
|
579
|
+
if (want != null && next.duration * 1000 >= want - 1) { seekWhenLonger.current = null; seek(want); }
|
|
580
|
+
}, [loaded, seek]);
|
|
581
|
+
|
|
582
|
+
/* ── the timeline: film.html + where the preview placed each clip ── */
|
|
583
|
+
const spans = React.useMemo(() => info?.spans ?? [], [info]);
|
|
584
|
+
/* a file's length draws a clip that plays to its end before the preview places it (a split's second half, a drop) */
|
|
585
|
+
const mediaFacts = React.useMemo<MediaFacts>(() => new Map([...facts].map(([path, f]) => [path, { audio: f.audio, ...(f.durationMs ? { duration: f.durationMs / 1000 } : {}) }])), [facts]);
|
|
586
|
+
const [pendingDrops, setPendingDrops] = React.useState<PendingDrop[]>([]);
|
|
587
|
+
const timelineTracks = React.useMemo<TimelineTrack[]>(() => {
|
|
588
|
+
if (!film.film) return [];
|
|
589
|
+
const base = layoutTracks(filmShape(film.film as unknown as FilmDoc, info?.spans ?? [], mediaFacts, Boolean(previewError)));
|
|
590
|
+
return pendingDrops.length ? withPendingBlocks(base, pendingDrops) : base;
|
|
591
|
+
}, [film.film, info, mediaFacts, pendingDrops, previewError]);
|
|
592
|
+
const tracksRef = React.useRef(timelineTracks);
|
|
593
|
+
tracksRef.current = timelineTracks;
|
|
594
|
+
/* the clips' links (`data-link`, by clip id): clips sharing one are edited together (lib/timeline-edit) */
|
|
595
|
+
const clipLinks = React.useMemo(() => new Map((film.film?.tracks ?? []).flatMap((tr) => tr.clips.flatMap((c) => (
|
|
596
|
+
c.attrs?.['data-link'] ? [[c.id, c.attrs['data-link']] as const] : []
|
|
597
|
+
)))), [film.film]);
|
|
598
|
+
const clipLinksRef = React.useRef(clipLinks);
|
|
599
|
+
clipLinksRef.current = clipLinks;
|
|
600
|
+
/** The timeline as an edit sees it now (lib/timeline-edit). */
|
|
601
|
+
const editModel = React.useCallback(() => editModelOf(tracksRef.current, clipLinksRef.current), []);
|
|
602
|
+
/** The main track (the lowest picture track), where the magnet acts: the inspector asks. */
|
|
603
|
+
const mainTrack = React.useMemo(() => mainTrackOf(editModelOf(timelineTracks)), [timelineTracks]);
|
|
604
|
+
const blocksRef = React.useRef<readonly TimelineBlock[]>([]);
|
|
605
|
+
blocksRef.current = React.useMemo(() => timelineTracks.flatMap((tr) => tr.blocks), [timelineTracks]);
|
|
606
|
+
/* the film's length: the preview's, unless it could not draw the film; that length is then of the film before
|
|
607
|
+
(a clip that lost its length still counted at its old one), and the timeline's own reading of film.html stands,
|
|
608
|
+
less the clips that have no length (drawn at a stand-in one) */
|
|
609
|
+
const totalMs = previewError || !info ? blocksRef.current.reduce((end, b) => (b.noLength ? end : Math.max(end, Math.round(b.endMs))), 0) : drawnMs;
|
|
610
|
+
/* before the preview is up (a long film still loading), that reading is the film's length too: the playhead moves
|
|
611
|
+
and plays at once, the picture and the sound join it as they come (see onInfo) */
|
|
612
|
+
React.useEffect(() => {
|
|
613
|
+
if (!info && totalMs > 0) player.current.duration = totalMs / 1000;
|
|
614
|
+
}, [info, totalMs]);
|
|
615
|
+
|
|
616
|
+
/* ── the project's editor settings (`.film/settings.json`): its frame rate, its tracks' names ── */
|
|
617
|
+
const [settings, setSettings] = React.useState<ProjectSettings | null>(null);
|
|
618
|
+
React.useEffect(() => {
|
|
619
|
+
setSettings(null);
|
|
620
|
+
void api.settings(projectId).then(setSettings, () => setSettings({}));
|
|
621
|
+
}, [projectId]);
|
|
622
|
+
const patchSettings = React.useCallback((patch: Parameters<typeof api.patchSettings>[1]) => {
|
|
623
|
+
void api.patchSettings(projectId, patch).then(setSettings, (e: Error) => toast.showError(e.message));
|
|
624
|
+
}, [projectId, toast]);
|
|
625
|
+
/* the rate frames are counted in: the project's own, else its first footage's (on the film by time, then the
|
|
626
|
+
project's other videos), else 30 */
|
|
627
|
+
const footageRate = React.useMemo(() => footageFps([
|
|
628
|
+
...timelineTracks.flatMap((tr) => tr.blocks).filter((b) => b.kind === 'video' && b.src).sort((a, b) => a.startMs - b.startMs).map((b) => facts.get(b.src!)?.fps),
|
|
629
|
+
...(rawListing?.files ?? []).filter((f) => f.kind === 'video').map((f) => facts.get(f.path)?.fps),
|
|
630
|
+
]), [timelineTracks, rawListing, facts]);
|
|
631
|
+
const projectFps = settings?.fps ?? footageRate;
|
|
632
|
+
/* stretches that play late, rendered in the background to play smoothly (editor/use-preview-renders) */
|
|
633
|
+
const { renders: rendersOn, stats: statsOn } = usePreviewPrefs();
|
|
634
|
+
const previewRenders = usePreviewRenders({ projectId, folder, fps: exactRate(projectFps), playing, on: rendersOn, film: film.film, files: filesTick, preview });
|
|
635
|
+
/* Settings → Developer: how playing goes, in the viewer's corner, said about once a second */
|
|
636
|
+
const [played, setPlayed] = React.useState<PlayedReport | null>(null);
|
|
637
|
+
const onPlayed = React.useCallback((report: PlayedReport) => {
|
|
638
|
+
previewRenders.onPlayed(report);
|
|
639
|
+
if (statsOn) setPlayed(report);
|
|
640
|
+
}, [previewRenders, statsOn]);
|
|
641
|
+
React.useEffect(() => { setTimecodeFps(projectFps); }, [projectFps]);
|
|
642
|
+
React.useEffect(() => () => setTimecodeFps(30), []);
|
|
643
|
+
const frameRate = React.useMemo(() => ({ fps: projectFps, fromFootage: settings?.fps == null, onChange: (fps: number) => patchSettings({ fps }) }), [projectFps, settings, patchSettings]);
|
|
644
|
+
/* tracks' names, by film.html track index (found again by their clips: lib/timeline-nav trackNamesOf) */
|
|
645
|
+
const trackNames = React.useMemo(() => trackNamesOf(settings?.trackNames ?? [], film.film?.tracks ?? []), [settings, film.film]);
|
|
646
|
+
const renameTrack = React.useCallback((docIndex: number, name: string) => {
|
|
647
|
+
if ((trackNames.get(docIndex) ?? '') === name.trim()) return;
|
|
648
|
+
patchSettings({ trackNames: namedTrack(settings?.trackNames ?? [], filmRef.current?.tracks ?? [], docIndex, name) });
|
|
649
|
+
}, [trackNames, settings, patchSettings]);
|
|
650
|
+
|
|
651
|
+
/* selection, by clip id: a block's own id is its place, and that changes when it moves */
|
|
652
|
+
const [selectedKeys, setSelectedKeys] = React.useState<readonly string[]>([]);
|
|
653
|
+
const selectedKeysRef = React.useRef(selectedKeys);
|
|
654
|
+
selectedKeysRef.current = selectedKeys;
|
|
655
|
+
const selectedBlocks = React.useMemo(() => {
|
|
656
|
+
const keys = new Set(selectedKeys);
|
|
657
|
+
return blocksRef.current.filter((b) => keys.has(b.clipId ?? b.id));
|
|
658
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
659
|
+
}, [timelineTracks, selectedKeys]);
|
|
660
|
+
const selectedIds = React.useMemo(() => selectedBlocks.map((b) => b.id), [selectedBlocks]);
|
|
661
|
+
/* the timeline's commands (⌘B, Q / W, ⌥← / →, its switches), for the keys bound here */
|
|
662
|
+
const timelineCommands = React.useRef<TimelineCommands | null>(null);
|
|
663
|
+
/**
|
|
664
|
+
* The picture has the keyboard (the last press was on it; the inspector and the picture's own bars and menus keep
|
|
665
|
+
* it): the arrow keys move what is in hand there. Anywhere else they move the playhead. The frame round what is in
|
|
666
|
+
* hand is the only mark: a ring round the whole picture read as the picture being selected.
|
|
667
|
+
*/
|
|
668
|
+
const [viewerFocus, setViewerFocus] = React.useState(false);
|
|
669
|
+
React.useEffect(() => {
|
|
670
|
+
const onDown = (e: PointerEvent) => {
|
|
671
|
+
const el = e.target as Element | null;
|
|
672
|
+
if (el?.closest?.('[data-viewer-picture]')) { setViewerFocus(true); return; }
|
|
673
|
+
if (el?.closest?.('[data-film-inspector], [data-selection-toolbar], [role="menu"], [data-popover-panel]')) return;
|
|
674
|
+
setViewerFocus(false);
|
|
675
|
+
};
|
|
676
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
677
|
+
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
678
|
+
}, []);
|
|
679
|
+
|
|
680
|
+
/* ── layout ── */
|
|
681
|
+
const dockColRef = React.useRef<HTMLDivElement | null>(null);
|
|
682
|
+
const viewerRowRef = React.useRef<HTMLDivElement | null>(null);
|
|
683
|
+
const { width: storedTimelineHeight, commit: commitTimelineHeight, saved: timelineHeightSaved } = usePaneSize('openfilm.project.timelineH', TIMELINE_H, MIN_DOCK_H);
|
|
684
|
+
const { width: inspectorWidth, commit: commitInspectorWidth } = usePaneSize('openfilm.project.inspectorW', DEFAULT_INSPECTOR, MIN_INSPECTOR);
|
|
685
|
+
const [assetsPaneOpen, setAssetsPaneOpen] = useKeptFlag('openfilm.project.assets');
|
|
686
|
+
const [assetsWidth, setAssetsWidth] = React.useState(280);
|
|
687
|
+
/* the Layers panel (components/LayersPanel), left of the picture, after the assets */
|
|
688
|
+
const [layersPaneOpen, setLayersPaneOpen] = useKeptFlag('openfilm.project.layers', false);
|
|
689
|
+
const [layersWidth, setLayersWidth] = React.useState(240);
|
|
690
|
+
const layersPaneRef = React.useRef<HTMLDivElement | null>(null);
|
|
691
|
+
const [timelineOpen, setTimelineOpen] = useKeptFlag('openfilm.project.timeline');
|
|
692
|
+
/* open whenever the project opens (a reload too); closed, it stays closed until then or until a selection opens it */
|
|
693
|
+
const [inspectorOpen, setInspectorOpen] = React.useState(true);
|
|
694
|
+
const closeInspector = React.useCallback(() => setInspectorOpen(false), [setInspectorOpen]);
|
|
695
|
+
const [rowWidth, setRowWidth] = React.useState(0);
|
|
696
|
+
const [colHeight, setColHeight] = React.useState(0);
|
|
697
|
+
React.useEffect(() => {
|
|
698
|
+
const row = viewerRowRef.current;
|
|
699
|
+
const col = dockColRef.current;
|
|
700
|
+
if (!row || !col) return undefined;
|
|
701
|
+
const ro = new ResizeObserver(([e]) => setRowWidth(Math.round(e?.contentRect.width ?? 0)));
|
|
702
|
+
const co = new ResizeObserver(([e]) => setColHeight(Math.round(e?.contentRect.height ?? 0)));
|
|
703
|
+
ro.observe(row);
|
|
704
|
+
co.observe(col);
|
|
705
|
+
return () => { ro.disconnect(); co.disconnect(); };
|
|
706
|
+
}, [loaded]);
|
|
707
|
+
/* the timeline fits its tracks (not taller than they need, up to 45% of the window) unless the person dragged it */
|
|
708
|
+
const fitTimelineHeight = React.useRef<number>(TIMELINE_H);
|
|
709
|
+
if (!pendingDrops.length) {
|
|
710
|
+
const cap = Math.round(window.innerHeight * 0.45);
|
|
711
|
+
fitTimelineHeight.current = Math.max(TIMELINE_H, Math.min(timelineFitHeight(timelineTracks), cap));
|
|
712
|
+
}
|
|
713
|
+
/* a height the person dragged is theirs, as tall as the picture's minimum allows; until then the tracks' fit (not
|
|
714
|
+
taller than they need, nor than 45% of the column) */
|
|
715
|
+
const timelineHeight = storedTimelineHeight == null ? null : timelineHeightSaved ? storedTimelineHeight : fitTimelineHeight.current;
|
|
716
|
+
const timelineTallest = colHeight ? Math.max(MIN_DOCK_H, colHeight - MIN_STAGE_H - DOCK_GAP) : Infinity;
|
|
717
|
+
const timelineShownHeight = timelineHeightSaved
|
|
718
|
+
? Math.min(timelineHeight ?? TIMELINE_H, timelineTallest)
|
|
719
|
+
: colHeight
|
|
720
|
+
? Math.min(timelineHeight ?? TIMELINE_H, fitTimelineHeight.current + TIMELINE_SPARE, Math.max(MIN_DOCK_H, Math.round(colHeight * TIMELINE_SHARE)))
|
|
721
|
+
: Math.min(timelineHeight ?? TIMELINE_H, fitTimelineHeight.current + TIMELINE_SPARE);
|
|
722
|
+
|
|
723
|
+
/* what is selected in the picture: a layer of a page, or a clip in hand */
|
|
724
|
+
const [stageElement, setStageElement] = React.useState<StageElement | null>(null);
|
|
725
|
+
/* the inspector's font menu: this project's fonts, for the page the layer in hand is in */
|
|
726
|
+
const stageClipLoc = stageElement?.clipLoc;
|
|
727
|
+
const fontScope = React.useMemo<FontScope>(() => {
|
|
728
|
+
const src = stageClipLoc ? blocksRef.current.find((b) => b.loc === stageClipLoc)?.src : undefined;
|
|
729
|
+
return { projectId, folder, page: src && filmSrcIsPage(src) ? src : null, tick: filesTick };
|
|
730
|
+
}, [projectId, folder, stageClipLoc, filesTick, timelineTracks]);
|
|
731
|
+
/* where the selection bar goes (components/SelectionToolbar): the picture or the timeline, whichever was last pressed */
|
|
732
|
+
const [barAt, setBarAt] = React.useState<'timeline' | 'stage'>('timeline');
|
|
733
|
+
const stageElementRef = React.useRef(stageElement);
|
|
734
|
+
stageElementRef.current = stageElement;
|
|
735
|
+
/* the inspector shows a media file, or what is selected */
|
|
736
|
+
const [pickedMedia, setPickedMedia] = React.useState<WorkspaceResource | null>(null);
|
|
737
|
+
/* the file picked is one of Saved (lib/saved): shown from there, and brought into the project when it is used */
|
|
738
|
+
const [pickedSaved, setPickedSaved] = React.useState(false);
|
|
739
|
+
const [inspectorContent, setInspectorContent] = React.useState<'selection' | 'subtitles' | WorkspaceResource>('selection');
|
|
740
|
+
const subs = useFilmSubtitles(projectId);
|
|
741
|
+
/* the subtitle over the picture renders again while playing only when another line, or another of its words, is said */
|
|
742
|
+
const subtitleStep = React.useCallback((ms: number) => {
|
|
743
|
+
const cue = subs.style.on ? cueAt(subs.cues, ms) : null;
|
|
744
|
+
if (!cue) return '';
|
|
745
|
+
return `${subs.cues.indexOf(cue)}:${subs.style.karaoke ? filmSubtitleWords(cue, ms).map((w) => (w.active ? 1 : 0)).join('') : ''}`;
|
|
746
|
+
}, [subs.cues, subs.style.on, subs.style.karaoke]);
|
|
747
|
+
const configureSubtitles = React.useCallback(() => {
|
|
748
|
+
setInspectorContent('subtitles');
|
|
749
|
+
setInspectorOpen(!(inspectorOpen && inspectorContent === 'subtitles'));
|
|
750
|
+
}, [inspectorOpen, inspectorContent]);
|
|
751
|
+
/* a subtitle picked (on the picture, or its line on the timeline) is what is selected now, as a layer picked is: the
|
|
752
|
+
clips let go, and the inspector shows the subtitles' look */
|
|
753
|
+
const showSubtitleLook = React.useCallback(() => {
|
|
754
|
+
setSelectedKeys([]);
|
|
755
|
+
setStageElement(null);
|
|
756
|
+
setInspectorContent('subtitles');
|
|
757
|
+
setInspectorOpen(true);
|
|
758
|
+
}, []);
|
|
759
|
+
const pickMedia = React.useCallback((file: WorkspaceResource | null) => {
|
|
760
|
+
setPickedMedia(file);
|
|
761
|
+
/* one of the project's, unless the Saved pane says otherwise right after (pickSaved) */
|
|
762
|
+
setPickedSaved(false);
|
|
763
|
+
if (file) { setInspectorContent(file); setInspectorOpen(true); }
|
|
764
|
+
else setInspectorContent((c) => (typeof c === 'object' ? 'selection' : c));
|
|
765
|
+
}, []);
|
|
766
|
+
const onTimelineSelected = React.useCallback((ids: readonly string[]) => {
|
|
767
|
+
setSelectedKeys(blocksRef.current.filter((b) => ids.includes(b.id)).map((b) => b.clipId ?? b.id));
|
|
768
|
+
setPickedTransition(null);
|
|
769
|
+
if (ids.length) {
|
|
770
|
+
setInspectorContent('selection');
|
|
771
|
+
setInspectorOpen(true);
|
|
772
|
+
setStageElement((el) => (el && el.kind !== 'clip' ? null : el));
|
|
773
|
+
}
|
|
774
|
+
}, []);
|
|
775
|
+
const rowNarrow = rowWidth > 0 && rowWidth < MIN_VIEWER + DEFAULT_INSPECTOR + 240;
|
|
776
|
+
const inspectorIdle = inspectorContent === 'selection' && !stageElement && !selectedBlocks.length;
|
|
777
|
+
const inspectorShown = inspectorOpen && !(rowNarrow && inspectorIdle);
|
|
778
|
+
const inspectorWanted = inspectorWidth ?? DEFAULT_INSPECTOR;
|
|
779
|
+
const assetsRoom = rowWidth - (inspectorShown ? inspectorWanted : 0) - VIEWER_BESIDE_ASSETS - 16;
|
|
780
|
+
const assetsShownWidth = rowWidth ? Math.min(assetsWidth, assetsRoom) : assetsWidth;
|
|
781
|
+
const assetsFit = !rowWidth || assetsShownWidth >= 200;
|
|
782
|
+
const inspectorShownWidth = rowWidth && inspectorShown ? Math.max(200, Math.min(inspectorWanted, rowWidth - MIN_VIEWER - 8)) : inspectorWanted;
|
|
783
|
+
|
|
784
|
+
/* ── edits: the timeline's and the inspector's, turned into the server's operations by clip id ── */
|
|
785
|
+
/* the picture's controls, set by FilmStageSelect (declared here: every edit asks it to write its arrow keys' first) */
|
|
786
|
+
const stageControl = React.useRef<FilmStageControl | null>(null);
|
|
787
|
+
/**
|
|
788
|
+
* Every edit goes through here: what the arrow keys moved on the picture and is not written yet is written first, so
|
|
789
|
+
* it is a step of its own before this one (and is never written after an undo, over it).
|
|
790
|
+
*/
|
|
791
|
+
const filmEdit = film.edit;
|
|
792
|
+
const edit = React.useCallback((ops: Op[], label?: string, quiet?: boolean) => {
|
|
793
|
+
stageControl.current?.flushPending();
|
|
794
|
+
return filmEdit(ops, label, quiet);
|
|
795
|
+
}, [filmEdit]);
|
|
796
|
+
/** `quiet`: the caller shows why an edit was refused itself (the inspector does, beside its field) */
|
|
797
|
+
const editBlocks = React.useCallback((asked: ClipEdit[], quiet = false): Promise<string | null> => {
|
|
798
|
+
const current = filmRef.current;
|
|
799
|
+
/* a locked track is the person's: nothing on it changes, whichever way the edit came (the picture, the inspector) */
|
|
800
|
+
if (lockedEdits(asked, current).length) return Promise.resolve(t('timeline.trackLocked'));
|
|
801
|
+
const edits = apartOnTracks(asked, blocksRef.current);
|
|
802
|
+
const mapped = edits.map((e) => ({ clip: clipIdAt(current, e.loc), prop: e.prop, value: e.value }));
|
|
803
|
+
if (mapped.some((e) => !e.clip)) return Promise.resolve(t('timeline.editFail'));
|
|
804
|
+
return edit([{ op: 'props', edits: mapped as { clip: string; prop: string; value: never }[] }], editStepLabel(edits, tracksRef.current), quiet);
|
|
805
|
+
}, [edit, t]);
|
|
806
|
+
/** An edit the timeline planned (lib/timeline-edit), as it planned it: one write, one undo step. */
|
|
807
|
+
const editOps = React.useCallback((ops: Op[], label: string) => edit(ops, label), [edit]);
|
|
808
|
+
|
|
809
|
+
/* ── a clip's attributes copied onto others (lib/clip-attrs, ⌘⇧C / ⌘⇧V) ── */
|
|
810
|
+
const [copiedAttrs, setCopiedAttrs] = React.useState<CopiedAttrs | null>(null);
|
|
811
|
+
const [pasteAttrsOpen, setPasteAttrsOpen] = React.useState(false);
|
|
812
|
+
const filmClipOf = React.useCallback((id: string) => filmRef.current?.tracks.flatMap((tr) => tr.clips).find((c) => c.id === id) ?? null, []);
|
|
813
|
+
const copyAttrs = React.useCallback((blocks: readonly TimelineBlock[]) => {
|
|
814
|
+
const clip = blocks.map((b) => (b.clipId ? filmClipOf(b.clipId) : null)).find(Boolean);
|
|
815
|
+
if (!clip) { toast.show(t('clipAttrs.selectFirst')); return; }
|
|
816
|
+
setCopiedAttrs(attrsOf(clip));
|
|
817
|
+
toast.show(t('clipAttrs.copied'));
|
|
818
|
+
}, [filmClipOf, toast, t]);
|
|
819
|
+
const askPasteAttrs = React.useCallback((blocks: readonly TimelineBlock[]) => {
|
|
820
|
+
if (!copiedAttrs) { toast.show(t('clipAttrs.nothing')); return; }
|
|
821
|
+
if (!blocks.some((b) => b.clipId && b.loc)) { toast.show(t('clipAttrs.selectFirst')); return; }
|
|
822
|
+
setPasteAttrsOpen(true);
|
|
823
|
+
}, [copiedAttrs, toast, t]);
|
|
824
|
+
const doPasteAttrs = React.useCallback((groups: ReadonlySet<AttrGroup>) => {
|
|
825
|
+
setPasteAttrsOpen(false);
|
|
826
|
+
if (!copiedAttrs) return;
|
|
827
|
+
const targets = selectedBlocks.flatMap((b) => {
|
|
828
|
+
const clip = b.clipId && b.loc ? filmClipOf(b.clipId) : null;
|
|
829
|
+
return clip ? [{ loc: b.loc!, clip, lengthS: (b.endMs - b.startMs) / 1000 }] : [];
|
|
830
|
+
});
|
|
831
|
+
const edits = pasteAttrs(copiedAttrs, groups, targets);
|
|
832
|
+
if (edits.length) void editBlocks(edits).then((err) => { if (err) toast.showError(err); });
|
|
833
|
+
}, [copiedAttrs, selectedBlocks, filmClipOf, editBlocks, toast]);
|
|
834
|
+
|
|
835
|
+
/* ── fades and transitions: each clip's fades (its `overrides`' own entry), and the transitions they make ── */
|
|
836
|
+
const clipFades = React.useMemo(() => new Map<string, FadeMs>((film.film?.tracks ?? []).flatMap((tr) => tr.clips.flatMap((c) => {
|
|
837
|
+
const fade = fadeMsOf(c);
|
|
838
|
+
return fade[0] > 0 || fade[1] > 0 ? [[c.id, fade] as const] : [];
|
|
839
|
+
}))), [film.film]);
|
|
840
|
+
/** What a transition's plan needs of each clip: picture or sound, its fades, whether it is the white page. */
|
|
841
|
+
const clipFacts = React.useMemo(() => {
|
|
842
|
+
const facts = new Map<string, ClipFacts>();
|
|
843
|
+
for (const tr of film.film?.tracks ?? []) {
|
|
844
|
+
for (const c of tr.clips) {
|
|
845
|
+
facts.set(c.id, { picture: filmClipKind(c) !== 'audio', fade: fadeMsOf(c), ...(c.src === WHITE_SRC ? { white: true } : {}) });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return (id: string) => facts.get(id);
|
|
849
|
+
}, [film.film]);
|
|
850
|
+
const filmTransitions = React.useMemo(() => findTransitions(editModelOf(timelineTracks, clipLinks), clipFacts), [timelineTracks, clipLinks, clipFacts]);
|
|
851
|
+
const [pickedTransition, setPickedTransition] = React.useState<string | null>(null);
|
|
852
|
+
const pickTransition = React.useCallback((key: string | null) => {
|
|
853
|
+
setPickedTransition(key);
|
|
854
|
+
if (key) { setSelectedKeys([]); setInspectorContent('selection'); setInspectorOpen(true); }
|
|
855
|
+
}, []);
|
|
856
|
+
const pickedTr = filmTransitions.find((x) => x.key === pickedTransition) ?? null;
|
|
857
|
+
/**
|
|
858
|
+
* A transition put on a cut, made longer or shorter, or taken away (lib/transitions): planned on the timeline as it
|
|
859
|
+
* is (on the main track with the magnet what follows an overlap closes up, linked clips along), one write and one
|
|
860
|
+
* undo step. The menus put on fades only; other transitions are an agent's to write.
|
|
861
|
+
*/
|
|
862
|
+
const askTransition = React.useCallback(async (ask: TransitionAsk): Promise<string | null> => {
|
|
863
|
+
const model = editModel();
|
|
864
|
+
/* the magnet acts on the main track: there an overlap closes up what follows, no gap opens after it */
|
|
865
|
+
const cutClip = ask.kind === 'add' ? ask.cut.a : filmTransitions.find((x) => x.key === ask.key)?.a;
|
|
866
|
+
const cutTrack = model.find((tr) => tr.clips.some((c) => c.id === cutClip))?.index;
|
|
867
|
+
const opts = { ripple: magnetOn(model, cutTrack, readTimelinePrefs().magnet), linked: true, whiteSrc: WHITE_SRC };
|
|
868
|
+
let plan: TransitionPlan;
|
|
869
|
+
let label: string;
|
|
870
|
+
if (ask.kind === 'add') {
|
|
871
|
+
/* the default length, on the frame grid */
|
|
872
|
+
const frame = frameMs(1);
|
|
873
|
+
plan = planTransition(model, clipFacts, ask.type, ask.cut, Math.round(TRANSITION_MS / frame) * frame, opts);
|
|
874
|
+
label = 'history.stepTransition';
|
|
875
|
+
} else {
|
|
876
|
+
const tr = filmTransitions.find((x) => x.key === ask.key);
|
|
877
|
+
if (!tr) return t('transitions.none');
|
|
878
|
+
plan = ask.kind === 'remove' ? planRemoveTransition(model, clipFacts, tr, opts) : planResizeTransition(model, clipFacts, tr, ask.durationMs, opts);
|
|
879
|
+
label = ask.kind === 'remove' ? 'history.stepTransitionRemove' : 'history.stepTransitionResize';
|
|
880
|
+
}
|
|
881
|
+
if ('error' in plan) return t(plan.error === 'locked' ? 'transitions.locked' : plan.error === 'short' ? 'transitions.short' : 'transitions.none');
|
|
882
|
+
return plan.ops.length ? edit(plan.ops, label) : null;
|
|
883
|
+
}, [editModel, clipFacts, filmTransitions, edit, t]);
|
|
884
|
+
/**
|
|
885
|
+
* The inspector's edits (its refusals it says beside its field). A new speed goes to the clips linked to the clip
|
|
886
|
+
* too. On the main track with the magnet (the timeline's switch) a speed or a length that makes the clip longer or
|
|
887
|
+
* shorter pushes or pulls what follows, as a ripple trim does; elsewhere a speed too slow to fit before the next clip
|
|
888
|
+
* is refused, and the inspector says why. With linkage what rides on the main track stays on its footage.
|
|
889
|
+
*/
|
|
890
|
+
const editClips = React.useCallback((edits: ClipEdit[]): Promise<string | null> => {
|
|
891
|
+
const [one] = edits;
|
|
892
|
+
const block = one && edits.length === 1 ? blocksRef.current.find((b) => b.loc === one.loc) : undefined;
|
|
893
|
+
if (block?.loc && lockedEdits(edits, filmRef.current).length) return Promise.resolve(t('timeline.trackLocked'));
|
|
894
|
+
const model = editModel();
|
|
895
|
+
const prefs = readTimelinePrefs();
|
|
896
|
+
const switches = switchesOf(prefs);
|
|
897
|
+
/* the magnet acts on the main track alone */
|
|
898
|
+
const magnet = magnetOn(model, model.find((tr) => tr.clips.some((c) => c.id === block?.clipId))?.index, prefs.magnet);
|
|
899
|
+
if (!block?.clipId || (one!.prop !== 'speed' && one!.prop !== 'at' && !(one!.prop === 'end' && magnet))) return editBlocks(edits, true);
|
|
900
|
+
const ids = [block.clipId, ...linkedIds(model, [block.clipId]).filter((id) => id !== block.clipId)];
|
|
901
|
+
if (one!.prop === 'at') {
|
|
902
|
+
/* a start typed in: the clip (and those linked to it) moves as a drag would: on the main track with the magnet
|
|
903
|
+
it goes in at the insert point there; elsewhere it lands there, or on a new track beside its own when that
|
|
904
|
+
time is taken. Nothing there is cut */
|
|
905
|
+
const startMs = Number(one!.value) * 1000 + (block.anchor?.parentStartMs ?? 0);
|
|
906
|
+
if (!Number.isFinite(startMs)) return Promise.resolve(t('timeline.editFail'));
|
|
907
|
+
const plan = planMove(model, { ids, deltaMs: startMs - block.startMs, pointerMs: startMs, mode: 'place', ...switches });
|
|
908
|
+
if (!plan) return Promise.resolve(t('timeline.trackLocked'));
|
|
909
|
+
return plan.ops.length ? edit(plan.ops, clipStep('history.stepMoved', [block.clipId]), true) : Promise.resolve(null);
|
|
910
|
+
}
|
|
911
|
+
if (one!.prop === 'speed') {
|
|
912
|
+
const speed = typeof one!.value === 'number' ? one!.value : 1;
|
|
913
|
+
const plan = planSpeed(model, ids, speed, { ripple: magnet, ...switches });
|
|
914
|
+
if (plan.slowest != null) return Promise.resolve(t('inspector.speedTooSlow').replace('{n}', String(plan.slowest)));
|
|
915
|
+
return plan.ops.length ? edit(plan.ops, `history.stepSpeed|${block.clipId}`, true) : Promise.resolve(null);
|
|
916
|
+
}
|
|
917
|
+
/* a new length, with the magnet: a ripple trim of the tail by the difference (source seconds over the speed) */
|
|
918
|
+
const k = block.anchor?.trimFrom != null && block.speed ? block.speed : 1;
|
|
919
|
+
const deltaMs = ((Number(one!.value) - Number(one!.from ?? one!.value)) * 1000) / k;
|
|
920
|
+
const plan = planRippleTrim(model, ids, 'end', deltaMs, switches);
|
|
921
|
+
return plan.ops.length ? edit(plan.ops, `history.stepRippleTrimmed|${block.clipId}`, true) : Promise.resolve(null);
|
|
922
|
+
}, [editBlocks, editModel, edit, t]);
|
|
923
|
+
const removeBlock = React.useCallback((block: TimelineBlock): Promise<string | null> => {
|
|
924
|
+
if (!block.clipId) return Promise.resolve(t('timeline.removeFail'));
|
|
925
|
+
return edit([{ op: 'remove', clip: block.clipId }], clipStep('history.stepRemoved', [block.clipId]));
|
|
926
|
+
}, [edit, t]);
|
|
927
|
+
const removeBlocks = React.useCallback((blocks: readonly TimelineBlock[]): Promise<string | null> => {
|
|
928
|
+
const ops: Op[] = blocks.flatMap((b) => (b.clipId ? [{ op: 'remove' as const, clip: b.clipId }] : []));
|
|
929
|
+
const gone = blocks.flatMap((b) => (b.clipId ? [b.clipId] : []));
|
|
930
|
+
return ops.length ? edit(ops, gone.length <= 2 ? clipStep('history.stepRemoved', gone) : 'history.stepRemove') : Promise.resolve(null);
|
|
931
|
+
}, [edit]);
|
|
932
|
+
/**
|
|
933
|
+
* Clips split at one moment, in one edit (the timeline sends a clip with the clips linked to it). The right halves
|
|
934
|
+
* of linked clips are linked to each other with a link of their own, as the left halves stay linked.
|
|
935
|
+
*/
|
|
936
|
+
const splitBlocks = React.useCallback((blocks: readonly TimelineBlock[], atMs: number): Promise<string | null> => {
|
|
937
|
+
const current = filmRef.current;
|
|
938
|
+
const clips = current?.tracks.flatMap((tr) => tr.clips) ?? [];
|
|
939
|
+
const model = editModel();
|
|
940
|
+
const halves = blocks.flatMap((block) => {
|
|
941
|
+
if (!block.clipId || !block.anchor?.move) return [];
|
|
942
|
+
/* the halves' trims are the source's seconds, and the rest starts exactly where the first half ends */
|
|
943
|
+
const clip = clips.find((c) => c.id === block.clipId);
|
|
944
|
+
const split = splitHalves(block, atMs, Array.isArray(clip?.time) && clip.time.length === 2 ? clip.time[1] : undefined);
|
|
945
|
+
return split ? [{ block, split, link: clip?.attrs?.['data-link'] }] : [];
|
|
946
|
+
});
|
|
947
|
+
if (!halves.length) return Promise.resolve(t('timeline.splitFail'));
|
|
948
|
+
const rightLinks = new Map<string, string>();
|
|
949
|
+
const taken = new Set<string>();
|
|
950
|
+
for (const { link } of halves) {
|
|
951
|
+
if (!link || rightLinks.has(link) || halves.filter((h) => h.link === link).length < 2) continue;
|
|
952
|
+
const made = freshLink(model, `${link}-b`, taken);
|
|
953
|
+
taken.add(made);
|
|
954
|
+
rightLinks.set(link, made);
|
|
955
|
+
}
|
|
956
|
+
/* a selected clip's selection goes to its right half: named here so it can be selected once written */
|
|
957
|
+
const selected = new Set(selectedKeysRef.current);
|
|
958
|
+
const used = new Set(clips.map((c) => c.id));
|
|
959
|
+
const rightIds = new Map<string, string>();
|
|
960
|
+
for (const { block } of halves) {
|
|
961
|
+
if (!selected.has(block.clipId!)) continue;
|
|
962
|
+
let id = `${block.clipId}-b`;
|
|
963
|
+
for (let n = 2; used.has(id); n++) id = `${block.clipId}-b-${n}`;
|
|
964
|
+
used.add(id);
|
|
965
|
+
rightIds.set(block.clipId!, id);
|
|
966
|
+
}
|
|
967
|
+
const ops: Op[] = halves.map(({ block, split, link }) => ({
|
|
968
|
+
op: 'split',
|
|
969
|
+
clip: block.clipId!,
|
|
970
|
+
left: split.left,
|
|
971
|
+
/* a half left alone with a link (its linked clip not under the cut) keeps none: it would pull the other along */
|
|
972
|
+
right: { ...split.right, link: link ? rightLinks.get(link) ?? null : null },
|
|
973
|
+
...(rightIds.has(block.clipId!) ? { rightId: rightIds.get(block.clipId!)! } : {}),
|
|
974
|
+
}));
|
|
975
|
+
return edit(ops, clipStep('history.stepSplit', halves.slice(0, 2).map((h) => h.block.clipId!))).then((err) => {
|
|
976
|
+
if (!err && rightIds.size) setSelectedKeys((keys) => keys.map((k) => rightIds.get(k) ?? k));
|
|
977
|
+
return err;
|
|
978
|
+
});
|
|
979
|
+
}, [edit, editModel, t]);
|
|
980
|
+
/**
|
|
981
|
+
* A video's sound taken apart from its picture, as an editor unlinks them: the same file's sound alone, at the same
|
|
982
|
+
* seconds and speed, on a sound track that has room for it (a new one under the sound tracks only when none has),
|
|
983
|
+
* linked to the video (`data-link`) so they still move, trim, split and delete together; the video silent from then on.
|
|
984
|
+
*/
|
|
985
|
+
const detachAudio = React.useCallback((block: TimelineBlock): Promise<string | null> => {
|
|
986
|
+
const current = filmRef.current;
|
|
987
|
+
const ti = current?.tracks.findIndex((tr) => tr.clips.some((c) => c.id === block.clipId)) ?? -1;
|
|
988
|
+
const clip = ti >= 0 ? current!.tracks[ti]!.clips.find((c) => c.id === block.clipId) : undefined;
|
|
989
|
+
if (!clip || !block.clipId) return Promise.resolve(t('timeline.detachFail'));
|
|
990
|
+
const model = editModel();
|
|
991
|
+
const link = clip.attrs?.['data-link'] ?? freshLink(model, clip.id);
|
|
992
|
+
const sound = {
|
|
993
|
+
src: clip.src,
|
|
994
|
+
sound: true as const,
|
|
995
|
+
...(clip.at != null ? { at: clip.at } : {}),
|
|
996
|
+
...(clip.time ? { time: clip.time } : {}),
|
|
997
|
+
...(clip.speed != null ? { speed: clip.speed } : {}),
|
|
998
|
+
...(clip.volume != null && clip.volume !== 0 ? { volume: clip.volume } : {}),
|
|
999
|
+
attrs: { 'data-link': link },
|
|
1000
|
+
};
|
|
1001
|
+
const track = soundTrackFor(model, { startMs: block.startMs, endMs: block.endMs }, ti);
|
|
1002
|
+
return edit([
|
|
1003
|
+
{ op: 'props', edits: [{ clip: block.clipId, prop: 'volume', value: 0 }, { clip: block.clipId, prop: 'link', value: link }] },
|
|
1004
|
+
{ op: 'insert', clip: sound, track },
|
|
1005
|
+
], clipStep('history.stepDetachAudio', [block.clipId]));
|
|
1006
|
+
}, [edit, editModel, t]);
|
|
1007
|
+
const [clipboard, setClipboard] = React.useState<readonly TimelineClipboardItem[]>([]);
|
|
1008
|
+
/* the clips copied as film.html had them then: a clip cut (⌘X) is gone from the film, and is pasted from these */
|
|
1009
|
+
const copiedClips = React.useRef(new Map<string, Clip>());
|
|
1010
|
+
const copyBlocks = React.useCallback((blocks: readonly TimelineBlock[]) => {
|
|
1011
|
+
const clips = new Map((filmRef.current?.tracks ?? []).flatMap((tr) => tr.clips.map((c) => [c.id, c] as const)));
|
|
1012
|
+
copiedClips.current = new Map(blocks.flatMap((b) => {
|
|
1013
|
+
const clip = b.clipId ? clips.get(b.clipId) : undefined;
|
|
1014
|
+
return clip ? [[clip.id, structuredClone(clip)] as const] : [];
|
|
1015
|
+
}));
|
|
1016
|
+
setClipboard(clipboardItemsOf(blocks, timelineTracks));
|
|
1017
|
+
}, [timelineTracks]);
|
|
1018
|
+
/**
|
|
1019
|
+
* A paste (⌘V, the menu) at a time: onto the track pointed at (`lane`) or each copy's own, never cutting what is
|
|
1020
|
+
* there: a taken spot opens a track beside it, and with the magnet a copy for the main track goes right above it
|
|
1021
|
+
* (lib/timeline-clipboard pasteEdit). A clip cut since it was copied comes back as it was copied (under a
|
|
1022
|
+
* new id).
|
|
1023
|
+
*/
|
|
1024
|
+
const pasteAt = React.useCallback((lane: string | null, atMs: number): Promise<string | null> => {
|
|
1025
|
+
if (!clipboard.length) return Promise.resolve(t('timeline.menuPasteEmpty'));
|
|
1026
|
+
const { items, missing } = clipboardNow(clipboard, timelineTracks);
|
|
1027
|
+
const cut = clipboard.filter((item) => !items.some((i) => i.clipId === item.clipId) && copiedClips.current.has(item.clipId));
|
|
1028
|
+
if (missing > cut.length) toast.show(t('timeline.pasteGone'));
|
|
1029
|
+
const all = [...items, ...cut].sort((a, b) => a.startMs - b.startMs);
|
|
1030
|
+
if (!all.length) return Promise.resolve(null);
|
|
1031
|
+
const ops = pasteEdit(all, timelineTracks, editModel(), atMs, lane, { mode: 'place', ...switchesOf(readTimelinePrefs()) });
|
|
1032
|
+
if (!ops) return Promise.resolve(t('timeline.trackLocked'));
|
|
1033
|
+
const gone = new Set(cut.map((item) => item.clipId));
|
|
1034
|
+
const written = ops.map((op): Op => {
|
|
1035
|
+
if (op.op !== 'insert' || !op.from || !gone.has(op.from)) return op;
|
|
1036
|
+
/* a new id: the place it had may be another clip's by now, and a stale reading of the timeline would edit it */
|
|
1037
|
+
const { from, ...rest } = op;
|
|
1038
|
+
const { id: _id, ...clip } = structuredClone(copiedClips.current.get(from)!);
|
|
1039
|
+
return { ...rest, clip };
|
|
1040
|
+
});
|
|
1041
|
+
return edit(written, 'history.stepPaste');
|
|
1042
|
+
}, [clipboard, timelineTracks, editModel, edit, t, toast]);
|
|
1043
|
+
/** Room for clips dropped onto a track (an insert pushes on, an overwrite cuts out): after the inserts, in one edit. */
|
|
1044
|
+
const roomFor = React.useCallback((spans: { track: number | { insert: number } | undefined; atSec: number; durMs: number }[], mode?: EditMode): Op[] => {
|
|
1045
|
+
const onTracks = spans.flatMap((s) => (typeof s.track === 'number' ? [{ track: s.track, startMs: Math.round(s.atSec * 1000), endMs: Math.round(s.atSec * 1000) + s.durMs }] : []));
|
|
1046
|
+
if (!mode || !onTracks.length) return [];
|
|
1047
|
+
return planRoom(editModel(), onTracks, { mode, ...switchesOf(readTimelinePrefs()) }).ops;
|
|
1048
|
+
}, [editModel]);
|
|
1049
|
+
const dropAsset = React.useCallback(async (file: ResourceDragItem, target: AssetDropTarget, atMs: number): Promise<string | null> => {
|
|
1050
|
+
const plan = assetDropInsert(file, timelineTracks, target, atMs, stageRef.current);
|
|
1051
|
+
if ('error' in plan) return plan.error === 'locked' ? t('timeline.trackLocked') : t('timeline.dropNotClip');
|
|
1052
|
+
/* a thing of Saved (lib/saved): brought into the project first (a file imported, a bundle's files copied in), then
|
|
1053
|
+
put where it was dropped: a file as a file of the project goes, a clip as it was saved */
|
|
1054
|
+
if (file.saved) {
|
|
1055
|
+
let taken: Awaited<ReturnType<typeof api.takeSaved>>;
|
|
1056
|
+
try { taken = await api.takeSaved(projectId, file.saved); } catch (e) { return e instanceof Error ? e.message : String(e); }
|
|
1057
|
+
/* a group: its tracks new ones, where it was dropped */
|
|
1058
|
+
if ('clips' in taken) return edit(groupInsertOps(taken.clips, plan.at, groupInsertAt(plan.track), editModel()), 'history.stepDrop');
|
|
1059
|
+
if ('path' in taken) {
|
|
1060
|
+
const { saved: _from, ...rest } = file;
|
|
1061
|
+
return dropAssetRef.current({ ...rest, path: taken.path }, target, atMs);
|
|
1062
|
+
}
|
|
1063
|
+
const clip = taken.clip;
|
|
1064
|
+
const durMs = file.durationMs ?? assetRunMs([file]);
|
|
1065
|
+
return edit([
|
|
1066
|
+
{ op: 'insert', clip: clip as never, at: plan.at, ...(plan.track != null ? { track: plan.track } : {}) },
|
|
1067
|
+
...roomFor([{ track: plan.track, atSec: plan.at, durMs }], target.edit),
|
|
1068
|
+
], 'history.stepDrop');
|
|
1069
|
+
}
|
|
1070
|
+
const pending = pendingDropOf(file, target, atMs);
|
|
1071
|
+
if (pending) setPendingDrops((all) => [...all, pending]);
|
|
1072
|
+
try {
|
|
1073
|
+
return await edit([
|
|
1074
|
+
{ op: 'insert', clip: plan.element as never, at: plan.at, ...(plan.track != null ? { track: plan.track } : {}) },
|
|
1075
|
+
...roomFor([{ track: plan.track, atSec: plan.at, durMs: assetRunMs([file]) }], target.edit),
|
|
1076
|
+
], 'history.stepDrop');
|
|
1077
|
+
} finally {
|
|
1078
|
+
if (pending) setPendingDrops((all) => all.filter((d) => d.id !== pending.id));
|
|
1079
|
+
}
|
|
1080
|
+
}, [timelineTracks, edit, roomFor, t, projectId, editModel]);
|
|
1081
|
+
const dropAssetRef = React.useRef(dropAsset);
|
|
1082
|
+
dropAssetRef.current = dropAsset;
|
|
1083
|
+
/**
|
|
1084
|
+
* A media file put on the timeline without a drag (lib/media-place): the media pane's "+" and Enter (in the
|
|
1085
|
+
* playhead on the targeted track, else at the end of the lowest picture track), the source viewer's
|
|
1086
|
+
* `,` and `.` (insert or overwrite the part marked, at the playhead; the playhead then goes to its end).
|
|
1087
|
+
*/
|
|
1088
|
+
const addMedia = React.useCallback(async (file: WorkspaceResource, how: { mode?: EditMode; range?: SourceRange | null; atPlayhead?: boolean } = {}) => {
|
|
1089
|
+
const target = timelineCommands.current?.target() ?? null;
|
|
1090
|
+
const at = how.atPlayhead || target != null ? snapToFrame(timeRef.current) : 'end';
|
|
1091
|
+
const plan = placeMedia(editModel(), { file, range: how.range ?? null, target, at, ...(how.mode ? { mode: how.mode } : {}), ...switchesOf(readTimelinePrefs()), stage: stageRef.current });
|
|
1092
|
+
if ('error' in plan) { toast.show(plan.error === 'locked' ? t('timeline.trackLocked') : t('timeline.dropNotClip')); return; }
|
|
1093
|
+
const failed = await edit(plan.ops, 'history.stepDrop');
|
|
1094
|
+
if (failed) { toast.show(failed); return; }
|
|
1095
|
+
if (how.atPlayhead) {
|
|
1096
|
+
const end = plan.startMs + plan.durMs;
|
|
1097
|
+
pause();
|
|
1098
|
+
if (end <= player.current.duration * 1000 + 1) seek(end);
|
|
1099
|
+
else seekWhenLonger.current = end;
|
|
1100
|
+
}
|
|
1101
|
+
}, [editModel, edit, pause, seek, t, toast]);
|
|
1102
|
+
/*
|
|
1103
|
+
* A thing of Saved put on the timeline without a drag (its "+", Enter, the source viewer's `,` and `.`): brought into
|
|
1104
|
+
* the project first, then as a file of the project goes (`how`), or a clip as it was saved, at the playhead on the
|
|
1105
|
+
* targeted track; a group on new tracks at the top, from the playhead.
|
|
1106
|
+
*/
|
|
1107
|
+
const placeSaved = React.useCallback(async (file: WorkspaceResource, how: { mode?: EditMode; range?: SourceRange | null; atPlayhead?: boolean } = {}) => {
|
|
1108
|
+
let taken: Awaited<ReturnType<typeof api.takeSaved>>;
|
|
1109
|
+
try { taken = await api.takeSaved(projectId, file.path); } catch (e) { toast.show(e instanceof Error ? e.message : String(e)); return; }
|
|
1110
|
+
if ('path' in taken) {
|
|
1111
|
+
const { bundle: _bundle, ...rest } = file;
|
|
1112
|
+
await addMedia({ ...rest, path: taken.path }, how);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
/* a group: on new tracks at the top, from the playhead */
|
|
1116
|
+
if ('clips' in taken) {
|
|
1117
|
+
const failed = await edit(groupInsertOps(taken.clips, snapToFrame(timeRef.current) / 1000, 0, editModel()), 'history.stepDrop');
|
|
1118
|
+
if (failed) toast.show(failed);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const target = timelineCommands.current?.target() ?? null;
|
|
1122
|
+
const plan = placeMedia(editModel(), { file, range: null, target, at: snapToFrame(timeRef.current), ...switchesOf(readTimelinePrefs()), stage: stageRef.current });
|
|
1123
|
+
if ('error' in plan) { toast.show(plan.error === 'locked' ? t('timeline.trackLocked') : t('timeline.dropNotClip')); return; }
|
|
1124
|
+
const clip = taken.clip;
|
|
1125
|
+
const failed = await edit(plan.ops.map((op) => (op.op === 'insert' ? { ...op, clip: clip as never } : op)), 'history.stepDrop');
|
|
1126
|
+
if (failed) toast.show(failed);
|
|
1127
|
+
}, [projectId, editModel, edit, addMedia, t, toast]);
|
|
1128
|
+
const placeFromSource = React.useCallback((mode: EditMode, range: SourceRange | null) => {
|
|
1129
|
+
if (pickedMedia && pickedSaved) void placeSaved(pickedMedia, { mode, range, atPlayhead: true });
|
|
1130
|
+
else if (pickedMedia) void addMedia(pickedMedia, { mode, range, atPlayhead: true });
|
|
1131
|
+
}, [addMedia, placeSaved, pickedMedia, pickedSaved]);
|
|
1132
|
+
/* clips (and files) kept to use again in any project (lib/saved, studio/server/saved.mjs): the timeline's, the
|
|
1133
|
+
picture's and the media pane's "Add to Saved" */
|
|
1134
|
+
const saveThings = React.useCallback(async (what: Parameters<typeof api.save>[1], n: number) => {
|
|
1135
|
+
if (!n) return;
|
|
1136
|
+
try {
|
|
1137
|
+
await api.save(projectId, what);
|
|
1138
|
+
refreshSaved();
|
|
1139
|
+
toast.show(n === 1 ? t('saved.added') : t('saved.addedMany').replace('{n}', String(n)));
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
toast.showError(t('saved.failed').replace('{error}', e instanceof Error ? e.message : String(e)));
|
|
1142
|
+
}
|
|
1143
|
+
}, [projectId, t, toast]);
|
|
1144
|
+
const saveBlocks = React.useCallback((blocks: readonly TimelineBlock[], together = false) => {
|
|
1145
|
+
const kept = blocks.filter((b) => b.clipId);
|
|
1146
|
+
/* several as one: laid out as they are, from the first one's start to the last one's end */
|
|
1147
|
+
if (together && kept.length > 1) {
|
|
1148
|
+
const startMs = Math.min(...kept.map((b) => b.startMs));
|
|
1149
|
+
const endMs = Math.max(...kept.map((b) => b.endMs));
|
|
1150
|
+
/* named and pictured by the top one that draws a picture */
|
|
1151
|
+
const trackOf = (b: TimelineBlock) => parseFilmDocLoc(b.loc ?? '')?.track ?? 0;
|
|
1152
|
+
const byTrack = [...kept].sort((a, b) => trackOf(a) - trackOf(b) || a.startMs - b.startMs);
|
|
1153
|
+
const top = byTrack.find((b) => b.kind === 'mg' || b.kind === 'video') ?? byTrack[0]!;
|
|
1154
|
+
const w = top.box?.w ?? top.size?.w;
|
|
1155
|
+
const h = top.box?.h ?? top.size?.h;
|
|
1156
|
+
const name = `${top.title}${kept.length > 1 ? ` +${kept.length - 1}` : ''}`;
|
|
1157
|
+
void saveThings({ groups: [{ ids: kept.map((b) => b.clipId!), name, durationMs: endMs - startMs, ...(w ? { w } : {}), ...(h ? { h } : {}) }] }, 1);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const clips = kept.map((b) => {
|
|
1161
|
+
const w = b.box?.w ?? b.size?.w;
|
|
1162
|
+
const h = b.box?.h ?? b.size?.h;
|
|
1163
|
+
return { id: b.clipId!, name: b.title, durationMs: Math.max(0, b.endMs - b.startMs), ...(w ? { w } : {}), ...(h ? { h } : {}) };
|
|
1164
|
+
});
|
|
1165
|
+
void saveThings({ clips }, clips.length);
|
|
1166
|
+
}, [saveThings]);
|
|
1167
|
+
const saveFiles = React.useCallback((files: WorkspaceResource[]) => {
|
|
1168
|
+
const list = files.map((f) => ({ path: f.path, name: f.name, ...(f.durationMs ? { durationMs: f.durationMs } : {}), ...(f.w ? { w: f.w } : {}), ...(f.h ? { h: f.h } : {}) }));
|
|
1169
|
+
void saveThings({ files: list }, list.length);
|
|
1170
|
+
}, [saveThings]);
|
|
1171
|
+
/* one clip, or several as one (each one alone, too) */
|
|
1172
|
+
const saveEntry = (blocks: readonly TimelineBlock[]): ContextMenuEntry[] => {
|
|
1173
|
+
const n = blocks.filter((b) => b.clipId).length;
|
|
1174
|
+
if (!n) return [];
|
|
1175
|
+
if (n === 1) return [{ id: 'save', label: t('saved.add'), icon: <BookmarkPlus size={14} />, onSelect: () => saveBlocks(blocks) }];
|
|
1176
|
+
return [
|
|
1177
|
+
{ id: 'save', label: t('saved.addTogether').replace('{n}', String(n)), icon: <BookmarkPlus size={14} />, onSelect: () => saveBlocks(blocks, true) },
|
|
1178
|
+
{ id: 'save-each', label: t('saved.addEach'), icon: <BookmarkPlus size={14} />, onSelect: () => saveBlocks(blocks) },
|
|
1179
|
+
];
|
|
1180
|
+
};
|
|
1181
|
+
/* the subtitle row (components/SubtitleRow): its edits go into the transcripts, each a step of the undo here */
|
|
1182
|
+
const subtitleEdits = useSubtitleEdits(projectId, { film: film.film, refresh: subs.refresh, record: film.record });
|
|
1183
|
+
const subtitleRow = React.useMemo<SubtitleRowSpec>(() => ({
|
|
1184
|
+
cues: subs.captions?.cues ?? [], edits: subtitleEdits,
|
|
1185
|
+
speakerAt: (ms) => speakerAt(tracksRef.current, subs.captions?.cues ?? [], ms),
|
|
1186
|
+
onPick: showSubtitleLook,
|
|
1187
|
+
}), [subs.captions, subtitleEdits, showSubtitleLook]);
|
|
1188
|
+
const relinkInputRef = React.useRef<HTMLInputElement>(null);
|
|
1189
|
+
const relinkTarget = React.useRef<TimelineBlock | null>(null);
|
|
1190
|
+
const relinkBlock = React.useCallback(async (block: TimelineBlock, path: string | null) => {
|
|
1191
|
+
if (!block.loc) return;
|
|
1192
|
+
if (path == null) { relinkTarget.current = block; relinkInputRef.current?.click(); return; }
|
|
1193
|
+
const error = await editBlocks([{ loc: block.loc, prop: 'src', value: path }]);
|
|
1194
|
+
if (error) toast.show(error);
|
|
1195
|
+
}, [editBlocks, toast]);
|
|
1196
|
+
/* a media file going to the trash takes its clips off the timeline first, in one step a person can undo */
|
|
1197
|
+
const dropClipsForAssets = React.useCallback((paths: readonly string[]): Promise<string | null> => {
|
|
1198
|
+
const doomed = blocksRef.current.filter((b) => !!b.src && paths.some((p) => srcHitsAsset(b.src!, p)));
|
|
1199
|
+
return doomed.length ? removeBlocks(doomed) : Promise.resolve(null);
|
|
1200
|
+
}, [removeBlocks]);
|
|
1201
|
+
/* markers, solo, track heights, loudness, freeze frame, replace (use-editor-tools); a replacement from this computer
|
|
1202
|
+
is picked and brought in as the media pane's Import does (pickReplacement, below) */
|
|
1203
|
+
const pickReplacementRef = React.useRef<(family: ReplaceFamily) => Promise<string | null>>(async () => null);
|
|
1204
|
+
const pickReplacement = React.useCallback((family: ReplaceFamily) => pickReplacementRef.current(family), []);
|
|
1205
|
+
const tools = useEditorTools({
|
|
1206
|
+
projectId, tracks: timelineTracks, film: film.film, settings, patchSettings, player, timeRef, seek, edit: editOps, record: film.record,
|
|
1207
|
+
editModel, selectedClipIds: selectedKeys, listing, toast, pickFile: pickReplacement,
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
/* how many of the person's edits an outside write undid: edits inside pages it left out, and clip edits it changed */
|
|
1211
|
+
const undoneCount = (dropped ?? []).reduce((n, d) => n + d.dropped.length, 0) + undone.length;
|
|
1212
|
+
/* edits left out or undone by an outside write: put them back on the clips as they are now */
|
|
1213
|
+
const restoreDropped = React.useCallback(async () => {
|
|
1214
|
+
const found = dropped;
|
|
1215
|
+
const again = undone;
|
|
1216
|
+
setDropped(null);
|
|
1217
|
+
setUndone([]);
|
|
1218
|
+
for (const e of again) settledEdits.current.add(e);
|
|
1219
|
+
if (again.length) { const failed = await film.edit([{ op: 'props', edits: again as never }], 'history.stepRestoreEdits'); if (failed) toast.show(failed); }
|
|
1220
|
+
const current = filmRef.current;
|
|
1221
|
+
if (!found?.length || !current) return;
|
|
1222
|
+
const edits: ClipEdit[] = [];
|
|
1223
|
+
for (const d of found) {
|
|
1224
|
+
const at = parseFilmDocLoc(d.loc);
|
|
1225
|
+
const clip = at ? current.tracks[at.track]?.clips[at.clip] : undefined;
|
|
1226
|
+
if (!clip || clip.src !== d.src) continue;
|
|
1227
|
+
const have = new Set(((clip.overrides ?? []) as never[]).map(overrideTarget));
|
|
1228
|
+
const back = d.dropped.filter((o) => !have.has(overrideTarget(o)));
|
|
1229
|
+
if (back.length) edits.push({ loc: d.loc, prop: 'overrides', value: [...(clip.overrides ?? []), ...back] as unknown as Record<string, unknown> });
|
|
1230
|
+
}
|
|
1231
|
+
if (edits.length) { const failed = await editBlocks(edits); if (failed) toast.show(failed); }
|
|
1232
|
+
}, [dropped, undone, film, editBlocks, toast]);
|
|
1233
|
+
|
|
1234
|
+
/* ── the picture: what is selected in it, and its edits (film.html `overrides` on the page clip, `box` on a clip) ── */
|
|
1235
|
+
const clipPreviewRef = React.useRef<((clipLoc: string, patch: Partial<MgTransform> | null) => void) | null>(null);
|
|
1236
|
+
const lookPreviewRef = React.useRef<((clipLoc: string, css: string | null) => void) | null>(null);
|
|
1237
|
+
/* where the thing in hand is, for the inspector only: it changes every frame of a drag */
|
|
1238
|
+
const geometryStore = React.useMemo(() => createGeometryStore(), []);
|
|
1239
|
+
const [stageSelectRequest, setStageSelectRequest] = React.useState<{ loc: string; clipLoc?: string; seq: number } | null>(null);
|
|
1240
|
+
const [inlineTextFailure, setInlineTextFailure] = React.useState<{ loc: string; value: string; failure: StageEditFailure; seq: number } | null>(null);
|
|
1241
|
+
const setSelectedLocs = React.useCallback((locs: readonly string[]) => {
|
|
1242
|
+
const want = new Set(locs);
|
|
1243
|
+
setSelectedKeys(blocksRef.current.filter((b) => b.loc && want.has(b.loc)).map((b) => b.clipId ?? b.id));
|
|
1244
|
+
}, []);
|
|
1245
|
+
/* the picture and the timeline share one selection of clips; a layer picked in the picture drops the timeline's */
|
|
1246
|
+
const selectStageElement = React.useCallback((element: StageElement | null) => {
|
|
1247
|
+
setStageElement(element);
|
|
1248
|
+
if (element) setInspectorContent('selection');
|
|
1249
|
+
if (element && element.kind !== 'clip') { setInspectorOpen(true); setSelectedLocs([]); return; }
|
|
1250
|
+
setSelectedLocs(element?.clipLoc ? [element.clipLoc] : []);
|
|
1251
|
+
}, [setSelectedLocs]);
|
|
1252
|
+
|
|
1253
|
+
/* ── the app Studio is embedded in (lib/host.ts): what the person points at, handed to it; what it asks to be shown ── */
|
|
1254
|
+
const hostPanel = useHostPanel();
|
|
1255
|
+
/* an app that hosts Studio can keep the media in a drawer of its own panel (window.openfilmHost panel.assets): no
|
|
1256
|
+
left pane then, the viewer first on the left */
|
|
1257
|
+
const hostAssets = hostPanel?.assets ?? null;
|
|
1258
|
+
const assetsShown = !hostAssets && assetsPaneOpen && assetsFit;
|
|
1259
|
+
const layersRoom = rowWidth - (inspectorShown ? inspectorShownWidth : 0) - (assetsShown ? assetsShownWidth : 0) - MIN_VIEWER - 16;
|
|
1260
|
+
const layersShownWidth = rowWidth ? Math.min(layersWidth, layersRoom) : layersWidth;
|
|
1261
|
+
const layersFit = !rowWidth || layersShownWidth >= 180;
|
|
1262
|
+
const layersShown = layersPaneOpen && layersFit;
|
|
1263
|
+
/* the media pane shows the project's media, or Saved (lib/saved): the same pane, the button beside Upload switches */
|
|
1264
|
+
const [mediaScope, setMediaScope] = React.useState<'project' | 'saved'>(() => MEDIA_SCOPE.current);
|
|
1265
|
+
const switchMedia = React.useCallback((next: 'project' | 'saved') => { MEDIA_SCOPE.current = next; setMediaScope(next); }, []);
|
|
1266
|
+
const savedListing = useSavedListing(mediaScope === 'saved');
|
|
1267
|
+
const savedImport = React.useCallback((files: File[], into: string, onProgress: ImportProgress) => {
|
|
1268
|
+
/* in the app a dropped file's path is known: copied on this computer, not sent through the page */
|
|
1269
|
+
const paths = files.map((file) => studioHost?.folders?.pathOf(file) ?? '');
|
|
1270
|
+
if (paths.length && paths.every(Boolean)) return savedActions.bring(paths, into);
|
|
1271
|
+
return savedActions.upload(files, into, onProgress, t('assets.uploadFailed'));
|
|
1272
|
+
}, [t]);
|
|
1273
|
+
const mediaPool = () => (mediaScope === 'saved' ? (
|
|
1274
|
+
<MediaPool
|
|
1275
|
+
key="saved"
|
|
1276
|
+
projectId={SAVED}
|
|
1277
|
+
listing={savedListing}
|
|
1278
|
+
sources={savedSources}
|
|
1279
|
+
selectedPath={pickedSaved ? pickedMedia?.path ?? null : null}
|
|
1280
|
+
onSelectFile={(file) => { pickMedia(file); setPickedSaved(Boolean(file)); }}
|
|
1281
|
+
onImport={savedImport}
|
|
1282
|
+
onMove={savedActions.move}
|
|
1283
|
+
onDelete={savedActions.remove}
|
|
1284
|
+
onNewFolder={savedActions.newFolder}
|
|
1285
|
+
onReveal={savedActions.reveal}
|
|
1286
|
+
onPick={onPick}
|
|
1287
|
+
onBring={(paths, into) => savedActions.bring(paths, into)}
|
|
1288
|
+
links={false}
|
|
1289
|
+
scope="saved"
|
|
1290
|
+
onScope={switchMedia}
|
|
1291
|
+
onAdd={(file) => void placeSaved(file)}
|
|
1292
|
+
/>
|
|
1293
|
+
) : (
|
|
1294
|
+
<MediaPool
|
|
1295
|
+
key="project"
|
|
1296
|
+
projectId={projectId}
|
|
1297
|
+
listing={listing}
|
|
1298
|
+
sources={mediaSources}
|
|
1299
|
+
usedSrcs={usedSrcs}
|
|
1300
|
+
selectedPath={pickedSaved ? null : pickedMedia?.path ?? null}
|
|
1301
|
+
onSelectFile={pickMedia}
|
|
1302
|
+
onImport={onImport}
|
|
1303
|
+
onMove={onMove}
|
|
1304
|
+
onMoveCheck={onMoveCheck}
|
|
1305
|
+
onDelete={onDelete}
|
|
1306
|
+
onExtractAudio={onExtractAudio}
|
|
1307
|
+
onSetCover={onSetCover}
|
|
1308
|
+
onNewFolder={onNewFolder}
|
|
1309
|
+
onDropClips={dropClipsForAssets}
|
|
1310
|
+
onReveal={reveal}
|
|
1311
|
+
onPick={onPick}
|
|
1312
|
+
onBring={onBring}
|
|
1313
|
+
onRelink={onRelink}
|
|
1314
|
+
onCollect={onCollect}
|
|
1315
|
+
onSave={saveFiles}
|
|
1316
|
+
scope="project"
|
|
1317
|
+
onScope={switchMedia}
|
|
1318
|
+
{...(studioHost?.refer ? { fileRef: refOfFile, onRefer: studioHost.refer } : {})}
|
|
1319
|
+
reveal={revealMedia}
|
|
1320
|
+
litPath={chatPoint?.file ?? null}
|
|
1321
|
+
onAdd={(file) => void addMedia(file)}
|
|
1322
|
+
/>
|
|
1323
|
+
));
|
|
1324
|
+
/* the range marked on the timeline (⇧-drag on the ruler, I and O): Studio's, told to the app with the selection */
|
|
1325
|
+
const [markedRange, setMarkedRange] = React.useState<FilmRange | null>(null);
|
|
1326
|
+
rangeNowRef.current = markedRange;
|
|
1327
|
+
/* the film's stage, for boxes in stage px (film.html's, set below with the picture) */
|
|
1328
|
+
const stageRef = React.useRef({ w: 1920, h: 1080 });
|
|
1329
|
+
/* a clip's reference shows its own frame, asked by its file's version: set with the thumbnails below, read through a
|
|
1330
|
+
ref (the menus' callbacks outlive the render they were made in, and the versions come later) */
|
|
1331
|
+
const refSource = React.useRef<ClipFrameSource>({ projectId });
|
|
1332
|
+
const refOfBlock = (b: TimelineBlock) => clipRef(b, refSource.current);
|
|
1333
|
+
/* a layer where it is drawn now: the picture reports the thing in hand (geometryStore) once it has measured it */
|
|
1334
|
+
const refOfLayer = (el: StageElement): StudioRef => {
|
|
1335
|
+
const g = geometryStore.get();
|
|
1336
|
+
const box: StageBox | null = g?.kind === 'layer' && g.loc === el.loc ? { x: g.x, y: g.y, w: g.w, h: g.h } : null;
|
|
1337
|
+
return layerRef(el, { projectId: refSource.current.projectId, ms: timeRef.current, box, stage: stageRef.current });
|
|
1338
|
+
};
|
|
1339
|
+
const layerPicked = (el: StageElement | null): el is StageElement => Boolean(el && el.kind !== 'clip' && !el.isMgOuter);
|
|
1340
|
+
/*
|
|
1341
|
+
* The subtitle line being worked on in the subtitle panel (focused there, or words selected in it): picked as a clip
|
|
1342
|
+
* is, it comes along with the selection until another line, a clip or a layer is picked, or the panel closes.
|
|
1343
|
+
*/
|
|
1344
|
+
const [subtitlePick, setSubtitlePick] = React.useState<SubtitleLineAt | null>(null);
|
|
1345
|
+
const subtitlesShown = inspectorShown && inspectorContent === 'subtitles';
|
|
1346
|
+
React.useEffect(() => { if (!subtitlesShown) setSubtitlePick(null); }, [subtitlesShown]);
|
|
1347
|
+
React.useEffect(() => { if (selectedBlocks.length || layerPicked(stageElement)) setSubtitlePick(null); }, [selectedBlocks, stageElement]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1348
|
+
const refOfSubtitle = (line: SubtitleLineAt) => subtitleRef(projectId, line.cue, { index: line.index, lang: line.language, text: line.text, pick: line.pick });
|
|
1349
|
+
/* the line picked, while the film still has it (a line emptied is gone) */
|
|
1350
|
+
const pickedSubtitleRef = (): StudioRef | null => {
|
|
1351
|
+
const lines = subs.captions?.cues ?? [];
|
|
1352
|
+
return subtitlePick && lines.some((c) => subtitleLineKey(c) === subtitleLineKey(subtitlePick.cue)) ? refOfSubtitle(subtitlePick) : null;
|
|
1353
|
+
};
|
|
1354
|
+
/* with the subtitle panel open and nothing picked, ⌘L takes the line at the playhead, in the language it shows */
|
|
1355
|
+
const subtitleNowRef = (): StudioRef | null => {
|
|
1356
|
+
const lines = subs.captions?.cues ?? [];
|
|
1357
|
+
const cue = subtitlesShown ? cueAt(lines, timeRef.current) : null;
|
|
1358
|
+
if (!cue) return null;
|
|
1359
|
+
const lang = subs.style.language && !sameFilmSubtitleLanguage(subs.style.language, subs.sourceLanguage) ? subs.style.language : null;
|
|
1360
|
+
const alt = lang ? cue.alt?.[lang] : undefined;
|
|
1361
|
+
return refOfSubtitle({ cue, index: lines.indexOf(cue) + 1, language: alt ? lang : null, text: alt ?? cue.text, pick: null });
|
|
1362
|
+
};
|
|
1363
|
+
const subtitleRefer = React.useMemo<SubtitleLineRefer | undefined>(() => {
|
|
1364
|
+
const refer = studioHost?.refer;
|
|
1365
|
+
if (!refer) return undefined;
|
|
1366
|
+
const same = (a: SubtitleLineAt | null, b: SubtitleLineAt) => !!a && a.cue === b.cue && a.language === b.language && a.text === b.text
|
|
1367
|
+
&& a.pick?.from === b.pick?.from && a.pick?.to === b.pick?.to;
|
|
1368
|
+
return {
|
|
1369
|
+
of: refOfSubtitle,
|
|
1370
|
+
send: refer,
|
|
1371
|
+
pick: (line) => setSubtitlePick((cur) => (same(cur, line) ? cur : line)),
|
|
1372
|
+
shortcut: keyHint('refer') ?? '',
|
|
1373
|
+
};
|
|
1374
|
+
}, [projectId, keyHint]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1375
|
+
/** A thing in hand on the picture as a reference: a whole clip as its block is, a layer where it is drawn. */
|
|
1376
|
+
const refOfStagePick = (pick: StagePick): StudioRef | null => {
|
|
1377
|
+
const el = pick.element;
|
|
1378
|
+
if (el.kind === 'clip' || el.isMgOuter) {
|
|
1379
|
+
const block = el.clipLoc ? blocksRef.current.find((b) => b.loc === el.clipLoc) : undefined;
|
|
1380
|
+
return block ? refOfBlock(block) : null;
|
|
1381
|
+
}
|
|
1382
|
+
return layerRef(el, { projectId: refSource.current.projectId, ms: timeRef.current, box: pick.box, stage: stageRef.current });
|
|
1383
|
+
};
|
|
1384
|
+
/**
|
|
1385
|
+
* What ⌘L references (and the selection bar's "Add to chat"): the subtitle line picked, else the layer picked in the
|
|
1386
|
+
* picture (each of several), else each clip selected and the range, else the line at the playhead (the subtitle
|
|
1387
|
+
* panel open), else the moment.
|
|
1388
|
+
*/
|
|
1389
|
+
const selectedRefs = (): StudioRef[] => {
|
|
1390
|
+
const line = pickedSubtitleRef();
|
|
1391
|
+
if (line) return [line];
|
|
1392
|
+
const el = stageElementRef.current;
|
|
1393
|
+
if (layerPicked(el)) return [refOfLayer(el)];
|
|
1394
|
+
const layers = stageControl.current?.picked() ?? [];
|
|
1395
|
+
if (layers.length > 1) return layers.flatMap((p) => refOfStagePick(p) ?? []);
|
|
1396
|
+
const refs = selectedBlocks.map(refOfBlock);
|
|
1397
|
+
if (markedRange) refs.push(rangeRef(projectId, markedRange, blocksRef.current));
|
|
1398
|
+
if (refs.length) return refs;
|
|
1399
|
+
return [subtitleNowRef() ?? momentRef(projectId, timeRef.current)];
|
|
1400
|
+
};
|
|
1401
|
+
const hostSelection = studioHost?.onSelection;
|
|
1402
|
+
React.useEffect(() => {
|
|
1403
|
+
if (!hostSelection) return undefined;
|
|
1404
|
+
const timer = setTimeout(() => {
|
|
1405
|
+
const refs: StudioRef[] = selectedBlocks.map(refOfBlock);
|
|
1406
|
+
if (layerPicked(stageElement)) refs.push(refOfLayer(stageElement));
|
|
1407
|
+
const line = pickedSubtitleRef();
|
|
1408
|
+
if (line) refs.push(line);
|
|
1409
|
+
hostSelection({ projectId, time: Math.round(timeMs) / 1000, refs, range: markedRange ? { start: Math.round(markedRange.startMs) / 1000, end: Math.round(markedRange.endMs) / 1000 } : null, stage: stageRef.current });
|
|
1410
|
+
}, 80);
|
|
1411
|
+
return () => clearTimeout(timer);
|
|
1412
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1413
|
+
}, [hostSelection, projectId, timeMs, selectedBlocks, stageElement, markedRange, subtitlePick, subs.captions]);
|
|
1414
|
+
/* a box the app shows on the picture (show-box), flashed there */
|
|
1415
|
+
const [flashBox, setFlashBox] = React.useState<{ box: StageBox; seq: number } | null>(null);
|
|
1416
|
+
/* a file the app shows in the media pane (show-file): its folder opened, the tile in view */
|
|
1417
|
+
const [revealMedia, setRevealMedia] = React.useState<{ path: string; seq: number } | null>(null);
|
|
1418
|
+
/* what the app's chat points at (lib/chat-marks): the pill hovered there, the references of the message being written */
|
|
1419
|
+
const [chatRef, setChatRef] = React.useState<StudioRef | null>(null);
|
|
1420
|
+
const [draftMarks, setDraftMarks] = React.useState<DraftMarks>(NO_DRAFT);
|
|
1421
|
+
const hostCommands = React.useRef<(command: StudioCommand) => void>(() => {});
|
|
1422
|
+
hostCommands.current = (command) => {
|
|
1423
|
+
if (command.type === 'highlight') { setChatRef(command.ref ?? null); return; }
|
|
1424
|
+
if (command.type === 'draft-refs') { setDraftMarks(draftMarksOf(Array.isArray(command.refs) ? command.refs : [])); return; }
|
|
1425
|
+
if (command.type === 'settings') { setSettingsOpen(command.section ?? 'agents'); return; }
|
|
1426
|
+
if (command.type === 'show-examples') { setExamplesOpen(command.open ?? true); return; }
|
|
1427
|
+
if (command.type === 'seek' && Number.isFinite(command.time)) { pause(); seek(command.time * 1000); return; }
|
|
1428
|
+
if ((command.type === 'show-range' || command.type === 'show-subtitle') && Number.isFinite(command.start) && Number.isFinite(command.end)) {
|
|
1429
|
+
/* a subtitle's span marked, and the subtitle panel open: the playhead at its start lights the line there */
|
|
1430
|
+
if (command.type === 'show-subtitle') { setInspectorContent('subtitles'); setInspectorOpen(true); }
|
|
1431
|
+
const startMs = Math.max(0, Math.min(command.start, command.end) * 1000);
|
|
1432
|
+
const endMs = Math.max(command.start, command.end) * 1000;
|
|
1433
|
+
if (endMs > startMs) setMarkedRange({ startMs, endMs });
|
|
1434
|
+
pause();
|
|
1435
|
+
seek(startMs);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
if (command.type === 'show-box' && Number.isFinite(command.time) && command.box) {
|
|
1439
|
+
pause();
|
|
1440
|
+
seek(command.time * 1000);
|
|
1441
|
+
setFlashBox((cur) => ({ box: command.box, seq: (cur?.seq ?? 0) + 1 }));
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (command.type === 'show-track') {
|
|
1445
|
+
/* the track's clips selected: the timeline frames them, the inspector shows them */
|
|
1446
|
+
const keys = blocksRef.current.filter((b) => b.loc && parseFilmDocLoc(b.loc)?.track === command.track).map((b) => b.clipId ?? b.id);
|
|
1447
|
+
if (!keys.length) return;
|
|
1448
|
+
setStageElement(null);
|
|
1449
|
+
setSelectedKeys(keys);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (command.type === 'show-file') {
|
|
1453
|
+
const file = listing ? [...listing.pages, ...listing.files].find((f) => f.path === command.path) : undefined;
|
|
1454
|
+
if (!file) return;
|
|
1455
|
+
if (hostAssets) hostAssets.setOpen(true);
|
|
1456
|
+
else setAssetsPaneOpen(true);
|
|
1457
|
+
pickMedia(file);
|
|
1458
|
+
setRevealMedia((cur) => ({ path: file.path, seq: (cur?.seq ?? 0) + 1 }));
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (command.type !== 'select-clip') return;
|
|
1462
|
+
const block = blocksRef.current.find((b) => (command.id && b.clipId === command.id) || (command.loc && b.loc === command.loc));
|
|
1463
|
+
if (!block) return;
|
|
1464
|
+
setStageElement(null);
|
|
1465
|
+
setSelectedKeys([block.clipId ?? block.id]);
|
|
1466
|
+
if (command.seek) { pause(); seek(block.startMs); }
|
|
1467
|
+
};
|
|
1468
|
+
React.useEffect(() => { studioHost?.onCommand?.((command) => hostCommands.current(command)); }, []);
|
|
1469
|
+
const chatPoint = React.useMemo(() => chatPointOf(chatRef), [chatRef]);
|
|
1470
|
+
const timelineChat = React.useMemo(() => ({
|
|
1471
|
+
clip: chatPoint?.clip ?? null, track: chatPoint?.track ?? null, span: chatPoint?.span ?? null, draft: draftMarks,
|
|
1472
|
+
}), [chatPoint, draftMarks]);
|
|
1473
|
+
/* on the picture: the hovered clip's box while it shows at the playhead, a hovered layer's or region's box and the
|
|
1474
|
+
message's numbered ones when the picture is of their moment */
|
|
1475
|
+
const chatOnPicture = ((): PictureMarks => {
|
|
1476
|
+
const hovered = chatRef?.kind === 'clip' && chatPoint?.clip
|
|
1477
|
+
? blocksRef.current.find((b) => sameClip(chatPoint.clip!, { clipId: b.clipId, loc: b.loc }))
|
|
1478
|
+
: undefined;
|
|
1479
|
+
const showing = hovered?.loc && (hovered.kind === 'mg' || hovered.kind === 'video') && timeMs >= hovered.startMs && timeMs < hovered.endMs;
|
|
1480
|
+
return { clipLoc: showing ? hovered.loc! : null, box: boxNow(chatPoint?.box, timeMs), numbered: draftBoxesAt(draftMarks, timeMs) };
|
|
1481
|
+
})();
|
|
1482
|
+
/* what the pointer is over, told to the app (its chat lights the pills that point at it) */
|
|
1483
|
+
const hostHover = studioHost?.onHover;
|
|
1484
|
+
const hoverBlock = React.useCallback((b: TimelineBlock | null) => hostHover?.(b ? refOfBlock(b) : null), [hostHover]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1485
|
+
const hoverLayer = React.useCallback((hit: { element: StageElement; box: StageBox } | null) => {
|
|
1486
|
+
hostHover?.(hit ? layerRef(hit.element, { projectId: refSource.current.projectId, ms: timeRef.current, box: hit.box, stage: stageRef.current }) : null);
|
|
1487
|
+
}, [hostHover]);
|
|
1488
|
+
React.useEffect(() => () => hostHover?.(null), [hostHover]);
|
|
1489
|
+
|
|
1490
|
+
const editStageOverride = React.useCallback(async (
|
|
1491
|
+
element: StageElement, rawPatch: OverrideGeometry | null, allCopies = false, pivot?: OverridePivot,
|
|
1492
|
+
): Promise<string | null> => {
|
|
1493
|
+
const patch = rawPatch && pivot && stageControl.current ? await stageControl.current.previewOverride(element, rawPatch, pivot) : rawPatch;
|
|
1494
|
+
const doc = filmRef.current as unknown as FilmDoc | null;
|
|
1495
|
+
const found = doc ? pageClipOf(doc, element) : null;
|
|
1496
|
+
const target = layerTarget(element, allCopies);
|
|
1497
|
+
if (!found || !target) return t('timeline.editFail');
|
|
1498
|
+
let next = nextOverrides(found.clip.overrides, target, patch);
|
|
1499
|
+
/* the blocks written inside a line keep their place and look: their overrides go with it, one edit */
|
|
1500
|
+
for (const r of await stageControl.current?.riders(element, patch) ?? []) {
|
|
1501
|
+
next = nextOverrides(next ?? undefined, { at: r.at, ...(r.n != null ? { n: r.n } : {}) }, r.patch);
|
|
1502
|
+
}
|
|
1503
|
+
/* the panel shows the new numbers at once; refused, they go back */
|
|
1504
|
+
const now = next ? overrideOf(next, target) : null;
|
|
1505
|
+
const before = element.override ?? null;
|
|
1506
|
+
setStageElement((el) => (el?.loc === element.loc ? { ...el, override: now ? {
|
|
1507
|
+
...(now.t ? { t: now.t } : {}), ...(now.s != null ? { s: now.s } : {}), ...(now.r != null ? { r: now.r } : {}),
|
|
1508
|
+
...(now.style ? { style: now.style } : {}), ...(now.lock ? { lock: true } : {}), ...(now.text != null ? { text: now.text } : {}),
|
|
1509
|
+
} as StageElement['override'] : null } : el));
|
|
1510
|
+
const failed = await editBlocks([{ loc: found.loc, prop: 'overrides', value: next as unknown as Record<string, unknown> | null }]);
|
|
1511
|
+
if (failed) {
|
|
1512
|
+
setStageElement((el) => (el?.loc === element.loc ? { ...el, override: before } : el));
|
|
1513
|
+
stageControl.current?.release(element);
|
|
1514
|
+
}
|
|
1515
|
+
/* a style taken away: the panel reads the page's own value again, once the picture has it */
|
|
1516
|
+
if (!failed && (patch === null || patch.style)) {
|
|
1517
|
+
window.setTimeout(() => {
|
|
1518
|
+
void stageControl.current?.describe(element).then((fresh) => {
|
|
1519
|
+
if (fresh) setStageElement((el) => (el?.loc === element.loc ? { ...el, style: fresh.style, ...(fresh.picture ? { picture: fresh.picture } : {}) } : el));
|
|
1520
|
+
});
|
|
1521
|
+
}, 120);
|
|
1522
|
+
}
|
|
1523
|
+
return failed;
|
|
1524
|
+
}, [editBlocks, t]);
|
|
1525
|
+
const editStageOverrideRef = React.useRef(editStageOverride);
|
|
1526
|
+
editStageOverrideRef.current = editStageOverride;
|
|
1527
|
+
/* several layers at once (moved, hidden, aligned together): one write, one undo step */
|
|
1528
|
+
const editStageOverrides = React.useCallback(async (list: ReadonlyArray<{ element: StageElement; patch: OverrideGeometry | null }>): Promise<string | null> => {
|
|
1529
|
+
const doc = filmRef.current as unknown as FilmDoc | null;
|
|
1530
|
+
if (!doc || !list.length) return t('timeline.editFail');
|
|
1531
|
+
const byClip = new Map<string, ReturnType<typeof nextOverrides>>();
|
|
1532
|
+
for (const { element, patch } of list) {
|
|
1533
|
+
const found = pageClipOf(doc, element);
|
|
1534
|
+
const target = layerTarget(element);
|
|
1535
|
+
if (!found || !target) continue;
|
|
1536
|
+
const prev = byClip.has(found.loc) ? byClip.get(found.loc) ?? undefined : found.clip.overrides;
|
|
1537
|
+
let next = nextOverrides(prev, target, patch);
|
|
1538
|
+
for (const r of await stageControl.current?.riders(element, patch) ?? []) {
|
|
1539
|
+
next = nextOverrides(next ?? undefined, { at: r.at, ...(r.n != null ? { n: r.n } : {}) }, r.patch);
|
|
1540
|
+
}
|
|
1541
|
+
byClip.set(found.loc, next);
|
|
1542
|
+
}
|
|
1543
|
+
if (!byClip.size) return t('timeline.editFail');
|
|
1544
|
+
return editBlocks([...byClip].map(([loc, value]) => ({ loc, prop: 'overrides', value: value as unknown as Record<string, unknown> | null })));
|
|
1545
|
+
}, [editBlocks, t]);
|
|
1546
|
+
const editStageStyle = React.useCallback(async (loc: string, patch: StageElementStyle): Promise<string | null> => {
|
|
1547
|
+
const held = stageElementRef.current;
|
|
1548
|
+
if (!held?.clipLoc) return t('timeline.editFail');
|
|
1549
|
+
const clean: Record<string, string | number | null> = {};
|
|
1550
|
+
for (const [key, v] of Object.entries(patch)) clean[key] = v ?? null;
|
|
1551
|
+
/* kept with what holds the other layers where they are (a wider font would push the words beside it on) */
|
|
1552
|
+
const hold = await stageControl.current?.holdStyle(held, clean) ?? {};
|
|
1553
|
+
const message = await editStageOverrideRef.current(held, { style: { ...clean, ...hold } } as OverrideGeometry);
|
|
1554
|
+
if (!message) setStageElement((cur) => (cur && (cur.loc === loc || cur.loc?.split('#')[0] === loc) ? { ...cur, style: { ...cur.style, ...patch } } : cur));
|
|
1555
|
+
return message;
|
|
1556
|
+
}, [t]);
|
|
1557
|
+
/* "All like it": the style for every layer of its tag and classes, in each page clip that may have one
|
|
1558
|
+
(lib/similar-layers), read from the pages' own files */
|
|
1559
|
+
const similarClips = React.useCallback(async (element: StageElement): Promise<{ doc: FilmDoc; holds: Set<string> } | null> => {
|
|
1560
|
+
const doc = filmRef.current as unknown as FilmDoc | null;
|
|
1561
|
+
if (!doc || !element.similar) return null;
|
|
1562
|
+
const srcs = [...new Set(pageClips(doc).map((c) => c.src))];
|
|
1563
|
+
const sources = await Promise.all(srcs.map((src) => fetch(`${folder}${src.split('/').map(encodeURIComponent).join('/')}`, { cache: 'no-store' })
|
|
1564
|
+
.then((r) => (r.ok ? r.text() : ''), () => '')));
|
|
1565
|
+
return { doc, holds: new Set(srcs.filter((_, i) => pageMayHave(sources[i]!, element.similar!))) };
|
|
1566
|
+
}, [folder]);
|
|
1567
|
+
const countSimilar = React.useCallback(async (element: StageElement): Promise<number> => {
|
|
1568
|
+
const found = await similarClips(element);
|
|
1569
|
+
const own = found ? pageClipOf(found.doc, element)?.loc : undefined;
|
|
1570
|
+
return found ? pageClips(found.doc).filter((c) => c.loc === own || found.holds.has(c.src)).length : 0;
|
|
1571
|
+
}, [similarClips]);
|
|
1572
|
+
const editStageStyleSimilar = React.useCallback(async (element: StageElement, patch: StageElementStyle): Promise<string | null> => {
|
|
1573
|
+
const found = await similarClips(element);
|
|
1574
|
+
const own = found ? pageClipOf(found.doc, element) : null;
|
|
1575
|
+
if (!found || !own || !element.similar) return t('timeline.editFail');
|
|
1576
|
+
const clean: Record<string, string | number | null> = {};
|
|
1577
|
+
for (const [key, v] of Object.entries(patch)) clean[key] = v ?? null;
|
|
1578
|
+
const edits = similarEdits(found.doc, element.similar, clean, (src) => found.holds.has(src), own.loc);
|
|
1579
|
+
const message = await editBlocks(edits.map((e) => ({ loc: e.loc, prop: 'overrides', value: e.value as unknown as Record<string, unknown> | null })));
|
|
1580
|
+
if (!message) setStageElement((cur) => (cur && cur.loc === element.loc ? { ...cur, style: { ...cur.style, ...patch } } : cur));
|
|
1581
|
+
return message;
|
|
1582
|
+
}, [similarClips, editBlocks, t]);
|
|
1583
|
+
const previewStageStyle = React.useCallback((_loc: string, patch: StageElementStyle) => {
|
|
1584
|
+
const held = stageElementRef.current;
|
|
1585
|
+
if (held) stageControl.current?.previewStyle(held, patch);
|
|
1586
|
+
}, []);
|
|
1587
|
+
const previewStageText = React.useCallback((_loc: string, v: string) => {
|
|
1588
|
+
const held = stageElementRef.current;
|
|
1589
|
+
if (held) stageControl.current?.previewText(held, v);
|
|
1590
|
+
}, []);
|
|
1591
|
+
/* `on`: the element typed in, when it is known (typing in place); the inspector edits the one it shows. A double-click
|
|
1592
|
+
selects and types at once, so the element held may still be the one before: written to that, the edit failed */
|
|
1593
|
+
/* kept with what holds the other layers where they are (more words would push those beside them on): `hold`, worked
|
|
1594
|
+
out as the words were typed in place, else asked for now */
|
|
1595
|
+
const editStageText = React.useCallback(async (e: StageTextEdit, on?: StageElement, typedHold?: Record<string, string | null>): Promise<StageEditFailure | null> => {
|
|
1596
|
+
const held = on ?? stageElementRef.current;
|
|
1597
|
+
if (!held?.clipLoc) return { error: t('timeline.editFail') };
|
|
1598
|
+
const hold = typedHold ?? await stageControl.current?.holdStyle(held, {}, e.value) ?? {};
|
|
1599
|
+
stageControl.current?.previewText(held, e.value);
|
|
1600
|
+
const failed = await editStageOverrideRef.current(held, { text: e.value, ...(Object.keys(hold).length ? { style: hold } : {}) } as OverrideGeometry);
|
|
1601
|
+
if (failed) { stageControl.current?.previewText(held, e.expect); return { error: failed }; }
|
|
1602
|
+
setStageElement((el) => (el?.text?.loc === e.loc ? { ...el, text: { ...el.text, value: e.value }, label: e.value.slice(0, 40) } : el));
|
|
1603
|
+
return null;
|
|
1604
|
+
}, [t]);
|
|
1605
|
+
const previewStageOverride = React.useCallback((_loc: string, g: NodeOverride, pivot?: OverridePivot) => {
|
|
1606
|
+
const held = stageElementRef.current;
|
|
1607
|
+
if (held) void stageControl.current?.previewOverride(held, g as OverrideGeometry, pivot);
|
|
1608
|
+
}, []);
|
|
1609
|
+
const transformStageLayer = React.useCallback(
|
|
1610
|
+
(element: StageElement, g: LayerGeometry) => editStageOverride(element, { t: g.t, s: compactScale(g.s[0], g.s[1]), r: g.r }),
|
|
1611
|
+
[editStageOverride],
|
|
1612
|
+
);
|
|
1613
|
+
const transformStageLayers = React.useCallback((list: ReadonlyArray<{ element: StageElement; g: LayerGeometry }>) => {
|
|
1614
|
+
void editStageOverrides(list.map(({ element, g }) => ({ element, patch: { t: g.t, s: compactScale(g.s[0], g.s[1]), r: g.r } })))
|
|
1615
|
+
.then((failed) => { if (failed) toast.show(failed); });
|
|
1616
|
+
}, [editStageOverrides, toast]);
|
|
1617
|
+
const editStageTextInline = React.useCallback(async (element: StageElement, v: string, hold?: Record<string, string | null>) => {
|
|
1618
|
+
const text = element.text;
|
|
1619
|
+
if (!text) return t('timeline.editFail');
|
|
1620
|
+
const failed = await editStageText({ loc: text.loc, value: v, expect: text.value }, element, hold ?? {});
|
|
1621
|
+
if (failed) {
|
|
1622
|
+
setInlineTextFailure((cur) => ({ loc: text.loc, value: v, failure: failed, seq: (cur?.seq ?? 0) + 1 }));
|
|
1623
|
+
return failed.error;
|
|
1624
|
+
}
|
|
1625
|
+
setInlineTextFailure(null);
|
|
1626
|
+
setStageElement((el) => (el?.loc === element.loc && el.text ? { ...el, text: { ...el.text, value: v } } : el));
|
|
1627
|
+
return null;
|
|
1628
|
+
}, [editStageText, t]);
|
|
1629
|
+
const editClipsFromStage = React.useCallback(async (edits: StageClipEdit[]) => {
|
|
1630
|
+
const failed = await editBlocks(edits as unknown as ClipEdit[]);
|
|
1631
|
+
if (failed) toast.show(failed);
|
|
1632
|
+
return failed;
|
|
1633
|
+
}, [editBlocks, toast]);
|
|
1634
|
+
const selectStageParent = React.useCallback((loc: string, clipLoc?: string) => {
|
|
1635
|
+
setStageSelectRequest((cur) => ({ loc, ...(clipLoc ? { clipLoc } : {}), seq: (cur?.seq ?? 0) + 1 }));
|
|
1636
|
+
}, []);
|
|
1637
|
+
const selectStageClip = React.useCallback((clipLoc: string) => { setStageElement(null); setSelectedLocs([clipLoc]); }, [setSelectedLocs]);
|
|
1638
|
+
/* a marquee on the picture around several clips: the timeline selects them */
|
|
1639
|
+
const selectStageClips = React.useCallback((locs: readonly string[]) => { setStageElement(null); setSelectedLocs([...locs]); }, [setSelectedLocs]);
|
|
1640
|
+
/* ── the Layers panel: its rows select on the picture, outline there, hide, lock and restack (overrides) ── */
|
|
1641
|
+
const [pointedLayer, setPointedLayer] = React.useState<{ clipId: string; loc: string } | null>(null);
|
|
1642
|
+
const askStage = React.useCallback(<T,>(op: string, args: object = {}): Promise<T | null> => (
|
|
1643
|
+
preview.current?.stage?.<T>(op, args) ?? Promise.resolve(null)
|
|
1644
|
+
), []);
|
|
1645
|
+
const layerClipOf = React.useCallback((clipId: string) => {
|
|
1646
|
+
const b = blocksRef.current.find((x) => x.clipId === clipId);
|
|
1647
|
+
return b ? { label: b.title, kind: b.kind, ...(b.loc ? { loc: b.loc } : {}) } : null;
|
|
1648
|
+
}, []);
|
|
1649
|
+
const selectLayerRow = React.useCallback((clipId: string, loc: string) => {
|
|
1650
|
+
const clipLoc = blocksRef.current.find((b) => b.clipId === clipId)?.loc;
|
|
1651
|
+
if (clipLoc) selectStageParent(loc, clipLoc);
|
|
1652
|
+
}, [selectStageParent]);
|
|
1653
|
+
const layerElement = (clipId: string, at: string, n: number | undefined, label: string): StageElement => (
|
|
1654
|
+
{ kind: 'box', loc: n ? `${at}#${n}` : at, label, clipId, ...(n ? { instance: n } : {}) }
|
|
1655
|
+
);
|
|
1656
|
+
const toggleLayerRow = React.useCallback((clipId: string, row: LayerRow, what: 'hidden' | 'locked') => {
|
|
1657
|
+
const at = row.instance ? row.loc.slice(0, -`#${row.instance}`.length) : row.loc;
|
|
1658
|
+
const patch = what === 'hidden' ? { style: { visibility: row.hidden ? null : 'hidden' } } : { lock: !row.locked };
|
|
1659
|
+
void editStageOverrides([{ element: layerElement(clipId, at, row.instance, row.label), patch: patch as OverrideGeometry }]).then((f) => { if (f) toast.show(f); });
|
|
1660
|
+
}, [editStageOverrides, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1661
|
+
const restackLayers = React.useCallback((clipId: string, edits: StageArrangeResult['edits']) => {
|
|
1662
|
+
void editStageOverrides(edits.map(({ target, style }) => ({ element: layerElement(clipId, target.at, target.n, target.at), patch: { style } as OverrideGeometry })))
|
|
1663
|
+
.then((f) => { if (f) toast.show(f); });
|
|
1664
|
+
}, [editStageOverrides, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1665
|
+
const previewClip = React.useCallback((clipLoc: string, patch: Partial<MgTransform> | null) => clipPreviewRef.current?.(clipLoc, patch), []);
|
|
1666
|
+
const previewLook = React.useCallback((clipLoc: string, css: string | null) => lookPreviewRef.current?.(clipLoc, css), []);
|
|
1667
|
+
/* the inspector's Crop: crop mode on the picture, for the video or still in hand there */
|
|
1668
|
+
const startStageCrop = React.useCallback(() => { stageControl.current?.startCrop(); }, []);
|
|
1669
|
+
const editStageMask = React.useCallback((session: MaskSession | null) => { stageControl.current?.editMask(session); }, []);
|
|
1670
|
+
const pageChangesOf = usePageChanges(preview, filmRef);
|
|
1671
|
+
/* the project's files as listed now, for the pictures a picture is replaced with (a host outlives a render) */
|
|
1672
|
+
const listingRef = React.useRef(listing);
|
|
1673
|
+
listingRef.current = listing;
|
|
1674
|
+
/* another file in the place of a picture inside a page (an <img>, a box painted with one image): the clip's override,
|
|
1675
|
+
the file named from the page's folder, as a picture property writes it */
|
|
1676
|
+
const putLayerPicture = React.useCallback((element: StageElement, path: string | null) => {
|
|
1677
|
+
const doc = filmRef.current as unknown as FilmDoc | null;
|
|
1678
|
+
const found = doc ? pageClipOf(doc, element) : null;
|
|
1679
|
+
const picture = element.picture;
|
|
1680
|
+
if (!found || !picture) { toast.show(t('timeline.editFail')); return; }
|
|
1681
|
+
const style = pictureStyle(picture.kind, path == null ? null : relativeTo(found.clip.src, path));
|
|
1682
|
+
/* a box holding one picture: the file goes on its <img> */
|
|
1683
|
+
const target: StageElement = picture.at
|
|
1684
|
+
? { ...element, loc: picture.n ? `${picture.at}#${picture.n}` : picture.at, ...(picture.n ? { instance: picture.n } : { instance: undefined }), override: null }
|
|
1685
|
+
: element;
|
|
1686
|
+
void editStageOverrideRef.current(target, { style }).then((failed) => {
|
|
1687
|
+
if (failed) { toast.show(failed); return; }
|
|
1688
|
+
/* the layer in hand shows the picture now on it once the page has it */
|
|
1689
|
+
window.setTimeout(() => {
|
|
1690
|
+
void stageControl.current?.describe(element).then((fresh) => {
|
|
1691
|
+
if (fresh) setStageElement((el) => (el?.loc === element.loc ? { ...el, ...(fresh.picture ? { picture: fresh.picture } : {}) } : el));
|
|
1692
|
+
});
|
|
1693
|
+
}, 150);
|
|
1694
|
+
});
|
|
1695
|
+
}, [t]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1696
|
+
const pictureActions = React.useMemo<StagePictureActions>(() => ({
|
|
1697
|
+
pictures: () => (listingRef.current?.files ?? []).filter((f) => PICTURE_FILE.test(f.path)).map((f) => ({ path: f.path, name: f.path.split('/').pop() ?? f.path, url: mediaSourcesRef.current.file(f) })),
|
|
1698
|
+
pickFromComputer: () => pickReplacement('picture'),
|
|
1699
|
+
put: putLayerPicture,
|
|
1700
|
+
}), [pickReplacement, putLayerPicture]);
|
|
1701
|
+
/* a page clip's component properties (lib/component-props): its data-props, read and written by its id */
|
|
1702
|
+
const clipPropsOf = React.useCallback((clipId: string | undefined): ComponentProp[] => (
|
|
1703
|
+
clipId ? propsOf(filmRef.current?.tracks.flatMap((tr) => tr.clips).find((c) => c.id === clipId)) : []
|
|
1704
|
+
), []);
|
|
1705
|
+
const writeClipProps = React.useCallback((clipId: string | undefined, next: readonly ComponentProp[]) => {
|
|
1706
|
+
const loc = blocksRef.current.find((b) => b.clipId === clipId)?.loc;
|
|
1707
|
+
if (!loc) { toast.show(t('timeline.editFail')); return; }
|
|
1708
|
+
void editBlocks([{ loc, prop: 'props', value: propsAttr(next) }]).then((failed) => { if (failed) toast.show(failed); });
|
|
1709
|
+
}, [editBlocks, t]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1710
|
+
/* a layer made a property of its clip (the inspector's ◇), or no longer one */
|
|
1711
|
+
const layerProps = React.useMemo<LayerPropsActions>(() => ({
|
|
1712
|
+
list: (element) => clipPropsOf(element.clipId),
|
|
1713
|
+
save: (element, next) => writeClipProps(element.clipId, next),
|
|
1714
|
+
}), [clipPropsOf, writeClipProps]);
|
|
1715
|
+
/* a page clip's Properties (components/PropertiesSection): what its page shows read on the picture, previewed there,
|
|
1716
|
+
filled in as its overrides by the inspector */
|
|
1717
|
+
const propsHostOf = React.useCallback((block: TimelineBlock): PropsHost | null => {
|
|
1718
|
+
const clip = block.clipId;
|
|
1719
|
+
if (!clip) return null;
|
|
1720
|
+
return {
|
|
1721
|
+
read: () => askStage<TemplateControls>('controls', { clip }),
|
|
1722
|
+
props: () => clipPropsOf(clip),
|
|
1723
|
+
setProps: (next) => writeClipProps(clip, next),
|
|
1724
|
+
overrides: () => {
|
|
1725
|
+
const at = parseFilmDocLoc(blocksRef.current.find((b) => b.clipId === clip)?.loc ?? '');
|
|
1726
|
+
return (at ? filmRef.current?.tracks[at.track]?.clips[at.clip]?.overrides : undefined) as readonly FilmOverride[] | undefined;
|
|
1727
|
+
},
|
|
1728
|
+
preview: (target, patch) => {
|
|
1729
|
+
void (patch ? askStage('tweak', { clip, at: target.at, ...(target.n ? { n: target.n } : {}), patch }) : askStage('release', { clip }));
|
|
1730
|
+
},
|
|
1731
|
+
page: block.src ?? '',
|
|
1732
|
+
pictures: pictureActions.pictures,
|
|
1733
|
+
pickPicture: () => pickReplacement('picture'),
|
|
1734
|
+
point: (target) => setPointedLayer(target ? { clipId: clip, loc: target.n ? `${target.at}#${target.n}` : target.at } : null),
|
|
1735
|
+
};
|
|
1736
|
+
}, [askStage, pickReplacement, pictureActions, clipPropsOf, writeClipProps]);
|
|
1737
|
+
/* a clip's own CSS as film.html has it, and the look the player measured (the Inspector's Look section) */
|
|
1738
|
+
const clipLookOf = React.useCallback((clipLoc: string) => {
|
|
1739
|
+
const at = parseFilmDocLoc(clipLoc);
|
|
1740
|
+
const clip = at ? filmRef.current?.tracks[at.track]?.clips[at.clip] : undefined;
|
|
1741
|
+
if (!clip) return null;
|
|
1742
|
+
return { css: typeof clip.style === 'string' ? clip.style : '', look: spans.find((s) => s.id === clip.id)?.look ?? null, cls: clip.class ?? '' };
|
|
1743
|
+
}, [spans]);
|
|
1744
|
+
const openClipExportRef = React.useRef<(clipId: string) => void>(() => {});
|
|
1745
|
+
/* the picture's menu is built before Duplicate is: it reaches it through a ref */
|
|
1746
|
+
const duplicateRef = React.useRef<(blocks: readonly TimelineBlock[]) => Promise<string | null>>(async () => null);
|
|
1747
|
+
/* the whole clip's actions in the picture's menu: the timeline's own */
|
|
1748
|
+
const blockAt = (clipLoc: string) => blocksRef.current.find((b) => b.loc === clipLoc) ?? null;
|
|
1749
|
+
/* a clip with the clips linked to it, as the timeline acts on them */
|
|
1750
|
+
const withLinkedBlocks = (b: TimelineBlock): TimelineBlock[] => {
|
|
1751
|
+
if (!b.clipId) return [b];
|
|
1752
|
+
const ids = new Set(linkedIds(editModel(), [b.clipId]));
|
|
1753
|
+
return blocksRef.current.filter((x) => x === b || (x.clipId != null && ids.has(x.clipId)));
|
|
1754
|
+
};
|
|
1755
|
+
const stageMenuFor = React.useCallback((el: StageElement, ctx: StageMenuContext) => stageMenu(el, ctx, {
|
|
1756
|
+
editOverride: (element, patch) => { void editStageOverrideRef.current(element, patch).then((f) => { if (f) toast.show(f); }); },
|
|
1757
|
+
editOverrides: (list) => { void editStageOverrides(list).then((f) => { if (f) toast.show(f); }); },
|
|
1758
|
+
selectParent: selectStageParent,
|
|
1759
|
+
selectClip: selectStageClip,
|
|
1760
|
+
notify: (message) => toast.show(message),
|
|
1761
|
+
picture: pictureActions,
|
|
1762
|
+
clip: {
|
|
1763
|
+
isLocked: (clipLoc) => Boolean(timelineTracks.find((tr) => tr.blocks.some((b) => b.loc === clipLoc))?.locked),
|
|
1764
|
+
canSplit: (clipLoc) => { const b = blockAt(clipLoc); const now = timeRef.current; return !!b && now > b.startMs + 100 && now < b.endMs - 100; },
|
|
1765
|
+
split: (clipLoc) => {
|
|
1766
|
+
const b = blockAt(clipLoc);
|
|
1767
|
+
const now = timeRef.current;
|
|
1768
|
+
const under = b ? withLinkedBlocks(b).filter((x) => now > x.startMs + 100 && now < x.endMs - 100) : [];
|
|
1769
|
+
if (under.length) void splitBlocks(under, now).then((f) => { if (f) toast.show(f); });
|
|
1770
|
+
},
|
|
1771
|
+
copy: (clipLoc) => { const b = blockAt(clipLoc); if (b) copyBlocks([b]); },
|
|
1772
|
+
canPaste: () => clipboard.length > 0,
|
|
1773
|
+
paste: (clipLoc) => {
|
|
1774
|
+
const lane = timelineTracks.find((tr) => tr.blocks.some((b) => b.loc === clipLoc))?.lane ?? null;
|
|
1775
|
+
void pasteAt(lane, timeRef.current).then((f) => { if (f) toast.show(f); });
|
|
1776
|
+
},
|
|
1777
|
+
/* as ⌘D: right after it on its own track, in the timeline's mode */
|
|
1778
|
+
duplicate: (clipLoc) => {
|
|
1779
|
+
const b = blockAt(clipLoc);
|
|
1780
|
+
if (b?.clipId) void duplicateRef.current(withLinkedBlocks(b));
|
|
1781
|
+
},
|
|
1782
|
+
bringToFront: (clipLoc) => { const at = parseFilmDocLoc(clipLoc); if (at) void editBlocks([{ loc: clipLoc, prop: 'trackOrder', value: { from: at.track, to: 0 } }]); },
|
|
1783
|
+
sendToBack: (clipLoc) => {
|
|
1784
|
+
const at = parseFilmDocLoc(clipLoc);
|
|
1785
|
+
const last = (filmRef.current?.tracks.length ?? 1) - 1;
|
|
1786
|
+
if (at) void editBlocks([{ loc: clipLoc, prop: 'trackOrder', value: { from: at.track, to: last } }]);
|
|
1787
|
+
},
|
|
1788
|
+
resetTransform: (clipLoc) => { void editBlocks([{ loc: clipLoc, prop: 'box', value: null }]); },
|
|
1789
|
+
exportClip: (clipId) => openClipExportRef.current(clipId),
|
|
1790
|
+
remove: (clipLoc) => { const b = blockAt(clipLoc); if (b) { void removeBlocks(withLinkedBlocks(b)); setStageElement(null); } },
|
|
1791
|
+
},
|
|
1792
|
+
}, t), [editStageOverrides, selectStageParent, selectStageClip, splitBlocks, copyBlocks, clipboard, timelineTracks, pasteAt, editBlocks, removeBlocks, t, toast, pictureActions]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1793
|
+
/*
|
|
1794
|
+
* "Reference in chat", first on the menus of a clip, a layer and a track, when the app around Studio has a chat.
|
|
1795
|
+
* The references are made when it is chosen: a layer right-clicked is measured just after its menu opens. ⌘L does
|
|
1796
|
+
* the same for what is selected (the clips or the layer right-clicked are), so a clip's and a layer's item says so.
|
|
1797
|
+
*/
|
|
1798
|
+
const referEntry = (refs: () => StudioRef[], withKey = true): ContextMenuEntry[] => {
|
|
1799
|
+
const refer = studioHost?.refer;
|
|
1800
|
+
if (!refer) return [];
|
|
1801
|
+
return [{
|
|
1802
|
+
id: 'refer', label: t('host.refer'), icon: <MessageSquarePlus size={14} />,
|
|
1803
|
+
...(withKey ? { shortcut: keyHint('refer') } : {}),
|
|
1804
|
+
onSelect: () => { for (const ref of refs()) refer(ref); },
|
|
1805
|
+
}];
|
|
1806
|
+
};
|
|
1807
|
+
const stageMenuWithRefer = React.useCallback((el: StageElement, ctx: StageMenuContext): readonly ContextMenuEntry[] => {
|
|
1808
|
+
const entries = stageMenuFor(el, ctx);
|
|
1809
|
+
const block = el.clipLoc ? blocksRef.current.find((b) => b.loc === el.clipLoc) : null;
|
|
1810
|
+
const whole = el.kind === 'clip' || el.isMgOuter;
|
|
1811
|
+
const extra = whole && !block ? [] : [...referEntry(() => [whole ? refOfBlock(block!) : refOfLayer(el)]), ...(whole && block ? saveEntry([block]) : [])];
|
|
1812
|
+
return extra.length ? [...extra, ...(entries.length ? [{ id: 'refer-sep', separator: true } as const] : []), ...entries] : entries;
|
|
1813
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1814
|
+
}, [stageMenuFor, t, keyHint, saveBlocks]);
|
|
1815
|
+
/** Two groups of menu entries, a line between them when both have some. */
|
|
1816
|
+
const menuGroups = (a: ContextMenuEntry[], b: ContextMenuEntry[]): ContextMenuEntry[] => (a.length && b.length ? [...a, { id: 'tools-sep', separator: true }, ...b] : [...a, ...b]);
|
|
1817
|
+
/* copy and paste attributes, in the clip menu */
|
|
1818
|
+
const attrsEntries = (blocks: readonly TimelineBlock[]): ContextMenuEntry[] => [
|
|
1819
|
+
{ id: 'copy-attrs', label: t('clipAttrs.copy'), icon: <ClipboardCopy size={14} />, shortcut: keyHint('copy-attrs'), disabled: blocks.length !== 1, onSelect: () => copyAttrs(blocks) },
|
|
1820
|
+
{ id: 'paste-attrs', label: t('clipAttrs.pasteMenu'), icon: <ClipboardPaste size={14} />, shortcut: keyHint('paste-attrs'), disabled: !copiedAttrs, hint: t('clipAttrs.nothing'), onSelect: () => askPasteAttrs(blocks) },
|
|
1821
|
+
];
|
|
1822
|
+
const timelineMenuExtra = React.useCallback((blocks: readonly TimelineBlock[]) => (blocks.length ? menuGroups(menuGroups([...referEntry(() => blocks.map(refOfBlock)), ...saveEntry(blocks)], tools.menuExtra(blocks)), attrsEntries(blocks)) : []), [t, keyHint, tools.menuExtra, copiedAttrs, copyAttrs, askPasteAttrs, saveBlocks]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1823
|
+
const trackMenuExtra = React.useCallback((track: TimelineTrack) => {
|
|
1824
|
+
const docIndex = track.docIndex;
|
|
1825
|
+
const refer = docIndex == null || !studioHost?.refer ? [] : referEntry(() => { const ref = trackRef(tracksRef.current, docIndex); return ref ? [ref] : []; }, false);
|
|
1826
|
+
return menuGroups(refer, tools.headMenuExtra(track));
|
|
1827
|
+
}, [t, tools.headMenuExtra]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1828
|
+
/* clips dragged out of the timeline onto the app's chat */
|
|
1829
|
+
const referBlocks = React.useCallback((blocks: TimelineBlock[]) => { for (const b of blocks) studioHost?.refer?.(refOfBlock(b)); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1830
|
+
const markedRangeRef = React.useCallback((range: FilmRange) => rangeRef(projectId, range, blocksRef.current), [projectId]);
|
|
1831
|
+
/* the selection bar's "Add to chat" (components/SelectionToolbar): on the timeline the clips and the range, or the
|
|
1832
|
+
moment of a point picked on the ruler; on the picture what is in hand there */
|
|
1833
|
+
const referFromTimeline = React.useCallback((what: { blocks: readonly TimelineBlock[]; range: FilmRange | null; atMs: number | null }) => {
|
|
1834
|
+
const refer = studioHost?.refer;
|
|
1835
|
+
if (!refer) return;
|
|
1836
|
+
if (what.atMs != null) { refer(momentRef(projectId, what.atMs)); return; }
|
|
1837
|
+
for (const b of what.blocks) refer(refOfBlock(b));
|
|
1838
|
+
if (what.range) refer(rangeRef(projectId, what.range, blocksRef.current));
|
|
1839
|
+
}, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1840
|
+
/* the ruler points at time for the chat: a ⌘-click, that moment; a ⌘-drag, that span */
|
|
1841
|
+
const referRuler = React.useCallback((what: { atMs: number } | { range: FilmRange }) => {
|
|
1842
|
+
const refer = studioHost?.refer;
|
|
1843
|
+
if (!refer) return;
|
|
1844
|
+
refer('atMs' in what ? momentRef(projectId, what.atMs) : rangeRef(projectId, what.range, blocksRef.current));
|
|
1845
|
+
}, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1846
|
+
const referFromStage = React.useCallback((picks: StagePick[]) => {
|
|
1847
|
+
const refer = studioHost?.refer;
|
|
1848
|
+
if (!refer) return;
|
|
1849
|
+
for (const p of picks) { const ref = refOfStagePick(p); if (ref) refer(ref); }
|
|
1850
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1851
|
+
/* the bar's Mask: the inspector open on its Mask section (the shapes are picked there) */
|
|
1852
|
+
/* the picture's Mask menu: the shape goes to the clip's Mask section (put on, its handles shown), opened */
|
|
1853
|
+
const [maskAsk, setMaskAsk] = React.useState<{ shape: MaskShape | 'none'; clipLoc: string; n: number } | null>(null);
|
|
1854
|
+
const maskAsked = React.useCallback(() => setMaskAsk(null), []);
|
|
1855
|
+
const openMaskSection = React.useCallback((shape: MaskShape | 'none', clipLoc: string) => {
|
|
1856
|
+
setMaskAsk({ shape, clipLoc, n: Date.now() });
|
|
1857
|
+
openSection('mask');
|
|
1858
|
+
setInspectorContent('selection');
|
|
1859
|
+
setInspectorOpen(true);
|
|
1860
|
+
window.setTimeout(() => {
|
|
1861
|
+
document.querySelector('[data-film-inspector] [data-section="mask"]')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1862
|
+
}, 60);
|
|
1863
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1864
|
+
/**
|
|
1865
|
+
* Duplicate (⌘D, the bar's, the picture menu's): copies right after the clips, each on its own track, going in
|
|
1866
|
+
* between (what follows on that track moves on; with linkage, what rides on the main track along), so nothing is
|
|
1867
|
+
* covered; never a new track.
|
|
1868
|
+
*/
|
|
1869
|
+
const duplicateBlocks = React.useCallback(async (blocks: readonly TimelineBlock[]) => {
|
|
1870
|
+
const picked = blocks.filter((b) => b.clipId);
|
|
1871
|
+
if (!picked.length) return null;
|
|
1872
|
+
const tracks = tracksRef.current;
|
|
1873
|
+
const ops = pasteEdit(clipboardItemsOf(picked, tracks), tracks, editModel(), Math.max(...picked.map((b) => b.endMs)), null, { mode: 'insert', ...switchesOf(readTimelinePrefs()) });
|
|
1874
|
+
const failed = !ops ? t('timeline.trackLocked') : ops.length ? await edit(ops, picked.length <= 2 ? clipStep('history.stepDuplicated', picked.map((b) => b.clipId!)) : 'history.stepDuplicate') : null;
|
|
1875
|
+
if (failed) toast.show(failed);
|
|
1876
|
+
return failed;
|
|
1877
|
+
}, [editModel, edit, t, toast]);
|
|
1878
|
+
duplicateRef.current = duplicateBlocks;
|
|
1879
|
+
/* a box drawn on the picture with ⌘ held, at the playhead */
|
|
1880
|
+
const referRegion = React.useCallback((region: { box: StageBox; clipIds: string[]; texts: string[] }) => {
|
|
1881
|
+
studioHost?.refer?.(regionRef(projectId, timeRef.current, region.box, region, stageRef.current));
|
|
1882
|
+
}, [projectId]);
|
|
1883
|
+
const recoveryMenu = React.useCallback(() => stageRecoveryMenu(filmRef.current as unknown as FilmDoc, (layer, action) => {
|
|
1884
|
+
const doc = filmRef.current as unknown as FilmDoc | null;
|
|
1885
|
+
const e = doc && recoverLayer(doc, layer, action);
|
|
1886
|
+
if (e) void editBlocks([{ ...e, value: e.value as unknown as Record<string, unknown> | null }]).then((f) => { if (f) toast.show(f); });
|
|
1887
|
+
}, t), [editBlocks, t, toast]);
|
|
1888
|
+
const selectedLocs = React.useMemo(() => selectedBlocks.flatMap((b) => (b.loc ? [b.loc] : [])), [selectedBlocks]);
|
|
1889
|
+
/* what the inspector shows is on a locked track: shown, not changed */
|
|
1890
|
+
const inspectorLocked = anyLocked(film.film, [stageElement?.clipLoc, ...selectedLocs]);
|
|
1891
|
+
|
|
1892
|
+
/* ── undo, redo and the edit history ── */
|
|
1893
|
+
/** Undo or redo, after what the arrow keys moved on the picture is written (so it is the step undone, not lost). */
|
|
1894
|
+
const stepHistory = React.useCallback((kind: 'undo' | 'redo') => {
|
|
1895
|
+
stageControl.current?.flushPending();
|
|
1896
|
+
if (kind === 'undo') film.undo(); else film.redo();
|
|
1897
|
+
}, [film.undo, film.redo]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1898
|
+
const sessionTimeline = React.useMemo<SessionTimeline>(() => ({
|
|
1899
|
+
steps: [...film.steps.done, ...[...film.steps.undone].reverse()].map((s) => ({ label: s.label, at: s.at })),
|
|
1900
|
+
cursor: film.steps.done.length,
|
|
1901
|
+
}), [film.steps]);
|
|
1902
|
+
const timelineHistory = React.useMemo<TimelineHistory>(() => ({
|
|
1903
|
+
canUndo: film.canUndo,
|
|
1904
|
+
canRedo: film.canRedo,
|
|
1905
|
+
step: stepHistory,
|
|
1906
|
+
ops: sessionTimeline,
|
|
1907
|
+
rollbackTo: film.rollbackTo,
|
|
1908
|
+
}), [film.canUndo, film.canRedo, stepHistory, film.rollbackTo, sessionTimeline]);
|
|
1909
|
+
|
|
1910
|
+
/* ── thumbnails: video frames from ffmpeg, page frames drawn headless (server), stills as they are ── */
|
|
1911
|
+
const [thumbView, setThumbView] = React.useState<{ fromMs: number; toMs: number; pxPerMs: number } | null>(null);
|
|
1912
|
+
const mtimeOf = React.useMemo(() => new Map([...(rawListing?.files ?? []), ...(rawListing?.pages ?? [])].map((f) => [f.path, f.mtime])), [rawListing]);
|
|
1913
|
+
/* a sound made again in place is heard as it is now, not as it was decoded */
|
|
1914
|
+
React.useEffect(() => { player.current.setVersions((src) => mtimeOf.get(src)); }, [mtimeOf]);
|
|
1915
|
+
refSource.current = { projectId, version: (path) => mtimeOf.get(path) };
|
|
1916
|
+
/* a page clip's pictures are drawn with the changes made inside it (its overrides), by its id: changed, asked again */
|
|
1917
|
+
const tweakTokens = React.useMemo(() => {
|
|
1918
|
+
const out = new Map<string, string>();
|
|
1919
|
+
for (const track of film.film?.tracks ?? []) {
|
|
1920
|
+
for (const clip of track.clips) {
|
|
1921
|
+
const token = clip.id && filmSrcIsPage(clip.src) ? overridesToken(clip.overrides) : '';
|
|
1922
|
+
if (token) out.set(clip.id!, token);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return out;
|
|
1926
|
+
}, [film.film]);
|
|
1927
|
+
const thumbs = React.useMemo(() => {
|
|
1928
|
+
const out = new Map<string, string>();
|
|
1929
|
+
if (!thumbView || !(thumbView.pxPerMs > 0)) return out;
|
|
1930
|
+
/* a page's frames change with the files it loads too (filesTick: asked again, an unchanged one answered 304), and
|
|
1931
|
+
with the clip's own changes (`clip`, and its token in the version) */
|
|
1932
|
+
const media = (path: string, ms: number, clip?: string) => {
|
|
1933
|
+
const token = clip ? tweakTokens.get(clip) : undefined;
|
|
1934
|
+
return `/api/projects/${encodeURIComponent(projectId)}/media?what=frame&path=${encodeURIComponent(path)}&ms=${Math.round(ms)}&w=${THUMB_CELL_PX * 2}&v=${mtimeOf.get(path) ?? 0}${filmSrcIsPage(path) ? `.${filesTick}` : ''}${token ? `.${token}&clip=${encodeURIComponent(clip!)}` : ''}`;
|
|
1935
|
+
};
|
|
1936
|
+
const span = thumbView.toMs - thumbView.fromMs;
|
|
1937
|
+
const fromMs = Math.max(0, thumbView.fromMs - span);
|
|
1938
|
+
const toMs = thumbView.toMs + span;
|
|
1939
|
+
const pictures = blocksRef.current.filter((b) => (b.kind === 'mg' || b.kind === 'video') && b.src && b.endMs > fromMs && b.startMs < toMs);
|
|
1940
|
+
/* every clip's own shots are asked for before any shot past a clip's edges: the server shoots them in that order,
|
|
1941
|
+
and until a clip's own arrive its cells borrow the nearest it has (lib/thumb-pick) — a trimmed clip borrowed the
|
|
1942
|
+
frame from before its in-point, often its source's black first frame */
|
|
1943
|
+
const margins: Array<[string, string]> = [];
|
|
1944
|
+
for (const b of pictures) {
|
|
1945
|
+
const still = /\.(png|jpe?g|webp|avif|gif|bmp|svg)$/i.test(b.src!);
|
|
1946
|
+
const token = b.clipId ? tweakTokens.get(b.clipId) ?? '' : '';
|
|
1947
|
+
const key = (into: number) => `${mtimeOf.get(b.src!) ?? 0}${token ? `.${token}` : ''}:${clipThumbId(b)}@${into}#v`;
|
|
1948
|
+
if (still) { out.set(key(0), `${folder}${b.src!.split('/').map(encodeURIComponent).join('/')}`); continue; }
|
|
1949
|
+
const speed = b.speed && b.speed > 0 ? b.speed : 1;
|
|
1950
|
+
const trimMs = (b.anchor?.trimFrom ?? 0) * 1000;
|
|
1951
|
+
/* shot on the source's own grid (from its start, a step a cell in source ms), as BlockThumbs draws them: a trim
|
|
1952
|
+
keeps the shots and only crops the row. Past the clip's edges too, half a screen each way (and inside the
|
|
1953
|
+
file): an edge pulled out shows the source it reveals, not its edge frame again */
|
|
1954
|
+
const stepMs = ladderStepMs((THUMB_CELL_PX / thumbView.pxPerMs) * speed);
|
|
1955
|
+
const marginMs = (span / 2) * speed;
|
|
1956
|
+
const seenFrom = trimMs + (Math.max(b.startMs, fromMs) - b.startMs) * speed;
|
|
1957
|
+
const seenTo = trimMs + (Math.min(b.endMs, toMs) - b.startMs) * speed;
|
|
1958
|
+
const lo = Math.max(0, seenFrom - (b.startMs >= fromMs ? marginMs : 0));
|
|
1959
|
+
/* past the end only when the source's length is known (before it is probed, a shot there is a frame after the
|
|
1960
|
+
file's end, which the server refuses) */
|
|
1961
|
+
const hi = b.sourceDurMs != null ? Math.min(b.sourceDurMs, seenTo + (b.endMs <= toMs ? marginMs : 0)) : seenTo;
|
|
1962
|
+
/* a page's tile shows the frame from its middle, not its first: a scene starts on its fade-in, often blank */
|
|
1963
|
+
const lastMs = (b.sourceDurMs ?? Infinity) - 40;
|
|
1964
|
+
const grid: number[] = [];
|
|
1965
|
+
for (let into = Math.floor(lo / stepMs) * stepMs; into < hi; into += stepMs) grid.push(Math.round(into));
|
|
1966
|
+
const own = { startMs: trimMs, endMs: trimMs + (b.endMs - b.startMs) * speed };
|
|
1967
|
+
for (const at of [...grid, ...thumbAnchorTimes({ times: grid, spans: [own] })]) {
|
|
1968
|
+
const drawn = b.kind === 'mg' ? Math.round(Math.max(at, Math.min(at + stepMs / 2, lastMs))) : at;
|
|
1969
|
+
if (at >= own.startMs - stepMs && at <= own.endMs) out.set(key(at), media(b.src!, drawn, b.clipId));
|
|
1970
|
+
else margins.push([key(at), media(b.src!, drawn, b.clipId)]);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
for (const [k, u] of margins) if (!out.has(k)) out.set(k, u);
|
|
1974
|
+
return out;
|
|
1975
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1976
|
+
}, [thumbView, timelineTracks, projectId, mtimeOf, folder, filesTick, tweakTokens]);
|
|
1977
|
+
/* drawn once loaded: a zoom keeps the pictures it has until the new shots arrive */
|
|
1978
|
+
const shownThumbs = useLoadedThumbs(thumbs);
|
|
1979
|
+
const posters = React.useMemo(() => {
|
|
1980
|
+
const out: Record<string, string> = {};
|
|
1981
|
+
for (const b of blocksRef.current) {
|
|
1982
|
+
if (!b.src || out[b.src]) continue;
|
|
1983
|
+
if (b.kind === 'mg' || (b.kind === 'video' && !/\.(png|jpe?g|webp|avif|gif|bmp|svg)$/i.test(b.src))) {
|
|
1984
|
+
out[b.src] = `/api/projects/${encodeURIComponent(projectId)}/media?what=poster&path=${encodeURIComponent(b.src)}&v=${mtimeOf.get(b.src) ?? 0}`;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return out;
|
|
1988
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1989
|
+
}, [timelineTracks, projectId, mtimeOf]);
|
|
1990
|
+
/* each drawn once it has had its turn among Studio's media requests (lib/media-queue) */
|
|
1991
|
+
const shownPosters = useMediaImages(posters);
|
|
1992
|
+
const waveUrl = React.useCallback((src: string) => `/api/projects/${encodeURIComponent(projectId)}/media?what=wave&path=${encodeURIComponent(src)}&v=${mtimeOf.get(src) ?? 0}`, [projectId, mtimeOf]);
|
|
1993
|
+
/* clips whose files are gone, and where the same name may be now */
|
|
1994
|
+
const mediaTrouble = React.useMemo(() => {
|
|
1995
|
+
const out = new Map<string, 'decode' | 'unreachable' | 'missing'>();
|
|
1996
|
+
if (!rawListing) return out;
|
|
1997
|
+
const paths = new Set([...rawListing.files.map((f) => f.path), ...rawListing.pages.map((p) => p.path)]);
|
|
1998
|
+
for (const b of blocksRef.current) if (b.src && (b.src.startsWith('assets/') || /\.html?$/i.test(b.src)) && !paths.has(b.src)) out.set(b.src, 'missing');
|
|
1999
|
+
return out;
|
|
2000
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2001
|
+
}, [rawListing, timelineTracks]);
|
|
2002
|
+
/** The file of a clip under the playhead that is gone (on a track that is seen), for the viewer to say. */
|
|
2003
|
+
const missingHere = React.useMemo(() => {
|
|
2004
|
+
if (!mediaTrouble.size) return null;
|
|
2005
|
+
const hit = timelineTracks.filter((tr) => !tr.hidden).flatMap((tr) => tr.blocks)
|
|
2006
|
+
.find((b) => b.src && mediaTrouble.get(b.src) === 'missing' && timeMs >= b.startMs && timeMs < b.endMs);
|
|
2007
|
+
return hit?.src ? hit.src.split('/').pop() ?? hit.src : null;
|
|
2008
|
+
}, [mediaTrouble, timelineTracks, timeMs]);
|
|
2009
|
+
const relinkCandidates = React.useCallback((src: string): string[] => {
|
|
2010
|
+
const name = src.split('/').pop()?.toLowerCase();
|
|
2011
|
+
return name ? (rawListing?.files ?? []).filter((f) => f.path.split('/').pop()?.toLowerCase() === name && f.path !== src).map((f) => f.path) : [];
|
|
2012
|
+
}, [rawListing]);
|
|
2013
|
+
const usedSrcs = React.useMemo(() => blocksRef.current.flatMap((b) => (b.src ? [b.src] : [])), [timelineTracks]);
|
|
2014
|
+
|
|
2015
|
+
/* ── media pane actions ── */
|
|
2016
|
+
const projectApi = `/api/projects/${encodeURIComponent(projectId)}`;
|
|
2017
|
+
const mediaResult = async (res: Response): Promise<MediaResult> => (res.ok ? 'ok' : res.status === 409 ? 'taken' : 'failed');
|
|
2018
|
+
/* media kept on this computer (files.mjs bringIn): picked in the system's dialog (the app's own for a folder, when
|
|
2019
|
+
it has one; 'no-dialog' where there is none), copied in (cloned where the disk can) or linked where it is kept (a
|
|
2020
|
+
NAS). A link pointed elsewhere, or copied into the project in its place */
|
|
2021
|
+
const postJson = React.useCallback(async (path: string, body: unknown) => {
|
|
2022
|
+
const res = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
2023
|
+
const answer = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
2024
|
+
return { ok: res.ok, status: res.status, answer };
|
|
2025
|
+
}, []);
|
|
2026
|
+
const onPick = React.useCallback(async (title: string, folder: boolean): Promise<string[] | 'no-dialog'> => {
|
|
2027
|
+
if (folder && studioHost?.folders?.pick) { const path = await studioHost.folders.pick({ title }); return path ? [path] : []; }
|
|
2028
|
+
const { ok, status, answer } = await postJson(folder ? '/api/pick-folder' : '/api/pick-files', { title });
|
|
2029
|
+
if (status === 501) return 'no-dialog';
|
|
2030
|
+
if (!ok) return [];
|
|
2031
|
+
return folder ? (typeof answer.path === 'string' ? [answer.path] : []) : Array.isArray(answer.paths) ? answer.paths.map(String) : [];
|
|
2032
|
+
}, [postJson]);
|
|
2033
|
+
const onBring = React.useCallback(async (paths: string[], into: string, mode: 'copy' | 'link'): Promise<string[]> => {
|
|
2034
|
+
const { ok, answer } = await postJson(`${projectApi}/files/bring`, { paths, into, mode });
|
|
2035
|
+
if (!ok) throw new Error(String(answer.error ?? t('assets.uploadFailed')));
|
|
2036
|
+
return Array.isArray(answer.landed) ? answer.landed.map(String) : [];
|
|
2037
|
+
}, [projectApi, postJson, t]);
|
|
2038
|
+
const onRelink = React.useCallback(async (path: string, folder: boolean): Promise<MediaResult> => {
|
|
2039
|
+
const to = (await onPick(t('media.pickRelink'), folder));
|
|
2040
|
+
if (to === 'no-dialog' || !to.length) return 'ok';
|
|
2041
|
+
return (await postJson(`${projectApi}/files/relink`, { path, to: to[0] })).ok ? 'ok' : 'failed';
|
|
2042
|
+
}, [projectApi, postJson, onPick, t]);
|
|
2043
|
+
const onCollect = React.useCallback(async (path: string): Promise<MediaResult> => (
|
|
2044
|
+
(await postJson(`${projectApi}/files/collect`, { path })).ok ? 'ok' : 'failed'
|
|
2045
|
+
), [projectApi, postJson]);
|
|
2046
|
+
/*
|
|
2047
|
+
* resolves with where each file landed (a taken name gets a number); one that is no readable media is said at once.
|
|
2048
|
+
* Each file's progress is its bytes sent (XMLHttpRequest: fetch cannot tell how much of a body has gone), then 1 and
|
|
2049
|
+
* where it landed
|
|
2050
|
+
*/
|
|
2051
|
+
const onImport = React.useCallback(async (files: File[], into: string, onProgress: ImportProgress): Promise<string[]> => {
|
|
2052
|
+
const landed: string[] = [];
|
|
2053
|
+
/* in the app a dropped file's path is known: copied on this computer (cloned where the disk can: at once, and no
|
|
2054
|
+
more room taken), not sent through the page */
|
|
2055
|
+
const paths = files.map((file) => studioHost?.folders?.pathOf(file) ?? '');
|
|
2056
|
+
if (paths.length && paths.every(Boolean)) {
|
|
2057
|
+
for (const [i, path] of paths.entries()) {
|
|
2058
|
+
onProgress(i, -1);
|
|
2059
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2060
|
+
const [at] = await onBring([path], into, 'copy');
|
|
2061
|
+
if (at) landed.push(at);
|
|
2062
|
+
onProgress(i, 1, at);
|
|
2063
|
+
}
|
|
2064
|
+
return landed;
|
|
2065
|
+
}
|
|
2066
|
+
for (const [i, file] of files.entries()) {
|
|
2067
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2068
|
+
const { ok, body } = await new Promise<{ ok: boolean; body: { path?: string; error?: string; unreadable?: string } }>((done) => {
|
|
2069
|
+
const xhr = new XMLHttpRequest();
|
|
2070
|
+
xhr.open('PUT', `${projectApi}/files?path=${encodeURIComponent(`${into}/${file.name}`)}`);
|
|
2071
|
+
xhr.upload.onprogress = (e) => { if (e.lengthComputable && e.total > 0) onProgress(i, Math.min(0.99, e.loaded / e.total)); };
|
|
2072
|
+
xhr.onload = () => {
|
|
2073
|
+
let body = {};
|
|
2074
|
+
try { body = JSON.parse(xhr.responseText); } catch { /* no answer to read */ }
|
|
2075
|
+
done({ ok: xhr.status >= 200 && xhr.status < 300, body });
|
|
2076
|
+
};
|
|
2077
|
+
xhr.onerror = () => done({ ok: false, body: {} });
|
|
2078
|
+
xhr.send(file);
|
|
2079
|
+
});
|
|
2080
|
+
if (!ok) throw new Error(String(body.error ?? t('assets.uploadFailed')));
|
|
2081
|
+
if (body.path) landed.push(body.path);
|
|
2082
|
+
if (body.unreadable) toast.show(t('assets.unreadable').replace('{name}', file.name).replace('{why}', body.unreadable));
|
|
2083
|
+
onProgress(i, 1, body.path);
|
|
2084
|
+
}
|
|
2085
|
+
return landed;
|
|
2086
|
+
}, [projectApi, t, toast, onBring]);
|
|
2087
|
+
pickReplacementRef.current = async (family) => {
|
|
2088
|
+
/* the system's dialog where there is one (the app, or Studio on this computer), else the browser's */
|
|
2089
|
+
const picked = await onPick(t('editorTools.replacePick'), false);
|
|
2090
|
+
if (picked !== 'no-dialog') {
|
|
2091
|
+
if (!picked.length) return null;
|
|
2092
|
+
const [at] = await onBring([picked[0]!], 'assets', 'copy');
|
|
2093
|
+
return at ?? null;
|
|
2094
|
+
}
|
|
2095
|
+
const file = await new Promise<File | null>((done) => {
|
|
2096
|
+
const input = document.createElement('input');
|
|
2097
|
+
input.type = 'file';
|
|
2098
|
+
input.accept = family === 'sound' ? 'audio/*,video/*' : 'video/*,image/*,.html,.htm';
|
|
2099
|
+
input.addEventListener('change', () => done(input.files?.[0] ?? null));
|
|
2100
|
+
input.addEventListener('cancel', () => done(null));
|
|
2101
|
+
input.click();
|
|
2102
|
+
});
|
|
2103
|
+
if (!file) return null;
|
|
2104
|
+
const [at] = await onImport([file], 'assets', () => {});
|
|
2105
|
+
return at ?? null;
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
/* ── files dropped from the computer: imported into assets/ (the media pane's own import), then put on the film ── */
|
|
2109
|
+
/** A file just imported, as the media pane would hand it to a drop: its kind from its name, its length and size probed. */
|
|
2110
|
+
const resourceOf = React.useCallback(async (path: string): Promise<ResourceDragItem> => {
|
|
2111
|
+
const kind = resourceKindOf(path);
|
|
2112
|
+
const base: ResourceDragItem = { path, name: path.slice(path.lastIndexOf('/') + 1), kind, dir: '', size: 0, mtimeMs: 0 };
|
|
2113
|
+
if (kind !== 'video' && kind !== 'image' && kind !== 'audio') return base;
|
|
2114
|
+
const probe = await fetch(`${projectApi}/media?what=probe&path=${encodeURIComponent(path)}`).then((r) => (r.ok ? r.json() : null), () => null);
|
|
2115
|
+
return { ...base, ...factsOfProbe(probe) };
|
|
2116
|
+
}, [projectApi]);
|
|
2117
|
+
/**
|
|
2118
|
+
* Import `files`, then insert them as `where` says (worked out from their real lengths), making room for them where
|
|
2119
|
+
* they land on a track with an edit mode: one step to undo.
|
|
2120
|
+
*/
|
|
2121
|
+
const importAndPlace = React.useCallback(async (
|
|
2122
|
+
files: File[],
|
|
2123
|
+
where: (items: ResourceDragItem[]) => { inserts: AssetRunInsert[] } | { error: 'not-clip' | 'locked' },
|
|
2124
|
+
mode?: EditMode,
|
|
2125
|
+
): Promise<string | null> => {
|
|
2126
|
+
let items: ResourceDragItem[];
|
|
2127
|
+
try {
|
|
2128
|
+
items = await Promise.all((await onImport(files, 'assets', () => {})).map(resourceOf));
|
|
2129
|
+
} catch (e) {
|
|
2130
|
+
return e instanceof Error && e.message ? e.message : t('assets.uploadFailed');
|
|
2131
|
+
}
|
|
2132
|
+
const plan = where(items);
|
|
2133
|
+
if ('error' in plan) return plan.error === 'locked' ? t('timeline.trackLocked') : t('timeline.dropNotClip');
|
|
2134
|
+
const first = plan.inserts[0];
|
|
2135
|
+
const last = plan.inserts.at(-1);
|
|
2136
|
+
/* the run is one span on its track: from the first's start to the last's end */
|
|
2137
|
+
const room = first && last ? roomFor([{ track: first.track, atSec: first.at, durMs: Math.round((last.at - first.at) * 1000) + last.durMs }], mode) : [];
|
|
2138
|
+
return edit([
|
|
2139
|
+
...plan.inserts.map((i): Op => ({ op: 'insert', clip: i.element as never, at: i.at, ...(i.track != null ? { track: i.track } : {}) })),
|
|
2140
|
+
...room,
|
|
2141
|
+
], 'history.stepDrop');
|
|
2142
|
+
}, [onImport, resourceOf, edit, roomFor, t]);
|
|
2143
|
+
/* on the timeline: where they were dropped, as a drag from the media pane lands */
|
|
2144
|
+
const dropFiles = React.useCallback((files: File[], hit: AssetLaneHit | null, atMs: number, mode?: EditMode) => (
|
|
2145
|
+
importAndPlace(files, (items) => assetRunInsert(items, tracksRef.current, hit, atMs, mode, stageRef.current), mode)
|
|
2146
|
+
), [importAndPlace]);
|
|
2147
|
+
/* on the picture: at the playhead, pictures on a new track on top, sounds on a sound track */
|
|
2148
|
+
const dropFilesOnPicture = React.useCallback((files: File[]) => {
|
|
2149
|
+
const atMs = snapToFrame(timeRef.current);
|
|
2150
|
+
return importAndPlace(files, (items) => {
|
|
2151
|
+
const sounds = items.filter((f) => f.kind === 'audio');
|
|
2152
|
+
const pictures = items.filter((f) => f.kind !== 'audio');
|
|
2153
|
+
const tracks = tracksRef.current;
|
|
2154
|
+
const heard = sounds.length ? assetRunInsert(sounds, tracks, soundLaneHit(tracks, atMs, assetRunMs(sounds)), atMs) : { inserts: [] };
|
|
2155
|
+
const seen = pictures.length ? assetRunInsert(pictures, tracks, { lane: -1, band: 'body' }, atMs, undefined, stageRef.current) : { inserts: [] };
|
|
2156
|
+
/* the sounds first: the pictures' new track goes in at the top and moves every track under it down one */
|
|
2157
|
+
const inserts = [...('inserts' in heard ? heard.inserts : []), ...('inserts' in seen ? seen.inserts : [])];
|
|
2158
|
+
if (inserts.length) return { inserts };
|
|
2159
|
+
return 'error' in seen ? seen : 'error' in heard ? heard : { error: 'not-clip' };
|
|
2160
|
+
});
|
|
2161
|
+
}, [importAndPlace]);
|
|
2162
|
+
const [pictureDrop, setPictureDrop] = React.useState(false);
|
|
2163
|
+
const pictureDropProps = {
|
|
2164
|
+
onDragOver: (e: React.DragEvent) => {
|
|
2165
|
+
if (!isComputerFileDrag(e.dataTransfer)) return;
|
|
2166
|
+
e.preventDefault();
|
|
2167
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
2168
|
+
setPictureDrop(true);
|
|
2169
|
+
},
|
|
2170
|
+
onDragLeave: (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setPictureDrop(false); },
|
|
2171
|
+
onDrop: (e: React.DragEvent) => {
|
|
2172
|
+
setPictureDrop(false);
|
|
2173
|
+
if (!isComputerFileDrag(e.dataTransfer) || !e.dataTransfer.files.length) return;
|
|
2174
|
+
e.preventDefault();
|
|
2175
|
+
void dropFilesOnPicture([...e.dataTransfer.files]).then((failed) => { if (failed) toast.show(failed); });
|
|
2176
|
+
},
|
|
2177
|
+
};
|
|
2178
|
+
/* anywhere else in the page a dropped file is imported into the media pane: left to the browser, it would open the
|
|
2179
|
+
file in place of Studio. While one is dragged, frames let it through (globals.css) */
|
|
2180
|
+
const importDroppedRef = React.useRef<(files: File[]) => void>(() => {});
|
|
2181
|
+
importDroppedRef.current = (files) => {
|
|
2182
|
+
void onImport(files, 'assets', () => {}).then(
|
|
2183
|
+
(landed) => { if (landed.length) toast.show(t('assets.imported').replace('{n}', String(landed.length))); },
|
|
2184
|
+
(e: Error) => toast.show(e.message || t('assets.uploadFailed')),
|
|
2185
|
+
);
|
|
2186
|
+
};
|
|
2187
|
+
React.useEffect(() => {
|
|
2188
|
+
const root = document.documentElement;
|
|
2189
|
+
const done = () => { delete root.dataset.fileDrag; setPictureDrop(false); };
|
|
2190
|
+
const over = (e: DragEvent) => {
|
|
2191
|
+
if (!e.dataTransfer || !isComputerFileDrag(e.dataTransfer)) return;
|
|
2192
|
+
root.dataset.fileDrag = '';
|
|
2193
|
+
if (e.defaultPrevented) return;
|
|
2194
|
+
e.preventDefault();
|
|
2195
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
2196
|
+
};
|
|
2197
|
+
const leave = (e: DragEvent) => { if (!e.relatedTarget) done(); };
|
|
2198
|
+
const drop = (e: DragEvent) => {
|
|
2199
|
+
if (!e.dataTransfer || !isComputerFileDrag(e.dataTransfer)) return;
|
|
2200
|
+
if (e.defaultPrevented) return;
|
|
2201
|
+
e.preventDefault();
|
|
2202
|
+
if (e.dataTransfer.files.length) importDroppedRef.current([...e.dataTransfer.files]);
|
|
2203
|
+
};
|
|
2204
|
+
window.addEventListener('dragenter', over);
|
|
2205
|
+
window.addEventListener('dragover', over);
|
|
2206
|
+
window.addEventListener('dragleave', leave);
|
|
2207
|
+
/* the drag is over wherever it lands, even where the drop goes no further (the timeline keeps it to itself) */
|
|
2208
|
+
window.addEventListener('drop', done, true);
|
|
2209
|
+
window.addEventListener('drop', drop);
|
|
2210
|
+
window.addEventListener('dragend', done);
|
|
2211
|
+
return () => {
|
|
2212
|
+
done();
|
|
2213
|
+
window.removeEventListener('dragenter', over);
|
|
2214
|
+
window.removeEventListener('dragover', over);
|
|
2215
|
+
window.removeEventListener('dragleave', leave);
|
|
2216
|
+
window.removeEventListener('drop', done, true);
|
|
2217
|
+
window.removeEventListener('drop', drop);
|
|
2218
|
+
window.removeEventListener('dragend', done);
|
|
2219
|
+
};
|
|
2220
|
+
}, []);
|
|
2221
|
+
const onMove = React.useCallback(async (from: string, to: string) => mediaResult(await fetch(`${projectApi}/files`, {
|
|
2222
|
+
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ from, to }),
|
|
2223
|
+
})), [projectApi]);
|
|
2224
|
+
const onMoveCheck = React.useCallback(async (from: string, to: string) => {
|
|
2225
|
+
const res = await fetch(`${projectApi}/files`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ from, to, check: true }) });
|
|
2226
|
+
const body = await res.json().catch(() => ({})) as { unseen?: { file: string; unseen: string[] }[] };
|
|
2227
|
+
return res.ok ? body.unseen ?? [] : [];
|
|
2228
|
+
}, [projectApi]);
|
|
2229
|
+
const onDelete = React.useCallback(async (path: string) => mediaResult(await fetch(`${projectApi}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' })), [projectApi]);
|
|
2230
|
+
const onExtractAudio = React.useCallback(async (path: string) => mediaResult(await fetch(`${projectApi}/files/audio`, {
|
|
2231
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }),
|
|
2232
|
+
})), [projectApi]);
|
|
2233
|
+
/* a picture of the project: the project's card in the projects list */
|
|
2234
|
+
const onSetCover = React.useCallback((path: string) => {
|
|
2235
|
+
void api.setCover(projectId, { path }).then(() => toast.show(t('projects.coverSet')), () => toast.showError(t('projects.coverFailed')));
|
|
2236
|
+
}, [projectId, toast, t]);
|
|
2237
|
+
const onNewFolder = React.useCallback(async (path: string) => mediaResult(await fetch(`${projectApi}/folders`, {
|
|
2238
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }),
|
|
2239
|
+
})), [projectApi]);
|
|
2240
|
+
const reveal = React.useCallback((path: string, original?: boolean) => {
|
|
2241
|
+
void fetch(`${projectApi}/reveal`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, ...(original ? { original } : {}) }) });
|
|
2242
|
+
}, [projectApi]);
|
|
2243
|
+
|
|
2244
|
+
/* ── picture: the delivery frame, fullscreen ── */
|
|
2245
|
+
const stage = film.film?.stage ?? { w: 1920, h: 1080 };
|
|
2246
|
+
stageRef.current = stage;
|
|
2247
|
+
const empty = !film.film?.tracks.some((tr) => tr.clips.length);
|
|
2248
|
+
/* the canvas (the stage in film.html), from the viewer's ratio button or the inspector: another shape
|
|
2249
|
+
keeps the canvas's short side (canvasFor; a typed size is that size), and every picture on it is scaled by one
|
|
2250
|
+
factor into the new canvas, centered, in the same edit (one undo). Laying the film out again for the new canvas
|
|
2251
|
+
is the agent's (the inspector gives the words) */
|
|
2252
|
+
/* "Fit (original)": the shape of the film's first picture (canvas-fit's firstPictureSize) */
|
|
2253
|
+
const originalSize = React.useMemo(() => firstPictureSize((film.film?.tracks ?? []).flatMap((track) => track.clips), spans), [spans, film.film]);
|
|
2254
|
+
/** The edit that makes the canvas `size`, the film fitted into it (see canvas-fit). */
|
|
2255
|
+
const canvasOps = React.useCallback((size: { w: number; h: number }): Op[] => {
|
|
2256
|
+
const own = new Map(spans.map((span) => [span.id, span.w && span.h ? { w: span.w, h: span.h } : null]));
|
|
2257
|
+
const reflows = new Set(spans.filter((span) => span.reflows).map((span) => span.id));
|
|
2258
|
+
const clips = (film.film?.tracks ?? []).flatMap((track) => track.clips.map((clip) => ({
|
|
2259
|
+
id: clip.id,
|
|
2260
|
+
kind: clip.sound ? 'sound' as const : filmSrcKind(clip.src),
|
|
2261
|
+
own: own.get(clip.id) ?? null,
|
|
2262
|
+
box: (clip.box as FilmBox | undefined) ?? null,
|
|
2263
|
+
reflows: reflows.has(clip.id),
|
|
2264
|
+
size: clip.size ?? null,
|
|
2265
|
+
})));
|
|
2266
|
+
return [
|
|
2267
|
+
{ op: 'stage', w: size.w, h: size.h },
|
|
2268
|
+
...fitToCanvas(clips, stage, size).flatMap((fit) => [
|
|
2269
|
+
{ op: 'set' as const, clip: fit.id, field: 'box', value: fit.box },
|
|
2270
|
+
...(fit.size ? [{ op: 'set' as const, clip: fit.id, field: 'size', value: fit.size }] : []),
|
|
2271
|
+
]),
|
|
2272
|
+
];
|
|
2273
|
+
}, [stage, spans, film.film]);
|
|
2274
|
+
/* a canvas size shown on the picture while its width or height is dragged, not kept yet */
|
|
2275
|
+
const [canvasDraft, setCanvasDraft] = React.useState<{ w: number; h: number } | null>(null);
|
|
2276
|
+
const shownStage = canvasDraft ?? stage;
|
|
2277
|
+
/* the picture is the canvas, nothing around it: another shape is another canvas (a delivery frame is the export's) */
|
|
2278
|
+
const frameBox = React.useMemo(() => filmFrameBox(shownStage, 'native'), [shownStage]);
|
|
2279
|
+
const previewCanvas = React.useCallback((size: { w: number; h: number } | null) => {
|
|
2280
|
+
const value = film.film;
|
|
2281
|
+
if (!value) return;
|
|
2282
|
+
if (!size || (size.w === stage.w && size.h === stage.h)) {
|
|
2283
|
+
setCanvasDraft((cur) => { if (cur) preview.current?.update(value); return null; });
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
let shown: Film;
|
|
2287
|
+
try { shown = applyOps(structuredClone(value), canvasOps(size)) as Film; } catch { return; }
|
|
2288
|
+
setCanvasDraft(size);
|
|
2289
|
+
preview.current?.update(shown);
|
|
2290
|
+
}, [film.film, stage, canvasOps]);
|
|
2291
|
+
const changeCanvas = React.useCallback((next: FilmFrameId, given?: { w: number; h: number }) => {
|
|
2292
|
+
const size = given ?? canvasFor(stage, next);
|
|
2293
|
+
setCanvasDraft(null);
|
|
2294
|
+
if (size.w === stage.w && size.h === stage.h) { if (canvasDraft && film.film) preview.current?.update(film.film); return; }
|
|
2295
|
+
void edit(canvasOps(size), 'history.stepEdit');
|
|
2296
|
+
}, [edit, stage, canvasOps, canvasDraft, film.film]);
|
|
2297
|
+
const stageFullRef = React.useRef<HTMLDivElement>(null);
|
|
2298
|
+
const [fullscreen, setFullscreen] = React.useState(false);
|
|
2299
|
+
React.useEffect(() => {
|
|
2300
|
+
const onChange = () => setFullscreen(document.fullscreenElement === stageFullRef.current && !!stageFullRef.current);
|
|
2301
|
+
document.addEventListener('fullscreenchange', onChange);
|
|
2302
|
+
return () => document.removeEventListener('fullscreenchange', onChange);
|
|
2303
|
+
}, []);
|
|
2304
|
+
const toggleFullscreen = React.useCallback(() => {
|
|
2305
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
2306
|
+
else void stageFullRef.current?.requestFullscreen();
|
|
2307
|
+
}, []);
|
|
2308
|
+
const [volume, setVolumeState] = React.useState(1);
|
|
2309
|
+
const [muted, setMutedState] = React.useState(false);
|
|
2310
|
+
const [rate, setRateState] = React.useState(1);
|
|
2311
|
+
/** Play at this speed from now on (the viewer's playback settings, the full-screen player's). */
|
|
2312
|
+
const setSpeed = React.useCallback((speed: number) => {
|
|
2313
|
+
player.current.setRate(speed);
|
|
2314
|
+
speedRef.current = player.current.rate;
|
|
2315
|
+
setRateState(player.current.rate);
|
|
2316
|
+
}, []);
|
|
2317
|
+
/* full screen only: elsewhere K, M, C and F are the editor's keys */
|
|
2318
|
+
useFullscreenKeys({
|
|
2319
|
+
enabled: fullscreen,
|
|
2320
|
+
onToggle: toggle,
|
|
2321
|
+
onToggleMute: () => { player.current.setMuted(!player.current.muted); setMutedState(player.current.muted); },
|
|
2322
|
+
onExit: toggleFullscreen,
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
/*
|
|
2326
|
+
* ── keys (lib/keymap, the scheme chosen in Settings; by default): Space plays, J / K / L shuttle, I / O mark a range,
|
|
2327
|
+
* ← / → step a frame (⇧ ten), ↑ / ↓ go from cut to cut, ⌘Z / ⇧⌘Z undo; the timeline's own (⌘B, Q / W, ⌥← / →, N…)
|
|
2328
|
+
* through its commands. Not while typing, nor in a dialog. ──
|
|
2329
|
+
*/
|
|
2330
|
+
const tabbed = React.useRef(false);
|
|
2331
|
+
React.useEffect(() => {
|
|
2332
|
+
const byKey = (e: KeyboardEvent) => { if (e.key === 'Tab') tabbed.current = true; };
|
|
2333
|
+
const byPointer = () => { tabbed.current = false; };
|
|
2334
|
+
window.addEventListener('keydown', byKey, true);
|
|
2335
|
+
window.addEventListener('pointerdown', byPointer, true);
|
|
2336
|
+
return () => { window.removeEventListener('keydown', byKey, true); window.removeEventListener('pointerdown', byPointer, true); };
|
|
2337
|
+
}, []);
|
|
2338
|
+
/* K (stop) held down: J and L (back, forward) step a frame (K+J, K+L) */
|
|
2339
|
+
const kHeld = React.useRef(false);
|
|
2340
|
+
React.useEffect(() => {
|
|
2341
|
+
const up = (e: KeyboardEvent) => {
|
|
2342
|
+
const key = parseCombo(comboOf({ key: e.key, code: e.code, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }, MAC) ?? '')?.key;
|
|
2343
|
+
if (key && currentKeymap()['shuttle-stop'].some((combo) => parseCombo(combo)?.key === key)) kHeld.current = false;
|
|
2344
|
+
};
|
|
2345
|
+
const lost = () => { kHeld.current = false; };
|
|
2346
|
+
window.addEventListener('keyup', up, true);
|
|
2347
|
+
window.addEventListener('blur', lost);
|
|
2348
|
+
return () => { window.removeEventListener('keyup', up, true); window.removeEventListener('blur', lost); };
|
|
2349
|
+
}, []);
|
|
2350
|
+
/**
|
|
2351
|
+
* With an app that has a chat (lib/host.ts `refer`): ⌘L (Ctrl+L) references what is selected (selectedRefs). True:
|
|
2352
|
+
* the key was taken.
|
|
2353
|
+
*/
|
|
2354
|
+
const referKeysRef = React.useRef<(event: KeyboardEvent) => boolean>(() => false);
|
|
2355
|
+
referKeysRef.current = (event) => {
|
|
2356
|
+
const refer = studioHost?.refer;
|
|
2357
|
+
const el = event.target as HTMLElement | null;
|
|
2358
|
+
if (!refer || event.defaultPrevented || isTypingTarget(el) || el?.closest?.('[role="dialog"]')) return false;
|
|
2359
|
+
if (keyAction(event) === 'refer') {
|
|
2360
|
+
event.preventDefault();
|
|
2361
|
+
for (const ref of selectedRefs()) refer(ref);
|
|
2362
|
+
return true;
|
|
2363
|
+
}
|
|
2364
|
+
return false;
|
|
2365
|
+
};
|
|
2366
|
+
/* the keys, read fresh each time (they act on what is selected and marked now) */
|
|
2367
|
+
const keyNowRef = React.useRef<(event: KeyboardEvent) => void>(() => {});
|
|
2368
|
+
keyNowRef.current = (event) => {
|
|
2369
|
+
if (referKeysRef.current(event)) return;
|
|
2370
|
+
const el = event.target as HTMLElement | null;
|
|
2371
|
+
const cmd = keyAction(event);
|
|
2372
|
+
if (cmd === 'undo' || cmd === 'redo') {
|
|
2373
|
+
if (isTypingTarget(el) || el?.closest('[role="dialog"]')) return;
|
|
2374
|
+
/* not checked against canUndo: a nudge on the picture not written yet is written first, and is the step undone */
|
|
2375
|
+
event.preventDefault();
|
|
2376
|
+
stepHistory(cmd);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (cmd === 'play-pause') {
|
|
2380
|
+
if (isTypingTarget(el)) return;
|
|
2381
|
+
if (event.repeat) { event.preventDefault(); return; }
|
|
2382
|
+
if (el?.closest('[role="dialog"]')) return;
|
|
2383
|
+
if (tabbed.current && el?.closest('button, a, [role="button"]')) return;
|
|
2384
|
+
event.preventDefault();
|
|
2385
|
+
const active = document.activeElement;
|
|
2386
|
+
if (!tabbed.current && active && active !== document.body && 'blur' in active) (active as HTMLElement).blur();
|
|
2387
|
+
toggle();
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (event.defaultPrevented || isTypingTarget(el) || el?.closest?.('[role="dialog"]')) return;
|
|
2391
|
+
/* Esc lets the range go as it lets the selection go (those listen on their own); a menu or a panel open takes it */
|
|
2392
|
+
if (event.key === 'Escape' && markedRange && !document.querySelector('[role="menu"], [role="listbox"], [data-popover-panel]')) setMarkedRange(null);
|
|
2393
|
+
if (!cmd) return;
|
|
2394
|
+
/* a menu or a list open has the arrows; a slider (the ruler, a volume) steps itself with ← / → */
|
|
2395
|
+
if (el?.closest?.('[role="menu"], [role="listbox"]') && /^(frame|second|prev|next|start|end|nudge)/.test(cmd)) return;
|
|
2396
|
+
if (el?.closest?.('[role="slider"]') && /^(frame|second|start|end)/.test(cmd)) return;
|
|
2397
|
+
const commands = timelineCommands.current;
|
|
2398
|
+
const now = player.current.time() * 1000;
|
|
2399
|
+
const go = (ms: number) => { pause(); seek(Math.max(0, Math.min(totalMs, ms))); };
|
|
2400
|
+
const take = () => event.preventDefault();
|
|
2401
|
+
const once = !event.repeat;
|
|
2402
|
+
switch (cmd) {
|
|
2403
|
+
case 'shuttle-back': case 'shuttle-stop': case 'shuttle-forward': {
|
|
2404
|
+
/* the source viewer has its own playhead: J / K / L are the film's only while the film is shown */
|
|
2405
|
+
if (pickedMedia || !(totalMs > 0)) return;
|
|
2406
|
+
take();
|
|
2407
|
+
if (cmd === 'shuttle-stop') { kHeld.current = true; if (once) shuttle('k'); return; }
|
|
2408
|
+
if (kHeld.current) { go(stepFrames(now, cmd === 'shuttle-back' ? -1 : 1)); return; }
|
|
2409
|
+
if (once) shuttle(cmd === 'shuttle-back' ? 'j' : 'l');
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
case 'mark-in': case 'mark-out':
|
|
2413
|
+
if (!(totalMs > 0)) return;
|
|
2414
|
+
take();
|
|
2415
|
+
/* an end that would leave no range (I at the film's end) leaves the range as it was */
|
|
2416
|
+
setMarkedRange((cur) => markRange(cur, cmd === 'mark-in' ? 'in' : 'out', snapToFrame(now), totalMs) ?? cur);
|
|
2417
|
+
return;
|
|
2418
|
+
case 'clear-range':
|
|
2419
|
+
if (!markedRange) return;
|
|
2420
|
+
take();
|
|
2421
|
+
setMarkedRange(null);
|
|
2422
|
+
return;
|
|
2423
|
+
case 'go-in': case 'go-out':
|
|
2424
|
+
if (!markedRange) return;
|
|
2425
|
+
take();
|
|
2426
|
+
go(cmd === 'go-in' ? markedRange.startMs : markedRange.endMs);
|
|
2427
|
+
return;
|
|
2428
|
+
case 'loop':
|
|
2429
|
+
take();
|
|
2430
|
+
if (once) setLoop((on) => !on);
|
|
2431
|
+
return;
|
|
2432
|
+
case 'play-range':
|
|
2433
|
+
if (!(totalMs > 0)) return;
|
|
2434
|
+
take();
|
|
2435
|
+
if (!once) return;
|
|
2436
|
+
if (markedRange) playRange(markedRange); else toggle();
|
|
2437
|
+
return;
|
|
2438
|
+
case 'frame-back': case 'frame-forward': case 'jump-back': case 'jump-forward': case 'start': case 'end': {
|
|
2439
|
+
if (!(totalMs > 0)) return;
|
|
2440
|
+
take();
|
|
2441
|
+
/* a frame (or ten, ⇧) on from the frame the playhead is on, so stepping never drifts */
|
|
2442
|
+
const frames = (cmd.endsWith('back') ? -1 : 1) * (cmd.startsWith('jump') ? JUMP_FRAMES : 1);
|
|
2443
|
+
const next = cmd === 'start' ? 0 : cmd === 'end' ? totalMs : stepFrames(now, frames);
|
|
2444
|
+
pause();
|
|
2445
|
+
seek(Math.max(0, Math.min(totalMs, next)));
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
case 'prev-cut': case 'next-cut': {
|
|
2449
|
+
if (!(totalMs > 0)) return;
|
|
2450
|
+
take();
|
|
2451
|
+
const to = nextCut(cutPoints(tracksRef.current), now, cmd === 'next-cut' ? 1 : -1);
|
|
2452
|
+
if (to != null) go(to);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
case 'duplicate':
|
|
2456
|
+
if (!selectedBlocks.length) return;
|
|
2457
|
+
take();
|
|
2458
|
+
if (once) void duplicateBlocks(selectedBlocks);
|
|
2459
|
+
return;
|
|
2460
|
+
case 'add-marker': case 'next-marker': case 'prev-marker':
|
|
2461
|
+
/* markers (use-editor-tools): the film's, while the film is shown */
|
|
2462
|
+
if (pickedMedia || !(totalMs > 0) || !once) return;
|
|
2463
|
+
take();
|
|
2464
|
+
tools.onKey(cmd);
|
|
2465
|
+
return;
|
|
2466
|
+
case 'insert-source': case 'overwrite-source':
|
|
2467
|
+
/* the source viewer's keys (it takes them first while it is up) */
|
|
2468
|
+
return;
|
|
2469
|
+
case 'range-clip': {
|
|
2470
|
+
/* ⇧X: the range is the selected clips' span */
|
|
2471
|
+
if (!selectedBlocks.length) return;
|
|
2472
|
+
take();
|
|
2473
|
+
setMarkedRange({ startMs: Math.min(...selectedBlocks.map((b) => b.startMs)), endMs: Math.max(...selectedBlocks.map((b) => b.endMs)) });
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
case 'copy-attrs': if (!selectedBlocks.length) return; take(); if (once) copyAttrs(selectedBlocks); return;
|
|
2477
|
+
case 'paste-attrs': if (!selectedBlocks.length) return; take(); if (once) askPasteAttrs(selectedBlocks); return;
|
|
2478
|
+
case 'speed': {
|
|
2479
|
+
/* ⌘R: the selected clip's speed, in the inspector */
|
|
2480
|
+
if (selectedBlocks.length !== 1 || !once) return;
|
|
2481
|
+
take();
|
|
2482
|
+
setInspectorContent('selection');
|
|
2483
|
+
setInspectorOpen(true);
|
|
2484
|
+
window.setTimeout(() => document.querySelector<HTMLInputElement>(`input[aria-label="${t('inspector.speed')}"]`)?.focus(), 80);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
case 'detach-audio': {
|
|
2488
|
+
/* ⇧⌃S: the selected video's sound on a track of its own */
|
|
2489
|
+
const video = selectedBlocks.find((b) => b.kind === 'video' && b.clipId);
|
|
2490
|
+
if (!video || !once) return;
|
|
2491
|
+
take();
|
|
2492
|
+
void detachAudio(video).then((err) => { if (err) toast.showError(err); });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
default:
|
|
2496
|
+
break;
|
|
2497
|
+
}
|
|
2498
|
+
if (!commands || !timelineOpen) return;
|
|
2499
|
+
switch (cmd) {
|
|
2500
|
+
case 'split': take(); if (once) commands.split(); return;
|
|
2501
|
+
case 'split-all': take(); if (once) commands.splitAll(); return;
|
|
2502
|
+
case 'cut': if (!selectedBlocks.length) return; take(); if (once) commands.cut(); return;
|
|
2503
|
+
case 'nudge-back': case 'nudge-forward':
|
|
2504
|
+
if (!selectedBlocks.length) return;
|
|
2505
|
+
take();
|
|
2506
|
+
commands.nudge(cmd === 'nudge-back' ? -1 : 1);
|
|
2507
|
+
return;
|
|
2508
|
+
case 'zoom-fit': take(); commands.zoomFit(); return;
|
|
2509
|
+
case 'select-left': case 'select-right': take(); if (once) commands.selectSide(cmd === 'select-left' ? -1 : 1); return;
|
|
2510
|
+
case 'ripple-start': take(); if (once) commands.rippleTrimStart(); return;
|
|
2511
|
+
case 'ripple-end': take(); if (once) commands.rippleTrimEnd(); return;
|
|
2512
|
+
case 'toggle-snap': case 'toggle-magnet': case 'toggle-linkage': case 'toggle-preview-axis': {
|
|
2513
|
+
take();
|
|
2514
|
+
if (!once) return;
|
|
2515
|
+
const pref = cmd === 'toggle-snap' ? 'snap' : cmd === 'toggle-magnet' ? 'magnet' : cmd === 'toggle-linkage' ? 'linkage' : 'previewAxis';
|
|
2516
|
+
const on = !readTimelinePrefs()[pref];
|
|
2517
|
+
if (pref === 'snap') commands.toggleSnap();
|
|
2518
|
+
else if (pref === 'magnet') commands.toggleMagnet();
|
|
2519
|
+
else if (pref === 'linkage') commands.toggleLinkage();
|
|
2520
|
+
else commands.togglePreviewAxis();
|
|
2521
|
+
toast.show(`${t(`timeline.${pref}`)} · ${t(`timeline.${pref}${on ? 'On' : 'Off'}`)}`);
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
default:
|
|
2525
|
+
}
|
|
2526
|
+
};
|
|
2527
|
+
/* the same keys pressed while the picture has focus come through the preview (key events stay in their document) */
|
|
2528
|
+
const onKeyRef = React.useRef<(event: KeyboardEvent) => void>(() => {});
|
|
2529
|
+
React.useEffect(() => {
|
|
2530
|
+
const onKey = (event: KeyboardEvent) => keyNowRef.current(event);
|
|
2531
|
+
onKeyRef.current = onKey;
|
|
2532
|
+
window.addEventListener('keydown', onKey);
|
|
2533
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
2534
|
+
}, []);
|
|
2535
|
+
const previewKey = React.useCallback((event: KeyboardEvent) => onKeyRef.current(event), []);
|
|
2536
|
+
|
|
2537
|
+
/* ── top bar: title, menus, panels ── */
|
|
2538
|
+
const [renaming, setRenaming] = React.useState(false);
|
|
2539
|
+
const [titleDraft, setTitleDraft] = React.useState('');
|
|
2540
|
+
const titleBoxRef = React.useRef<HTMLButtonElement>(null);
|
|
2541
|
+
const [titleEditWidth, setTitleEditWidth] = React.useState<number | null>(null);
|
|
2542
|
+
const title = loaded?.project.name ?? '';
|
|
2543
|
+
/* the film as a whole, for the inspector when nothing is selected, and its tracks' badges */
|
|
2544
|
+
const filmSummary = React.useMemo(() => {
|
|
2545
|
+
const tracks = film.film?.tracks ?? [];
|
|
2546
|
+
return { title, durationMs: totalMs, tracks: tracks.length, clips: tracks.reduce((n, tr) => n + tr.clips.length, 0), empty, badges: filmTrackBadges(tracks) };
|
|
2547
|
+
}, [title, totalMs, film.film?.tracks, empty]);
|
|
2548
|
+
const startRename = () => { setTitleEditWidth(titleBoxRef.current?.offsetWidth ?? null); setTitleDraft(title); setRenaming(true); };
|
|
2549
|
+
/* in an app with tabs: the tab says this project's title; the tabs are where the projects button and the title are */
|
|
2550
|
+
const inTabs = Boolean(studioHost?.tabs);
|
|
2551
|
+
/* the film at the playhead, drawn by Studio as the projects list will show it */
|
|
2552
|
+
const coverFromFrame = () => {
|
|
2553
|
+
if (previewError || totalMs <= 0) { toast.show(t('project.filmNotReady')); return; }
|
|
2554
|
+
void api.setCover(projectId, { ms: Math.max(0, Math.min(totalMs - 1, timeRef.current)) })
|
|
2555
|
+
.then(() => toast.show(t('projects.coverSet')), () => toast.showError(t('projects.coverFailed')));
|
|
2556
|
+
};
|
|
2557
|
+
/* the app's "New Tab" (its menu): the projects, to pick what the new tab shows */
|
|
2558
|
+
React.useEffect(() => studioHost?.tabs?.onPick(() => setProjectsOpen(true)), []);
|
|
2559
|
+
React.useEffect(() => { if (title) studioHost?.tabs?.report({ title }); }, [title]);
|
|
2560
|
+
const renameError = (e: unknown, name: string) =>
|
|
2561
|
+
toast.showError(e instanceof ApiError && e.body.code === 'agent-running' ? t('projects.agentRunningRename')
|
|
2562
|
+
: e instanceof ApiError && e.body.code === 'export-running' ? t('projects.exportRunningRename')
|
|
2563
|
+
: e instanceof ApiError && e.status === 409 ? t('projects.renameTaken').replace('{name}', name) : t('project.renameFailed'));
|
|
2564
|
+
const commitTitle = async (draft = titleDraft) => {
|
|
2565
|
+
setRenaming(false);
|
|
2566
|
+
const next = draft.trim();
|
|
2567
|
+
if (!next || next === title || !loaded) return false;
|
|
2568
|
+
setLoaded({ ...loaded, project: { ...loaded.project, name: next } });
|
|
2569
|
+
try { const project = await api.rename(projectId, next); setLoaded((l) => (l ? { ...l, project } : l)); return true; }
|
|
2570
|
+
catch (e) {
|
|
2571
|
+
setLoaded((l) => (l ? { ...l, project: { ...l.project, name: title } } : l));
|
|
2572
|
+
renameError(e, next);
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
/* a tab renamed in the tab strip: this page's project, as its title is, or another tab's (its page hears it) */
|
|
2577
|
+
const renameTab = async (id: string, name: string) => {
|
|
2578
|
+
if (id === projectId) return commitTitle(name);
|
|
2579
|
+
try { await api.rename(id, name); return true; }
|
|
2580
|
+
catch (e) { renameError(e, name); return false; }
|
|
2581
|
+
};
|
|
2582
|
+
const [projectsOpen, setProjectsOpen] = React.useState(false);
|
|
2583
|
+
/* the examples over the film, opened from the app's chat */
|
|
2584
|
+
/* the Community over the film: closed, open on its list (true), or on one skill's or example's page */
|
|
2585
|
+
const [examplesOpen, setExamplesOpen] = React.useState<boolean | CommunityItem>(false);
|
|
2586
|
+
const [settingsOpen, setSettingsOpen] = useSettings();
|
|
2587
|
+
/* making subtitles: what went wrong, shown on the CC button; with no service to transcribe, it opens Providers */
|
|
2588
|
+
const [subtitleProblem, setSubtitleProblem] = React.useState<{ text: string; connect: boolean } | null>(null);
|
|
2589
|
+
const makeSubtitles = React.useCallback(() => {
|
|
2590
|
+
if (subtitleProblem?.connect) { setSettingsOpen('providers'); setSubtitleProblem(null); return; }
|
|
2591
|
+
setSubtitleProblem(null);
|
|
2592
|
+
void subs.transcribe().then((r) => { if (!r.ok) setSubtitleProblem({ text: r.error ?? '', connect: !!r.needsTranscriber }); });
|
|
2593
|
+
}, [subs, subtitleProblem]);
|
|
2594
|
+
const [desktopOpen, setDesktopOpen] = React.useState(false);
|
|
2595
|
+
const headerOpsRef = React.useRef<HTMLDivElement>(null);
|
|
2596
|
+
const [historyOpen, setHistoryOpen] = React.useState(false);
|
|
2597
|
+
const [historyAnchor, setHistoryAnchor] = React.useState<{ x: number; y: number } | null>(null);
|
|
2598
|
+
const history = useProjectHistory(projectId, {
|
|
2599
|
+
onError: toast.showError,
|
|
2600
|
+
onMoved: () => { film.forget(); void api.film(projectId).then((f) => { setLoaded(f); }); },
|
|
2601
|
+
beforeCommit: film.settled,
|
|
2602
|
+
});
|
|
2603
|
+
const refreshHistory = React.useRef(history.refresh);
|
|
2604
|
+
refreshHistory.current = history.refresh;
|
|
2605
|
+
const historyOpenRef = React.useRef(historyOpen);
|
|
2606
|
+
historyOpenRef.current = historyOpen;
|
|
2607
|
+
React.useEffect(() => {
|
|
2608
|
+
/* a history action, at once; the folder changing (an edit, the agent's writes) while the panel shows its uncommitted
|
|
2609
|
+
changes, once the burst settles */
|
|
2610
|
+
let later: ReturnType<typeof setTimeout> | null = null;
|
|
2611
|
+
const stop = projectEvents(projectId, (e) => {
|
|
2612
|
+
if (e.type === 'history') { void refreshHistory.current(); return; }
|
|
2613
|
+
if ((e.type === 'film' || e.type === 'files') && historyOpenRef.current) {
|
|
2614
|
+
if (later) clearTimeout(later);
|
|
2615
|
+
later = setTimeout(() => { later = null; void refreshHistory.current(); }, 400);
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
return () => { if (later) clearTimeout(later); stop(); };
|
|
2619
|
+
}, [projectId]);
|
|
2620
|
+
const openHistory = () => {
|
|
2621
|
+
const rect = headerOpsRef.current?.getBoundingClientRect();
|
|
2622
|
+
if (rect) setHistoryAnchor({ x: rect.right, y: rect.bottom + 4 });
|
|
2623
|
+
setHistoryOpen((v) => !v);
|
|
2624
|
+
};
|
|
2625
|
+
const [exportOpen, setExportOpen] = React.useState(false);
|
|
2626
|
+
const [exportClipId, setExportClipId] = React.useState<string | null>(null);
|
|
2627
|
+
/* the picture's menu is built before this: it reaches export through a ref */
|
|
2628
|
+
const exportTrayRef = React.useRef<ExportTrayHandle>(null);
|
|
2629
|
+
const openClipExport = React.useCallback((clipId: string) => {
|
|
2630
|
+
if (!(totalMs > 0)) { toast.show(t('project.filmNotReady')); return; }
|
|
2631
|
+
setExportClipId(clipId);
|
|
2632
|
+
setExportOpen(true);
|
|
2633
|
+
}, [totalMs, toast, t]);
|
|
2634
|
+
openClipExportRef.current = openClipExport;
|
|
2635
|
+
const mgBlocks = React.useMemo(() => timelineTracks
|
|
2636
|
+
.filter((tr) => !tr.hidden)
|
|
2637
|
+
.flatMap((tr) => tr.blocks)
|
|
2638
|
+
.filter((b) => b.kind === 'mg' && b.clipId && b.endMs > b.startMs)
|
|
2639
|
+
.sort((a, b) => a.startMs - b.startMs)
|
|
2640
|
+
.map((b) => ({ clipId: b.clipId!, label: b.title || b.clipId!, startMs: b.startMs, durMs: b.endMs - b.startMs })), [timelineTracks]);
|
|
2641
|
+
const exportFilm = React.useMemo(
|
|
2642
|
+
() => (film.film ? filmShape(film.film as unknown as FilmDoc, info?.spans ?? [], mediaFacts) : null),
|
|
2643
|
+
[film.film, info, mediaFacts],
|
|
2644
|
+
);
|
|
2645
|
+
|
|
2646
|
+
if (failure) {
|
|
2647
|
+
return (
|
|
2648
|
+
<div className="workspace-shell flex flex-col items-center justify-center gap-3 bg-[var(--dock-shell)] text-[13px] text-[var(--text-muted)]">
|
|
2649
|
+
<p>{failure}</p>
|
|
2650
|
+
<button type="button" onClick={onLeave} className="rounded-lg border border-[var(--border)] px-3 py-1.5 hover:bg-[var(--bg-hover)]">{t('nav.projects')}</button>
|
|
2651
|
+
</div>
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
if (!loaded) return <BrandLoadingScreen labelKey="projects.opening" />;
|
|
2655
|
+
|
|
2656
|
+
const value = film.film;
|
|
2657
|
+
const viewerReady = Boolean(info);
|
|
2658
|
+
return (
|
|
2659
|
+
<div className="workspace-shell flex flex-col bg-[var(--dock-shell)]">
|
|
2660
|
+
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col" style={{ paddingLeft: DOCK_GAP, paddingRight: DOCK_GAP, paddingBottom: DOCK_GAP }}>
|
|
2661
|
+
<div className="relative z-10 grid min-h-0 min-w-0 flex-1" style={{ gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'auto minmax(0, 1fr)', gridTemplateAreas: '"bar" "main"' }}>
|
|
2662
|
+
{/* ── top bar: the title centered, whatever is on either side ── */}
|
|
2663
|
+
<div data-titlebar className="relative flex h-8 min-w-0 items-center gap-1" style={{ gridArea: 'bar' }}>
|
|
2664
|
+
{inTabs ? null : <div className="pointer-events-none absolute inset-0 flex min-w-0 items-center justify-center">
|
|
2665
|
+
<div className="pointer-events-auto flex min-w-0 max-w-full items-center">
|
|
2666
|
+
{renaming ? (
|
|
2667
|
+
<input
|
|
2668
|
+
autoFocus
|
|
2669
|
+
value={titleDraft}
|
|
2670
|
+
onChange={(e) => setTitleDraft(e.target.value)}
|
|
2671
|
+
onBlur={() => void commitTitle()}
|
|
2672
|
+
onKeyDown={(e) => {
|
|
2673
|
+
if (e.key === 'Enter') void commitTitle();
|
|
2674
|
+
if (e.key === 'Escape') { setTitleDraft(title); setRenaming(false); }
|
|
2675
|
+
}}
|
|
2676
|
+
placeholder={t('project.untitled')}
|
|
2677
|
+
maxLength={TITLE_MAX_CHARS}
|
|
2678
|
+
style={titleEditWidth ? { width: titleEditWidth } : undefined}
|
|
2679
|
+
className="box-border h-[26px] max-w-full rounded-md bg-transparent px-2 text-[13px] font-medium text-[var(--text-muted)] outline-none"
|
|
2680
|
+
/>
|
|
2681
|
+
) : (
|
|
2682
|
+
<Tooltip label={t('projectMenu.rename')} side="bottom">
|
|
2683
|
+
<button ref={titleBoxRef} type="button" onClick={startRename}
|
|
2684
|
+
className="flex h-[26px] w-fit min-w-0 max-w-[24ch] items-center rounded-md px-2 transition hover:bg-[var(--bg-hover)]">
|
|
2685
|
+
<span className="min-w-0 truncate text-[13px] font-medium text-[var(--text-muted)]">{clampTitle(title || t('project.untitled'))}</span>
|
|
2686
|
+
</button>
|
|
2687
|
+
</Tooltip>
|
|
2688
|
+
)}
|
|
2689
|
+
</div>
|
|
2690
|
+
</div>}
|
|
2691
|
+
<div aria-hidden className="shrink-0 self-stretch" style={{ width: 'var(--titlebar-logo-x)' }} />
|
|
2692
|
+
<div className="pointer-events-auto relative flex min-w-0 items-center gap-1">
|
|
2693
|
+
<Tooltip label={t('project.appMenu')} side="bottom">
|
|
2694
|
+
<button type="button" aria-label={t('project.appMenu')} aria-haspopup="dialog"
|
|
2695
|
+
onClick={() => setSettingsOpen('general')}
|
|
2696
|
+
className="flex h-[26px] w-[26px] items-center justify-center rounded-md text-[var(--text-dim)] transition hover:bg-[var(--bg-hover)]">
|
|
2697
|
+
<BrandLogo size={15} />
|
|
2698
|
+
</button>
|
|
2699
|
+
</Tooltip>
|
|
2700
|
+
{inTabs ? null : <Tooltip label={t('nav.projects')} side="bottom">
|
|
2701
|
+
<button type="button" aria-label={t('nav.projects')} onClick={() => setProjectsOpen((v) => !v)}
|
|
2702
|
+
className={`${TOP_BTN} ${projectsOpen ? 'bg-[var(--bg-hover)] text-[var(--text)]' : ''}`}>
|
|
2703
|
+
<GalleryVerticalEnd size={15} />
|
|
2704
|
+
</button>
|
|
2705
|
+
</Tooltip>}
|
|
2706
|
+
<UpdateButton />
|
|
2707
|
+
<WebUpdateButton />
|
|
2708
|
+
{!studioHost && <Tooltip label={t('desktop.title')} side="bottom">
|
|
2709
|
+
<button type="button" aria-haspopup="dialog" onClick={() => setDesktopOpen(true)}
|
|
2710
|
+
className={`flex h-[26px] shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] text-[var(--text-dim)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)] ${desktopOpen ? 'bg-[var(--bg-hover)] text-[var(--text)]' : ''}`}>
|
|
2711
|
+
<MonitorDown size={14} />
|
|
2712
|
+
{t('nav.desktop')}
|
|
2713
|
+
</button>
|
|
2714
|
+
</Tooltip>}
|
|
2715
|
+
</div>
|
|
2716
|
+
{inTabs ? (
|
|
2717
|
+
<TabStrip onRename={renameTab} onNew={() => setProjectsOpen(true)} onCoverFromFrame={coverFromFrame} maxLength={TITLE_MAX_CHARS} />
|
|
2718
|
+
) : <div className="min-w-0 flex-1" />}
|
|
2719
|
+
<div ref={headerOpsRef} className="pointer-events-auto flex min-w-0 items-center justify-end gap-1">
|
|
2720
|
+
<ProjectLayoutMenu className={TOP_BTN}
|
|
2721
|
+
/* an app that keeps the media in its own panel opens it there, from its own button: not a pane of Studio's */
|
|
2722
|
+
assets={hostAssets ? null : assetsPaneOpen} onAssets={setAssetsPaneOpen}
|
|
2723
|
+
layers={layersPaneOpen} onLayers={setLayersPaneOpen}
|
|
2724
|
+
timeline={timelineOpen} inspector={inspectorOpen}
|
|
2725
|
+
onTimeline={setTimelineOpen} onInspector={setInspectorOpen}
|
|
2726
|
+
hostPanel={hostPanel ? { label: hostPanel.label, open: hostPanel.open, onChange: hostPanel.setOpen } : null} />
|
|
2727
|
+
<ProjectHistoryButton open={historyOpen} onClick={openHistory} className={TOP_BTN} />
|
|
2728
|
+
<Tooltip label={t('projectMenu.export')} side="bottom">
|
|
2729
|
+
<button type="button" aria-label={t('projectMenu.export')} aria-haspopup="dialog" aria-expanded={exportOpen} className={TOP_BTN}
|
|
2730
|
+
onClick={() => {
|
|
2731
|
+
if (previewError) toast.showError(`${t('project.filmCannotExport')} ${previewError}`);
|
|
2732
|
+
else if (totalMs > 0) setExportOpen(true);
|
|
2733
|
+
else toast.show(t('project.filmNotReady'));
|
|
2734
|
+
}}>
|
|
2735
|
+
<Upload size={16} />
|
|
2736
|
+
</button>
|
|
2737
|
+
</Tooltip>
|
|
2738
|
+
{/* in an app's tabs these are in the tab's menu */}
|
|
2739
|
+
{inTabs ? null : <ProjectMenu className={TOP_BTN} onCoverFromFrame={coverFromFrame} onReveal={() => reveal('')} />}
|
|
2740
|
+
</div>
|
|
2741
|
+
<div aria-hidden className="shrink-0 self-stretch" style={{ width: 'var(--titlebar-right-x)' }} />
|
|
2742
|
+
</div>
|
|
2743
|
+
|
|
2744
|
+
{/* the app's own panel (lib/host.ts `panel`) sits right of this, under the top bar: room left for it */}
|
|
2745
|
+
<section ref={dockColRef} className="relative flex min-h-0 min-w-0 flex-col" style={{ gridArea: 'main', ...(hostPanel?.open ? { marginRight: Math.max(0, hostPanel.width - DOCK_GAP) } : null) }}>
|
|
2746
|
+
<div ref={viewerRowRef} className="relative flex min-h-0 min-w-0 flex-1 flex-row">
|
|
2747
|
+
{!hostAssets && assetsPaneOpen && assetsFit ? (
|
|
2748
|
+
<>
|
|
2749
|
+
<div className={`${PANE} shrink-0 bg-[var(--dock-pane)]`} style={{ width: assetsShownWidth }}>
|
|
2750
|
+
{mediaPool()}
|
|
2751
|
+
</div>
|
|
2752
|
+
<PaneDivider width={assetsShownWidth} onResize={setAssetsWidth} containerRef={viewerRowRef} label={t('layout.assets')} min={200} minRest={MIN_VIEWER} />
|
|
2753
|
+
</>
|
|
2754
|
+
) : null}
|
|
2755
|
+
{layersShown ? (
|
|
2756
|
+
<>
|
|
2757
|
+
<div ref={layersPaneRef} className={`${PANE} shrink-0 bg-[var(--dock-pane)]`} style={{ width: layersShownWidth }}>
|
|
2758
|
+
<LayersPanel
|
|
2759
|
+
ask={askStage}
|
|
2760
|
+
timeMs={timeMs}
|
|
2761
|
+
playing={playing}
|
|
2762
|
+
filmKey={value}
|
|
2763
|
+
clipOf={layerClipOf}
|
|
2764
|
+
selected={stageElement && stageElement.kind !== 'clip' ? { ...(stageElement.clipId ? { clipId: stageElement.clipId } : {}), loc: stageElement.loc } : null}
|
|
2765
|
+
selectedClipLocs={selectedLocs}
|
|
2766
|
+
onSelectLayer={selectLayerRow}
|
|
2767
|
+
onSelectClip={selectStageClip}
|
|
2768
|
+
onPoint={setPointedLayer}
|
|
2769
|
+
onToggle={toggleLayerRow}
|
|
2770
|
+
onRestack={restackLayers}
|
|
2771
|
+
onNotify={toast.show}
|
|
2772
|
+
width={layersShownWidth}
|
|
2773
|
+
onClose={() => { setPointedLayer(null); setLayersPaneOpen(false); }}
|
|
2774
|
+
/>
|
|
2775
|
+
</div>
|
|
2776
|
+
{/* the divider measures from the row's left edge: the assets left of this pane are taken off */}
|
|
2777
|
+
<PaneDivider width={layersShownWidth} containerRef={viewerRowRef} label={t('layers.resize')} min={180} minRest={MIN_VIEWER}
|
|
2778
|
+
onResize={(w) => {
|
|
2779
|
+
const left = (layersPaneRef.current?.getBoundingClientRect().left ?? 0) - (viewerRowRef.current?.getBoundingClientRect().left ?? 0);
|
|
2780
|
+
setLayersWidth(Math.max(180, w - left));
|
|
2781
|
+
}} />
|
|
2782
|
+
</>
|
|
2783
|
+
) : null}
|
|
2784
|
+
|
|
2785
|
+
<div className={`${PANE} min-w-0 flex-1 bg-[var(--dock-pane)]`} data-viewer-pane="">
|
|
2786
|
+
<div className={PANE_BAR}>
|
|
2787
|
+
{hostAssets ? null : (
|
|
2788
|
+
<Tooltip label={t(assetsPaneOpen && assetsFit ? 'assetsPane.hide' : 'assetsPane.show')} side="bottom">
|
|
2789
|
+
<button type="button"
|
|
2790
|
+
onClick={() => {
|
|
2791
|
+
if (assetsPaneOpen && assetsFit) { setAssetsPaneOpen(false); return; }
|
|
2792
|
+
setAssetsPaneOpen(true);
|
|
2793
|
+
/* asked for in a window too narrow for all three: the inspector makes room */
|
|
2794
|
+
if (!assetsFit) setInspectorOpen(false);
|
|
2795
|
+
}}
|
|
2796
|
+
aria-label={t(assetsPaneOpen && assetsFit ? 'assetsPane.hide' : 'assetsPane.show')}
|
|
2797
|
+
className={`${PANE_TOGGLE} ${assetsPaneOpen && assetsFit ? PANE_BTN_ON : ''}`}>
|
|
2798
|
+
<LibraryBig size={PANE_TOGGLE_ICON} />
|
|
2799
|
+
</button>
|
|
2800
|
+
</Tooltip>
|
|
2801
|
+
)}
|
|
2802
|
+
<Tooltip label={t(layersShown ? 'layers.hidePane' : 'layers.showPane')} side="bottom">
|
|
2803
|
+
<button type="button"
|
|
2804
|
+
onClick={() => {
|
|
2805
|
+
if (layersShown) { setPointedLayer(null); setLayersPaneOpen(false); return; }
|
|
2806
|
+
setLayersPaneOpen(true);
|
|
2807
|
+
/* asked for in a window too narrow for it: the inspector makes room */
|
|
2808
|
+
if (!layersFit) setInspectorOpen(false);
|
|
2809
|
+
}}
|
|
2810
|
+
aria-label={t(layersShown ? 'layers.hidePane' : 'layers.showPane')}
|
|
2811
|
+
className={`${PANE_TOGGLE} ${layersShown ? PANE_BTN_ON : ''}`}>
|
|
2812
|
+
<Layers size={PANE_TOGGLE_ICON} />
|
|
2813
|
+
</button>
|
|
2814
|
+
</Tooltip>
|
|
2815
|
+
<span className="min-w-0 flex-1" />
|
|
2816
|
+
<Tooltip label={t(inspectorOpen ? 'inspector.hide' : 'inspector.show')} side="bottom">
|
|
2817
|
+
<button type="button" onClick={() => setInspectorOpen(!inspectorOpen)}
|
|
2818
|
+
aria-label={t(inspectorOpen ? 'inspector.hide' : 'inspector.show')}
|
|
2819
|
+
className={`${PANE_TOGGLE} ${inspectorOpen ? PANE_BTN_ON : ''}`}>
|
|
2820
|
+
<Settings2 size={PANE_TOGGLE_ICON} />
|
|
2821
|
+
</button>
|
|
2822
|
+
</Tooltip>
|
|
2823
|
+
</div>
|
|
2824
|
+
<input ref={relinkInputRef} type="file" accept="video/*,image/*,audio/*" className="hidden"
|
|
2825
|
+
onChange={(e) => {
|
|
2826
|
+
const picked = e.target.files?.[0];
|
|
2827
|
+
e.target.value = '';
|
|
2828
|
+
const block = relinkTarget.current;
|
|
2829
|
+
if (!picked || !block) return;
|
|
2830
|
+
void onImport([picked], 'assets', () => {}).then(([landed]) => relinkBlock(block, landed ?? `assets/${picked.name}`), (err: Error) => toast.show(err.message));
|
|
2831
|
+
}} />
|
|
2832
|
+
{pickedMedia ? (
|
|
2833
|
+
<div className="absolute inset-x-0 bottom-0 top-8 z-20">
|
|
2834
|
+
{/* a bundle of Saved plays its page as its clip had it; any other bundle shows its picture */}
|
|
2835
|
+
<SourcePreview file={pickedSaved && pickedMedia.bundle && !pickedMedia.bundle.page ? { ...pickedMedia, kind: 'image' } : pickedMedia} sources={pickedSaved ? savedSources : mediaSources} onClose={() => pickMedia(null)} onPlace={placeFromSource} />
|
|
2836
|
+
</div>
|
|
2837
|
+
) : null}
|
|
2838
|
+
{dropped?.length || undone.length ? (
|
|
2839
|
+
<div role="status" className="@container mx-3 mt-1.5 shrink-0 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-1 text-[12.5px] text-[var(--text)]">
|
|
2840
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
2841
|
+
{/* edits inside pages left out and clip edits changed back are one thing to the person: their edits, undone */}
|
|
2842
|
+
<span className="min-w-0 basis-full truncate @xs:basis-0 @xs:flex-1" title={t('project.editsUndone').replace('{n}', String(undoneCount))}>
|
|
2843
|
+
<span className="hidden @xl:inline">{t('project.editsUndone').replace('{n}', String(undoneCount))}</span>
|
|
2844
|
+
<span className="@xl:hidden">{t('project.editsUndoneShort').replace('{n}', String(undoneCount))}</span>
|
|
2845
|
+
</span>
|
|
2846
|
+
<button type="button" onClick={() => void restoreDropped()} className="shrink-0 rounded-md bg-[var(--text)] px-2.5 py-1 text-[var(--surface)] transition hover:opacity-85">{t('project.editsRestore')}</button>
|
|
2847
|
+
<button type="button" onClick={() => { setDropped(null); for (const e of undone) settledEdits.current.add(e); setUndone([]); }} className="shrink-0 rounded-md px-2 py-1 text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]">{t('project.editsLeaveOut')}</button>
|
|
2848
|
+
</div>
|
|
2849
|
+
</div>
|
|
2850
|
+
) : null}
|
|
2851
|
+
<div className="relative flex min-h-0 flex-1 items-center justify-center px-3 py-2.5" style={{ containerType: 'size' }} {...pictureDropProps}
|
|
2852
|
+
data-viewer-picture="" data-viewer-focus={viewerFocus ? '1' : '0'} onPointerDownCapture={() => setBarAt('stage')}>
|
|
2853
|
+
{pictureDrop ? (
|
|
2854
|
+
<div className="pointer-events-none absolute inset-2 z-30 flex items-center justify-center rounded-lg bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] ring-2 ring-inset ring-[var(--accent)]">
|
|
2855
|
+
<span className="rounded-md bg-[var(--surface)] px-2.5 py-1 text-[12.5px] text-[var(--text)] shadow-sm">{t('project.dropOnPicture')}</span>
|
|
2856
|
+
</div>
|
|
2857
|
+
) : null}
|
|
2858
|
+
{value ? (
|
|
2859
|
+
<div ref={stageFullRef} className={`relative flex h-full w-full items-center justify-center ${fullscreen ? 'bg-black' : ''}`}
|
|
2860
|
+
style={{ containerType: 'size' }} onClick={fullscreen ? toggle : undefined}>
|
|
2861
|
+
<div className={`relative bg-black ${frameBox.inner.left < -0.5 || frameBox.inner.top < -0.5 ? 'overflow-hidden' : 'overflow-visible'}`}
|
|
2862
|
+
style={{ ...fitBox(frameBox.w, frameBox.h), containerType: 'size' }}>
|
|
2863
|
+
<div className="absolute" style={{
|
|
2864
|
+
left: `${(frameBox.inner.left / frameBox.w) * 100}%`,
|
|
2865
|
+
top: `${(frameBox.inner.top / frameBox.h) * 100}%`,
|
|
2866
|
+
width: `${(frameBox.inner.w / frameBox.w) * 100}%`,
|
|
2867
|
+
height: `${(frameBox.inner.h / frameBox.h) * 100}%`,
|
|
2868
|
+
}}>
|
|
2869
|
+
<Preview ref={preview} folder={folder} stage={shownStage} time={() => player.current.time()} fps={exactRate(projectFps)}
|
|
2870
|
+
onInfo={onInfo} onKey={previewKey} onError={setPreviewError} onPlayed={onPlayed}
|
|
2871
|
+
onSpans={(next) => setInfo((had) => (had ? { ...had, spans: next } : had))}>
|
|
2872
|
+
{!fullscreen ? (
|
|
2873
|
+
<FilmStageSelect
|
|
2874
|
+
previewRef={preview}
|
|
2875
|
+
stage={shownStage}
|
|
2876
|
+
doc={value as unknown as FilmDoc}
|
|
2877
|
+
spans={spans}
|
|
2878
|
+
timeMs={timeMs}
|
|
2879
|
+
playing={playing}
|
|
2880
|
+
onPause={pause}
|
|
2881
|
+
onSelectElement={selectStageElement}
|
|
2882
|
+
onEditClip={editClipsFromStage}
|
|
2883
|
+
linkedLocs={selectedLocs}
|
|
2884
|
+
onSelectClips={selectStageClips}
|
|
2885
|
+
selectRequest={stageSelectRequest}
|
|
2886
|
+
clipPreviewRef={clipPreviewRef}
|
|
2887
|
+
lookPreviewRef={lookPreviewRef}
|
|
2888
|
+
controlRef={stageControl}
|
|
2889
|
+
onTransformLayer={transformStageLayer}
|
|
2890
|
+
onTransformLayers={transformStageLayers}
|
|
2891
|
+
onEditTextInline={editStageTextInline}
|
|
2892
|
+
onGeometry={geometryStore.set}
|
|
2893
|
+
menuFor={stageMenuWithRefer}
|
|
2894
|
+
recoveryMenu={recoveryMenu}
|
|
2895
|
+
{...(studioHost?.refer ? { onRegion: referRegion, regionHint: t('host.regionHint') } : {})}
|
|
2896
|
+
flashBox={flashBox}
|
|
2897
|
+
chat={chatOnPicture}
|
|
2898
|
+
{...(hostHover ? { onHoverLayer: hoverLayer } : {})}
|
|
2899
|
+
selectionBar={barAt === 'stage'}
|
|
2900
|
+
{...(studioHost?.refer ? { onRefer: referFromStage } : {})}
|
|
2901
|
+
onStartMask={openMaskSection}
|
|
2902
|
+
pointLayer={pointedLayer}
|
|
2903
|
+
onDropPicture={putLayerPicture}
|
|
2904
|
+
arrowsMove={viewerFocus}
|
|
2905
|
+
/>
|
|
2906
|
+
) : null}
|
|
2907
|
+
</Preview>
|
|
2908
|
+
</div>
|
|
2909
|
+
{/* a clip under the playhead whose file is gone plays as nothing (src/timeline.mjs, film-missing):
|
|
2910
|
+
said here, over the picture, with what to do */}
|
|
2911
|
+
{statsOn && playing && played ? (
|
|
2912
|
+
<div data-playback-stats="" className="pointer-events-none absolute right-2 top-2 z-10 rounded-[4px] bg-black/75 px-2 py-1 font-mono text-[11px] tabular-nums text-white">
|
|
2913
|
+
{t('viewer.stats').replace('{fps}', String(Math.round((played.drawn * 1000) / Math.max(1, played.ms)))).replace('{late}', String(Math.round((played.late * 1000) / Math.max(1, played.ms))))}
|
|
2914
|
+
{played.rendered ? ` · ${t('viewer.statsRendered')}` : ''}
|
|
2915
|
+
</div>
|
|
2916
|
+
) : null}
|
|
2917
|
+
{missingHere && !fullscreen ? (
|
|
2918
|
+
<div data-preview-missing="" className="pointer-events-none absolute left-2 top-2 z-10 max-w-[80%] truncate rounded-[4px] bg-black/75 px-2 py-1 text-[11px] text-white">
|
|
2919
|
+
{t('project.previewMissing').replace('{file}', missingHere)}
|
|
2920
|
+
</div>
|
|
2921
|
+
) : null}
|
|
2922
|
+
{!viewerReady && !empty && !previewError ? (
|
|
2923
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black">
|
|
2924
|
+
<span role="status" className="text-sweep relative text-[13px] text-white/60">{t('project.previewStageMount')}</span>
|
|
2925
|
+
</div>
|
|
2926
|
+
) : null}
|
|
2927
|
+
{fullscreen ? <div className="absolute inset-0 z-[5]" aria-hidden /> : null}
|
|
2928
|
+
{/* subtitles sit on the frame, not on the film: their place is a share of the frame, so a 9:16
|
|
2929
|
+
frame puts them on its own bottom (the bar), not pushed up with the picture */}
|
|
2930
|
+
<LiveTime clock={playing ? clock : null} timeMs={timeMs} step={subtitleStep}>{(liveMs) => (
|
|
2931
|
+
<SubtitleOverlay
|
|
2932
|
+
cues={subs.cues}
|
|
2933
|
+
timeMs={liveMs}
|
|
2934
|
+
style={filmSubtitleStyleFor(subs.style, frameBox)}
|
|
2935
|
+
onMove={fullscreen ? undefined : subs.move}
|
|
2936
|
+
onPick={fullscreen ? undefined : showSubtitleLook}
|
|
2937
|
+
/>
|
|
2938
|
+
)}</LiveTime>
|
|
2939
|
+
</div>
|
|
2940
|
+
{fullscreen ? (
|
|
2941
|
+
<FilmFullscreenBar
|
|
2942
|
+
hostRef={stageFullRef}
|
|
2943
|
+
timeMs={timeMs}
|
|
2944
|
+
totalMs={totalMs}
|
|
2945
|
+
playing={playing}
|
|
2946
|
+
volume={volume}
|
|
2947
|
+
muted={muted}
|
|
2948
|
+
rate={rate}
|
|
2949
|
+
onToggle={toggle}
|
|
2950
|
+
onScrubPreview={(ms) => seek(ms)}
|
|
2951
|
+
onScrubCommit={(ms) => seek(ms)}
|
|
2952
|
+
onVolume={(v) => { player.current.setVolume(v); setVolumeState(player.current.volume); setMutedState(player.current.muted); }}
|
|
2953
|
+
onToggleMute={() => { player.current.setMuted(!player.current.muted); setMutedState(player.current.muted); }}
|
|
2954
|
+
onRate={setSpeed}
|
|
2955
|
+
onExit={toggleFullscreen}
|
|
2956
|
+
captionsOn={subs.count ? subs.style.on : undefined}
|
|
2957
|
+
onToggleCaptions={subs.toggle}
|
|
2958
|
+
/>
|
|
2959
|
+
) : null}
|
|
2960
|
+
</div>
|
|
2961
|
+
) : (
|
|
2962
|
+
<p className="max-w-[280px] text-center text-[12.5px] text-[var(--text-faint)]">{loaded.problems.join(' ') || t('project.filmEmptyHint')}</p>
|
|
2963
|
+
)}
|
|
2964
|
+
</div>
|
|
2965
|
+
{film.broken ? (
|
|
2966
|
+
<div role="status" className="mx-2 mb-1 flex items-start gap-2 rounded-md border border-[var(--warn)] px-2.5 py-1.5 text-[12px] leading-snug text-[var(--text)]">
|
|
2967
|
+
<TriangleAlert size={14} className="mt-px shrink-0 text-[var(--warn)]" aria-hidden />
|
|
2968
|
+
<span className="min-w-0 break-words"><span className="font-medium">{t('project.filmBroken')}</span>{' '}<span className="text-[var(--text-dim)]">{film.broken}</span></span>
|
|
2969
|
+
</div>
|
|
2970
|
+
) : null}
|
|
2971
|
+
<ViewerBar
|
|
2972
|
+
timeMs={timeMs}
|
|
2973
|
+
clock={clock}
|
|
2974
|
+
totalMs={totalMs}
|
|
2975
|
+
playing={playing}
|
|
2976
|
+
onPlayPause={toggle}
|
|
2977
|
+
playback={{
|
|
2978
|
+
loop,
|
|
2979
|
+
ranged: Boolean(markedRange),
|
|
2980
|
+
onLoop: () => setLoop((on) => !on),
|
|
2981
|
+
speed: rate,
|
|
2982
|
+
onSpeed: setSpeed,
|
|
2983
|
+
}}
|
|
2984
|
+
onFullscreen={toggleFullscreen}
|
|
2985
|
+
/>
|
|
2986
|
+
</div>
|
|
2987
|
+
|
|
2988
|
+
{inspectorShown ? (
|
|
2989
|
+
<>
|
|
2990
|
+
<PaneDivider width={inspectorShownWidth} onResize={commitInspectorWidth} containerRef={viewerRowRef} label={t('inspector.resize')} min={MIN_INSPECTOR} minRest={MIN_VIEWER} side="end" />
|
|
2991
|
+
{typeof inspectorContent === 'object' ? (
|
|
2992
|
+
<ResourceInspector file={inspectorContent} url={(pickedSaved && inspectorContent === pickedMedia ? savedSources : mediaSources).file(inspectorContent)} width={inspectorShownWidth} onClose={closeInspector} />
|
|
2993
|
+
) : inspectorContent === 'subtitles' ? (
|
|
2994
|
+
<SubtitleInspector
|
|
2995
|
+
subtitles={{ style: subs.style, onStyle: subs.patchStyle, translate: subs.translate, sourceLanguage: subs.sourceLanguage, lines: subs.captions?.cues ?? [], onEditLine: (cue, text, language) => void subs.setLine(cue, text, language), timeMs, onSeek: seek, untranscribed: subs.captions?.untranscribed.length ?? 0, onMake: makeSubtitles, making: subs.transcribing, ...(subtitleRefer ? { refer: subtitleRefer } : {}) }}
|
|
2996
|
+
width={inspectorShownWidth}
|
|
2997
|
+
onClose={closeInspector}
|
|
2998
|
+
/>
|
|
2999
|
+
) : (
|
|
3000
|
+
<FontScopeContext.Provider value={fontScope}>
|
|
3001
|
+
<LiveGeometryInspector
|
|
3002
|
+
store={geometryStore}
|
|
3003
|
+
element={stageElement}
|
|
3004
|
+
clips={selectedBlocks}
|
|
3005
|
+
mainTrack={mainTrack}
|
|
3006
|
+
onEditClips={editClips}
|
|
3007
|
+
width={inspectorShownWidth}
|
|
3008
|
+
onClose={closeInspector}
|
|
3009
|
+
onEditStyle={editStageStyle}
|
|
3010
|
+
onEditSimilar={editStageStyleSimilar}
|
|
3011
|
+
countSimilar={countSimilar}
|
|
3012
|
+
onPreviewStyle={previewStageStyle}
|
|
3013
|
+
onPreviewText={previewStageText}
|
|
3014
|
+
onEditText={editStageText}
|
|
3015
|
+
inlineTextFailure={inlineTextFailure}
|
|
3016
|
+
onEditOverride={editStageOverride}
|
|
3017
|
+
onPreviewOverride={previewStageOverride}
|
|
3018
|
+
onSelectParent={selectStageParent}
|
|
3019
|
+
onSelectClip={selectStageClip}
|
|
3020
|
+
onPreviewClip={previewClip}
|
|
3021
|
+
pageChangesOf={pageChangesOf}
|
|
3022
|
+
propsHostOf={propsHostOf}
|
|
3023
|
+
layerProps={layerProps}
|
|
3024
|
+
pictures={pictureActions}
|
|
3025
|
+
replaceEntries={tools.replaceEntries}
|
|
3026
|
+
clipLookOf={clipLookOf}
|
|
3027
|
+
onPreviewLook={previewLook}
|
|
3028
|
+
onStartCrop={startStageCrop}
|
|
3029
|
+
onEditMask={editStageMask}
|
|
3030
|
+
maskAsk={maskAsk}
|
|
3031
|
+
onMaskAsked={maskAsked}
|
|
3032
|
+
stage={stage}
|
|
3033
|
+
film={filmSummary}
|
|
3034
|
+
onCanvas={changeCanvas}
|
|
3035
|
+
onCanvasPreview={previewCanvas}
|
|
3036
|
+
canvasDraft={canvasDraft}
|
|
3037
|
+
canvasOriginal={originalSize}
|
|
3038
|
+
onEditLayers={editStageOverrides}
|
|
3039
|
+
onPlaceLayers={(to) => stageControl.current?.placeLayers(to)}
|
|
3040
|
+
locked={inspectorLocked}
|
|
3041
|
+
rate={frameRate}
|
|
3042
|
+
fades={clipFades}
|
|
3043
|
+
transition={pickedTr && !selectedBlocks.length ? {
|
|
3044
|
+
kind: pickedTr.kind,
|
|
3045
|
+
durationMs: pickedTr.endMs - pickedTr.startMs,
|
|
3046
|
+
onDuration: (ms) => askTransition({ kind: 'resize', key: pickedTr.key, durationMs: ms }),
|
|
3047
|
+
onRemove: () => { void askTransition({ kind: 'remove', key: pickedTr.key }).then((err) => { if (err) toast.show(err); }); setPickedTransition(null); },
|
|
3048
|
+
} : null}
|
|
3049
|
+
/>
|
|
3050
|
+
</FontScopeContext.Provider>
|
|
3051
|
+
)}
|
|
3052
|
+
</>
|
|
3053
|
+
) : null}
|
|
3054
|
+
</div>
|
|
3055
|
+
|
|
3056
|
+
{timelineOpen ? <DockPaneDivider height={timelineShownHeight} onResize={commitTimelineHeight} containerRef={dockColRef} label={t('timeline.resize')} /> : null}
|
|
3057
|
+
{timelineOpen && value ? (
|
|
3058
|
+
<div className={`${PANE} shrink-0 bg-[var(--dock-pane)]`} style={{ height: timelineShownHeight }}>
|
|
3059
|
+
<div className="absolute inset-0 flex min-h-0 min-w-0 flex-col" data-timeline-scope="main" style={{ right: METERS_W }}
|
|
3060
|
+
onPointerDownCapture={() => { setBarAt('timeline'); if (pickedMedia) pickMedia(null); }}>
|
|
3061
|
+
<Timeline
|
|
3062
|
+
blockMenuExtra={timelineMenuExtra}
|
|
3063
|
+
chat={timelineChat}
|
|
3064
|
+
{...(hostHover ? { onHoverBlock: hoverBlock } : {})}
|
|
3065
|
+
/* the range (I / O, ⇧-drag on the ruler, Play range) is Studio's own; referring it is the app's */
|
|
3066
|
+
range={markedRange}
|
|
3067
|
+
onRangeChange={setMarkedRange}
|
|
3068
|
+
trackMenuExtra={trackMenuExtra}
|
|
3069
|
+
{...(studioHost?.refer ? {
|
|
3070
|
+
onReferBlocks: referBlocks, rangeRef: markedRangeRef, onReferSelection: referFromTimeline, onReferRuler: referRuler,
|
|
3071
|
+
} : {})}
|
|
3072
|
+
commandsRef={timelineCommands}
|
|
3073
|
+
trackNames={trackNames}
|
|
3074
|
+
onRenameTrack={renameTrack}
|
|
3075
|
+
selectionBar={barAt === 'timeline'}
|
|
3076
|
+
onDuplicate={duplicateBlocks}
|
|
3077
|
+
onPlayRange={playRange}
|
|
3078
|
+
thumbs={shownThumbs}
|
|
3079
|
+
posters={shownPosters}
|
|
3080
|
+
mediaTrouble={mediaTrouble}
|
|
3081
|
+
relinkCandidates={relinkCandidates}
|
|
3082
|
+
onRelink={(block, path) => void relinkBlock(block, path)}
|
|
3083
|
+
onViewChange={setThumbView}
|
|
3084
|
+
tracks={tools.tracks}
|
|
3085
|
+
totalMs={totalMs}
|
|
3086
|
+
timeMs={timeMs}
|
|
3087
|
+
clock={playing ? clock : null}
|
|
3088
|
+
playing={playing}
|
|
3089
|
+
onSeek={(ms) => seek(ms)}
|
|
3090
|
+
onScrubPreview={scrubPreview}
|
|
3091
|
+
onScrubCommit={scrubCommit}
|
|
3092
|
+
onHoverPreview={hoverPreview}
|
|
3093
|
+
onRemove={removeBlock}
|
|
3094
|
+
onRemoveMany={removeBlocks}
|
|
3095
|
+
onSplit={splitBlocks}
|
|
3096
|
+
onDetachAudio={detachAudio}
|
|
3097
|
+
onEditBlocks={editBlocks}
|
|
3098
|
+
onEditOps={editOps}
|
|
3099
|
+
links={clipLinks}
|
|
3100
|
+
fades={clipFades}
|
|
3101
|
+
transitions={filmTransitions}
|
|
3102
|
+
onTransition={askTransition}
|
|
3103
|
+
pickedTransition={pickedTr?.key ?? null}
|
|
3104
|
+
onPickTransition={pickTransition}
|
|
3105
|
+
onCopy={copyBlocks}
|
|
3106
|
+
onPaste={pasteAt}
|
|
3107
|
+
onDropAsset={dropAsset}
|
|
3108
|
+
onDropFiles={dropFiles}
|
|
3109
|
+
onExportBlock={openClipExport}
|
|
3110
|
+
hasClipboard={clipboard.length > 0}
|
|
3111
|
+
selected={selectedIds}
|
|
3112
|
+
onSelectedChange={onTimelineSelected}
|
|
3113
|
+
subtitles={{ on: subs.style.on, onToggle: subs.toggle, count: subs.count, untranscribed: subs.captions?.untranscribed.length ?? 0, onConfigure: configureSubtitles, configuring: inspectorShown && inspectorContent === 'subtitles', onMake: makeSubtitles, making: subs.transcribing, problem: subtitleProblem }}
|
|
3114
|
+
subtitleRow={subtitleRow}
|
|
3115
|
+
waveUrl={waveUrl}
|
|
3116
|
+
history={timelineHistory}
|
|
3117
|
+
{...tools.timeline}
|
|
3118
|
+
/>
|
|
3119
|
+
</div>
|
|
3120
|
+
{/* the audio meters, beside the timeline */}
|
|
3121
|
+
<div className="absolute inset-y-0 right-0"><AudioMeters levels={tools.levels} playing={playing} /></div>
|
|
3122
|
+
</div>
|
|
3123
|
+
) : null}
|
|
3124
|
+
</section>
|
|
3125
|
+
</div>
|
|
3126
|
+
</div>
|
|
3127
|
+
|
|
3128
|
+
<ProjectHistoryControls projectId={projectId} history={history} open={historyOpen} anchor={historyAnchor} onClose={() => setHistoryOpen(false)} />
|
|
3129
|
+
<ProjectsPopover open={projectsOpen} currentProjectId={projectId} onOpen={(id) => { if (!studioHost?.tabs) setProjectsOpen(false); onProject(id); }} onCurrentRemoved={onLeave} onClose={() => setProjectsOpen(false)} />
|
|
3130
|
+
{examplesOpen ? (
|
|
3131
|
+
<ExamplesDialog projectId={projectId} open={examplesOpen === true ? undefined : examplesOpen} onClose={() => setExamplesOpen(false)}
|
|
3132
|
+
/* a film started from an example: in a tab of its own where there are tabs, else here */
|
|
3133
|
+
onStarted={(id) => { if (studioHost?.tabs) studioHost.tabs.open(id); else onProject(id); }} />
|
|
3134
|
+
) : null}
|
|
3135
|
+
<SettingsModal open={settingsOpen !== null} section={settingsOpen ?? undefined} onClose={() => setSettingsOpen(null)} />
|
|
3136
|
+
{/* the host app's media drawer (panel.assets): over its panel, under Studio's top bar, down to the `bottom` px the
|
|
3137
|
+
panel keeps for itself (the panel goes on below it, so the two read as one) */}
|
|
3138
|
+
{hostAssets?.open && hostPanel ? <PanelSeam panel={hostPanel} bottom={hostAssets.bottom} /> : null}
|
|
3139
|
+
{hostAssets?.open && hostPanel ? (
|
|
3140
|
+
<div className="fixed z-30 flex min-h-0 min-w-0 flex-col overflow-hidden rounded-t-[7px] bg-[var(--dock-pane)]"
|
|
3141
|
+
style={{ top: HOST_PANEL_TOP, right: DOCK_GAP, bottom: hostAssets.bottom, width: Math.max(0, hostPanel.width - 2 * DOCK_GAP) }}>
|
|
3142
|
+
{mediaPool()}
|
|
3143
|
+
</div>
|
|
3144
|
+
) : null}
|
|
3145
|
+
<DesktopDialog open={desktopOpen} onClose={() => setDesktopOpen(false)} />
|
|
3146
|
+
<ExportCenter
|
|
3147
|
+
open={exportOpen}
|
|
3148
|
+
onClose={() => { setExportOpen(false); setExportClipId(null); }}
|
|
3149
|
+
projectId={projectId}
|
|
3150
|
+
title={title || 'video'}
|
|
3151
|
+
durationMs={totalMs}
|
|
3152
|
+
stage={stage}
|
|
3153
|
+
frame="native"
|
|
3154
|
+
film={exportFilm as never}
|
|
3155
|
+
playheadMs={timeMs}
|
|
3156
|
+
mgBlocks={mgBlocks}
|
|
3157
|
+
{...(exportClipId ? { focusClipId: exportClipId } : {})}
|
|
3158
|
+
onQueued={(jobs) => exportTrayRef.current?.push(jobs)}
|
|
3159
|
+
projectFps={projectFps}
|
|
3160
|
+
missingFiles={[...mediaTrouble].flatMap(([src, why]) => (why === 'missing' ? [src] : []))}
|
|
3161
|
+
/>
|
|
3162
|
+
<PasteAttrsDialog attrs={pasteAttrsOpen ? copiedAttrs : null} onPaste={doPasteAttrs} onCancel={() => setPasteAttrsOpen(false)} />
|
|
3163
|
+
<ExportTray ref={exportTrayRef} projectId={projectId} onError={toast.showError} inset={hostPanel?.open ? hostPanel.width : 0} />
|
|
3164
|
+
<SpendAsks projectId={projectId} />
|
|
3165
|
+
<Toast toast={toast.current} />
|
|
3166
|
+
</div>
|
|
3167
|
+
);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
interface GeometryStore {
|
|
3171
|
+
get: () => StageGeometry | null;
|
|
3172
|
+
set: (g: StageGeometry | null) => void;
|
|
3173
|
+
subscribe: (fn: () => void) => () => void;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
/** Where the thing in hand is: only the inspector subscribes, so a drag re-renders the inspector, not the editor. */
|
|
3177
|
+
function createGeometryStore(): GeometryStore {
|
|
3178
|
+
let value: StageGeometry | null = null;
|
|
3179
|
+
const listeners = new Set<() => void>();
|
|
3180
|
+
return {
|
|
3181
|
+
get: () => value,
|
|
3182
|
+
set: (g) => { value = g; for (const fn of listeners) fn(); },
|
|
3183
|
+
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
function LiveGeometryInspector({ store, ...props }: Omit<React.ComponentProps<typeof FilmInspector>, 'geometry'> & { store: GeometryStore }) {
|
|
3188
|
+
const geometry = React.useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
3189
|
+
return <FilmInspector {...props} geometry={geometry} />;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/**
|
|
3193
|
+
* The seam that sizes the app's panel while Studio's assets drawer covers it (the panel's own seam is under the
|
|
3194
|
+
* drawer then): the same 10 px strip at the column's left edge, dragged or moved with the arrow keys.
|
|
3195
|
+
*/
|
|
3196
|
+
function PanelSeam({ panel, bottom }: { panel: HostPanel; bottom: number }) {
|
|
3197
|
+
const drag = React.useRef<{ x: number; width: number } | null>(null);
|
|
3198
|
+
return (
|
|
3199
|
+
<div role="separator" aria-orientation="vertical" aria-label={panel.label} tabIndex={0}
|
|
3200
|
+
className="fixed z-[31] w-[10px] cursor-col-resize outline-none focus-visible:bg-[var(--border-strong)]"
|
|
3201
|
+
style={{ top: HOST_PANEL_TOP, bottom, right: panel.width - 10 }}
|
|
3202
|
+
onPointerDown={(e) => {
|
|
3203
|
+
if (e.button !== 0) return;
|
|
3204
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
3205
|
+
drag.current = { x: e.screenX, width: panel.width };
|
|
3206
|
+
document.body.style.cursor = 'col-resize';
|
|
3207
|
+
}}
|
|
3208
|
+
onPointerMove={(e) => { if (drag.current) panel.setWidth(drag.current.width + (drag.current.x - e.screenX)); }}
|
|
3209
|
+
onPointerUp={(e) => {
|
|
3210
|
+
if (!drag.current) return;
|
|
3211
|
+
panel.setWidth(drag.current.width + (drag.current.x - e.screenX), true);
|
|
3212
|
+
drag.current = null;
|
|
3213
|
+
document.body.style.cursor = '';
|
|
3214
|
+
}}
|
|
3215
|
+
onPointerCancel={() => { drag.current = null; document.body.style.cursor = ''; }}
|
|
3216
|
+
onKeyDown={(e) => {
|
|
3217
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
3218
|
+
e.preventDefault();
|
|
3219
|
+
panel.setWidth(panel.width + (e.key === 'ArrowLeft' ? 24 : -24), true);
|
|
3220
|
+
}} />
|
|
3221
|
+
);
|
|
3222
|
+
}
|