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 30ed6e00c..9d01dba05 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -965,6 +965,32 @@ 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 } => { @@ -978,65 +1004,50 @@ 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))?; + // Use the longer duration + let duration = display_duration.max(camera_duration); - ret.size += (file - .metadata() - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .len() as f64) - / (1024.0 * 1024.0); + // 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 reader = BufReader::new(file); - let file_size = path - .metadata() - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .len(); + 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 + }; - 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 - } - }; - } + 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(ret) + Ok(VideoRecordingMetadata { + size: estimated_size_mb, + duration, + }) } #[tauri::command(async)] @@ -1851,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 beaada1b7..ba63b3af5 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -63,6 +63,12 @@ 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(); @@ -78,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(); @@ -175,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); @@ -273,9 +297,9 @@ export function Header() {
- + Export Video + + {(est) => ( +
+

+ + + {(() => { + 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")}`; + })()} + + + + {est().estimated_size_mb.toFixed(2)} MB + + + + {(() => { + 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")}`; + })()} + +

+
+ )} +
@@ -608,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/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index dd7cff3d8..624dcf9fd 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, @@ -181,11 +182,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); @@ -593,25 +596,37 @@ export default function () {
-

- - {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/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 } 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 891872317..a814bce9b 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'] @@ -49,10 +51,12 @@ 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'] 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']