From 5c1979870d9f2187fc0f890c7499e66cdb4fff37 Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Fri, 27 Dec 2024 20:27:13 +0100 Subject: [PATCH 1/9] Add video metadata display --- apps/desktop/src/routes/editor/Header.tsx | 34 +++++++++++++++++++ ...s.timestamp-1735325995918-46a167c39672.mjs | 11 ++++++ packages/ui-solid/src/auto-imports.d.ts | 3 ++ 3 files changed, 48 insertions(+) create mode 100644 apps/storybook/vite.config.ts.timestamp-1735325995918-46a167c39672.mjs diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 2742f59b0..f4635ff84 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -27,6 +27,10 @@ import Titlebar from "~/components/titlebar/Titlebar"; import { initializeTitlebar, setTitlebar } from "~/utils/titlebar-state"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; +import IconLucideHardDrive from "~icons/lucide/hard-drive"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideLoaderCircle from "~icons/lucide/loader-circle"; +import IconLucideRotateCcw from "~icons/lucide/rotate-ccw"; export function Header() { const currentWindow = getCurrentWindow(); @@ -239,6 +243,17 @@ import { getRequestEvent } from "solid-js/web"; function ExportButton() { const { videoId, project, prettyName } = useEditorContext(); + const [metadata] = createResource(async () => { + const result = await commands.getVideoMetadata(videoId, null).catch((e) => { + console.error(`Failed to get metadata: ${e}`); + }); + if (!result) return; + + const { duration, size } = result; + console.log(`Metadata for video: duration=${duration}, size=${size}`); + return { duration, size }; + }); + const exportVideo = createMutation(() => ({ mutationFn: async (useCustomMuxer: boolean) => { const path = await save({ @@ -304,6 +319,7 @@ function ExportButton() { })); return ( +
+ + {(meta) => ( +
+ + + {Math.floor(meta().duration / 60)}:{Math.floor(meta().duration % 60) + .toString() + .padStart(2, "0")} + +
+ + + {meta().size.toFixed(2)} MB + +
+ )} +
+
); } diff --git a/apps/storybook/vite.config.ts.timestamp-1735325995918-46a167c39672.mjs b/apps/storybook/vite.config.ts.timestamp-1735325995918-46a167c39672.mjs new file mode 100644 index 000000000..e6e738e70 --- /dev/null +++ b/apps/storybook/vite.config.ts.timestamp-1735325995918-46a167c39672.mjs @@ -0,0 +1,11 @@ +// vite.config.ts +import { defineConfig } from "file:///Users/onyedikachi/Documents/codes/algora-bounties/Cap/node_modules/.pnpm/vite@5.4.8_@types+node@20.16.9_terser@5.34.0/node_modules/vite/dist/node/index.js"; +import solid from "file:///Users/onyedikachi/Documents/codes/algora-bounties/Cap/node_modules/.pnpm/vite-plugin-solid@2.10.2_@testing-library+jest-dom@6.5.0_solid-js@1.9.3_vite@5.4.8_@types+node@20.16.9_terser@5.34.0_/node_modules/vite-plugin-solid/dist/esm/index.mjs"; +import capUIPlugin from "file:///Users/onyedikachi/Documents/codes/algora-bounties/Cap/packages/ui-solid/vite.js"; +var vite_config_default = defineConfig({ + plugins: [solid(), capUIPlugin] +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvb255ZWRpa2FjaGkvRG9jdW1lbnRzL2NvZGVzL2FsZ29yYS1ib3VudGllcy9DYXAvYXBwcy9zdG9yeWJvb2tcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9vbnllZGlrYWNoaS9Eb2N1bWVudHMvY29kZXMvYWxnb3JhLWJvdW50aWVzL0NhcC9hcHBzL3N0b3J5Ym9vay92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvb255ZWRpa2FjaGkvRG9jdW1lbnRzL2NvZGVzL2FsZ29yYS1ib3VudGllcy9DYXAvYXBwcy9zdG9yeWJvb2svdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuaW1wb3J0IHNvbGlkIGZyb20gXCJ2aXRlLXBsdWdpbi1zb2xpZFwiO1xuaW1wb3J0IGNhcFVJUGx1Z2luIGZyb20gXCJAY2FwL3VpLXNvbGlkL3ZpdGVcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW3NvbGlkKCksIGNhcFVJUGx1Z2luXSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFpWSxTQUFTLG9CQUFvQjtBQUM5WixPQUFPLFdBQVc7QUFDbEIsT0FBTyxpQkFBaUI7QUFFeEIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUyxDQUFDLE1BQU0sR0FBRyxXQUFXO0FBQ2hDLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg== diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 1a8c60b25..3bc75950a 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -14,12 +14,14 @@ declare global { const IconCapCircleCheck: typeof import('~icons/cap/circle-check.jsx')['default'] const IconCapCirclePlus: typeof import('~icons/cap/circle-plus.jsx')['default'] const IconCapCircleX: typeof import('~icons/cap/circle-x.jsx')['default'] + const IconCapClock: typeof import('~icons/cap/clock.jsx')['default'] const IconCapCopy: typeof import('~icons/cap/copy.jsx')['default'] const IconCapCorners: typeof import('~icons/cap/corners.jsx')['default'] const IconCapCrop: typeof import('~icons/cap/crop.jsx')['default'] const IconCapCursor: typeof import('~icons/cap/cursor.jsx')['default'] const IconCapEditor: typeof import('~icons/cap/editor.jsx')['default'] const IconCapEnlarge: typeof import('~icons/cap/enlarge.jsx')['default'] + const IconCapFile: typeof import('~icons/cap/file.jsx')['default'] const IconCapFrameFirst: typeof import('~icons/cap/frame-first.jsx')['default'] const IconCapFrameLast: typeof import('~icons/cap/frame-last.jsx')['default'] const IconCapHotkeys: typeof import('~icons/cap/hotkeys.jsx')['default'] @@ -53,6 +55,7 @@ declare global { const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] + const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default'] const IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default'] From 5c8d72fb680df65ff071b6c53f83445c114e0f1b Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Fri, 27 Dec 2024 20:56:13 +0100 Subject: [PATCH 2/9] Refactor ExportButton layout for improved UI consistency --- apps/desktop/src/routes/editor/Header.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index f4635ff84..bd5a9357b 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -319,16 +319,16 @@ function ExportButton() { })); return ( -
- +
+ {(meta) => (
From 1905d69a0e65a95f449624fe8781b3daba33146e Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Fri, 27 Dec 2024 21:08:53 +0100 Subject: [PATCH 3/9] Add estimated export time to video metadata display --- apps/desktop/src/routes/editor/Header.tsx | 13 ++++++-- .../desktop/src/routes/recordings-overlay.tsx | 33 ++++++++++++++----- packages/ui-solid/src/auto-imports.d.ts | 1 + 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index bd5a9357b..af0ada061 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -31,6 +31,7 @@ import IconLucideHardDrive from "~icons/lucide/hard-drive"; import IconLucideCheck from "~icons/lucide/check"; import IconLucideLoaderCircle from "~icons/lucide/loader-circle"; import IconLucideRotateCcw from "~icons/lucide/rotate-ccw"; +import IconLucideClock from "~icons/lucide/clock"; export function Header() { const currentWindow = getCurrentWindow(); @@ -250,8 +251,9 @@ function ExportButton() { if (!result) return; const { duration, size } = result; - console.log(`Metadata for video: duration=${duration}, size=${size}`); - return { duration, size }; + const estimatedExportTime = Math.ceil(duration * 1.5); + console.log(`Metadata for video: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}`); + return { duration, size, estimatedExportTime }; }); const exportVideo = createMutation(() => ({ @@ -343,6 +345,13 @@ function ExportButton() { {meta().size.toFixed(2)} MB +
+ + + ~{Math.floor(meta().estimatedExportTime / 60)}:{Math.floor(meta().estimatedExportTime % 60) + .toString() + .padStart(2, "0")} +
)}
diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index b0a6b2d69..456f305e2 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -24,6 +24,7 @@ import { TransitionGroup } from "solid-transition-group"; import { makePersisted } from "@solid-primitives/storage"; import { Channel } from "@tauri-apps/api/core"; import { createStore, produce } from "solid-js/store"; +import IconLucideClock from "~icons/lucide/clock"; import { commands, @@ -177,11 +178,13 @@ export default function () { if (!result) return; const { duration, size } = result; + // Calculate estimated export time (rough estimation: 1.5x real-time for 1080p) + const estimatedExportTime = Math.ceil(duration * 1.5); console.log( - `Metadata for ${media.path}: duration=${duration}, size=${size}` + `Metadata for ${media.path}: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}` ); - return { duration, size }; + return { duration, size, estimatedExportTime }; }); const [imageExists, setImageExists] = createSignal(true); @@ -585,14 +588,26 @@ export default function () { : "group-hover:opacity-0" )} > -

- - {Math.floor(metadata().duration / 60)}: - {Math.floor(metadata().duration % 60) - .toString() - .padStart(2, "0")} +

+ + + {Math.floor(metadata().duration / 60)}: + {Math.floor(metadata().duration % 60) + .toString() + .padStart(2, "0")} + + + + {metadata().size.toFixed(2)} MB + + + + ~{Math.floor(metadata().estimatedExportTime / 60)}: + {Math.floor(metadata().estimatedExportTime % 60) + .toString() + .padStart(2, "0")} +

-

{metadata().size.toFixed(2)} MB

)} diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 3bc75950a..e11b6f9ee 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -51,6 +51,7 @@ declare global { const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideCamera: typeof import('~icons/lucide/camera.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] + const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default'] From 5e6c679465f02bb45fda937cd497c9025de6edbe Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Fri, 27 Dec 2024 21:12:00 +0100 Subject: [PATCH 4/9] Refactor UI components for video metadata display; adjust font sizes and layout for consistency --- apps/desktop/src/routes/editor/Header.tsx | 12 ++++++------ apps/desktop/src/routes/recordings-overlay.tsx | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index af0ada061..cdd3becd8 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -333,21 +333,21 @@ function ExportButton() { {(meta) => ( -
+
- + {Math.floor(meta().duration / 60)}:{Math.floor(meta().duration % 60) .toString() .padStart(2, "0")} -
+
- + {meta().size.toFixed(2)} MB -
+
- + ~{Math.floor(meta().estimatedExportTime / 60)}:{Math.floor(meta().estimatedExportTime % 60) .toString() .padStart(2, "0")} diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index 456f305e2..2a7efce4c 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -577,31 +577,31 @@ export default function () {
-

+

- + {Math.floor(metadata().duration / 60)}: {Math.floor(metadata().duration % 60) .toString() .padStart(2, "0")} - + {metadata().size.toFixed(2)} MB - + ~{Math.floor(metadata().estimatedExportTime / 60)}: {Math.floor(metadata().estimatedExportTime % 60) .toString() From 9eea2167f877ffcc754f855def64ed4cd154d46e Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Mon, 13 Jan 2025 16:03:32 +0100 Subject: [PATCH 5/9] fix(metadata): implement accurate video duration and size calculation - Use longest duration between camera.mp4 and display.mp4 - Replace source file size with encoder-based size estimation (8-10 Mbps video + 128 Kbps audio) --- apps/desktop/src-tauri/src/lib.rs | 119 ++++++++++++++++-------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9f450d783..7dd8a9849 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -916,6 +916,23 @@ pub struct VideoRecordingMetadata { size: f64, } +// Helper function to estimate rendered file size based on duration and quality +fn estimate_rendered_size(duration: f64) -> f64 { + // Use actual encoder bitrates: + // - Video: 8-10 Mbps (from H264 encoder implementations) + // - Audio: 128 Kbps (fixed in all encoders) + #[cfg(target_os = "macos")] + let video_bitrate = 10_000_000.0; // 10 Mbps (AVAssetWriter) + #[cfg(not(target_os = "macos"))] + let video_bitrate = 8_000_000.0; // 8 Mbps (libx264) + + let audio_bitrate = 128_000.0; // 128 Kbps (fixed in encoders) + + // Total data = (video_bitrate + audio_bitrate) * duration / 8 bits per byte + // Convert to MB by dividing by (1024 * 1024) + ((video_bitrate + audio_bitrate) * duration) / (8.0 * 1024.0 * 1024.0) +} + #[tauri::command] #[specta::specta] async fn get_video_metadata( @@ -938,6 +955,30 @@ async fn get_video_metadata( let meta = RecordingMeta::load_for_project(&project_path)?; + fn get_duration_for_paths(paths: Vec) -> Result { + let mut max_duration: f64 = 0.0; + for path in paths { + let reader = BufReader::new(File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?); + let file_size = path + .metadata() + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .len(); + + let current_duration = match Mp4Reader::read_header(reader, file_size) { + Ok(mp4) => mp4.duration().as_secs_f64(), + Err(e) => { + println!( + "Failed to read MP4 header: {}. Falling back to default duration.", + e + ); + 0.0_f64 + } + }; + max_duration = max_duration.max(current_duration); + } + Ok(max_duration) + } + fn content_paths(project_path: &PathBuf, meta: &RecordingMeta) -> Vec { match &meta.content { Content::SingleSegment { segment } => { @@ -951,65 +992,33 @@ async fn get_video_metadata( } } - let paths = match video_type { - Some(VideoType::Screen) => content_paths(&project_path, &meta), - Some(VideoType::Camera) => match &meta.content { - Content::SingleSegment { segment } => segment - .camera - .as_ref() - .map_or(vec![], |c| vec![segment.path(&meta, &c.path)]), - Content::MultipleSegments { inner } => inner - .segments - .iter() - .filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path))) - .collect(), - }, - Some(VideoType::Output) | None => { - let output_video_path = project_path.join("output").join("result.mp4"); - println!("Using output video path: {:?}", output_video_path); - if output_video_path.exists() { - vec![output_video_path] - } else { - println!("Output video not found, falling back to screen paths"); - content_paths(&project_path, &meta) - } - } - }; - - let mut ret = VideoRecordingMetadata { - size: 0.0, - duration: 0.0, + // Get display duration + let display_duration = get_duration_for_paths(content_paths(&project_path, &meta))?; + + // Get camera duration + let camera_paths = match &meta.content { + Content::SingleSegment { segment } => segment + .camera + .as_ref() + .map_or(vec![], |c| vec![segment.path(&meta, &c.path)]), + Content::MultipleSegments { inner } => inner + .segments + .iter() + .filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path))) + .collect(), }; + let camera_duration = get_duration_for_paths(camera_paths)?; - for path in paths { - let file = File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?; - - ret.size += (file - .metadata() - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .len() as f64) - / (1024.0 * 1024.0); - - let reader = BufReader::new(file); - let file_size = path - .metadata() - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .len(); + // Use the longer duration + let duration = display_duration.max(camera_duration); - ret.duration += match Mp4Reader::read_header(reader, file_size) { - Ok(mp4) => mp4.duration().as_secs_f64(), - Err(e) => { - println!( - "Failed to read MP4 header: {}. Falling back to default duration.", - e - ); - // Return a default duration (e.g., 0.0) or try to estimate it based on file size - 0.0 // or some estimated value - } - }; - } + // Estimate the rendered file size based on the duration + let estimated_size = estimate_rendered_size(duration); - Ok(ret) + Ok(VideoRecordingMetadata { + size: estimated_size, + duration, + }) } #[tauri::command(async)] From c21c44bca29041ef4b79e2f57de6595c9709da5e Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Mon, 13 Jan 2025 16:27:50 +0100 Subject: [PATCH 6/9] refactor: remove duplicate VideoRecordingMetadata struct definition --- apps/desktop/src-tauri/src/lib.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a67d28aa0..6e8562170 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -941,11 +941,6 @@ async fn copy_video_to_clipboard( } -#[derive(Serialize, Deserialize, specta::Type)] -pub struct VideoRecordingMetadata { - duration: f64, - size: f64, -} // Helper function to estimate rendered file size based on duration and quality fn estimate_rendered_size(duration: f64) -> f64 { From 44bbe11f670b778aefc1d65e86437a28a22458f6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Jan 2025 08:50:55 +0000 Subject: [PATCH 7/9] wip: Metadata moved to export options --- apps/desktop/src/routes/editor/Header.tsx | 492 ++++++++++++++-------- 1 file changed, 325 insertions(+), 167 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index f14bf5b89..fa9ec6a20 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -7,35 +7,90 @@ import { batch, createEffect, createResource, + createSignal, onCleanup, onMount, } from "solid-js"; import { type as ostype } from "@tauri-apps/plugin-os"; import { Tooltip } from "@kobalte/core"; +import { Select as KSelect } from "@kobalte/core/select"; +import { createMutation } from "@tanstack/solid-query"; +import { getRequestEvent } from "solid-js/web"; +import { save } from "@tauri-apps/plugin-dialog"; +import { Channel } from "@tauri-apps/api/core"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; import { type RenderProgress, commands } from "~/utils/tauri"; - -import { useEditorContext } from "./context"; -import { Dialog, DialogContent } from "./ui"; +import { + canCreateShareableLink, + checkIsUpgradedAndUpdate, +} from "~/utils/plans"; +import { FPS, useEditorContext } from "./context"; +import { + Dialog, + DialogContent, + MenuItem, + MenuItemList, + PopperContent, + topLeftAnimateClasses, +} from "./ui"; +import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; import { type ProgressState, progressState, setProgressState, } from "~/store/progress"; - import { events } from "~/utils/tauri"; import Titlebar from "~/components/titlebar/Titlebar"; import { initializeTitlebar, setTitlebar } from "~/utils/titlebar-state"; -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; -import IconLucideHardDrive from "~icons/lucide/hard-drive"; -import IconLucideCheck from "~icons/lucide/check"; -import IconLucideLoaderCircle from "~icons/lucide/loader-circle"; -import IconLucideRotateCcw from "~icons/lucide/rotate-ccw"; -import IconLucideClock from "~icons/lucide/clock"; + +type ResolutionOption = { + label: string; + value: string; + width: number; + height: number; +}; + +const RESOLUTION_OPTIONS: ResolutionOption[] = [ + { label: "720p", value: "720p", width: 1280, height: 720 }, + { label: "1080p", value: "1080p", width: 1920, height: 1080 }, + { label: "4K", value: "4k", width: 3840, height: 2160 }, +]; + +const FPS_OPTIONS = [ + { label: "30 FPS", value: 30 }, + { label: "60 FPS", value: 60 }, +] satisfies Array<{ label: string; value: number }>; export function Header() { const currentWindow = getCurrentWindow(); + const { videoId, project, prettyName } = useEditorContext(); + + const [metadata] = createResource(async () => { + const result = await commands.getVideoMetadata(videoId, null).catch((e) => { + console.error(`Failed to get metadata: ${e}`); + }); + if (!result) return; + + const { duration, size } = result; + const estimatedExportTime = Math.ceil(duration * 1.5); + console.log( + `Metadata for video: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}` + ); + return { duration, size, estimatedExportTime }; + }); + + const [showExportOptions, setShowExportOptions] = createSignal(false); + const [selectedFps, setSelectedFps] = createSignal( + Number(localStorage.getItem("cap-export-fps")) || 30 + ); + const [selectedResolution, setSelectedResolution] = + createSignal( + RESOLUTION_OPTIONS.find( + (opt) => opt.value === localStorage.getItem("cap-export-resolution") + ) || RESOLUTION_OPTIONS[0] + ); let unlistenTitlebar: UnlistenFn | undefined; onMount(async () => { @@ -43,6 +98,12 @@ export function Header() { }); onCleanup(() => unlistenTitlebar?.()); + // Save settings when they change + createEffect(() => { + localStorage.setItem("cap-export-fps", selectedFps().toString()); + localStorage.setItem("cap-export-resolution", selectedResolution().value); + }); + createEffect(() => { const state = progressState; if (state === undefined || state.type === "idle") { @@ -65,6 +126,91 @@ export function Header() { currentWindow.setProgressBar({ progress: Math.round(percentage) }); }); + const exportWithSettings = async () => { + setShowExportOptions(false); + + const path = await save({ + filters: [{ name: "mp4 filter", extensions: ["mp4"] }], + defaultPath: `~/Desktop/${prettyName()}.mp4`, + }); + if (!path) return; + + setProgressState({ + type: "saving", + progress: 0, + renderProgress: 0, + totalFrames: 0, + message: "Preparing to render...", + mediaPath: path, + stage: "rendering", + }); + + const progress = new Channel(); + progress.onmessage = (p) => { + if (p.type === "FrameRendered" && progressState.type === "saving") { + const percentComplete = Math.min( + Math.round( + (p.current_frame / (progressState.totalFrames || 1)) * 100 + ), + 100 + ); + + setProgressState({ + ...progressState, + renderProgress: p.current_frame, + message: `Rendering video - ${percentComplete}%`, + }); + + // If rendering is complete, update to finalizing state + if (percentComplete === 100) { + setProgressState({ + ...progressState, + message: "Finalizing export...", + }); + } + } + if ( + p.type === "EstimatedTotalFrames" && + progressState.type === "saving" + ) { + setProgressState({ + ...progressState, + totalFrames: p.total_frames, + message: "Starting render...", + }); + } + }; + + try { + const videoPath = await commands.exportVideo( + videoId, + project, + progress, + true, + selectedFps(), + { + x: selectedResolution().width, + y: selectedResolution().height, + } + ); + await commands.copyFileToPath(videoPath, path); + + setProgressState({ + type: "saving", + progress: 100, + message: "Saved successfully!", + mediaPath: path, + }); + + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1500); + } catch (error) { + setProgressState({ type: "idle" }); + throw error; + } + }; + batch(() => { setTitlebar("border", false); setTitlebar("height", "4rem"); @@ -80,8 +226,151 @@ export function Header() { >

- - + +
+ + +
+
+
+ + + options={RESOLUTION_OPTIONS} + optionValue="value" + optionTextValue="label" + placeholder="Select Resolution" + value={selectedResolution()} + onChange={setSelectedResolution} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.label} + + + )} + > + + class="flex-1 text-sm text-left truncate"> + {(state) => ( + {state.selectedOption()?.label} + )} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + + +
+
+ + + options={FPS_OPTIONS} + optionValue="value" + optionTextValue="label" + placeholder="Select FPS" + value={FPS_OPTIONS.find( + (opt) => opt.value === selectedFps() + )} + onChange={(option) => setSelectedFps(option?.value ?? 30)} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.label} + + + )} + > + + class="flex-1 text-sm text-left truncate"> + {(state) => ( + {state.selectedOption()?.label} + )} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + + +
+ + {(meta) => ( +
+ + + {Math.floor(meta().duration / 60)}: + {Math.floor(meta().duration % 60) + .toString() + .padStart(2, "0")} + +
+ + + {meta().size.toFixed(2)} MB + +
+ + ~ + {Math.floor(meta().estimatedExportTime / 60)}: + {Math.floor(meta().estimatedExportTime % 60) + .toString() + .padStart(2, "0")} + +
+ )} +
+ +
+
+
+
); @@ -239,149 +528,12 @@ export function Header() { ); } -import { Channel } from "@tauri-apps/api/core"; -import { save } from "@tauri-apps/plugin-dialog"; -import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; -import { createMutation } from "@tanstack/solid-query"; -import { getRequestEvent } from "solid-js/web"; -import { checkIsUpgradedAndUpdate } from "~/utils/plans"; +type ShareButtonProps = { + selectedResolution: () => ResolutionOption; + selectedFps: () => number; +}; -function ExportButton() { - const { videoId, project, prettyName } = useEditorContext(); - - const [metadata] = createResource(async () => { - const result = await commands.getVideoMetadata(videoId, null).catch((e) => { - console.error(`Failed to get metadata: ${e}`); - }); - if (!result) return; - - const { duration, size } = result; - const estimatedExportTime = Math.ceil(duration * 1.5); - console.log(`Metadata for video: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}`); - return { duration, size, estimatedExportTime }; - }); - - const exportVideo = createMutation(() => ({ - mutationFn: async (useCustomMuxer: boolean) => { - const path = await save({ - filters: [{ name: "mp4 filter", extensions: ["mp4"] }], - defaultPath: `~/Desktop/${prettyName()}.mp4`, - }); - if (!path) return; - - setProgressState({ - type: "saving", - progress: 0, - renderProgress: 0, - totalFrames: 0, - message: "Preparing to render...", - mediaPath: path, - stage: "rendering", - }); - - const progress = new Channel(); - progress.onmessage = (p) => { - if (p.type === "FrameRendered" && progressState.type === "saving") { - const percentComplete = Math.min( - Math.round( - (p.current_frame / (progressState.totalFrames || 1)) * 100 - ), - 100 - ); - - setProgressState({ - ...progressState, - renderProgress: p.current_frame, - message: `Rendering video - ${percentComplete}%`, - }); - - // If rendering is complete, update to finalizing state - if (percentComplete === 100) { - setProgressState({ - ...progressState, - message: "Finalizing export...", - }); - } - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "saving" - ) { - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - message: "Starting render...", - }); - } - }; - - try { - const videoPath = await commands.exportVideo( - videoId, - project, - progress, - true, - useCustomMuxer - ); - await commands.copyFileToPath(videoPath, path); - - setProgressState({ - type: "saving", - progress: 100, - message: "Saved successfully!", - mediaPath: path, - }); - - setTimeout(() => { - setProgressState({ type: "idle" }); - }, 1500); - } catch (error) { - setProgressState({ type: "idle" }); - throw error; - } - }, - })); - - return ( -
- - - {(meta) => ( -
- - - {Math.floor(meta().duration / 60)}:{Math.floor(meta().duration % 60) - .toString() - .padStart(2, "0")} - -
- - - {meta().size.toFixed(2)} MB - -
- - - ~{Math.floor(meta().estimatedExportTime / 60)}:{Math.floor(meta().estimatedExportTime % 60) - .toString() - .padStart(2, "0")} - -
- )} -
-
- ); -} - -function ShareButton() { +function ShareButton(props: ShareButtonProps) { const { videoId, project, presets } = useEditorContext(); const [recordingMeta, metaActions] = createResource(() => commands.getRecordingMeta(videoId, "recording") @@ -390,16 +542,22 @@ function ShareButton() { const uploadVideo = createMutation(() => ({ mutationFn: async (useCustomMuxer: boolean) => { console.log("Starting upload process..."); - if (!recordingMeta()) { + const meta = recordingMeta(); + if (!meta) { console.error("No recording metadata available"); throw new Error("Recording metadata not available"); } - // Check for pro access first before starting the export - const isUpgraded = await checkIsUpgradedAndUpdate(); - if (!isUpgraded) { - await commands.showWindow("Upgrade"); - throw new Error("Upgrade required to share recordings"); + const metadata = await commands.getVideoMetadata(videoId, null); + const canShare = await canCreateShareableLink(metadata?.duration); + + if (!canShare.allowed) { + if (canShare.reason === "upgrade_required") { + await commands.showWindow("Upgrade"); + throw new Error( + "Upgrade required to share recordings over 5 minutes" + ); + } } let unlisten: (() => void) | undefined; @@ -443,8 +601,6 @@ function ShareButton() { }); console.log("Starting actual upload..."); - const projectConfig = - project ?? presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG; setProgressState({ type: "uploading", @@ -487,10 +643,14 @@ function ShareButton() { await commands.exportVideo( videoId, - projectConfig, + project, progress, true, - false + props.selectedFps(), + { + x: props.selectedResolution().width, + y: props.selectedResolution().height, + } ); // Now proceed with upload @@ -500,8 +660,6 @@ function ShareButton() { Initial: { pre_created_video: null }, }); - console.log("Upload result:", result); - if (result === "NotAuthenticated") { throw new Error("You need to sign in to share recordings"); } From 2bddf21c27cacd37c000851aed7f69b454dd1a12 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Jan 2025 10:29:05 +0000 Subject: [PATCH 8/9] feat: bring to export overlay --- apps/desktop/src/routes/editor/Header.tsx | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index be0f47d3f..6e13390fe 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -341,6 +341,36 @@ export function Header() { > Export Video + + {(metadata) => ( +
+

+ + + {Math.floor(metadata().duration / 60)}: + {Math.floor(metadata().duration % 60) + .toString() + .padStart(2, "0")} + + + + {metadata().size.toFixed(2)} MB + + + + ~{Math.floor(metadata().estimatedExportTime / 60)}: + {Math.floor(metadata().estimatedExportTime % 60) + .toString() + .padStart(2, "0")} + +

+
+ )} +
From 1980d659d795278530b96107cb34c5afe5003da8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Jan 2025 11:48:49 +0000 Subject: [PATCH 9/9] feat: Better export estimates --- apps/desktop/src-tauri/src/export.rs | 84 +++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 47 +++++----- apps/desktop/src/routes/editor/Header.tsx | 108 +++++++++++++++------- apps/desktop/src/utils/tauri.ts | 4 + 4 files changed, 189 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 0189b235a..31805c62a 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -105,3 +105,87 @@ pub async fn export_video( } } } + +#[derive(Debug, serde::Serialize, specta::Type)] +pub struct ExportEstimates { + pub duration_seconds: f64, + pub estimated_time_seconds: f64, + pub estimated_size_mb: f64, +} + +// This will need to be refactored at some point to be more accurate. +#[tauri::command] +#[specta::specta] +pub async fn get_export_estimates( + app: AppHandle, + video_id: String, + resolution: XY, + fps: u32, +) -> Result { + let screen_metadata = + get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)).await?; + let camera_metadata = + get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Camera)) + .await + .ok(); + + let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; + let total_frames = editor_instance.get_total_frames(fps); + + let raw_duration = screen_metadata.duration.max( + camera_metadata + .map(|m| m.duration) + .unwrap_or(screen_metadata.duration), + ); + + let meta = editor_instance.meta(); + let project_config = meta.project_config(); + let duration_seconds = if let Some(timeline) = &project_config.timeline { + timeline + .segments + .iter() + .map(|s| (s.end - s.start) / s.timescale) + .sum() + } else { + raw_duration + }; + + let (width, height) = (resolution.x, resolution.y); + + let base_bitrate = if width <= 1280 && height <= 720 { + 4_000_000.0 + } else if width <= 1920 && height <= 1080 { + 8_000_000.0 + } else if width <= 2560 && height <= 1440 { + 14_000_000.0 + } else { + 20_000_000.0 + }; + + let fps_factor = (fps as f64) / 30.0; + let video_bitrate = base_bitrate * fps_factor; + + let audio_bitrate = 192_000.0; + + let total_bitrate = video_bitrate + audio_bitrate; + + let estimated_size_mb = (total_bitrate * duration_seconds) / (8.0 * 1024.0 * 1024.0); + + let base_factor = match (width, height) { + (w, h) if w <= 1280 && h <= 720 => 0.43, + (w, h) if w <= 1920 && h <= 1080 => 0.64, + (w, h) if w <= 2560 && h <= 1440 => 0.75, + _ => 0.86, + }; + + let processing_time = duration_seconds * base_factor * fps_factor; + let overhead_time = 0.0; + + let estimated_time_seconds = processing_time + overhead_time; + + Ok(ExportEstimates { + duration_seconds, + estimated_time_seconds, + estimated_size_mb, + }) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e099a4bd1..9d01dba05 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -943,25 +943,6 @@ async fn copy_video_to_clipboard( Ok(()) } - - -// Helper function to estimate rendered file size based on duration and quality -fn estimate_rendered_size(duration: f64) -> f64 { - // Use actual encoder bitrates: - // - Video: 8-10 Mbps (from H264 encoder implementations) - // - Audio: 128 Kbps (fixed in all encoders) - #[cfg(target_os = "macos")] - let video_bitrate = 10_000_000.0; // 10 Mbps (AVAssetWriter) - #[cfg(not(target_os = "macos"))] - let video_bitrate = 8_000_000.0; // 8 Mbps (libx264) - - let audio_bitrate = 128_000.0; // 128 Kbps (fixed in encoders) - - // Total data = (video_bitrate + audio_bitrate) * duration / 8 bits per byte - // Convert to MB by dividing by (1024 * 1024) - ((video_bitrate + audio_bitrate) * duration) / (8.0 * 1024.0 * 1024.0) -} - #[tauri::command] #[specta::specta] async fn get_video_metadata( @@ -987,7 +968,9 @@ async fn get_video_metadata( fn get_duration_for_paths(paths: Vec) -> Result { let mut max_duration: f64 = 0.0; for path in paths { - let reader = BufReader::new(File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?); + let reader = BufReader::new( + File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?, + ); let file_size = path .metadata() .map_err(|e| format!("Failed to get file metadata: {}", e))? @@ -1041,11 +1024,28 @@ async fn get_video_metadata( // Use the longer duration let duration = display_duration.max(camera_duration); - // Estimate the rendered file size based on the duration - let estimated_size = estimate_rendered_size(duration); + // Calculate estimated size using same logic as get_export_estimates + let (width, height) = (1920, 1080); // Default to 1080p + let fps = 30; // Default to 30fps + + let base_bitrate = if width <= 1280 && height <= 720 { + 4_000_000.0 + } else if width <= 1920 && height <= 1080 { + 8_000_000.0 + } else if width <= 2560 && height <= 1440 { + 14_000_000.0 + } else { + 20_000_000.0 + }; + + let fps_factor = (fps as f64) / 30.0; + let video_bitrate = base_bitrate * fps_factor; + let audio_bitrate = 192_000.0; + let total_bitrate = video_bitrate + audio_bitrate; + let estimated_size_mb = (total_bitrate * duration) / (8.0 * 1024.0 * 1024.0); Ok(VideoRecordingMetadata { - size: estimated_size, + size: estimated_size_mb, duration, }) } @@ -1862,6 +1862,7 @@ pub async fn run() { focus_captures_panel, get_current_recording, export::export_video, + export::get_export_estimates, copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 6e13390fe..ba63b3af5 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -63,24 +63,16 @@ const FPS_OPTIONS = [ { label: "60 FPS", value: 60 }, ] satisfies Array<{ label: string; value: number }>; +export interface ExportEstimates { + duration_seconds: number; + estimated_time_seconds: number; + estimated_size_mb: number; +} + export function Header() { const currentWindow = getCurrentWindow(); const { videoId, project, prettyName } = useEditorContext(); - const [metadata] = createResource(async () => { - const result = await commands.getVideoMetadata(videoId, null).catch((e) => { - console.error(`Failed to get metadata: ${e}`); - }); - if (!result) return; - - const { duration, size } = result; - const estimatedExportTime = Math.ceil(duration * 1.5); - console.log( - `Metadata for video: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}` - ); - return { duration, size, estimatedExportTime }; - }); - const [showExportOptions, setShowExportOptions] = createSignal(false); const [selectedFps, setSelectedFps] = createSignal( Number(localStorage.getItem("cap-export-fps")) || 30 @@ -92,6 +84,24 @@ export function Header() { ) || RESOLUTION_OPTIONS[0] ); + const [exportEstimates] = createResource( + () => ({ + videoId, + resolution: { + x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width, + y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height, + }, + fps: selectedFps(), + }), + async (params) => { + return commands.getExportEstimates( + params.videoId, + params.resolution, + params.fps + ); + } + ); + let unlistenTitlebar: UnlistenFn | undefined; onMount(async () => { unlistenTitlebar = await initializeTitlebar(); @@ -189,8 +199,8 @@ export function Header() { true, selectedFps(), { - x: selectedResolution().width, - y: selectedResolution().height, + x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width, + y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height, } ); await commands.copyFileToPath(videoPath, path); @@ -287,9 +297,9 @@ export function Header() {
- + Export Video - - {(metadata) => ( + + {(est) => (
- {Math.floor(metadata().duration / 60)}: - {Math.floor(metadata().duration % 60) - .toString() - .padStart(2, "0")} + {(() => { + const totalSeconds = Math.round( + est().duration_seconds + ); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor( + (totalSeconds % 3600) / 60 + ); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes + .toString() + .padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${seconds + .toString() + .padStart(2, "0")}`; + })()} - {metadata().size.toFixed(2)} MB + {est().estimated_size_mb.toFixed(2)} MB - ~{Math.floor(metadata().estimatedExportTime / 60)}: - {Math.floor(metadata().estimatedExportTime % 60) - .toString() - .padStart(2, "0")} + {(() => { + const totalSeconds = Math.round( + est().estimated_time_seconds + ); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor( + (totalSeconds % 3600) / 60 + ); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `~${hours}:${minutes + .toString() + .padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `~${minutes}:${seconds + .toString() + .padStart(2, "0")}`; + })()}

@@ -652,8 +696,10 @@ function ShareButton(props: ShareButtonProps) { true, props.selectedFps(), { - x: props.selectedResolution().width, - y: props.selectedResolution().height, + x: props.selectedResolution()?.width || RESOLUTION_OPTIONS[0].width, + y: + props.selectedResolution()?.height || + RESOLUTION_OPTIONS[0].height, } ); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 2d4157038..5b0bdae89 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -56,6 +56,9 @@ async getCurrentRecording() : Promise> { async exportVideo(videoId: string, project: ProjectConfiguration, progress: TAURI_CHANNEL, force: boolean, fps: number, resolutionBase: XY) : Promise { return await TAURI_INVOKE("export_video", { videoId, project, progress, force, fps, resolutionBase }); }, +async getExportEstimates(videoId: string, resolution: XY, fps: number) : Promise { + return await TAURI_INVOKE("get_export_estimates", { videoId, resolution, fps }); +}, async copyFileToPath(src: string, dst: string) : Promise { return await TAURI_INVOKE("copy_file_to_path", { src, dst }); }, @@ -240,6 +243,7 @@ export type CursorConfiguration = { hideWhenIdle: boolean; size: number; type: C export type CursorType = "pointer" | "circle" export type Display = { path: string; fps?: number } export type EditorStateChanged = { playhead_position: number } +export type ExportEstimates = { duration_seconds: number; estimated_time_seconds: number; estimated_size_mb: number } export type Flags = { recordMouse: boolean; split: boolean; pauseResume: boolean; zoom: boolean } export type GeneralSettingsStore = { uploadIndividualFiles?: boolean; openEditorAfterRecording?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; recordingConfig?: RecordingConfig | null } export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean }