diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs new file mode 100644 index 000000000..a02208096 --- /dev/null +++ b/apps/desktop/src-tauri/src/import.rs @@ -0,0 +1,84 @@ +use cap_project::RecordingMeta; +use std::path::PathBuf; +use tauri::Manager; +use tracing::{error, info}; +use uuid::Uuid; + +pub fn get_projects_dir(app: &tauri::AppHandle) -> Result { + let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(app_data.join("recordings")) +} + +#[tauri::command] +#[specta::specta] +pub async fn import_video_to_project( + app: tauri::AppHandle, + video_path: PathBuf, +) -> Result { + info!("Attempting to import video from path: {:?}", video_path); + + // Verify the video file exists and is MP4 + if !video_path.exists() { + let err = format!("Video path {:?} not found!", video_path); + error!("{}", err); + return Err(err); + } + + if video_path.extension().and_then(|ext| ext.to_str()) != Some("mp4") { + return Err("Only MP4 files are supported".to_string()); + } + + let project_id = Uuid::new_v4().to_string(); + info!("Generated project ID: {}", project_id); + + // Create the project directory with .cap extension + let project_dir = get_projects_dir(&app)?.join(format!("{}.cap", project_id)); + info!("Project directory: {:?}", project_dir); + + std::fs::create_dir_all(&project_dir).map_err(|e| { + let err = format!("Failed to create project directory: {}", e); + error!("{}", err); + err + })?; + + let content_dir = project_dir.join("content"); + info!("Creating content directory: {:?}", content_dir); + + std::fs::create_dir_all(&content_dir).map_err(|e| { + let err = format!("Failed to create content directory: {}", e); + error!("{}", err); + err + })?; + + // Always copy to display.mp4 + let project_video_path = content_dir.join("display.mp4"); + info!("Copying video to: {:?}", project_video_path); + + std::fs::copy(&video_path, &project_video_path).map_err(|e| { + let err = format!("Failed to copy video file: {}", e); + error!("{}", err); + err + })?; + + // Create project metadata + let meta = RecordingMeta { + project_path: project_dir.clone(), + sharing: None, + pretty_name: format!( + "Imported Video {}", + chrono::Local::now().format("%Y-%m-%d at %H.%M.%S") + ), + display: cap_project::Display { + path: PathBuf::from("content").join("display.mp4"), // Always use display.mp4 + }, + camera: None, + audio: None, + segments: vec![], + cursor: None, + }; + + meta.save_for_project(); + info!("Project metadata saved successfully"); + + Ok(project_id) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c8b4c9e65..334ba6247 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod platform; mod recording; // mod resource; mod cursor; +mod import; mod tray; mod upload; mod web_api; @@ -33,8 +34,9 @@ use cap_rendering::{ProjectUniforms, ZOOM_DURATION}; // use display::{list_capture_windows, Bounds, CaptureTarget, FPS}; use general_settings::GeneralSettingsStore; use image::{ImageBuffer, Rgba}; +use import::{get_projects_dir, import_video_to_project}; use mp4::Mp4Reader; -use num_traits::ToBytes; + use png::{ColorType, Encoder}; use recording::{ list_cameras, list_capture_screens, list_capture_windows, InProgressRecording, FPS, @@ -306,12 +308,7 @@ async fn start_recording(app: AppHandle, state: MutableState<'_, App>) -> Result let id = uuid::Uuid::new_v4().to_string(); - let recording_dir = app - .path() - .app_data_dir() - .unwrap() - .join("recordings") - .join(format!("{id}.cap")); + let recording_dir = get_projects_dir(&app).unwrap().join(format!("{id}.cap")); // Check if auto_create_shareable_link is true and user is upgraded let general_settings = GeneralSettingsStore::get(&app)?; @@ -1387,11 +1384,8 @@ async fn get_video_metadata( video_id }; - let video_dir = app - .path() - .app_data_dir() + let video_dir = get_projects_dir(&app) .unwrap() - .join("recordings") .join(format!("{}.cap", video_id)); let screen_video_path = video_dir.join("content").join("display.mp4"); @@ -1904,7 +1898,7 @@ async fn upload_screenshot( } if !auth.is_upgraded() { - open_upgrade_window(app).await; + open_upgrade_window(app.clone()).await; return Ok(UploadResult::UpgradeRequired); } } @@ -2451,6 +2445,7 @@ pub async fn run() { is_camera_window_open, seek_to, send_feedback_request, + import_video_to_project, ]) .events(tauri_specta::collect_events![ RecordingOptionsChanged, diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 1797b5e2d..855e8db4a 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -18,6 +18,7 @@ pub enum TrayItem { OpenCap, StartNewRecording, TakeScreenshot, + CreateProjectFromVideo, PreviousRecordings, PreviousScreenshots, OpenSettings, @@ -30,6 +31,7 @@ impl From for MenuId { TrayItem::OpenCap => "open_cap", TrayItem::StartNewRecording => "new_recording", TrayItem::TakeScreenshot => "take_screenshot", + TrayItem::CreateProjectFromVideo => "create_project_from_video", TrayItem::PreviousRecordings => "previous_recordings", TrayItem::PreviousScreenshots => "previous_screenshots", TrayItem::OpenSettings => "open_settings", @@ -45,6 +47,7 @@ impl From for TrayItem { "open_cap" => TrayItem::OpenCap, "new_recording" => TrayItem::StartNewRecording, "take_screenshot" => TrayItem::TakeScreenshot, + "create_project_from_video" => TrayItem::CreateProjectFromVideo, "previous_recordings" => TrayItem::PreviousRecordings, "previous_screenshots" => TrayItem::PreviousScreenshots, "open_settings" => TrayItem::OpenSettings, @@ -81,6 +84,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { true, None::<&str>, )?, + &MenuItem::with_id( + app, + TrayItem::CreateProjectFromVideo, + "Create Project from Video", + true, + None::<&str>, + )?, &MenuItem::with_id( app, TrayItem::PreviousRecordings, @@ -122,6 +132,11 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { TrayItem::TakeScreenshot => { let _ = RequestNewScreenshot.emit(&app_handle); } + TrayItem::CreateProjectFromVideo => { + if let Err(e) = CapWindow::ImportVideo.show(&app_handle) { + eprintln!("Failed to show import video window: {:?}", e); + } + } TrayItem::PreviousRecordings => { let _ = RequestOpenSettings { page: "recordings".to_string(), diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 6445ecd1c..50092218e 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -10,6 +10,7 @@ pub enum CapWindow { Main, Settings { page: Option }, Editor { project_id: String }, + ImportVideo, Permissions, PrevRecordings, WindowCaptureOccluder, @@ -23,6 +24,7 @@ pub enum CapWindowId { Main, Settings, Editor { project_id: String }, + ImportVideo, Permissions, PrevRecordings, WindowCaptureOccluder, @@ -36,6 +38,7 @@ impl CapWindowId { match label { "main" => Self::Main, "settings" => Self::Settings, + "import-video" => Self::ImportVideo, "camera" => Self::Camera, "window-capture-occluder" => Self::WindowCaptureOccluder, "in-progress-recording" => Self::InProgressRecording, @@ -53,19 +56,21 @@ impl CapWindowId { match self { Self::Main => "main".to_string(), Self::Settings => "settings".to_string(), + Self::ImportVideo => "import-video".to_string(), Self::Camera => "camera".to_string(), Self::WindowCaptureOccluder => "window-capture-occluder".to_string(), Self::InProgressRecording => "in-progress-recording".to_string(), Self::PrevRecordings => "prev-recordings".to_string(), - Self::Editor { project_id } => format!("editor-{}", project_id), Self::Permissions => "permissions".to_string(), Self::Upgrade => "upgrade".to_string(), + Self::Editor { project_id } => format!("editor-{}", project_id), } } pub fn title(&self) -> String { match self { Self::Settings => "Cap Settings".to_string(), + Self::ImportVideo => "Cap Import Video".to_string(), Self::WindowCaptureOccluder => "Cap Window Capture Occluder".to_string(), Self::InProgressRecording => "Cap In Progress Recording".to_string(), Self::Editor { .. } => "Cap Editor".to_string(), @@ -97,6 +102,7 @@ impl CapWindowId { | Self::WindowCaptureOccluder | Self::PrevRecordings => None, Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 48.0))), + Self::ImportVideo => Some(Some(LogicalPosition::new(14.0, 22.0))), _ => Some(None), } } @@ -341,6 +347,24 @@ impl CapWindow { window } + Self::ImportVideo => { + let mut window_builder = self + .window_builder(app, "/import-video") + .inner_size(500.0, 400.0) + .resizable(false) + .maximized(false) + .shadow(true) + .transparent(true); + + #[cfg(target_os = "macos")] + { + window_builder = window_builder + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Overlay); + } + + window_builder.build()? + } }; if let Some(position) = id.traffic_lights_position() { @@ -388,6 +412,7 @@ impl CapWindow { CapWindow::Camera { .. } => CapWindowId::Camera, CapWindow::InProgressRecording { .. } => CapWindowId::InProgressRecording, CapWindow::Upgrade => CapWindowId::Upgrade, + CapWindow::ImportVideo => CapWindowId::ImportVideo, } } } diff --git a/apps/desktop/src/routes/(window-chrome)/import-video.tsx b/apps/desktop/src/routes/(window-chrome)/import-video.tsx new file mode 100644 index 000000000..db9622068 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/import-video.tsx @@ -0,0 +1,151 @@ +import { createSignal } from "solid-js"; +import { commands } from "~/utils/tauri"; +import { Button } from "@cap/ui-solid"; +import { open } from "@tauri-apps/plugin-dialog"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +export default function ImportVideo() { + const [isImporting, setIsImporting] = createSignal(false); + const [error, setError] = createSignal(null); + const [isDragging, setIsDragging] = createSignal(false); + + const handleFileDrop = async (e: DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer?.files[0]; + if (file) { + const filePath = (file as any).path; + if (filePath) { + await importVideo(filePath); + } + } + }; + + const handleFileSelect = async () => { + try { + const selected = await open({ + multiple: false, + filters: [ + { + name: "Video", + extensions: ["mp4"], + }, + ], + }); + + console.log("Selected file:", selected); + + if (!selected) { + console.log("No file selected"); + return; + } + + if (typeof selected === "string") { + await importVideo(selected); + } else { + console.error("Invalid file selection type:", typeof selected); + setError("Invalid file selection. Please try again."); + } + } catch (err) { + console.error("Error opening dialog:", err); + setError("Failed to open file dialog. Please try again."); + } + }; + + const importVideo = async (path: string) => { + setIsImporting(true); + setError(null); + + try { + console.log("Importing video from path:", path); + const response = await commands.importVideoToProject(path); + + console.log("Project ID received:", response); + + // Extract the project ID from the response + const projectId = response.data; + + if (typeof projectId !== "string") { + throw new Error("Invalid project ID received"); + } + + // Open the editor using the existing command + await commands.openEditor(projectId); + + // Close the current window + const currentWindow = await getCurrentWindow(); + await currentWindow.close(); + } catch (err) { + console.error("Import error:", err); + setError( + `Failed to import video: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } finally { + setIsImporting(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + return ( +
+
{ + handleFileDrop(e); + setIsDragging(false); + }} + > +
+
+ + + +
+

+ Drop your MP4 video here +

+

or

+ + {error() && ( +

{error()}

+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 16a6f1b54..a58b76c5e 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -318,6 +318,14 @@ async sendFeedbackRequest(feedback: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async importVideoToProject(videoPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("import_video_to_project", { videoPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } }