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
package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx
ADDED
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The export center: the project menu's "Export".
|
|
3
|
+
*
|
|
4
|
+
* Organized by where the film is going, not by format: a platform, an editor, a GIF, the sound, stills. Each
|
|
5
|
+
* destination has its own few settings and a one-line summary of what comes out, and one primary button says what
|
|
6
|
+
* happens.
|
|
7
|
+
*
|
|
8
|
+
* Everything renders on this machine, in Studio's server (see lib/export-jobs): the dialog closes as soon as the
|
|
9
|
+
* exports are queued, and the export tray follows them.
|
|
10
|
+
*
|
|
11
|
+
* What was chosen last time for each destination is kept in this browser (localStorage) and comes back next time.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
AlertTriangle, AudioLines, Captions, Check, ChevronDown, Clapperboard, Download, Film, FolderArchive, FolderOpen, Images, Layers, Loader2, MonitorPlay, Presentation,
|
|
15
|
+
Sparkles, X,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import { createPortal } from 'react-dom';
|
|
19
|
+
|
|
20
|
+
import { useLanguage, useT } from '@/i18n';
|
|
21
|
+
import { studioHost } from '@/lib/host';
|
|
22
|
+
import { MenuSelect } from '@/components/MenuSelect';
|
|
23
|
+
import { CustomFrame } from '@/components/FramePicker';
|
|
24
|
+
import { FRAME_RATES } from '@/lib/timecode';
|
|
25
|
+
import { api } from '@/api';
|
|
26
|
+
import {
|
|
27
|
+
CODEC_LABEL,
|
|
28
|
+
EXPORT_ALPHA_CODECS,
|
|
29
|
+
exportPresetsFor,
|
|
30
|
+
exportDimsForShortEdge,
|
|
31
|
+
exportEstimateBytes,
|
|
32
|
+
exportFilmPixels,
|
|
33
|
+
exportFramedDims,
|
|
34
|
+
exportGifDims,
|
|
35
|
+
exportGifEstimateBytes,
|
|
36
|
+
exportPreset,
|
|
37
|
+
presetFps,
|
|
38
|
+
rangeMarks,
|
|
39
|
+
type ExportPresetGroup,
|
|
40
|
+
type ExportQuality,
|
|
41
|
+
type ExportVideoCodec,
|
|
42
|
+
} from '@/lib/export-spec';
|
|
43
|
+
import {
|
|
44
|
+
formatBytes,
|
|
45
|
+
formatFilmTime,
|
|
46
|
+
listExports,
|
|
47
|
+
probeFps,
|
|
48
|
+
PROJECT_PARTS,
|
|
49
|
+
startExports,
|
|
50
|
+
type ExportCapabilities,
|
|
51
|
+
type ExportJob,
|
|
52
|
+
type ExportRequest,
|
|
53
|
+
type NleAlpha,
|
|
54
|
+
type ProjectPart,
|
|
55
|
+
type ProjectSizes,
|
|
56
|
+
} from '@/lib/export-jobs';
|
|
57
|
+
import { FILM_FRAME_IDS, filmFrameBox, filmFrameRatio, type FilmFrameFill, type FilmFrameId } from '@/lib/film-frame';
|
|
58
|
+
|
|
59
|
+
type Dest = 'social' | 'video' | 'alpha' | 'nle' | 'gif' | 'audio' | 'subtitles' | 'images' | 'slides' | 'project';
|
|
60
|
+
|
|
61
|
+
const DESTS: readonly { id: Dest; icon: React.ReactNode }[] = [
|
|
62
|
+
{ id: 'video', icon: <Film size={16} /> },
|
|
63
|
+
{ id: 'nle', icon: <Clapperboard size={16} /> },
|
|
64
|
+
{ id: 'social', icon: <MonitorPlay size={16} /> },
|
|
65
|
+
{ id: 'alpha', icon: <Layers size={16} /> },
|
|
66
|
+
{ id: 'gif', icon: <Sparkles size={16} /> },
|
|
67
|
+
{ id: 'audio', icon: <AudioLines size={16} /> },
|
|
68
|
+
{ id: 'subtitles', icon: <Captions size={16} /> },
|
|
69
|
+
{ id: 'images', icon: <Images size={16} /> },
|
|
70
|
+
{ id: 'slides', icon: <Presentation size={16} /> },
|
|
71
|
+
{ id: 'project', icon: <FolderArchive size={16} /> },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** XML destinations: names are not translated; formats and help stay with their editor. */
|
|
75
|
+
const NLE_APPS = {
|
|
76
|
+
premiere: { name: 'Premiere Pro', format: 'xml', hint: 'exportCenter.nleHintPremiere' },
|
|
77
|
+
resolve: { name: 'DaVinci Resolve', format: 'xml', hint: 'exportCenter.nleHintResolve' },
|
|
78
|
+
finalcut: { name: 'Final Cut Pro', format: 'fcpxml', hint: 'exportCenter.nleHintFinalCut' },
|
|
79
|
+
} as const;
|
|
80
|
+
const DRAFT_APP_NAME = { jianying: '剪映', capcut: 'CapCut' } as const;
|
|
81
|
+
|
|
82
|
+
/** "As now" in a frame choice: the delivery frame picked beside the player. */
|
|
83
|
+
type FrameChoice = FilmFrameId | 'current';
|
|
84
|
+
|
|
85
|
+
/** The stems the server makes (exports.mjs STEMS): a video's own sound is the footage's, whatever the timeline calls it. */
|
|
86
|
+
type StemKind = 'voice' | 'music' | 'sfx' | 'footage';
|
|
87
|
+
const STEM_KINDS: readonly StemKind[] = ['voice', 'music', 'sfx', 'footage'];
|
|
88
|
+
|
|
89
|
+
/** "The whole film" in the transparent source list (a block is its clip id). */
|
|
90
|
+
const WHOLE_FILM = '__film__';
|
|
91
|
+
|
|
92
|
+
interface Settings {
|
|
93
|
+
dest: Dest;
|
|
94
|
+
presets: string[];
|
|
95
|
+
socialQuality: ExportQuality;
|
|
96
|
+
/** Burned-in subtitles on platform exports: each platform's habit, all, or none. */
|
|
97
|
+
socialSubs: 'auto' | 'on' | 'off';
|
|
98
|
+
video: {
|
|
99
|
+
codec: ExportVideoCodec;
|
|
100
|
+
shortEdge: number;
|
|
101
|
+
fps: number | 'source';
|
|
102
|
+
quality: ExportQuality;
|
|
103
|
+
frame: FrameChoice;
|
|
104
|
+
fill: FilmFrameFill;
|
|
105
|
+
audio: boolean;
|
|
106
|
+
burn: boolean;
|
|
107
|
+
sidecar: 'none' | 'srt' | 'vtt';
|
|
108
|
+
};
|
|
109
|
+
gif: { width: number; fps: number; frame: FrameChoice; fill: FilmFrameFill; burn: boolean };
|
|
110
|
+
/** `source`: a block's clip id, or the whole film. */
|
|
111
|
+
alpha: { codec: ExportVideoCodec; source: string; shortEdge: number; scale: number; fps: number; audio: boolean };
|
|
112
|
+
audio: { format: 'wav' | 'm4a' | 'mp3'; mix: boolean; stems: boolean };
|
|
113
|
+
/** For an editing program: the sound as clips or as mixed stems. */
|
|
114
|
+
/** the app it is for: `draft` 剪映 (in Chinese) or CapCut (elsewhere), the same draft; `auto`: the draft when that app
|
|
115
|
+
is on this computer, else Premiere Pro */
|
|
116
|
+
nle: { app: 'auto' | 'draft' | keyof typeof NLE_APPS; fps: number | 'source'; audio: 'clips' | 'stems'; subtitles: boolean;
|
|
117
|
+
/** How the pages are written: for a draft, and for Premiere Pro or DaVinci Resolve (Final Cut Pro takes ProRes). */
|
|
118
|
+
alphaDraft: NleAlpha; alphaXml: NleAlpha };
|
|
119
|
+
subtitles: { format: 'srt' | 'vtt' | 'txt' };
|
|
120
|
+
images: { format: 'png' | 'jpg'; shortEdge: number; frame: FrameChoice; fill: FilmFrameFill; burn: boolean };
|
|
121
|
+
slides: { format: 'pptx' | 'pdf'; shortEdge: number; frame: FrameChoice; fill: FilmFrameFill; burn: boolean };
|
|
122
|
+
/** The project itself: a zip or a folder, and which parts besides its work (conversations and logs are private). */
|
|
123
|
+
project: { as: 'zip' | 'folder' } & Record<ProjectPart, boolean>;
|
|
124
|
+
/** Where exports are written. Empty = the default folder. */
|
|
125
|
+
folder: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const DEFAULTS: Settings = {
|
|
129
|
+
dest: 'video',
|
|
130
|
+
presets: ['youtube'],
|
|
131
|
+
socialQuality: 'high',
|
|
132
|
+
socialSubs: 'auto',
|
|
133
|
+
video: {
|
|
134
|
+
codec: 'h264', shortEdge: 1080, fps: 'source', quality: 'high', frame: 'current', fill: 'contain', audio: true, burn: true, sidecar: 'none',
|
|
135
|
+
},
|
|
136
|
+
gif: { width: 640, fps: 15, frame: 'current', fill: 'contain', burn: true },
|
|
137
|
+
alpha: { codec: 'prores4444', source: WHOLE_FILM, shortEdge: 1080, scale: 1, fps: 30, audio: true },
|
|
138
|
+
audio: { format: 'wav', mix: true, stems: false },
|
|
139
|
+
nle: { app: 'auto', fps: 'source', audio: 'clips', subtitles: true, alphaDraft: 'auto', alphaXml: 'prores4444' },
|
|
140
|
+
subtitles: { format: 'srt' },
|
|
141
|
+
images: { format: 'png', shortEdge: 1080, frame: 'current', fill: 'contain', burn: false },
|
|
142
|
+
slides: { format: 'pptx', shortEdge: 1080, frame: 'current', fill: 'contain', burn: false },
|
|
143
|
+
project: { as: 'zip', history: false, chat: false, logs: false },
|
|
144
|
+
folder: '',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const STORE_KEY = 'openfilm.exportCenter.v1';
|
|
148
|
+
|
|
149
|
+
/** What was chosen last time; the first time, the defaults (哔哩哔哩 rather than YouTube in Chinese). */
|
|
150
|
+
function loadSettings(language: string): Settings {
|
|
151
|
+
const first = language === 'zh-CN' ? { ...DEFAULTS, presets: ['bilibili'] } : DEFAULTS;
|
|
152
|
+
try {
|
|
153
|
+
const raw = JSON.parse(localStorage.getItem(STORE_KEY) ?? 'null') as Partial<Settings> | null;
|
|
154
|
+
if (!raw || typeof raw !== 'object') return first;
|
|
155
|
+
const nle = { ...DEFAULTS.nle, ...raw.nle };
|
|
156
|
+
if (nle.app !== 'auto' && nle.app !== 'draft' && !Object.hasOwn(NLE_APPS, nle.app)) nle.app = 'auto';
|
|
157
|
+
return {
|
|
158
|
+
...DEFAULTS,
|
|
159
|
+
...raw,
|
|
160
|
+
presets: Array.isArray(raw.presets) ? raw.presets.filter((id) => exportPreset(id)) : DEFAULTS.presets,
|
|
161
|
+
video: { ...DEFAULTS.video, ...raw.video },
|
|
162
|
+
gif: { ...DEFAULTS.gif, ...raw.gif },
|
|
163
|
+
alpha: { ...DEFAULTS.alpha, ...raw.alpha },
|
|
164
|
+
audio: { ...DEFAULTS.audio, ...raw.audio },
|
|
165
|
+
nle,
|
|
166
|
+
subtitles: { ...DEFAULTS.subtitles, ...raw.subtitles },
|
|
167
|
+
images: { ...DEFAULTS.images, ...raw.images },
|
|
168
|
+
slides: { ...DEFAULTS.slides, ...raw.slides },
|
|
169
|
+
/* the parts besides the work start off each time (history, chat and logs are the person's own): only zip or
|
|
170
|
+
folder is kept */
|
|
171
|
+
project: { ...DEFAULTS.project, ...(raw.project?.as === 'folder' ? { as: 'folder' } : {}) },
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return first;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function saveSettings(s: Settings): void {
|
|
179
|
+
try { localStorage.setItem(STORE_KEY, JSON.stringify(s)); } catch { /* storage off (private window) */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const SHORT_EDGES = [480, 720, 1080, 1440, 2160];
|
|
183
|
+
const STILL_EDGES = [720, 1080, 1440, 2160];
|
|
184
|
+
/** A video's rates: the project's list (lib/timecode), one spec for both. */
|
|
185
|
+
const FPS_CHOICES = FRAME_RATES;
|
|
186
|
+
const GIF_WIDTHS = [320, 480, 640, 800, 1080];
|
|
187
|
+
const GIF_FPS = [10, 12, 15, 20, 24];
|
|
188
|
+
const VIDEO_CODECS: ExportVideoCodec[] = ['h264', 'hevc', 'prores422hq', 'prores4444', 'hevc_alpha', 'vp9', 'png'];
|
|
189
|
+
const ALPHA_SCALES = [0.5, 1, 2];
|
|
190
|
+
const PRESET_GROUPS: ExportPresetGroup[] = ['landscape', 'vertical', 'feed'];
|
|
191
|
+
/** The rates editors take; a source at another NTSC rate (23.976, 59.94) comes as the "source" choice. */
|
|
192
|
+
const NLE_FPS = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
|
|
193
|
+
/** How a draft and an XML write their pages (exports.mjs), the default first. */
|
|
194
|
+
const NLE_ALPHA: Record<'draft' | 'xml', readonly NleAlpha[]> = { draft: ['auto', 'png', 'prores'], xml: ['prores4444', 'png'] };
|
|
195
|
+
const NLE_ALPHA_NAME: Record<Exclude<NleAlpha, 'auto'>, string> = { png: 'PNG', prores: 'ProRes', prores4444: 'ProRes 4444' };
|
|
196
|
+
const VIDEO_SRC = /\.(mp4|m4v|mov|webm|mkv|avi|ogv)$/i;
|
|
197
|
+
|
|
198
|
+
/** Whether this machine's ffmpeg makes a codec (unknown until the server answers: assumed yes). */
|
|
199
|
+
function codecAvailable(codec: ExportVideoCodec, can: ExportCapabilities | null): boolean {
|
|
200
|
+
if (!can) return true;
|
|
201
|
+
switch (codec) {
|
|
202
|
+
case 'h264': return can.h264;
|
|
203
|
+
case 'hevc': return can.hevc;
|
|
204
|
+
case 'hevc_alpha': return can.hevcAlpha;
|
|
205
|
+
case 'vp9': return can.vp9;
|
|
206
|
+
case 'prores422hq':
|
|
207
|
+
case 'prores4444': return can.prores;
|
|
208
|
+
case 'png': return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ExportCenterProps {
|
|
213
|
+
open: boolean;
|
|
214
|
+
onClose: () => void;
|
|
215
|
+
projectId: string;
|
|
216
|
+
title: string;
|
|
217
|
+
/** The film's length. */
|
|
218
|
+
durationMs: number;
|
|
219
|
+
stage: { w: number; h: number };
|
|
220
|
+
/** The delivery frame picked beside the player. */
|
|
221
|
+
frame: FilmFrameId;
|
|
222
|
+
/**
|
|
223
|
+
* What the timeline shows (its picture clips and sounds): scene boundaries for the range, the count of scene
|
|
224
|
+
* stills, which stems there are. Missing: the range is the whole film or a part from 0, and the server finds the rest.
|
|
225
|
+
*/
|
|
226
|
+
film?: {
|
|
227
|
+
scenes: readonly { startMs: number; durMs: number; label: string; src?: string }[];
|
|
228
|
+
sounds: readonly { kind: Exclude<StemKind, 'footage'>; durMs: number; src?: string }[];
|
|
229
|
+
} | null;
|
|
230
|
+
/** Where the playhead is: the poster is that frame (the middle of the film without it). */
|
|
231
|
+
playheadMs?: number;
|
|
232
|
+
/** The timeline's motion-graphic blocks: a transparent export can be just one of them. */
|
|
233
|
+
mgBlocks?: readonly { clipId: string; label: string; startMs: number; durMs: number }[];
|
|
234
|
+
/** From a block's "Export this clip": opens on transparent assets with that block chosen. */
|
|
235
|
+
focusClipId?: string;
|
|
236
|
+
/** The exports were queued (the tray shows them). */
|
|
237
|
+
onQueued?: (jobs: ExportJob[]) => void;
|
|
238
|
+
/** The project's frame rate (`.film/settings.json`, else its footage's): a video's rate unless another is picked. */
|
|
239
|
+
projectFps?: number;
|
|
240
|
+
/** Media files the film uses that are gone: an export that draws or plays the film stops at them, so it is held back. */
|
|
241
|
+
missingFiles?: readonly string[];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What draws or plays the film, and so needs every file it uses. */
|
|
245
|
+
const RENDERS: ReadonlySet<Dest> = new Set(['social', 'video', 'alpha', 'gif', 'audio', 'images', 'slides']);
|
|
246
|
+
|
|
247
|
+
export function ExportCenter(props: ExportCenterProps) {
|
|
248
|
+
const { open, onClose } = props;
|
|
249
|
+
const t = useT();
|
|
250
|
+
const language = useLanguage();
|
|
251
|
+
/** the platforms offered in this language (a platform picked in another and not offered here is not exported) */
|
|
252
|
+
const presets = React.useMemo(() => exportPresetsFor(language), [language]);
|
|
253
|
+
const [can, setCan] = React.useState<ExportCapabilities | null>(null);
|
|
254
|
+
/** 剪映 and CapCut on this computer: an editing project for one goes into its drafts */
|
|
255
|
+
const [draftApps, setDraftApps] = React.useState<{ jianying: boolean; capcut: boolean }>({ jianying: false, capcut: false });
|
|
256
|
+
const [s, setS] = React.useState<Settings>(DEFAULTS);
|
|
257
|
+
const [hydrated, setHydrated] = React.useState(false);
|
|
258
|
+
const [range, setRange] = React.useState<{ on: boolean; from: number; to: number }>({ on: false, from: 0, to: 0 });
|
|
259
|
+
const [working, setWorking] = React.useState<string | null>(null);
|
|
260
|
+
const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null);
|
|
261
|
+
const [defaultFolder, setDefaultFolder] = React.useState<string>('');
|
|
262
|
+
/** How many subtitle cues the film has (null until the server says). */
|
|
263
|
+
const [captionCount, setCaptionCount] = React.useState<number | null>(null);
|
|
264
|
+
/** How big the project and each of its parts are (null: not known yet). */
|
|
265
|
+
const [projectSizes, setProjectSizes] = React.useState<ProjectSizes | null>(null);
|
|
266
|
+
/** The footage's frame rate, as an editor's rate (an editor wants the source's exact rate: 29.97 in a 30 timeline slips a frame every 1,000). */
|
|
267
|
+
const [nleSourceFps, setNleSourceFps] = React.useState<number | null>(null);
|
|
268
|
+
/** The folder being typed after "Change…" (null when not editing). */
|
|
269
|
+
const [folderDraft, setFolderDraft] = React.useState<string | null>(null);
|
|
270
|
+
/** What the files are called, as typed here: the project's name to begin with, and when left empty. */
|
|
271
|
+
const [exportName, setExportName] = React.useState<string>('');
|
|
272
|
+
/** Whether that name was typed over: only then is it sent, so a dialog left alone exports as it always did. */
|
|
273
|
+
const [nameTyped, setNameTyped] = React.useState(false);
|
|
274
|
+
|
|
275
|
+
React.useEffect(() => {
|
|
276
|
+
if (!open) return;
|
|
277
|
+
const loaded = loadSettings(language);
|
|
278
|
+
setS(props.focusClipId
|
|
279
|
+
? { ...loaded, dest: 'alpha', alpha: { ...loaded.alpha, source: props.focusClipId } }
|
|
280
|
+
: loaded);
|
|
281
|
+
setHydrated(true);
|
|
282
|
+
setResult(null);
|
|
283
|
+
setWorking(null);
|
|
284
|
+
setFolderDraft(null);
|
|
285
|
+
/* a name is this export's, not a setting: each visit starts from the project's own */
|
|
286
|
+
setExportName(props.title);
|
|
287
|
+
setNameTyped(false);
|
|
288
|
+
setRange((r) => ({ ...r, on: false }));
|
|
289
|
+
void listExports(props.projectId).then((r) => { setCan(r.can); setDraftApps({ jianying: Boolean(r.drafts?.jianying), capcut: Boolean(r.drafts?.capcut) }); setDefaultFolder(r.folder); setCaptionCount(r.subtitles ?? 0); setProjectSizes(r.project ?? null); }).catch(() => {});
|
|
290
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
291
|
+
}, [open]);
|
|
292
|
+
|
|
293
|
+
React.useEffect(() => {
|
|
294
|
+
if (hydrated) saveSettings(s);
|
|
295
|
+
}, [s, hydrated]);
|
|
296
|
+
|
|
297
|
+
React.useEffect(() => {
|
|
298
|
+
if (!open) return undefined;
|
|
299
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !working) onClose(); };
|
|
300
|
+
window.addEventListener('keydown', onKey);
|
|
301
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
302
|
+
}, [open, onClose, working]);
|
|
303
|
+
|
|
304
|
+
const stage = props.stage;
|
|
305
|
+
const totalMs = props.durationMs;
|
|
306
|
+
const film = props.film ?? null;
|
|
307
|
+
const soundKinds = React.useMemo(
|
|
308
|
+
() => new Set<StemKind>((film?.sounds ?? []).filter((x) => x.durMs > 0).map((x) => (x.src && VIDEO_SRC.test(x.src) ? 'footage' : x.kind))),
|
|
309
|
+
[film],
|
|
310
|
+
);
|
|
311
|
+
const knownSilent = film != null && soundKinds.size === 0;
|
|
312
|
+
|
|
313
|
+
const blocks = React.useMemo(() => props.mgBlocks ?? [], [props.mgBlocks]);
|
|
314
|
+
const captions = captionCount ?? 0;
|
|
315
|
+
|
|
316
|
+
/* the first footage clip's rate, for the editor's "source" choice */
|
|
317
|
+
const footageSrc = React.useMemo(
|
|
318
|
+
() => [...(film?.scenes ?? [])].sort((a, b) => a.startMs - b.startMs).find((sc) => sc.src && VIDEO_SRC.test(sc.src))?.src ?? null,
|
|
319
|
+
[film],
|
|
320
|
+
);
|
|
321
|
+
React.useEffect(() => {
|
|
322
|
+
setNleSourceFps(null);
|
|
323
|
+
if (!open || !footageSrc) return undefined;
|
|
324
|
+
let live = true;
|
|
325
|
+
void probeFps(props.projectId, footageSrc).then((fps) => {
|
|
326
|
+
if (!live || fps == null) return;
|
|
327
|
+
setNleSourceFps(NLE_FPS.find((f) => Math.abs(f - fps) < 0.05) ?? null);
|
|
328
|
+
}).catch(() => {});
|
|
329
|
+
return () => { live = false; };
|
|
330
|
+
}, [open, footageSrc, props.projectId]);
|
|
331
|
+
|
|
332
|
+
/* where a part can start and end: the picture clips' edges, each named by its clips (rangeMarks) */
|
|
333
|
+
const boundaries = React.useMemo(() => rangeMarks(film?.scenes ?? [], totalMs), [film, totalMs]);
|
|
334
|
+
/* one still from the middle of each picture clip (clips starting together give one): what the server makes */
|
|
335
|
+
const stillCount = React.useMemo(() => {
|
|
336
|
+
if (!film) return 1;
|
|
337
|
+
let lastAt = -Infinity;
|
|
338
|
+
let n = 0;
|
|
339
|
+
for (const sc of [...film.scenes].sort((a, b) => a.startMs - b.startMs)) {
|
|
340
|
+
if (!(sc.durMs > 0) || sc.startMs >= totalMs || sc.startMs - lastAt < 400) continue;
|
|
341
|
+
lastAt = sc.startMs;
|
|
342
|
+
n++;
|
|
343
|
+
}
|
|
344
|
+
return Math.max(1, Math.min(120, n));
|
|
345
|
+
}, [film, totalMs]);
|
|
346
|
+
|
|
347
|
+
React.useEffect(() => {
|
|
348
|
+
setRange((r) => ({ ...r, from: 0, to: totalMs }));
|
|
349
|
+
}, [totalMs]);
|
|
350
|
+
|
|
351
|
+
const exportRange = range.on && range.to > range.from && (range.from > 0 || range.to < totalMs)
|
|
352
|
+
? { fromMs: range.from, toMs: range.to }
|
|
353
|
+
: undefined;
|
|
354
|
+
const rangeMs = exportRange ? exportRange.toMs - exportRange.fromMs : totalMs;
|
|
355
|
+
const rangeSec: [number, number] | undefined = exportRange ? [exportRange.fromMs / 1000, exportRange.toMs / 1000] : undefined;
|
|
356
|
+
|
|
357
|
+
const resolveFrame = (f: FrameChoice): FilmFrameId => (f === 'current' ? props.frame : f);
|
|
358
|
+
/* 'source' is the project's own rate */
|
|
359
|
+
const projectFps = props.projectFps ?? 30;
|
|
360
|
+
const fpsFor = (f: number | 'source'): number => (f === 'source' ? projectFps : f);
|
|
361
|
+
|
|
362
|
+
const patch = <K extends keyof Settings>(key: K, value: Partial<Settings[K]> | Settings[K]) => {
|
|
363
|
+
setS((prev) => ({
|
|
364
|
+
...prev,
|
|
365
|
+
[key]: typeof value === 'object' && !Array.isArray(value) && value !== null
|
|
366
|
+
? { ...(prev[key] as object), ...(value as object) }
|
|
367
|
+
: value,
|
|
368
|
+
}));
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const dest: Dest = DESTS.some((d) => d.id === s.dest) ? s.dest : 'video';
|
|
372
|
+
const alphaBlock = blocks.find((b) => b.clipId === s.alpha.source);
|
|
373
|
+
/* how long what comes out is: one element alone is its own length, not the film's */
|
|
374
|
+
const outMs = dest === 'alpha' && alphaBlock ? alphaBlock.durMs : rangeMs;
|
|
375
|
+
const videoCodec: ExportVideoCodec = codecAvailable(s.video.codec, can) && VIDEO_CODECS.includes(s.video.codec) ? s.video.codec : 'h264';
|
|
376
|
+
const alphaCodec: ExportVideoCodec = EXPORT_ALPHA_CODECS.includes(s.alpha.codec) && codecAvailable(s.alpha.codec, can) ? s.alpha.codec : 'prores4444';
|
|
377
|
+
const audioFormat = s.audio.format === 'mp3' && can && !can.mp3 ? 'wav' : s.audio.format;
|
|
378
|
+
/* the same draft is 剪映's or CapCut's: the one on this computer, else (both or neither) 剪映 in Chinese and CapCut in
|
|
379
|
+
any other language; always by its own name */
|
|
380
|
+
const known: 'jianying' | 'capcut' = language === 'zh-CN' ? 'jianying' : 'capcut';
|
|
381
|
+
const draftApp = draftApps[known] || !draftApps[known === 'jianying' ? 'capcut' : 'jianying'] ? known : known === 'jianying' ? 'capcut' : 'jianying';
|
|
382
|
+
const draftHere = draftApps[draftApp];
|
|
383
|
+
/** a translation naming the draft's app */
|
|
384
|
+
const tApp = (path: string) => t(path).replaceAll('{app}', DRAFT_APP_NAME[draftApp]);
|
|
385
|
+
const nleApp = s.nle.app === 'auto' ? (draftHere ? 'draft' : 'premiere') : s.nle.app;
|
|
386
|
+
/* a draft goes into its app's drafts folder, whatever folder is picked */
|
|
387
|
+
const toDrafts = dest === 'nle' && nleApp === 'draft' && draftHere;
|
|
388
|
+
/* Final Cut Pro takes ProRes only: no choice */
|
|
389
|
+
const nleAlphaKind = nleApp === 'draft' ? 'draft' : nleApp === 'finalcut' ? null : 'xml';
|
|
390
|
+
const nleAlphaPicked = nleAlphaKind === 'draft' ? s.nle.alphaDraft : s.nle.alphaXml;
|
|
391
|
+
const nleAlpha: NleAlpha = !nleAlphaKind ? 'prores4444'
|
|
392
|
+
: NLE_ALPHA[nleAlphaKind].includes(nleAlphaPicked) && (!nleAlphaPicked.startsWith('prores') || codecAvailable('prores4444', can)) ? nleAlphaPicked : NLE_ALPHA[nleAlphaKind][0];
|
|
393
|
+
const codecName = (c: ExportVideoCodec) => CODEC_LABEL[c] + (c === 'png' ? ` ${t('exportCenter.sequence')}` : '');
|
|
394
|
+
|
|
395
|
+
/* the parts of the project asked that it has (a part it has none of is left out) */
|
|
396
|
+
const projectParts = React.useMemo(
|
|
397
|
+
() => PROJECT_PARTS.filter((part) => s.project[part] && (projectSizes == null || projectSizes[part] != null)),
|
|
398
|
+
[s.project, projectSizes],
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
/* ── the exports this screen asks for ── */
|
|
402
|
+
const requests = React.useMemo((): ExportRequest[] => {
|
|
403
|
+
const ranged = rangeSec ? { range: rangeSec } : {};
|
|
404
|
+
/* subtitles drawn into the picture: only a film that has some */
|
|
405
|
+
const burn = (on: boolean) => (captions > 0 && on ? { burnSubtitles: true } : {});
|
|
406
|
+
switch (dest) {
|
|
407
|
+
case 'social':
|
|
408
|
+
return s.presets.map((id) => presets.find((p) => p.id === id) ?? null).filter((p) => p !== null).map((p) => ({
|
|
409
|
+
kind: 'video', codec: 'h264', frame: p.frame, fill: 'contain', shortEdge: p.shortEdge, fps: presetFps(p, projectFps),
|
|
410
|
+
quality: s.socialQuality, audio: true, preset: p.name, label: p.name,
|
|
411
|
+
...burn(s.socialSubs === 'auto' ? p.burnSubtitles : s.socialSubs === 'on'), ...ranged,
|
|
412
|
+
}));
|
|
413
|
+
case 'video': {
|
|
414
|
+
const v = s.video;
|
|
415
|
+
return [{
|
|
416
|
+
kind: 'video', codec: videoCodec, frame: resolveFrame(v.frame), fill: v.fill, shortEdge: v.shortEdge, fps: fpsFor(v.fps),
|
|
417
|
+
quality: v.quality, audio: v.audio, label: codecName(videoCodec), ...burn(v.burn),
|
|
418
|
+
...(captions > 0 && v.sidecar !== 'none' && videoCodec !== 'png' ? { sidecar: v.sidecar } : {}), ...ranged,
|
|
419
|
+
}];
|
|
420
|
+
}
|
|
421
|
+
case 'alpha': {
|
|
422
|
+
const a = s.alpha;
|
|
423
|
+
return alphaBlock
|
|
424
|
+
? [{ kind: 'video', codec: alphaCodec, clip: alphaBlock.clipId, scale: a.scale, fps: a.fps, quality: 'high', audio: a.audio, label: alphaBlock.label }]
|
|
425
|
+
: [{ kind: 'video', codec: alphaCodec, frame: 'native', shortEdge: a.shortEdge, fps: a.fps, quality: 'high', audio: a.audio, label: codecName(alphaCodec), ...ranged }];
|
|
426
|
+
}
|
|
427
|
+
case 'gif':
|
|
428
|
+
return [{ kind: 'gif', frame: resolveFrame(s.gif.frame), fill: s.gif.fill, width: s.gif.width, fps: s.gif.fps, label: 'GIF', ...burn(s.gif.burn), ...ranged }];
|
|
429
|
+
case 'audio': {
|
|
430
|
+
const stems = s.audio.stems;
|
|
431
|
+
return [{ kind: 'audio', format: audioFormat, stems, mix: stems ? s.audio.mix : true, label: t(stems ? 'exportCenter.audioStems' : 'exportCenter.audioMix'), ...ranged }];
|
|
432
|
+
}
|
|
433
|
+
case 'nle':
|
|
434
|
+
return [{
|
|
435
|
+
kind: 'nle', format: nleApp === 'draft' ? draftApp : NLE_APPS[nleApp].format, fps: s.nle.fps === 'source' ? (nleSourceFps ?? projectFps) : s.nle.fps, audio: s.nle.audio, media: 'copy', alpha: nleAlpha,
|
|
436
|
+
subtitles: s.nle.subtitles, label: nleApp === 'draft' ? tApp('exportCenter.nleDraftTitle') : `${NLE_APPS[nleApp].name} XML`,
|
|
437
|
+
}];
|
|
438
|
+
case 'subtitles':
|
|
439
|
+
return [{ kind: 'subtitles', format: s.subtitles.format, label: s.subtitles.format === 'txt' ? t('exportCenter.transcript') : s.subtitles.format.toUpperCase(), ...ranged }];
|
|
440
|
+
case 'images':
|
|
441
|
+
return [{
|
|
442
|
+
kind: 'stills', format: s.images.format, shortEdge: s.images.shortEdge, frame: resolveFrame(s.images.frame), fill: s.images.fill,
|
|
443
|
+
label: t('exportCenter.stills'), ...burn(s.images.burn),
|
|
444
|
+
}];
|
|
445
|
+
case 'slides':
|
|
446
|
+
return [{
|
|
447
|
+
kind: 'slides', format: s.slides.format, shortEdge: s.slides.shortEdge, frame: resolveFrame(s.slides.frame), fill: s.slides.fill,
|
|
448
|
+
label: s.slides.format === 'pdf' ? 'PDF' : 'PowerPoint', ...burn(s.slides.burn),
|
|
449
|
+
}];
|
|
450
|
+
case 'project':
|
|
451
|
+
return [{
|
|
452
|
+
kind: 'project', as: s.project.as, parts: projectParts,
|
|
453
|
+
label: t(s.project.as === 'zip' ? 'exportCenter.projectZip' : 'exportCenter.projectFolder'),
|
|
454
|
+
}];
|
|
455
|
+
}
|
|
456
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
457
|
+
}, [dest, s, can, presets, nleApp, nleAlpha, draftApp, rangeSec?.[0], rangeSec?.[1], props.frame, blocks, nleSourceFps, captions, projectParts]);
|
|
458
|
+
|
|
459
|
+
/* a range, not a size: what is in the picture decides a video's or a GIF's size (export-spec.ts); sound is exact */
|
|
460
|
+
const estBytes = React.useMemo(() => requests.reduce<[number, number]>(([low, high], r) => {
|
|
461
|
+
const add = ([a, b]: readonly [number, number]): [number, number] => [low + a, high + b];
|
|
462
|
+
if (r.kind === 'video') {
|
|
463
|
+
/* one block's size depends on the block: not guessed */
|
|
464
|
+
if (r.clip) return [low, high];
|
|
465
|
+
const frame = r.frame ?? 'native', fill = r.fill ?? 'contain';
|
|
466
|
+
const size = exportFramedDims(stage, frame, fill, r.shortEdge ?? Math.min(stage.w, stage.h));
|
|
467
|
+
return add(exportEstimateBytes({
|
|
468
|
+
codec: r.codec, size, filmPixels: exportFilmPixels(stage, frame, fill, size), fps: r.fps, quality: r.quality, durationMs: rangeMs,
|
|
469
|
+
audio: r.audio && r.codec !== 'png' && !knownSilent,
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
if (r.kind === 'gif') {
|
|
473
|
+
const frame = r.frame ?? 'native', fill = r.fill ?? 'contain';
|
|
474
|
+
const size = exportGifDims(filmFrameBox(stage, frame, fill), r.width);
|
|
475
|
+
return add(exportGifEstimateBytes({ size, filmPixels: exportFilmPixels(stage, frame, fill, size), fps: r.fps, durationMs: rangeMs }));
|
|
476
|
+
}
|
|
477
|
+
if (r.kind === 'audio') {
|
|
478
|
+
/* 48 kHz 16-bit stereo; AAC and MP3 at 256k (exports.mjs SOUND_CODECS) */
|
|
479
|
+
const per = r.format === 'wav' ? 192_000 : 32_000;
|
|
480
|
+
const files = (r.mix ? 1 : 0) + (r.stems ? soundKinds.size : 0);
|
|
481
|
+
const bytes = per * (rangeMs / 1000) * Math.max(1, files);
|
|
482
|
+
return add([bytes, bytes]);
|
|
483
|
+
}
|
|
484
|
+
if (r.kind === 'project' && projectSizes) {
|
|
485
|
+
const bytes = r.parts.reduce((n, part) => n + (projectSizes[part] ?? 0), projectSizes.work);
|
|
486
|
+
return add([bytes, bytes]);
|
|
487
|
+
}
|
|
488
|
+
return [low, high];
|
|
489
|
+
}, [0, 0]), [requests, stage, rangeMs, soundKinds, knownSilent, projectSizes]);
|
|
490
|
+
/* "about 0.8 MB–8 MB"; one size when both ends read the same */
|
|
491
|
+
const estText = estBytes[1] > 0 ? [...new Set(estBytes.map((b) => formatBytes(b)))].join('–') : '';
|
|
492
|
+
|
|
493
|
+
const folder = s.folder || defaultFolder;
|
|
494
|
+
|
|
495
|
+
const fileCount = requests.reduce((n, r) => {
|
|
496
|
+
if (r.kind === 'audio') return n + (r.mix ? 1 : 0) + (r.stems ? Math.max(1, soundKinds.size) : 0);
|
|
497
|
+
if (r.kind === 'video' && r.sidecar) return n + 2;
|
|
498
|
+
return n + 1;
|
|
499
|
+
}, 0);
|
|
500
|
+
|
|
501
|
+
/* files the film uses that are gone: said first, by name, before anything is queued (it would stop at them) */
|
|
502
|
+
const missing = props.missingFiles ?? [];
|
|
503
|
+
const missingReason = missing.length && RENDERS.has(dest)
|
|
504
|
+
? t('exportCenter.missingMedia').replace('{n}', String(missing.length))
|
|
505
|
+
.replace('{files}', missing.slice(0, 3).map((f) => f.split('/').pop()).join(', ') + (missing.length > 3 ? ', …' : ''))
|
|
506
|
+
: null;
|
|
507
|
+
const emptyReason = ((): string | null => {
|
|
508
|
+
if (missingReason) return missingReason;
|
|
509
|
+
if (!totalMs && dest !== 'project') return t('exportCenter.emptyFilm');
|
|
510
|
+
if (dest === 'social' && requests.length === 0) return t('exportCenter.pickPreset');
|
|
511
|
+
if (dest === 'subtitles' && captionCount != null && captions === 0) return t('exportCenter.noCaptions');
|
|
512
|
+
if (dest === 'audio' && knownSilent) return t('exportCenter.noAudio');
|
|
513
|
+
return null;
|
|
514
|
+
})();
|
|
515
|
+
|
|
516
|
+
/* ── doing it ── */
|
|
517
|
+
const queue = async (jobs: ExportRequest[]) => {
|
|
518
|
+
if (working) return;
|
|
519
|
+
setResult(null);
|
|
520
|
+
if (!folder) {
|
|
521
|
+
setResult({ ok: false, text: t('exportCenter.noFolder') });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
setWorking(t('exportCenter.queueing'));
|
|
525
|
+
try {
|
|
526
|
+
/* the name typed here, unless there is none or its field is hidden (a draft is named by its app): every way out
|
|
527
|
+
of this screen comes through here */
|
|
528
|
+
const typed = nameTyped && !toDrafts ? exportName.trim() : '';
|
|
529
|
+
const named = typed ? jobs.map((job) => ({ ...job, name: typed })) : jobs;
|
|
530
|
+
const queued = await startExports(props.projectId, named, s.folder || undefined);
|
|
531
|
+
props.onQueued?.(queued);
|
|
532
|
+
onClose();
|
|
533
|
+
} catch (e) {
|
|
534
|
+
setResult({ ok: false, text: e instanceof TypeError ? t('exportCenter.engineDown') : (e instanceof Error ? e.message : String(e)) });
|
|
535
|
+
} finally {
|
|
536
|
+
setWorking(null);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/* the poster is the frame at the playhead; at the very start (often a fade from black) the film's middle */
|
|
541
|
+
const posterAt = props.playheadMs != null && props.playheadMs > 0 && props.playheadMs < totalMs ? props.playheadMs / 1000 : undefined;
|
|
542
|
+
const exportPoster = () => {
|
|
543
|
+
const at = posterAt;
|
|
544
|
+
void queue([{
|
|
545
|
+
kind: 'poster', format: s.images.format, shortEdge: s.images.shortEdge, frame: resolveFrame(s.images.frame), fill: s.images.fill,
|
|
546
|
+
label: t('exportCenter.poster'), ...(at != null ? { at } : {}), ...(captions > 0 && s.images.burn ? { burnSubtitles: true } : {}),
|
|
547
|
+
}]);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const commitFolder = () => {
|
|
551
|
+
if (folderDraft == null) return;
|
|
552
|
+
const next = folderDraft.trim();
|
|
553
|
+
patch('folder', next === defaultFolder ? '' : next);
|
|
554
|
+
setFolderDraft(null);
|
|
555
|
+
};
|
|
556
|
+
/* the app's own folder dialog where there is an app (lib/host.ts `folders`); in a browser, the path typed */
|
|
557
|
+
/* the system's folder dialog: the app's own where there is an app (lib/host.ts `folders`), else the one Studio opens
|
|
558
|
+
on this computer for the page; only where neither can, the path is typed */
|
|
559
|
+
const [picking, setPicking] = React.useState(false);
|
|
560
|
+
const changeFolder = async () => {
|
|
561
|
+
if (picking) return;
|
|
562
|
+
setPicking(true);
|
|
563
|
+
try {
|
|
564
|
+
const ask = { title: t('exportCenter.pickTitle'), button: t('exportCenter.pickButton'), ...(folder ? { at: folder } : {}) };
|
|
565
|
+
const pick = studioHost?.folders?.pick;
|
|
566
|
+
const picked = pick ? await pick(ask) : await api.pickFolder(ask.title, ask.at);
|
|
567
|
+
if (picked) patch('folder', picked === defaultFolder ? '' : picked);
|
|
568
|
+
} catch {
|
|
569
|
+
setFolderDraft(folder);
|
|
570
|
+
} finally {
|
|
571
|
+
setPicking(false);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
if (!open || typeof document === 'undefined') return null;
|
|
576
|
+
|
|
577
|
+
const primaryLabel = working
|
|
578
|
+
?? (fileCount > 1 ? t('exportCenter.exportN').replace('{n}', String(fileCount)) : t('exportCenter.export'));
|
|
579
|
+
const disabled = Boolean(working) || Boolean(emptyReason) || !folder;
|
|
580
|
+
|
|
581
|
+
return createPortal(
|
|
582
|
+
<div
|
|
583
|
+
/* a plain dim, no backdrop blur: a blur over the whole window is drawn again on every frame of a wheel scroll in
|
|
584
|
+
the dialog, and the window flickered on macOS */
|
|
585
|
+
className="fixed inset-0 z-[130] flex items-center justify-center bg-black/50 p-4"
|
|
586
|
+
onClick={() => { if (!working) onClose(); }}
|
|
587
|
+
>
|
|
588
|
+
<div
|
|
589
|
+
role="dialog"
|
|
590
|
+
aria-modal="true"
|
|
591
|
+
aria-label={t('exportCenter.title')}
|
|
592
|
+
className="flex h-[min(640px,calc(100vh-32px))] w-full max-w-[880px] overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-lg)]"
|
|
593
|
+
style={{ animation: 'openfilm-rise 0.16s ease-out both' }}
|
|
594
|
+
onClick={(e) => e.stopPropagation()}
|
|
595
|
+
>
|
|
596
|
+
{/* left: destinations */}
|
|
597
|
+
{/* the list scrolls when the window is short; the note under it stays whole */}
|
|
598
|
+
<nav className="flex min-h-0 w-[212px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[var(--surface-2)]">
|
|
599
|
+
<h2 className="shrink-0 px-5 pb-2 pt-4 text-[15px] font-semibold tracking-tight text-[var(--text)]">{t('exportCenter.title')}</h2>
|
|
600
|
+
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto overscroll-contain px-3 pb-2">
|
|
601
|
+
{DESTS.map((d) => {
|
|
602
|
+
const on = dest === d.id;
|
|
603
|
+
return (
|
|
604
|
+
<button
|
|
605
|
+
key={d.id}
|
|
606
|
+
type="button"
|
|
607
|
+
aria-pressed={on}
|
|
608
|
+
onClick={() => { patch('dest', d.id); setResult(null); }}
|
|
609
|
+
className={`flex items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition ${
|
|
610
|
+
on ? 'bg-[var(--surface)] shadow-[var(--shadow-sm)]' : 'hover:bg-[var(--bg-hover)]'
|
|
611
|
+
}`}
|
|
612
|
+
>
|
|
613
|
+
<span className={`mt-0.5 shrink-0 ${on ? 'text-[var(--text)]' : 'text-[var(--text-muted)]'}`}>{d.icon}</span>
|
|
614
|
+
<span className="min-w-0">
|
|
615
|
+
<span className={`block text-[13px] font-medium ${on ? 'text-[var(--text)]' : 'text-[var(--text-muted)]'}`}>
|
|
616
|
+
{t(`exportCenter.dest.${d.id}`)}
|
|
617
|
+
</span>
|
|
618
|
+
<span className="block text-[11px] leading-snug text-[var(--text-faint)]">{tApp(`exportCenter.destHint.${d.id}`)}</span>
|
|
619
|
+
</span>
|
|
620
|
+
</button>
|
|
621
|
+
);
|
|
622
|
+
})}
|
|
623
|
+
</div>
|
|
624
|
+
<div className="shrink-0 border-t border-[var(--border-soft)] px-5 py-3 text-[10.5px] leading-relaxed text-[var(--text-faint)]">
|
|
625
|
+
{t('exportCenter.engineDesktop')}
|
|
626
|
+
</div>
|
|
627
|
+
</nav>
|
|
628
|
+
|
|
629
|
+
{/* right: settings and summary */}
|
|
630
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
631
|
+
<div className="flex shrink-0 items-center gap-2 px-5 pb-1 pt-4">
|
|
632
|
+
<div className="min-w-0 flex-1">
|
|
633
|
+
<h3 className="text-[14px] font-semibold text-[var(--text)]">{t(`exportCenter.dest.${dest}`)}</h3>
|
|
634
|
+
<p className="mt-0.5 text-[12px] leading-relaxed text-[var(--text-muted)]">{tApp(`exportCenter.destDesc.${dest}`)}</p>
|
|
635
|
+
</div>
|
|
636
|
+
<button
|
|
637
|
+
type="button"
|
|
638
|
+
onClick={onClose}
|
|
639
|
+
disabled={Boolean(working)}
|
|
640
|
+
aria-label={t('exportDialog.close')}
|
|
641
|
+
className="self-start rounded-lg p-1 text-[var(--text-faint)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
642
|
+
>
|
|
643
|
+
<X size={16} />
|
|
644
|
+
</button>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-4 pt-3">
|
|
648
|
+
<div className="flex flex-col gap-5">
|
|
649
|
+
{dest === 'social' && (
|
|
650
|
+
<>
|
|
651
|
+
{PRESET_GROUPS.map((group) => (
|
|
652
|
+
<Field key={group} label={t(`exportCenter.group.${group}`)}>
|
|
653
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
654
|
+
{presets.filter((p) => p.group === group).map((p) => {
|
|
655
|
+
const on = s.presets.includes(p.id);
|
|
656
|
+
const dims = exportDimsForShortEdge(filmFrameBox(stage, p.frame), p.shortEdge);
|
|
657
|
+
const tooLong = p.maxDurationSec != null && rangeMs / 1000 > p.maxDurationSec;
|
|
658
|
+
return (
|
|
659
|
+
<button
|
|
660
|
+
key={p.id}
|
|
661
|
+
type="button"
|
|
662
|
+
aria-pressed={on}
|
|
663
|
+
onClick={() => patch('presets', on ? s.presets.filter((id) => id !== p.id) : [...s.presets, p.id])}
|
|
664
|
+
className={`relative flex flex-col items-start gap-0.5 rounded-xl border px-3 py-2.5 text-left transition ${
|
|
665
|
+
on ? 'border-[var(--text)] bg-[var(--bg-hover)]' : 'border-[var(--border)] hover:border-[var(--text-faint)]'
|
|
666
|
+
}`}
|
|
667
|
+
>
|
|
668
|
+
<span className="flex w-full items-center gap-1.5">
|
|
669
|
+
<AspectGlyph frame={p.frame} stage={stage} />
|
|
670
|
+
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-[var(--text)]">{p.name}</span>
|
|
671
|
+
{on && <Check size={13} className="shrink-0 text-[var(--text)]" />}
|
|
672
|
+
</span>
|
|
673
|
+
<span className="text-[11px] tabular-nums text-[var(--text-faint)]">
|
|
674
|
+
{dims.w}×{dims.h} · {presetFps(p, projectFps)}fps
|
|
675
|
+
</span>
|
|
676
|
+
{tooLong && (
|
|
677
|
+
<span className="text-[10.5px] leading-snug text-[var(--warn)]">
|
|
678
|
+
{t('exportCenter.tooLong').replace('{max}', formatFilmTime(p.maxDurationSec! * 1000))}
|
|
679
|
+
</span>
|
|
680
|
+
)}
|
|
681
|
+
</button>
|
|
682
|
+
);
|
|
683
|
+
})}
|
|
684
|
+
</div>
|
|
685
|
+
</Field>
|
|
686
|
+
))}
|
|
687
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
688
|
+
<Field label={t('exportCenter.quality')}>
|
|
689
|
+
<QualityPicker value={s.socialQuality} onChange={(q) => patch('socialQuality', q)} t={t} />
|
|
690
|
+
</Field>
|
|
691
|
+
{captions > 0 && (
|
|
692
|
+
<Field label={t('exportCenter.burnSubtitles')}>
|
|
693
|
+
<Segmented
|
|
694
|
+
value={s.socialSubs}
|
|
695
|
+
options={[
|
|
696
|
+
{ value: 'auto', label: t('exportCenter.subsAuto') },
|
|
697
|
+
{ value: 'on', label: t('exportCenter.subsOn') },
|
|
698
|
+
{ value: 'off', label: t('exportCenter.subsOff') },
|
|
699
|
+
]}
|
|
700
|
+
onChange={(v) => patch('socialSubs', v)}
|
|
701
|
+
/>
|
|
702
|
+
</Field>
|
|
703
|
+
)}
|
|
704
|
+
</div>
|
|
705
|
+
<Hint>{t('exportCenter.socialHint')}</Hint>
|
|
706
|
+
</>
|
|
707
|
+
)}
|
|
708
|
+
|
|
709
|
+
{dest === 'video' && (
|
|
710
|
+
<>
|
|
711
|
+
<Field label={t('exportCenter.codec')}>
|
|
712
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
713
|
+
{VIDEO_CODECS.map((c) => {
|
|
714
|
+
const unavailable = !codecAvailable(c, can);
|
|
715
|
+
return (
|
|
716
|
+
<OptionCard
|
|
717
|
+
key={c}
|
|
718
|
+
on={videoCodec === c}
|
|
719
|
+
disabled={unavailable}
|
|
720
|
+
title={codecName(c)}
|
|
721
|
+
desc={unavailable ? t('exportCenter.notOnThisComputer') : t(`exportCenter.codecHint.${c}`)}
|
|
722
|
+
onClick={() => patch('video', { codec: c })}
|
|
723
|
+
/>
|
|
724
|
+
);
|
|
725
|
+
})}
|
|
726
|
+
</div>
|
|
727
|
+
</Field>
|
|
728
|
+
<FrameField
|
|
729
|
+
value={s.video.frame}
|
|
730
|
+
fill={s.video.fill}
|
|
731
|
+
current={props.frame}
|
|
732
|
+
stage={stage}
|
|
733
|
+
onChange={(frame) => patch('video', { frame })}
|
|
734
|
+
onFill={(fill) => patch('video', { fill })}
|
|
735
|
+
t={t}
|
|
736
|
+
/>
|
|
737
|
+
<Field label={t('exportCenter.resolution')}>
|
|
738
|
+
<Chips
|
|
739
|
+
value={s.video.shortEdge}
|
|
740
|
+
options={SHORT_EDGES.map((p) => {
|
|
741
|
+
const d = exportFramedDims(stage, resolveFrame(s.video.frame), s.video.fill, p);
|
|
742
|
+
return { value: p, label: p === 2160 ? '4K' : `${p}p`, sub: `${d.w}×${d.h}` };
|
|
743
|
+
})}
|
|
744
|
+
onChange={(shortEdge) => patch('video', { shortEdge })}
|
|
745
|
+
/>
|
|
746
|
+
</Field>
|
|
747
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
748
|
+
<Field label={t('exportCenter.fps')}>
|
|
749
|
+
<FpsSelect
|
|
750
|
+
label={t('exportCenter.fps')}
|
|
751
|
+
value={fpsFor(s.video.fps)}
|
|
752
|
+
rates={FPS_CHOICES}
|
|
753
|
+
own={{ fps: projectFps, note: t('exportCenter.fpsProject') }}
|
|
754
|
+
onPick={(fps) => patch('video', { fps: fps === projectFps ? 'source' : fps })}
|
|
755
|
+
/>
|
|
756
|
+
</Field>
|
|
757
|
+
{!(videoCodec.startsWith('prores') || videoCodec === 'png') && (
|
|
758
|
+
<Field label={t('exportCenter.quality')}>
|
|
759
|
+
<QualityPicker value={s.video.quality} onChange={(quality) => patch('video', { quality })} t={t} />
|
|
760
|
+
</Field>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
{videoCodec !== 'png' && (
|
|
764
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
765
|
+
<Field label={t('exportCenter.audio')}>
|
|
766
|
+
<Toggle checked={s.video.audio} onChange={(audio) => patch('video', { audio })} label={t('exportCenter.includeAudio')} />
|
|
767
|
+
</Field>
|
|
768
|
+
{captions > 0 && (
|
|
769
|
+
<Field label={t('exportCenter.subtitles')}>
|
|
770
|
+
<Toggle checked={s.video.burn} onChange={(burn) => patch('video', { burn })} label={t('exportCenter.burnIn')} />
|
|
771
|
+
<div className="mt-2">
|
|
772
|
+
<Segmented
|
|
773
|
+
value={s.video.sidecar}
|
|
774
|
+
options={[
|
|
775
|
+
{ value: 'none', label: t('exportCenter.noSidecar') },
|
|
776
|
+
{ value: 'srt', label: '+ SRT' },
|
|
777
|
+
{ value: 'vtt', label: '+ VTT' },
|
|
778
|
+
]}
|
|
779
|
+
onChange={(sidecar) => patch('video', { sidecar })}
|
|
780
|
+
/>
|
|
781
|
+
</div>
|
|
782
|
+
</Field>
|
|
783
|
+
)}
|
|
784
|
+
</div>
|
|
785
|
+
)}
|
|
786
|
+
</>
|
|
787
|
+
)}
|
|
788
|
+
|
|
789
|
+
{dest === 'alpha' && (
|
|
790
|
+
<>
|
|
791
|
+
<Field label={t('exportCenter.alphaSource')}>
|
|
792
|
+
<div className="flex max-h-[200px] flex-col gap-1.5 overflow-y-auto overscroll-contain pr-1">
|
|
793
|
+
{[{ clipId: WHOLE_FILM, label: t('exportCenter.alphaWhole'), startMs: 0, durMs: totalMs }, ...blocks].map((b) => {
|
|
794
|
+
const on = (alphaBlock ? alphaBlock.clipId : WHOLE_FILM) === b.clipId;
|
|
795
|
+
return (
|
|
796
|
+
<button
|
|
797
|
+
key={b.clipId}
|
|
798
|
+
type="button"
|
|
799
|
+
aria-pressed={on}
|
|
800
|
+
onClick={() => patch('alpha', { source: b.clipId })}
|
|
801
|
+
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition ${
|
|
802
|
+
on ? 'border-[var(--text)] bg-[var(--bg-hover)]' : 'border-[var(--border)] hover:border-[var(--text-faint)]'
|
|
803
|
+
}`}
|
|
804
|
+
>
|
|
805
|
+
<span className={`h-3.5 w-3.5 shrink-0 rounded-full border ${on ? 'border-[4px] border-[var(--text)]' : 'border-[var(--text-faint)]'}`} />
|
|
806
|
+
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-[var(--text)]">{b.label}</span>
|
|
807
|
+
<span className="shrink-0 text-[11px] tabular-nums text-[var(--text-faint)]">
|
|
808
|
+
{b.clipId === WHOLE_FILM ? t('exportCenter.alphaWholeHint') : `${formatFilmTime(b.startMs)} · ${formatFilmTime(b.durMs)}`}
|
|
809
|
+
</span>
|
|
810
|
+
</button>
|
|
811
|
+
);
|
|
812
|
+
})}
|
|
813
|
+
{blocks.length === 0 && <Hint>{t('exportCenter.alphaNoBlocks')}</Hint>}
|
|
814
|
+
</div>
|
|
815
|
+
</Field>
|
|
816
|
+
<Field label={t('exportCenter.format')}>
|
|
817
|
+
<div className="grid grid-cols-2 gap-2">
|
|
818
|
+
{EXPORT_ALPHA_CODECS.map((c) => {
|
|
819
|
+
const unavailable = !codecAvailable(c, can);
|
|
820
|
+
return (
|
|
821
|
+
<OptionCard
|
|
822
|
+
key={c}
|
|
823
|
+
on={alphaCodec === c}
|
|
824
|
+
disabled={unavailable}
|
|
825
|
+
title={codecName(c)}
|
|
826
|
+
desc={unavailable ? t('exportCenter.notOnThisComputer') : t(`exportCenter.alphaCodecHint.${c}`)}
|
|
827
|
+
onClick={() => patch('alpha', { codec: c })}
|
|
828
|
+
/>
|
|
829
|
+
);
|
|
830
|
+
})}
|
|
831
|
+
</div>
|
|
832
|
+
</Field>
|
|
833
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
834
|
+
{alphaBlock ? (
|
|
835
|
+
<Field label={t('exportCenter.alphaScale')}>
|
|
836
|
+
<Chips value={s.alpha.scale} options={ALPHA_SCALES.map((k) => ({ value: k, label: `${k}×` }))} onChange={(scale) => patch('alpha', { scale })} />
|
|
837
|
+
</Field>
|
|
838
|
+
) : (
|
|
839
|
+
<Field label={t('exportCenter.resolution')}>
|
|
840
|
+
<Chips
|
|
841
|
+
value={s.alpha.shortEdge}
|
|
842
|
+
options={SHORT_EDGES.map((p) => ({ value: p, label: p === 2160 ? '4K' : `${p}p` }))}
|
|
843
|
+
onChange={(shortEdge) => patch('alpha', { shortEdge })}
|
|
844
|
+
/>
|
|
845
|
+
</Field>
|
|
846
|
+
)}
|
|
847
|
+
<Field label={t('exportCenter.fps')}>
|
|
848
|
+
<FpsSelect
|
|
849
|
+
label={t('exportCenter.fps')}
|
|
850
|
+
value={s.alpha.fps}
|
|
851
|
+
rates={FPS_CHOICES}
|
|
852
|
+
own={{ fps: projectFps, note: t('exportCenter.fpsProject') }}
|
|
853
|
+
onPick={(fps) => patch('alpha', { fps })}
|
|
854
|
+
/>
|
|
855
|
+
</Field>
|
|
856
|
+
</div>
|
|
857
|
+
{alphaCodec !== 'png' && (
|
|
858
|
+
<Toggle checked={s.alpha.audio} onChange={(audio) => patch('alpha', { audio })} label={t('exportCenter.includeAudio')} />
|
|
859
|
+
)}
|
|
860
|
+
<Callout>{t('exportCenter.alphaHint')}</Callout>
|
|
861
|
+
</>
|
|
862
|
+
)}
|
|
863
|
+
|
|
864
|
+
{dest === 'nle' && (
|
|
865
|
+
<>
|
|
866
|
+
<Field label={t('exportCenter.nleFor')}>
|
|
867
|
+
<Segmented
|
|
868
|
+
value={nleApp}
|
|
869
|
+
options={[
|
|
870
|
+
{ value: 'draft', label: DRAFT_APP_NAME[draftApp] },
|
|
871
|
+
{ value: 'premiere', label: NLE_APPS.premiere.name },
|
|
872
|
+
{ value: 'resolve', label: NLE_APPS.resolve.name },
|
|
873
|
+
{ value: 'finalcut', label: NLE_APPS.finalcut.name },
|
|
874
|
+
]}
|
|
875
|
+
onChange={(app) => patch('nle', { app })}
|
|
876
|
+
/>
|
|
877
|
+
</Field>
|
|
878
|
+
{nleAlphaKind && (
|
|
879
|
+
<Field label={t('exportCenter.nleAlpha')}>
|
|
880
|
+
<div className="grid grid-cols-2 gap-2">
|
|
881
|
+
{NLE_ALPHA[nleAlphaKind].map((c) => {
|
|
882
|
+
const unavailable = c.startsWith('prores') && !codecAvailable('prores4444', can);
|
|
883
|
+
return (
|
|
884
|
+
<OptionCard
|
|
885
|
+
key={c}
|
|
886
|
+
on={nleAlpha === c}
|
|
887
|
+
disabled={unavailable}
|
|
888
|
+
title={c === 'auto' ? t('exportCenter.nleAlphaAuto') : NLE_ALPHA_NAME[c]}
|
|
889
|
+
desc={unavailable ? t('exportCenter.notOnThisComputer') : t(`exportCenter.nleAlphaHint.${c}`)}
|
|
890
|
+
onClick={() => patch('nle', nleAlphaKind === 'draft' ? { alphaDraft: c } : { alphaXml: c })}
|
|
891
|
+
/>
|
|
892
|
+
);
|
|
893
|
+
})}
|
|
894
|
+
</div>
|
|
895
|
+
</Field>
|
|
896
|
+
)}
|
|
897
|
+
<Field label={t('exportCenter.fps')}>
|
|
898
|
+
<FpsSelect
|
|
899
|
+
label={t('exportCenter.fps')}
|
|
900
|
+
value={s.nle.fps === 'source' ? (nleSourceFps ?? 30) : s.nle.fps}
|
|
901
|
+
rates={NLE_FPS}
|
|
902
|
+
own={nleSourceFps ? { fps: nleSourceFps, note: t('exportCenter.fpsSource') } : null}
|
|
903
|
+
onPick={(fps) => patch('nle', { fps: fps === nleSourceFps ? 'source' : fps })}
|
|
904
|
+
/>
|
|
905
|
+
</Field>
|
|
906
|
+
<Field label={t('exportCenter.nleAudio')}>
|
|
907
|
+
<Segmented
|
|
908
|
+
value={s.nle.audio}
|
|
909
|
+
options={[
|
|
910
|
+
{ value: 'clips', label: t('exportCenter.nleAudioClips') },
|
|
911
|
+
{ value: 'stems', label: t('exportCenter.nleAudioStems') },
|
|
912
|
+
]}
|
|
913
|
+
onChange={(audio) => patch('nle', { audio })}
|
|
914
|
+
/>
|
|
915
|
+
</Field>
|
|
916
|
+
{captions > 0 && (
|
|
917
|
+
<Toggle checked={s.nle.subtitles} onChange={(subtitles) => patch('nle', { subtitles })} label={t(nleApp === 'draft' || nleApp === 'finalcut' ? 'exportCenter.nleSubs' : 'exportCenter.nleSrt')} />
|
|
918
|
+
)}
|
|
919
|
+
<Hint>{tApp(nleApp === 'draft'
|
|
920
|
+
? draftHere ? 'exportCenter.nleHintDraft' : 'exportCenter.nleHintDraftNone'
|
|
921
|
+
: NLE_APPS[nleApp].hint)}</Hint>
|
|
922
|
+
</>
|
|
923
|
+
)}
|
|
924
|
+
|
|
925
|
+
{dest === 'gif' && (
|
|
926
|
+
<>
|
|
927
|
+
<FrameField
|
|
928
|
+
value={s.gif.frame}
|
|
929
|
+
fill={s.gif.fill}
|
|
930
|
+
current={props.frame}
|
|
931
|
+
stage={stage}
|
|
932
|
+
onChange={(frame) => patch('gif', { frame })}
|
|
933
|
+
onFill={(fill) => patch('gif', { fill })}
|
|
934
|
+
t={t}
|
|
935
|
+
/>
|
|
936
|
+
<Field label={t('exportCenter.gifWidth')}>
|
|
937
|
+
<Chips value={s.gif.width} options={GIF_WIDTHS.map((w) => ({ value: w, label: `${w}px` }))} onChange={(width) => patch('gif', { width })} />
|
|
938
|
+
</Field>
|
|
939
|
+
<Field label={t('exportCenter.fps')}>
|
|
940
|
+
<Chips value={s.gif.fps} options={GIF_FPS.map((f) => ({ value: f, label: `${f}` }))} onChange={(fps) => patch('gif', { fps })} />
|
|
941
|
+
</Field>
|
|
942
|
+
{captions > 0 && (
|
|
943
|
+
<Toggle checked={s.gif.burn} onChange={(burn) => patch('gif', { burn })} label={t('exportCenter.burnIn')} />
|
|
944
|
+
)}
|
|
945
|
+
<Hint>{t('exportCenter.gifHint')}</Hint>
|
|
946
|
+
</>
|
|
947
|
+
)}
|
|
948
|
+
|
|
949
|
+
{dest === 'audio' && (
|
|
950
|
+
<>
|
|
951
|
+
<Field label={t('exportCenter.format')}>
|
|
952
|
+
<div className="grid grid-cols-3 gap-2">
|
|
953
|
+
{(['wav', 'm4a', 'mp3'] as const).map((f) => {
|
|
954
|
+
const unavailable = f === 'mp3' && can != null && !can.mp3;
|
|
955
|
+
return (
|
|
956
|
+
<OptionCard
|
|
957
|
+
key={f}
|
|
958
|
+
on={audioFormat === f}
|
|
959
|
+
disabled={unavailable}
|
|
960
|
+
title={f.toUpperCase()}
|
|
961
|
+
desc={unavailable ? t('exportCenter.notOnThisComputer') : t(`exportCenter.audioHint.${f}`)}
|
|
962
|
+
onClick={() => patch('audio', { format: f })}
|
|
963
|
+
/>
|
|
964
|
+
);
|
|
965
|
+
})}
|
|
966
|
+
</div>
|
|
967
|
+
</Field>
|
|
968
|
+
<Field label={t('exportCenter.contents')}>
|
|
969
|
+
<div className="flex flex-col gap-2">
|
|
970
|
+
<Toggle checked={s.audio.mix || !s.audio.stems} onChange={(mix) => patch('audio', { mix })} label={t('exportCenter.audioMix')} disabled={!s.audio.stems} />
|
|
971
|
+
<Toggle checked={s.audio.stems} onChange={(stems) => patch('audio', { stems })} label={t('exportCenter.audioStems')} />
|
|
972
|
+
{s.audio.stems && film && (
|
|
973
|
+
<p className="pl-6 text-[11.5px] text-[var(--text-faint)]">
|
|
974
|
+
{STEM_KINDS.filter((k) => soundKinds.has(k)).map((k) => t(`exportCenter.stem.${k}`)).join(' · ') || t('exportCenter.noAudio')}
|
|
975
|
+
</p>
|
|
976
|
+
)}
|
|
977
|
+
</div>
|
|
978
|
+
</Field>
|
|
979
|
+
<Hint>{t('exportCenter.stemsHint')}</Hint>
|
|
980
|
+
</>
|
|
981
|
+
)}
|
|
982
|
+
|
|
983
|
+
{dest === 'subtitles' && (
|
|
984
|
+
<>
|
|
985
|
+
<Field label={t('exportCenter.format')}>
|
|
986
|
+
<div className="grid grid-cols-3 gap-2">
|
|
987
|
+
{(['srt', 'vtt', 'txt'] as const).map((f) => (
|
|
988
|
+
<OptionCard
|
|
989
|
+
key={f}
|
|
990
|
+
on={s.subtitles.format === f}
|
|
991
|
+
title={f === 'txt' ? t('exportCenter.transcript') : f.toUpperCase()}
|
|
992
|
+
desc={t(`exportCenter.subsHint.${f}`)}
|
|
993
|
+
onClick={() => patch('subtitles', { format: f })}
|
|
994
|
+
/>
|
|
995
|
+
))}
|
|
996
|
+
</div>
|
|
997
|
+
</Field>
|
|
998
|
+
<Hint>{t('exportCenter.captionCount').replace('{n}', String(captions))}</Hint>
|
|
999
|
+
</>
|
|
1000
|
+
)}
|
|
1001
|
+
|
|
1002
|
+
{dest === 'project' && (
|
|
1003
|
+
<>
|
|
1004
|
+
<Field label={t('exportCenter.format')}>
|
|
1005
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1006
|
+
{(['zip', 'folder'] as const).map((as) => (
|
|
1007
|
+
<OptionCard
|
|
1008
|
+
key={as}
|
|
1009
|
+
on={s.project.as === as}
|
|
1010
|
+
title={t(as === 'zip' ? 'exportCenter.projectZip' : 'exportCenter.projectFolder')}
|
|
1011
|
+
desc={t(as === 'zip' ? 'exportCenter.projectZipHint' : 'exportCenter.projectFolderHint')}
|
|
1012
|
+
onClick={() => patch('project', { as })}
|
|
1013
|
+
/>
|
|
1014
|
+
))}
|
|
1015
|
+
</div>
|
|
1016
|
+
</Field>
|
|
1017
|
+
<Field label={t('exportCenter.contents')}>
|
|
1018
|
+
<div className="flex flex-col gap-2.5">
|
|
1019
|
+
<PartToggle
|
|
1020
|
+
checked
|
|
1021
|
+
locked
|
|
1022
|
+
onChange={() => {}}
|
|
1023
|
+
label={t('exportCenter.projectPart.work')}
|
|
1024
|
+
hint={t('exportCenter.projectPartHint.work')}
|
|
1025
|
+
size={projectSizes ? formatBytes(projectSizes.work) : ''}
|
|
1026
|
+
/>
|
|
1027
|
+
{PROJECT_PARTS.map((part) => {
|
|
1028
|
+
const none = projectSizes != null && projectSizes[part] == null;
|
|
1029
|
+
return (
|
|
1030
|
+
<PartToggle
|
|
1031
|
+
key={part}
|
|
1032
|
+
checked={s.project[part] && !none}
|
|
1033
|
+
disabled={none}
|
|
1034
|
+
onChange={(on) => patch('project', { [part]: on })}
|
|
1035
|
+
label={t(`exportCenter.projectPart.${part}`)}
|
|
1036
|
+
hint={none ? t('exportCenter.projectPartNone') : t(`exportCenter.projectPartHint.${part}`)}
|
|
1037
|
+
size={!none && projectSizes ? formatBytes(projectSizes[part] ?? 0) : ''}
|
|
1038
|
+
/>
|
|
1039
|
+
);
|
|
1040
|
+
})}
|
|
1041
|
+
</div>
|
|
1042
|
+
</Field>
|
|
1043
|
+
<Hint>{t('exportCenter.projectHint')}</Hint>
|
|
1044
|
+
</>
|
|
1045
|
+
)}
|
|
1046
|
+
|
|
1047
|
+
{dest === 'images' && (
|
|
1048
|
+
<>
|
|
1049
|
+
<Field label={t('exportCenter.poster')}>
|
|
1050
|
+
<button
|
|
1051
|
+
type="button"
|
|
1052
|
+
onClick={exportPoster}
|
|
1053
|
+
disabled={Boolean(working) || !totalMs || !folder}
|
|
1054
|
+
className="inline-flex h-9 items-center gap-2 self-start rounded-lg border border-[var(--border)] px-3 text-[12.5px] font-medium text-[var(--text)] transition hover:bg-[var(--bg-hover)] disabled:cursor-not-allowed disabled:opacity-40"
|
|
1055
|
+
>
|
|
1056
|
+
<Download size={14} />
|
|
1057
|
+
{posterAt != null ? t('exportCenter.posterAt').replace('{time}', formatFilmTime(posterAt * 1000)) : t('exportCenter.posterMiddle')}
|
|
1058
|
+
</button>
|
|
1059
|
+
</Field>
|
|
1060
|
+
<Field label={t('exportCenter.sceneStills')}>
|
|
1061
|
+
<p className="-mt-1 mb-1 text-[12px] text-[var(--text-muted)]">
|
|
1062
|
+
{t('exportCenter.sceneStillsDesc').replace('{n}', String(stillCount))}
|
|
1063
|
+
</p>
|
|
1064
|
+
<Segmented
|
|
1065
|
+
value={s.images.format}
|
|
1066
|
+
options={[{ value: 'png', label: 'PNG' }, { value: 'jpg', label: 'JPG' }]}
|
|
1067
|
+
onChange={(format) => patch('images', { format })}
|
|
1068
|
+
/>
|
|
1069
|
+
</Field>
|
|
1070
|
+
<FrameField
|
|
1071
|
+
value={s.images.frame}
|
|
1072
|
+
fill={s.images.fill}
|
|
1073
|
+
current={props.frame}
|
|
1074
|
+
stage={stage}
|
|
1075
|
+
onChange={(frame) => patch('images', { frame })}
|
|
1076
|
+
onFill={(fill) => patch('images', { fill })}
|
|
1077
|
+
t={t}
|
|
1078
|
+
/>
|
|
1079
|
+
<Field label={t('exportCenter.resolution')}>
|
|
1080
|
+
<Chips
|
|
1081
|
+
value={s.images.shortEdge}
|
|
1082
|
+
options={STILL_EDGES.map((p) => ({ value: p, label: p === 2160 ? '4K' : `${p}p` }))}
|
|
1083
|
+
onChange={(shortEdge) => patch('images', { shortEdge })}
|
|
1084
|
+
/>
|
|
1085
|
+
</Field>
|
|
1086
|
+
{captions > 0 && (
|
|
1087
|
+
<Toggle checked={s.images.burn} onChange={(burn) => patch('images', { burn })} label={t('exportCenter.burnIn')} />
|
|
1088
|
+
)}
|
|
1089
|
+
</>
|
|
1090
|
+
)}
|
|
1091
|
+
|
|
1092
|
+
{dest === 'slides' && (
|
|
1093
|
+
<>
|
|
1094
|
+
<Field label={t('exportCenter.format')}>
|
|
1095
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1096
|
+
{(['pptx', 'pdf'] as const).map((f) => (
|
|
1097
|
+
<OptionCard
|
|
1098
|
+
key={f}
|
|
1099
|
+
on={s.slides.format === f}
|
|
1100
|
+
title={f === 'pptx' ? 'PowerPoint' : 'PDF'}
|
|
1101
|
+
desc={t(`exportCenter.slidesHint.${f}`)}
|
|
1102
|
+
onClick={() => patch('slides', { format: f })}
|
|
1103
|
+
/>
|
|
1104
|
+
))}
|
|
1105
|
+
</div>
|
|
1106
|
+
</Field>
|
|
1107
|
+
<FrameField
|
|
1108
|
+
value={s.slides.frame}
|
|
1109
|
+
fill={s.slides.fill}
|
|
1110
|
+
current={props.frame}
|
|
1111
|
+
stage={stage}
|
|
1112
|
+
onChange={(frame) => patch('slides', { frame })}
|
|
1113
|
+
onFill={(fill) => patch('slides', { fill })}
|
|
1114
|
+
t={t}
|
|
1115
|
+
/>
|
|
1116
|
+
{captions > 0 && (
|
|
1117
|
+
<Toggle checked={s.slides.burn} onChange={(burn) => patch('slides', { burn })} label={t('exportCenter.burnIn')} />
|
|
1118
|
+
)}
|
|
1119
|
+
</>
|
|
1120
|
+
)}
|
|
1121
|
+
|
|
1122
|
+
{/* range: any destination with a picture or a sound over time can take a part */}
|
|
1123
|
+
{(dest === 'social' || dest === 'video' || dest === 'gif' || dest === 'audio' || dest === 'subtitles' || (dest === 'alpha' && !alphaBlock)) && totalMs > 0 && (
|
|
1124
|
+
<Field label={t('exportCenter.range')}>
|
|
1125
|
+
<Segmented
|
|
1126
|
+
value={range.on ? 'part' : 'all'}
|
|
1127
|
+
options={[
|
|
1128
|
+
{ value: 'all', label: `${t('exportCenter.rangeAll')} · ${formatFilmTime(totalMs)}` },
|
|
1129
|
+
{ value: 'part', label: t('exportCenter.rangePart') },
|
|
1130
|
+
]}
|
|
1131
|
+
onChange={(v) => setRange((r) => ({ ...r, on: v === 'part' }))}
|
|
1132
|
+
/>
|
|
1133
|
+
{range.on && (
|
|
1134
|
+
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
|
1135
|
+
<div className="flex w-[220px] min-w-0">
|
|
1136
|
+
<MenuSelect
|
|
1137
|
+
label={t('exportCenter.rangeFrom')}
|
|
1138
|
+
size="md"
|
|
1139
|
+
value={String(range.from)}
|
|
1140
|
+
groups={[{ choices: boundaries.starts.map((m) => ({ value: String(m.ms), label: [formatFilmTime(m.ms), m.title].filter(Boolean).join(' · ') })) }]}
|
|
1141
|
+
onPick={(v) => {
|
|
1142
|
+
const from = Number(v);
|
|
1143
|
+
setRange((r) => ({ ...r, from, to: r.to > from ? r.to : (boundaries.ends.find((m) => m.ms > from)?.ms ?? totalMs) }));
|
|
1144
|
+
}}
|
|
1145
|
+
/>
|
|
1146
|
+
</div>
|
|
1147
|
+
<span>→</span>
|
|
1148
|
+
<div className="flex w-[220px] min-w-0">
|
|
1149
|
+
<MenuSelect
|
|
1150
|
+
label={t('exportCenter.rangeTo')}
|
|
1151
|
+
size="md"
|
|
1152
|
+
value={String(range.to)}
|
|
1153
|
+
groups={[{ choices: boundaries.ends.filter((m) => m.ms > range.from).map((m) => ({ value: String(m.ms), label: [formatFilmTime(m.ms), m.title].filter(Boolean).join(' · ') })) }]}
|
|
1154
|
+
onPick={(v) => setRange((r) => ({ ...r, to: Number(v) }))}
|
|
1155
|
+
/>
|
|
1156
|
+
</div>
|
|
1157
|
+
<span className="tabular-nums">{formatFilmTime(rangeMs)}</span>
|
|
1158
|
+
</div>
|
|
1159
|
+
)}
|
|
1160
|
+
</Field>
|
|
1161
|
+
)}
|
|
1162
|
+
</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
|
|
1165
|
+
{/* bottom: where it goes (a button like the dialog's other pickers: a click picks another folder), what it is
|
|
1166
|
+
called, what comes out, and the primary button */}
|
|
1167
|
+
<div className="flex shrink-0 flex-wrap items-center gap-x-5 gap-y-3 border-t border-[var(--border-soft)] bg-[color-mix(in_srgb,var(--surface-2)_45%,transparent)] px-5 py-3.5">
|
|
1168
|
+
<div className="flex min-w-[280px] max-w-[560px] flex-1 items-center gap-2">
|
|
1169
|
+
{toDrafts ? (
|
|
1170
|
+
<span className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-[var(--border)] px-3 text-[13px] text-[var(--text-muted)]">
|
|
1171
|
+
<Clapperboard size={15} className="shrink-0" />
|
|
1172
|
+
<span className="truncate">{tApp('exportCenter.nleDraftTitle')}</span>
|
|
1173
|
+
</span>
|
|
1174
|
+
) : folderDraft != null ? (
|
|
1175
|
+
<input
|
|
1176
|
+
autoFocus
|
|
1177
|
+
value={folderDraft}
|
|
1178
|
+
spellCheck={false}
|
|
1179
|
+
aria-label={t('exportCenter.saveTo')}
|
|
1180
|
+
onChange={(e) => setFolderDraft(e.target.value)}
|
|
1181
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
1182
|
+
onBlur={commitFolder}
|
|
1183
|
+
onKeyDown={(e) => {
|
|
1184
|
+
if (e.key === 'Enter') { e.preventDefault(); commitFolder(); }
|
|
1185
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFolderDraft(null); }
|
|
1186
|
+
}}
|
|
1187
|
+
className="h-10 min-w-0 flex-1 rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 font-mono text-[12px] text-[var(--text)] outline-none focus:border-[var(--text-faint)]"
|
|
1188
|
+
/>
|
|
1189
|
+
) : (
|
|
1190
|
+
<button
|
|
1191
|
+
type="button"
|
|
1192
|
+
onClick={() => void changeFolder()}
|
|
1193
|
+
disabled={picking}
|
|
1194
|
+
title={folder}
|
|
1195
|
+
aria-label={`${t('exportCenter.saveTo')}: ${folder || '—'}. ${t('exportCenter.change')}`}
|
|
1196
|
+
className="flex h-10 min-w-0 max-w-[45%] shrink-0 items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--fill-tsp)] pl-3 pr-2.5 text-[13px] text-[var(--text)] outline-none transition hover:border-[var(--text-faint)] hover:bg-[var(--bg-hover)] focus-visible:border-[var(--accent)] disabled:opacity-60"
|
|
1197
|
+
>
|
|
1198
|
+
{picking ? <Loader2 size={15} className="shrink-0 animate-spin text-[var(--text-muted)]" /> : <FolderOpen size={15} className="shrink-0 text-[var(--text-muted)]" />}
|
|
1199
|
+
<span className="min-w-0 truncate">{folderName(folder) || '—'}</span>
|
|
1200
|
+
<ChevronDown size={12} aria-hidden className="shrink-0 text-[var(--text-faint)]" />
|
|
1201
|
+
</button>
|
|
1202
|
+
)}
|
|
1203
|
+
{/* what the files are called: the project's own name unless typed over (a draft is named by its app) */}
|
|
1204
|
+
{!toDrafts && folderDraft == null && (
|
|
1205
|
+
<input
|
|
1206
|
+
value={exportName}
|
|
1207
|
+
spellCheck={false}
|
|
1208
|
+
placeholder={props.title}
|
|
1209
|
+
aria-label={t('exportCenter.fileName')}
|
|
1210
|
+
title={t('exportCenter.fileName')}
|
|
1211
|
+
onChange={(e) => { setExportName(e.target.value); setNameTyped(true); }}
|
|
1212
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
1213
|
+
onKeyDown={(e) => {
|
|
1214
|
+
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !disabled) { e.preventDefault(); void queue(requests); }
|
|
1215
|
+
}}
|
|
1216
|
+
className="h-10 min-w-0 flex-1 rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 text-[13px] text-[var(--text)] outline-none transition focus:border-[var(--text-faint)]"
|
|
1217
|
+
/>
|
|
1218
|
+
)}
|
|
1219
|
+
</div>
|
|
1220
|
+
<div className="ml-auto flex min-w-0 items-center gap-4">
|
|
1221
|
+
<div className="min-w-0 text-right text-[12px] leading-snug">
|
|
1222
|
+
{result ? (
|
|
1223
|
+
<span className={result.ok ? 'text-[var(--ok)]' : 'text-[var(--err)]'}>{result.text}</span>
|
|
1224
|
+
) : emptyReason ? (
|
|
1225
|
+
<span data-export-reason="" className={missingReason ? 'text-[var(--warn)]' : 'text-[var(--text-faint)]'}>{emptyReason}</span>
|
|
1226
|
+
) : (
|
|
1227
|
+
<>
|
|
1228
|
+
{/* a project is not a length of film: its size only */}
|
|
1229
|
+
{dest !== 'project' ? (
|
|
1230
|
+
<span className="block text-[var(--text-muted)] tabular-nums">
|
|
1231
|
+
{t('exportCenter.summary').replace('{n}', String(fileCount)).replace('{dur}', formatFilmTime(outMs))}
|
|
1232
|
+
</span>
|
|
1233
|
+
) : null}
|
|
1234
|
+
{estText ? <span className="block text-[11px] text-[var(--text-faint)] tabular-nums">{`${t('exportCenter.approx')} ${estText}`}</span> : null}
|
|
1235
|
+
</>
|
|
1236
|
+
)}
|
|
1237
|
+
</div>
|
|
1238
|
+
<button
|
|
1239
|
+
type="button"
|
|
1240
|
+
onClick={() => void queue(requests)}
|
|
1241
|
+
disabled={disabled}
|
|
1242
|
+
className="flex h-10 shrink-0 items-center gap-2 rounded-xl bg-[var(--text)] px-6 text-[13.5px] font-medium text-[var(--bg)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
1243
|
+
>
|
|
1244
|
+
{working ? <Loader2 size={14} className="animate-spin" /> : null}
|
|
1245
|
+
{primaryLabel}
|
|
1246
|
+
</button>
|
|
1247
|
+
</div>
|
|
1248
|
+
</div>
|
|
1249
|
+
</div>
|
|
1250
|
+
</div>
|
|
1251
|
+
</div>,
|
|
1252
|
+
document.body,
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/* ── small parts ────────────────────────────────────────────────────────── */
|
|
1257
|
+
|
|
1258
|
+
/** A folder's own name, the last part of its path (the whole path is the button's tooltip). */
|
|
1259
|
+
function folderName(path: string): string {
|
|
1260
|
+
return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
1264
|
+
return (
|
|
1265
|
+
<div className="flex flex-col gap-2">
|
|
1266
|
+
<span className="text-[11.5px] font-medium uppercase tracking-wide text-[var(--text-faint)]">{label}</span>
|
|
1267
|
+
{children}
|
|
1268
|
+
</div>
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function Hint({ children }: { children: React.ReactNode }) {
|
|
1273
|
+
return <p className="text-[11.5px] leading-relaxed text-[var(--text-faint)]">{children}</p>;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function Callout({ children }: { children: React.ReactNode }) {
|
|
1277
|
+
return (
|
|
1278
|
+
<div className="flex items-start gap-2 rounded-xl border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2.5 text-[12px] leading-relaxed text-[var(--text-muted)]">
|
|
1279
|
+
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-[var(--warn)]" />
|
|
1280
|
+
<span>{children}</span>
|
|
1281
|
+
</div>
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function OptionCard({ on, disabled, title, desc, onClick }: {
|
|
1286
|
+
on: boolean; disabled?: boolean; title: string; desc: string; onClick: () => void;
|
|
1287
|
+
}) {
|
|
1288
|
+
return (
|
|
1289
|
+
<button
|
|
1290
|
+
type="button"
|
|
1291
|
+
aria-pressed={on}
|
|
1292
|
+
disabled={disabled}
|
|
1293
|
+
onClick={onClick}
|
|
1294
|
+
className={`flex flex-col items-start gap-0.5 rounded-xl border px-3 py-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
|
1295
|
+
on ? 'border-[var(--text)] bg-[var(--bg-hover)]' : 'border-[var(--border)] hover:border-[var(--text-faint)]'
|
|
1296
|
+
}`}
|
|
1297
|
+
>
|
|
1298
|
+
<span className="text-[12.5px] font-medium text-[var(--text)]">{title}</span>
|
|
1299
|
+
<span className="text-[11px] leading-snug text-[var(--text-faint)]">{desc}</span>
|
|
1300
|
+
</button>
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* A frame rate, from an editor's list: the film's own (or its footage's) is in it whatever it is, said beside its
|
|
1306
|
+
* number ("30 fps · project").
|
|
1307
|
+
*/
|
|
1308
|
+
function FpsSelect({ label, value, rates, own, onPick }: {
|
|
1309
|
+
label: string;
|
|
1310
|
+
value: number;
|
|
1311
|
+
rates: readonly number[];
|
|
1312
|
+
own: { fps: number; note: string } | null;
|
|
1313
|
+
onPick: (fps: number) => void;
|
|
1314
|
+
}) {
|
|
1315
|
+
const all = [...new Set([...rates, ...(own ? [own.fps] : [])])].sort((a, b) => a - b);
|
|
1316
|
+
const name = (f: number) => `${f} fps${own && f === own.fps ? ` · ${own.note}` : ''}`;
|
|
1317
|
+
return (
|
|
1318
|
+
<MenuSelect
|
|
1319
|
+
size="md"
|
|
1320
|
+
label={label}
|
|
1321
|
+
value={String(value)}
|
|
1322
|
+
groups={[{ choices: all.map((f) => ({ value: String(f), label: name(f) })) }]}
|
|
1323
|
+
onPick={(v) => onPick(Number(v))}
|
|
1324
|
+
/>
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function Chips<V extends string | number>({ value, options, onChange }: {
|
|
1329
|
+
value: V;
|
|
1330
|
+
options: readonly { value: V; label: string; sub?: string }[];
|
|
1331
|
+
onChange: (v: V) => void;
|
|
1332
|
+
}) {
|
|
1333
|
+
return (
|
|
1334
|
+
<div className="flex flex-wrap items-start gap-1.5">
|
|
1335
|
+
{options.map((o) => {
|
|
1336
|
+
const on = o.value === value;
|
|
1337
|
+
return (
|
|
1338
|
+
<button
|
|
1339
|
+
key={String(o.value)}
|
|
1340
|
+
type="button"
|
|
1341
|
+
aria-pressed={on}
|
|
1342
|
+
onClick={() => onChange(o.value)}
|
|
1343
|
+
className={`flex min-w-[56px] flex-col items-center rounded-lg border px-2.5 py-1.5 transition ${
|
|
1344
|
+
on ? 'border-[var(--text)] bg-[var(--bg-hover)] text-[var(--text)]' : 'border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--text-faint)]'
|
|
1345
|
+
}`}
|
|
1346
|
+
>
|
|
1347
|
+
<span className="text-[12.5px] font-medium tabular-nums">{o.label}</span>
|
|
1348
|
+
{o.sub ? <span className="text-[10px] tabular-nums text-[var(--text-faint)]">{o.sub}</span> : null}
|
|
1349
|
+
</button>
|
|
1350
|
+
);
|
|
1351
|
+
})}
|
|
1352
|
+
</div>
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function Segmented<V extends string>({ value, options, onChange }: {
|
|
1357
|
+
value: V;
|
|
1358
|
+
options: readonly { value: V; label: string }[];
|
|
1359
|
+
onChange: (v: V) => void;
|
|
1360
|
+
}) {
|
|
1361
|
+
return (
|
|
1362
|
+
/* a capsule in a capsule: the same even gap all round, so their curves run side by side */
|
|
1363
|
+
<div className="inline-flex max-w-full self-start overflow-x-auto rounded-full border border-[var(--border)] bg-[var(--bg)] p-[3px]">
|
|
1364
|
+
{options.map((o) => (
|
|
1365
|
+
<button
|
|
1366
|
+
key={o.value}
|
|
1367
|
+
type="button"
|
|
1368
|
+
aria-pressed={o.value === value}
|
|
1369
|
+
onClick={() => onChange(o.value)}
|
|
1370
|
+
className={`shrink-0 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-medium transition ${
|
|
1371
|
+
o.value === value ? 'bg-[var(--surface-3,var(--bg-hover))] text-[var(--text)] shadow-[var(--shadow-sm)]' : 'text-[var(--text-muted)] hover:text-[var(--text)]'
|
|
1372
|
+
}`}
|
|
1373
|
+
>
|
|
1374
|
+
{o.label}
|
|
1375
|
+
</button>
|
|
1376
|
+
))}
|
|
1377
|
+
</div>
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* A switch and its words: lit when on, quieter when off, faint when it can't be had. `locked`: always on (a part that
|
|
1383
|
+
* is always taken), shown on in full, not to be switched.
|
|
1384
|
+
*/
|
|
1385
|
+
function Toggle({ checked, onChange, label, disabled, locked }: {
|
|
1386
|
+
checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean; locked?: boolean;
|
|
1387
|
+
}) {
|
|
1388
|
+
const tone = disabled ? 'text-[var(--text-faint)]' : checked || locked ? 'text-[var(--text)]' : 'text-[var(--text-muted)]';
|
|
1389
|
+
return (
|
|
1390
|
+
<label className={`flex select-none items-center gap-2.5 text-[12.5px] ${tone} ${disabled || locked ? 'cursor-default' : 'cursor-pointer'}`}>
|
|
1391
|
+
<button
|
|
1392
|
+
type="button"
|
|
1393
|
+
role="switch"
|
|
1394
|
+
aria-checked={checked || Boolean(locked)}
|
|
1395
|
+
aria-readonly={locked || undefined}
|
|
1396
|
+
disabled={disabled}
|
|
1397
|
+
onClick={() => { if (!locked) onChange(!checked); }}
|
|
1398
|
+
className={`relative h-[18px] w-[30px] shrink-0 rounded-full transition ${checked || locked ? 'bg-[var(--text)]' : 'bg-[var(--border-strong,var(--border))]'} ${locked ? 'cursor-default' : ''} disabled:opacity-50`}
|
|
1399
|
+
>
|
|
1400
|
+
<span className={`absolute top-[2px] h-[14px] w-[14px] rounded-full bg-[var(--bg)] transition-[left] ${checked || locked ? 'left-[14px]' : 'left-[2px]'}`} />
|
|
1401
|
+
</button>
|
|
1402
|
+
{label}
|
|
1403
|
+
</label>
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** A part of the project to take or leave: the switch, what the part is, and how big. */
|
|
1408
|
+
function PartToggle({ checked, onChange, label, hint, size, disabled, locked }: {
|
|
1409
|
+
checked: boolean; onChange: (v: boolean) => void; label: string; hint: string; size: string; disabled?: boolean; locked?: boolean;
|
|
1410
|
+
}) {
|
|
1411
|
+
return (
|
|
1412
|
+
<div className="flex flex-col gap-0.5">
|
|
1413
|
+
<div className="flex items-center gap-2">
|
|
1414
|
+
<Toggle checked={checked} onChange={onChange} label={label} disabled={disabled} locked={locked} />
|
|
1415
|
+
{size && <span className="ml-auto font-mono text-[11px] tabular-nums text-[var(--text-faint)]">{size}</span>}
|
|
1416
|
+
</div>
|
|
1417
|
+
<p className="pl-[40px] text-[11.5px] leading-snug text-[var(--text-faint)]">{hint}</p>
|
|
1418
|
+
</div>
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function QualityPicker({ value, onChange, t }: { value: ExportQuality; onChange: (q: ExportQuality) => void; t: (k: string) => string }) {
|
|
1423
|
+
return (
|
|
1424
|
+
<Segmented
|
|
1425
|
+
value={value}
|
|
1426
|
+
options={[
|
|
1427
|
+
{ value: 'standard', label: t('exportCenter.qStandard') },
|
|
1428
|
+
{ value: 'high', label: t('exportCenter.qHigh') },
|
|
1429
|
+
{ value: 'master', label: t('exportCenter.qMaster') },
|
|
1430
|
+
]}
|
|
1431
|
+
onChange={onChange}
|
|
1432
|
+
/>
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** A small glyph: the frame's shape, and inside it the film as it lands. */
|
|
1437
|
+
function AspectGlyph({ frame, stage, fill = 'contain' }: { frame: FilmFrameId; stage: { w: number; h: number }; fill?: FilmFrameFill }) {
|
|
1438
|
+
const box = filmFrameBox(stage, frame, fill);
|
|
1439
|
+
const k = 14 / Math.max(box.w, box.h);
|
|
1440
|
+
const w = Math.max(6, box.w * k);
|
|
1441
|
+
const h = Math.max(6, box.h * k);
|
|
1442
|
+
return (
|
|
1443
|
+
<span className="relative inline-block shrink-0 overflow-hidden rounded-[2px] border border-[var(--text-faint)]" style={{ width: w, height: h }}>
|
|
1444
|
+
<span
|
|
1445
|
+
className="absolute bg-[var(--text-muted)] opacity-50"
|
|
1446
|
+
style={{
|
|
1447
|
+
left: box.inner.left * k,
|
|
1448
|
+
top: box.inner.top * k,
|
|
1449
|
+
width: box.inner.w * k,
|
|
1450
|
+
height: box.inner.h * k,
|
|
1451
|
+
}}
|
|
1452
|
+
/>
|
|
1453
|
+
</span>
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function FrameField({ value, fill, current, stage, onChange, onFill, t }: {
|
|
1458
|
+
value: FrameChoice;
|
|
1459
|
+
fill: FilmFrameFill;
|
|
1460
|
+
current: FilmFrameId;
|
|
1461
|
+
stage: { w: number; h: number };
|
|
1462
|
+
onChange: (f: FrameChoice) => void;
|
|
1463
|
+
onFill: (f: FilmFrameFill) => void;
|
|
1464
|
+
t: (k: string) => string;
|
|
1465
|
+
}) {
|
|
1466
|
+
const resolved = value === 'current' ? current : value;
|
|
1467
|
+
const differs = resolved !== 'native' && Math.abs(filmFrameRatio(resolved, stage) - stage.w / Math.max(1, stage.h)) > 0.01;
|
|
1468
|
+
return (
|
|
1469
|
+
<Field label={t('exportCenter.frame')}>
|
|
1470
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1471
|
+
{[...FILM_FRAME_IDS, ...(FILM_FRAME_IDS.includes(resolved) ? [] : [resolved])].map((f) => {
|
|
1472
|
+
const on = resolved === f;
|
|
1473
|
+
return (
|
|
1474
|
+
<button
|
|
1475
|
+
key={f}
|
|
1476
|
+
type="button"
|
|
1477
|
+
aria-pressed={on}
|
|
1478
|
+
onClick={() => onChange(f === current ? 'current' : f)}
|
|
1479
|
+
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[12px] font-medium transition ${
|
|
1480
|
+
on ? 'border-[var(--text)] bg-[var(--bg-hover)] text-[var(--text)]' : 'border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--text-faint)]'
|
|
1481
|
+
}`}
|
|
1482
|
+
>
|
|
1483
|
+
<AspectGlyph frame={f} stage={stage} fill={fill} />
|
|
1484
|
+
{f === 'native' ? t('exportCenter.frameNative') : f}
|
|
1485
|
+
</button>
|
|
1486
|
+
);
|
|
1487
|
+
})}
|
|
1488
|
+
<CustomFrame chip value={resolved} onPick={(f) => onChange(f === current ? 'current' : f)} />
|
|
1489
|
+
</div>
|
|
1490
|
+
{differs && (
|
|
1491
|
+
<Segmented
|
|
1492
|
+
value={fill}
|
|
1493
|
+
options={[
|
|
1494
|
+
{ value: 'contain', label: t('exportCenter.fillContain') },
|
|
1495
|
+
{ value: 'cover', label: t('exportCenter.fillCover') },
|
|
1496
|
+
]}
|
|
1497
|
+
onChange={onFill}
|
|
1498
|
+
/>
|
|
1499
|
+
)}
|
|
1500
|
+
</Field>
|
|
1501
|
+
);
|
|
1502
|
+
}
|