From 0c30a4b1011a6ca1d9c3696017ecdee288b2f90e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 14:47:44 +0100 Subject: [PATCH 1/5] feat: import recordings into editor --- apps/desktop/src-tauri/src/import.rs | 1025 ++++++++++++++++++++- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/routes/editor/Header.tsx | 303 +++++- apps/desktop/src/utils/tauri.ts | 10 +- 4 files changed, 1324 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index d8f89da385a..f700459f680 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -6,9 +6,10 @@ use cap_enc_ffmpeg::{ }; use cap_media_info::{AudioInfo, FFRational, Pixel, VideoInfo, ensure_even}; use cap_project::{ - AudioMeta, Cursors, InstantRecordingMeta, MultipleSegment, MultipleSegments, Platform, - ProjectConfiguration, RecordingMeta, RecordingMetaInner, SingleSegment, StudioRecordingMeta, - StudioRecordingStatus, VideoMeta, + AudioMeta, ClipConfiguration, CursorEvents, CursorMeta, Cursors, InstantRecordingMeta, + MultipleSegment, MultipleSegments, Platform, ProjectConfiguration, RecordingMeta, + RecordingMetaInner, SingleSegment, StudioRecordingMeta, StudioRecordingStatus, + TimelineConfiguration, TimelineSegment, VideoMeta, XY, }; use ffmpeg::{ ChannelLayout, @@ -19,12 +20,20 @@ use image::ImageEncoder; use relative_path::RelativePathBuf; use serde::Serialize; use specta::Type; -use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + str::FromStr, +}; +use tauri::{AppHandle, Manager, Window}; use tauri_specta::Event; use tracing::{debug, error, info}; -use crate::create_screenshot; +use crate::{ + create_screenshot, + editor_window::EditorInstances, + windows::{CapWindowId, EditorWindowIds}, +}; const VIDEO_IMPORT_EXTENSIONS: &[&str] = &["mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv"]; const IMAGE_IMPORT_EXTENSIONS: &[&str] = @@ -137,6 +146,721 @@ pub fn is_supported_image_import_path(path: &Path) -> bool { path.is_file() && has_supported_extension(path, IMAGE_IMPORT_EXTENSIONS) } +fn is_mp4_import_path(path: &Path) -> bool { + path.is_file() + && path + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) +} + +fn is_cap_project_path(path: &Path) -> bool { + path.is_dir() && path.join("recording-meta.json").is_file() +} + +fn editor_project_path_from_window(window: &Window) -> Result { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { + return Err("Import can only be started from an editor window".to_string()); + }; + + let window_ids = EditorWindowIds::get(window.app_handle()); + let window_ids = window_ids + .ids + .lock() + .map_err(|_| "Editor window registry unavailable".to_string())?; + + window_ids + .iter() + .find(|(_, window_id)| *window_id == id) + .map(|(path, _)| path.clone()) + .ok_or_else(|| "Editor project path not found".to_string()) +} + +fn same_project_path(a: &Path, b: &Path) -> bool { + let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf()); + let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf()); + a == b +} + +fn ensure_multiple_segments(meta: &mut RecordingMeta) -> Result<&mut MultipleSegments, String> { + let RecordingMetaInner::Studio(studio_meta) = &mut meta.inner else { + return Err("Instant mode recordings cannot be edited".to_string()); + }; + + if let StudioRecordingMeta::SingleSegment { segment } = studio_meta.as_ref() { + let segment = segment.clone(); + **studio_meta = StudioRecordingMeta::MultipleSegments { + inner: MultipleSegments { + segments: vec![MultipleSegment { + display: segment.display, + camera: segment.camera, + mic: segment.audio, + system_audio: None, + cursor: segment.cursor, + keyboard: None, + }], + cursors: Cursors::default(), + status: Some(StudioRecordingStatus::Complete), + }, + }; + } + + match studio_meta.as_mut() { + StudioRecordingMeta::MultipleSegments { inner } => Ok(inner), + StudioRecordingMeta::SingleSegment { .. } => { + Err("Failed to normalize project recording segments".to_string()) + } + } +} + +fn get_video_duration_secs(path: &Path) -> Result { + get_media_duration(path) + .map(|duration| duration.as_secs_f64()) + .ok_or_else(|| format!("Could not determine video duration: {}", path.display())) +} + +fn full_timeline_for_segments( + project_path: &Path, + segments: &[MultipleSegment], +) -> Result, String> { + segments + .iter() + .enumerate() + .map(|(index, segment)| { + let duration = get_video_duration_secs(&segment.display.path.to_path(project_path))?; + Ok(TimelineSegment { + recording_clip: index as u32, + timescale: 1.0, + start: 0.0, + end: duration, + }) + }) + .collect() +} + +fn ensure_project_timeline<'a>( + config: &'a mut ProjectConfiguration, + project_path: &Path, + segments: &[MultipleSegment], +) -> Result<&'a mut TimelineConfiguration, String> { + if config.timeline.is_none() { + config.timeline = Some(TimelineConfiguration { + segments: full_timeline_for_segments(project_path, segments)?, + zoom_segments: Vec::new(), + scene_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + }); + } + + config + .timeline + .as_mut() + .ok_or_else(|| "Failed to prepare project timeline".to_string()) +} + +fn add_clip_configs( + config: &mut ProjectConfiguration, + base_index: u32, + segments: &[MultipleSegment], +) { + for (offset, segment) in segments.iter().enumerate() { + let index = base_index + offset as u32; + let offsets = segment.calculate_audio_offsets(); + + if let Some(existing) = config.clips.iter_mut().find(|clip| clip.index == index) { + existing.offsets = offsets; + } else { + config.clips.push(ClipConfiguration { index, offsets }); + } + } +} + +fn unique_segment_dir( + project_path: &Path, + index: u32, +) -> Result<(PathBuf, String), std::io::Error> { + let segments_root = project_path.join("content").join("segments"); + std::fs::create_dir_all(&segments_root)?; + + let mut counter = 0; + loop { + let name = if counter == 0 { + format!("segment-{index}") + } else { + format!("segment-{index}-import-{counter}") + }; + let path = segments_root.join(&name); + if !path.exists() { + std::fs::create_dir_all(&path)?; + return Ok((path, format!("content/segments/{name}"))); + } + counter += 1; + } +} + +fn relative_file_extension(path: &RelativePathBuf, fallback: &str) -> String { + Path::new(path.as_str()) + .extension() + .and_then(|ext| ext.to_str()) + .filter(|ext| !ext.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn relative_file_name(path: &RelativePathBuf, fallback: &str) -> String { + Path::new(path.as_str()) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn unique_file_name(dir: &Path, preferred: &str) -> String { + let sanitized = sanitize_filename(preferred); + let sanitized = if sanitized.is_empty() { + "file".to_string() + } else { + sanitized + }; + + let path = Path::new(&sanitized); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("file") + .to_string(); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()); + + let mut counter = 0; + loop { + let candidate = if counter == 0 { + sanitized.clone() + } else if let Some(extension) = &extension { + format!("{stem}-{counter}.{extension}") + } else { + format!("{stem}-{counter}") + }; + + if !dir.join(&candidate).exists() { + return candidate; + } + + counter += 1; + } +} + +fn copy_file_to_relative_path( + source_path: &Path, + target_project_path: &Path, + target_relative_path: &RelativePathBuf, +) -> Result<(), String> { + let target_path = target_relative_path.to_path(target_project_path); + + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create import directory: {e}"))?; + } + + std::fs::copy(source_path, &target_path) + .map(|_| ()) + .map_err(|e| format!("Failed to copy {}: {e}", source_path.display())) +} + +fn copy_video_meta( + source_project_path: &Path, + target_project_path: &Path, + source: &VideoMeta, + target_relative_dir: &str, + name: &str, + required: bool, +) -> Result, String> { + let source_path = source.path.to_path(source_project_path); + if !source_path.is_file() { + if required { + return Err(format!("Missing video file: {}", source_path.display())); + } + return Ok(None); + } + + let can_decode = probe_video_can_decode(&source_path) + .map_err(|e| format!("Cannot decode video {}: {e}", source_path.display()))?; + if !can_decode { + if required { + return Err(format!("Unsupported video file: {}", source_path.display())); + } + return Ok(None); + } + + let extension = relative_file_extension(&source.path, "mp4"); + let target_relative_path = + RelativePathBuf::from(format!("{target_relative_dir}/{name}.{extension}")); + copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; + + let mut copied = source.clone(); + copied.path = target_relative_path; + Ok(Some(copied)) +} + +fn copy_audio_meta( + source_project_path: &Path, + target_project_path: &Path, + source: &AudioMeta, + target_relative_dir: &str, + name: &str, +) -> Result, String> { + let source_path = source.path.to_path(source_project_path); + if !source_path.is_file() { + return Ok(None); + } + + let extension = relative_file_extension(&source.path, "ogg"); + let target_relative_path = + RelativePathBuf::from(format!("{target_relative_dir}/{name}.{extension}")); + copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; + + let mut copied = source.clone(); + copied.path = target_relative_path; + Ok(Some(copied)) +} + +fn copy_keyboard_path( + source_meta: &RecordingMeta, + source_segment: &MultipleSegment, + target_project_path: &Path, + target_relative_dir: &str, +) -> Result, String> { + let explicit = source_segment.keyboard.as_ref().map(|path| { + ( + path.clone(), + relative_file_name(path, cap_project::KEYBOARD_EVENTS_FILE_NAME), + ) + }); + + let implicit = || { + let display_dir = source_segment.display.path.parent()?; + for file_name in [ + cap_project::KEYBOARD_EVENTS_FILE_NAME, + cap_project::LEGACY_KEYBOARD_EVENTS_FILE_NAME, + ] { + let path = display_dir.join(file_name); + if path.to_path(&source_meta.project_path).is_file() { + return Some((path, file_name.to_string())); + } + } + None + }; + + let Some((source_relative_path, file_name)) = explicit.or_else(implicit) else { + return Ok(None); + }; + + let source_path = source_relative_path.to_path(&source_meta.project_path); + if !source_path.is_file() { + return Ok(None); + } + + let target_relative_path = RelativePathBuf::from(format!( + "{target_relative_dir}/{}", + sanitize_filename(&file_name) + )); + copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; + + Ok(Some(target_relative_path)) +} + +fn normalize_cursors_to_correct(cursors: &mut Cursors) -> &mut HashMap { + if let Cursors::Old(old) = cursors { + let converted = old + .iter() + .map(|(id, path)| { + ( + id.clone(), + CursorMeta { + image_path: RelativePathBuf::from(path.as_str()), + hotspot: XY::new(0.0, 0.0), + shape: None, + }, + ) + }) + .collect(); + *cursors = Cursors::Correct(converted); + } + + match cursors { + Cursors::Correct(map) => map, + Cursors::Old(_) => unreachable!(), + } +} + +fn unique_cursor_id( + cursors: &HashMap, + import_token: &str, + source_id: &str, +) -> String { + let source_id = if source_id.is_empty() { + "cursor" + } else { + source_id + }; + let base = format!("{import_token}-{source_id}"); + if !cursors.contains_key(&base) { + return base; + } + + let mut counter = 1; + loop { + let candidate = format!("{base}-{counter}"); + if !cursors.contains_key(&candidate) { + return candidate; + } + counter += 1; + } +} + +fn copy_source_cursor_images( + source_meta: &RecordingMeta, + source_cursors: &Cursors, + target_project_path: &Path, + target_cursors: &mut Cursors, + import_token: &str, +) -> Result, String> { + let target_cursor_dir = target_project_path.join("content").join("cursors"); + std::fs::create_dir_all(&target_cursor_dir) + .map_err(|e| format!("Failed to create cursor directory: {e}"))?; + + let target_cursors = normalize_cursors_to_correct(target_cursors); + let mut id_map = HashMap::new(); + + match source_cursors { + Cursors::Correct(source_map) => { + for (source_id, cursor) in source_map { + let source_path = cursor.image_path.to_path(&source_meta.project_path); + if !source_path.is_file() { + continue; + } + + let new_id = unique_cursor_id(target_cursors, import_token, source_id); + let source_file_name = relative_file_name(&cursor.image_path, "cursor.png"); + let target_file_name = + unique_file_name(&target_cursor_dir, &format!("{new_id}-{source_file_name}")); + let target_relative_path = + RelativePathBuf::from(format!("content/cursors/{target_file_name}")); + + copy_file_to_relative_path( + &source_path, + target_project_path, + &target_relative_path, + )?; + + target_cursors.insert( + new_id.clone(), + CursorMeta { + image_path: target_relative_path, + hotspot: cursor.hotspot, + shape: cursor.shape.clone(), + }, + ); + id_map.insert(source_id.clone(), new_id); + } + } + Cursors::Old(source_map) => { + for (source_id, source_path) in source_map { + let source_path = PathBuf::from(source_path); + let source_path = if source_path.is_absolute() { + source_path + } else { + source_meta.project_path.join(source_path) + }; + if !source_path.is_file() { + continue; + } + + let new_id = unique_cursor_id(target_cursors, import_token, source_id); + let source_file_name = source_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("cursor.png"); + let target_file_name = + unique_file_name(&target_cursor_dir, &format!("{new_id}-{source_file_name}")); + let target_relative_path = + RelativePathBuf::from(format!("content/cursors/{target_file_name}")); + + copy_file_to_relative_path( + &source_path, + target_project_path, + &target_relative_path, + )?; + + target_cursors.insert( + new_id.clone(), + CursorMeta { + image_path: target_relative_path, + hotspot: XY::new(0.0, 0.0), + shape: None, + }, + ); + id_map.insert(source_id.clone(), new_id); + } + } + } + + Ok(id_map) +} + +fn copy_cursor_events_path( + source_meta: &RecordingMeta, + source_relative_path: &RelativePathBuf, + target_project_path: &Path, + target_relative_dir: &str, + cursor_id_map: &HashMap, +) -> Result, String> { + let source_path = source_relative_path.to_path(&source_meta.project_path); + if !source_path.is_file() { + return Ok(None); + } + + let target_relative_path = RelativePathBuf::from(format!("{target_relative_dir}/cursor.json")); + let target_path = target_relative_path.to_path(target_project_path); + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create cursor event directory: {e}"))?; + } + + if cursor_id_map.is_empty() { + std::fs::copy(&source_path, &target_path) + .map(|_| ()) + .map_err(|e| format!("Failed to copy cursor events: {e}"))?; + return Ok(Some(target_relative_path)); + } + + match CursorEvents::load_from_file(&source_path) { + Ok(mut events) => { + for event in &mut events.moves { + if let Some(new_id) = cursor_id_map.get(&event.cursor_id) { + event.cursor_id = new_id.clone(); + } + } + for event in &mut events.clicks { + if let Some(new_id) = cursor_id_map.get(&event.cursor_id) { + event.cursor_id = new_id.clone(); + } + } + + let file = std::fs::File::create(&target_path) + .map_err(|e| format!("Failed to create cursor event file: {e}"))?; + serde_json::to_writer_pretty(file, &events) + .map_err(|e| format!("Failed to write cursor event file: {e}"))?; + } + Err(_) => { + std::fs::copy(&source_path, &target_path) + .map(|_| ()) + .map_err(|e| format!("Failed to copy cursor events: {e}"))?; + } + } + + Ok(Some(target_relative_path)) +} + +fn single_segment_to_multiple(segment: &SingleSegment) -> MultipleSegment { + MultipleSegment { + display: segment.display.clone(), + camera: segment.camera.clone(), + mic: segment.audio.clone(), + system_audio: None, + cursor: segment.cursor.clone(), + keyboard: None, + } +} + +fn studio_segments_for_import(studio_meta: &StudioRecordingMeta) -> Vec { + match studio_meta { + StudioRecordingMeta::SingleSegment { segment } => { + vec![single_segment_to_multiple(segment)] + } + StudioRecordingMeta::MultipleSegments { inner } => inner.segments.clone(), + } +} + +fn source_timeline_segments_for_import( + source_meta: &RecordingMeta, + source_segments: &[MultipleSegment], +) -> Result, String> { + let source_config = ProjectConfiguration::load(&source_meta.project_path).unwrap_or_default(); + let Some(timeline) = source_config.timeline else { + return full_timeline_for_segments(&source_meta.project_path, source_segments); + }; + + if timeline.segments.is_empty() { + return full_timeline_for_segments(&source_meta.project_path, source_segments); + } + + let mut duration_cache = HashMap::new(); + let mut imported_segments = Vec::new(); + + for segment in timeline.segments { + let source_index = segment.recording_clip; + let Some(source_segment) = source_segments.get(source_index as usize) else { + continue; + }; + + let max_duration = if let Some(duration) = duration_cache.get(&source_index) { + *duration + } else { + let duration = get_video_duration_secs( + &source_segment + .display + .path + .to_path(&source_meta.project_path), + )?; + duration_cache.insert(source_index, duration); + duration + }; + + if max_duration <= 0.0 { + continue; + } + + let raw_start = if segment.start.is_finite() { + segment.start + } else { + 0.0 + }; + let raw_end = if segment.end.is_finite() { + segment.end + } else { + max_duration + }; + let start = raw_start.clamp(0.0, max_duration); + let end = raw_end.clamp(start, max_duration); + if end <= start { + continue; + } + + imported_segments.push(TimelineSegment { + recording_clip: source_index, + timescale: if segment.timescale.is_finite() && segment.timescale > 0.0 { + segment.timescale + } else { + 1.0 + }, + start, + end, + }); + } + + if imported_segments.is_empty() { + full_timeline_for_segments(&source_meta.project_path, source_segments) + } else { + Ok(imported_segments) + } +} + +fn copy_source_segment( + source_meta: &RecordingMeta, + source_segment: &MultipleSegment, + target_project_path: &Path, + target_relative_dir: &str, + cursor_id_map: &HashMap, +) -> Result { + let display = copy_video_meta( + &source_meta.project_path, + target_project_path, + &source_segment.display, + target_relative_dir, + "display", + true, + )? + .ok_or_else(|| "Missing display video".to_string())?; + + let camera = source_segment + .camera + .as_ref() + .map(|camera| { + copy_video_meta( + &source_meta.project_path, + target_project_path, + camera, + target_relative_dir, + "camera", + false, + ) + }) + .transpose()? + .flatten(); + + let mic = source_segment + .mic + .as_ref() + .map(|mic| { + copy_audio_meta( + &source_meta.project_path, + target_project_path, + mic, + target_relative_dir, + "mic", + ) + }) + .transpose()? + .flatten(); + + let system_audio = source_segment + .system_audio + .as_ref() + .map(|system_audio| { + copy_audio_meta( + &source_meta.project_path, + target_project_path, + system_audio, + target_relative_dir, + "system-audio", + ) + }) + .transpose()? + .flatten(); + + let cursor = source_segment + .cursor + .as_ref() + .map(|cursor| { + copy_cursor_events_path( + source_meta, + cursor, + target_project_path, + target_relative_dir, + cursor_id_map, + ) + }) + .transpose()? + .flatten(); + + let keyboard = copy_keyboard_path( + source_meta, + source_segment, + target_project_path, + target_relative_dir, + )?; + + Ok(MultipleSegment { + display, + camera, + mic, + system_audio, + cursor, + keyboard, + }) +} + fn get_video_stream_info( input: &avformat::context::Input, ) -> Result<(usize, VideoInfo), ImportError> { @@ -710,6 +1434,295 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< Ok(return_path) } +async fn append_mp4_to_editor_project( + app: AppHandle, + target_project_path: PathBuf, + source_path: PathBuf, +) -> Result { + if !is_mp4_import_path(&source_path) { + return Err("Select an MP4 video file to import".to_string()); + } + + let mut target_meta = RecordingMeta::load_for_project(&target_project_path) + .map_err(|e| format!("Failed to load target project metadata: {e}"))?; + let mut config = ProjectConfiguration::load(&target_project_path).unwrap_or_default(); + let existing_segments = { + let inner = ensure_multiple_segments(&mut target_meta)?; + inner.status = Some(StudioRecordingStatus::Complete); + inner.segments.clone() + }; + ensure_project_timeline(&mut config, &target_project_path, &existing_segments)?; + + let new_index = existing_segments.len() as u32; + let (_, target_relative_dir) = unique_segment_dir(&target_project_path, new_index) + .map_err(|e| format!("Failed to create imported segment directory: {e}"))?; + + let output_video_relative_path = + RelativePathBuf::from(format!("{target_relative_dir}/display.mp4")); + let output_audio_relative_path = + RelativePathBuf::from(format!("{target_relative_dir}/audio.ogg")); + let output_video_path = output_video_relative_path.to_path(&target_project_path); + let output_audio_path = output_audio_relative_path.to_path(&target_project_path); + let project_path_str = target_project_path.to_string_lossy().to_string(); + + emit_progress( + &app, + &project_path_str, + ImportStage::Probing, + 0.0, + "Analyzing video file...", + ); + + let can_decode = + probe_video_can_decode(&source_path).map_err(|e| format!("Cannot decode video: {e}"))?; + if !can_decode { + return Err("Video format not supported or file is corrupted".to_string()); + } + + emit_progress( + &app, + &project_path_str, + ImportStage::Converting, + 0.0, + "Starting conversion...", + ); + + let app_for_transcode = app.clone(); + let source_path_for_transcode = source_path.clone(); + let output_video_path_for_transcode = output_video_path.clone(); + let output_audio_path_for_transcode = output_audio_path.clone(); + let project_path_str_for_transcode = project_path_str.clone(); + let target_project_path_for_transcode = target_project_path.clone(); + + let (fps, sample_rate) = tokio::task::spawn_blocking(move || { + transcode_video( + &app_for_transcode, + &source_path_for_transcode, + &output_video_path_for_transcode, + Some(&output_audio_path_for_transcode), + &project_path_str_for_transcode, + &target_project_path_for_transcode, + ) + }) + .await + .map_err(|e| format!("Video import task failed: {e}"))? + .map_err(|e| e.to_string())?; + + let duration = get_video_duration_secs(&output_video_path)?; + let audio_file_size = std::fs::metadata(&output_audio_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + const MIN_VALID_AUDIO_SIZE: u64 = 1000; + let system_audio = if sample_rate.is_some() && audio_file_size > MIN_VALID_AUDIO_SIZE { + Some(AudioMeta { + path: output_audio_relative_path, + start_time: Some(0.0), + device_id: None, + }) + } else { + None + }; + + let imported_segment = MultipleSegment { + display: VideoMeta { + path: output_video_relative_path, + fps, + start_time: Some(0.0), + device_id: None, + }, + camera: None, + mic: None, + system_audio, + cursor: None, + keyboard: None, + }; + + { + let inner = ensure_multiple_segments(&mut target_meta)?; + inner.status = Some(StudioRecordingStatus::Complete); + inner.segments.push(imported_segment.clone()); + } + + ensure_project_timeline(&mut config, &target_project_path, &existing_segments)? + .segments + .push(TimelineSegment { + recording_clip: new_index, + timescale: 1.0, + start: 0.0, + end: duration, + }); + add_clip_configs( + &mut config, + new_index, + std::slice::from_ref(&imported_segment), + ); + + target_meta + .save_for_project() + .map_err(|e| format!("Failed to save project metadata: {e:?}"))?; + config + .write(&target_project_path) + .map_err(|e| format!("Failed to save project config: {e}"))?; + + emit_progress( + &app, + &project_path_str, + ImportStage::Complete, + 1.0, + "Import complete!", + ); + + Ok(1) +} + +async fn append_cap_project_to_editor_project( + app: AppHandle, + target_project_path: PathBuf, + source_project_path: PathBuf, +) -> Result { + let source_meta = RecordingMeta::load_for_project(&source_project_path) + .map_err(|e| format!("Failed to load source project metadata: {e}"))?; + + let RecordingMetaInner::Studio(source_studio_meta) = &source_meta.inner else { + return match &source_meta.inner { + RecordingMetaInner::Instant(InstantRecordingMeta::Complete { .. }) => { + append_mp4_to_editor_project(app, target_project_path, source_meta.output_path()) + .await + } + RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { .. }) => { + Err("Source Cap project is still recording".to_string()) + } + RecordingMetaInner::Instant(InstantRecordingMeta::Failed { error }) => { + Err(format!("Source Cap project failed: {error}")) + } + RecordingMetaInner::Studio(_) => unreachable!(), + }; + }; + + let source_segments = studio_segments_for_import(source_studio_meta); + if source_segments.is_empty() { + return Err("Source Cap project has no recording segments".to_string()); + } + + let source_timeline = source_timeline_segments_for_import(&source_meta, &source_segments)?; + let source_cursors = match source_studio_meta.as_ref() { + StudioRecordingMeta::MultipleSegments { inner } => Some(&inner.cursors), + StudioRecordingMeta::SingleSegment { .. } => None, + }; + + let mut target_meta = RecordingMeta::load_for_project(&target_project_path) + .map_err(|e| format!("Failed to load target project metadata: {e}"))?; + let mut config = ProjectConfiguration::load(&target_project_path).unwrap_or_default(); + let existing_segments = { + let inner = ensure_multiple_segments(&mut target_meta)?; + inner.status = Some(StudioRecordingStatus::Complete); + inner.segments.clone() + }; + ensure_project_timeline(&mut config, &target_project_path, &existing_segments)?; + + let (base_index, copied_segments, source_to_target_index) = { + let inner = ensure_multiple_segments(&mut target_meta)?; + inner.status = Some(StudioRecordingStatus::Complete); + let base_index = inner.segments.len() as u32; + let import_token = format!("import-{}", uuid::Uuid::new_v4().simple()); + let cursor_id_map = if let Some(source_cursors) = source_cursors { + copy_source_cursor_images( + &source_meta, + source_cursors, + &target_project_path, + &mut inner.cursors, + &import_token, + )? + } else { + HashMap::new() + }; + + let mut copied_segments = Vec::new(); + let mut source_to_target_index = HashMap::new(); + + for (source_index, source_segment) in source_segments.iter().enumerate() { + let target_index = base_index + copied_segments.len() as u32; + let (_, target_relative_dir) = + unique_segment_dir(&target_project_path, target_index) + .map_err(|e| format!("Failed to create imported segment directory: {e}"))?; + let copied_segment = copy_source_segment( + &source_meta, + source_segment, + &target_project_path, + &target_relative_dir, + &cursor_id_map, + )?; + + inner.segments.push(copied_segment.clone()); + copied_segments.push(copied_segment); + source_to_target_index.insert(source_index as u32, target_index); + } + + (base_index, copied_segments, source_to_target_index) + }; + + if copied_segments.is_empty() { + return Err("Source Cap project has no importable recording segments".to_string()); + } + + { + let timeline = + ensure_project_timeline(&mut config, &target_project_path, &existing_segments)?; + for source_segment in source_timeline { + let Some(target_index) = source_to_target_index.get(&source_segment.recording_clip) + else { + continue; + }; + + timeline.segments.push(TimelineSegment { + recording_clip: *target_index, + timescale: source_segment.timescale, + start: source_segment.start, + end: source_segment.end, + }); + } + } + + add_clip_configs(&mut config, base_index, &copied_segments); + + target_meta + .save_for_project() + .map_err(|e| format!("Failed to save project metadata: {e:?}"))?; + config + .write(&target_project_path) + .map_err(|e| format!("Failed to save project config: {e}"))?; + + Ok(copied_segments.len()) +} + +#[tauri::command] +#[specta::specta] +pub async fn add_existing_recording_to_editor( + window: Window, + source_path: PathBuf, +) -> Result { + let target_project_path = editor_project_path_from_window(&window)?; + + if same_project_path(&target_project_path, &source_path) { + return Err("Cannot import a recording into itself".to_string()); + } + + let app = window.app_handle().clone(); + let imported_count = if is_mp4_import_path(&source_path) { + append_mp4_to_editor_project(app, target_project_path, source_path).await? + } else if is_cap_project_path(&source_path) { + append_cap_project_to_editor_project(app, target_project_path, source_path).await? + } else { + return Err("Select an MP4 file or a Cap project folder".to_string()); + }; + let imported_count = + u32::try_from(imported_count).map_err(|_| "Too many recordings imported".to_string())?; + + EditorInstances::remove(window).await; + + Ok(imported_count) +} + #[tauri::command] #[specta::specta] pub async fn start_image_import(app: AppHandle, source_path: PathBuf) -> Result { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 26f5c2ca6cd..0f51d4fa72b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3952,6 +3952,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { export::generate_export_preview, export::generate_export_preview_fast, import::start_video_import, + import::add_existing_recording_to_editor, import::start_image_import, import::check_import_ready, copy_file_to_path, diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index aedc91b03e6..eb6b10a3a70 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -1,6 +1,10 @@ import { Button } from "@cap/ui-solid"; +import { Dialog as KDialog } from "@kobalte/core/dialog"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; import type { UnlistenFn } from "@tauri-apps/api/event"; -import { ask } from "@tauri-apps/plugin-dialog"; +import { Menu, MenuItem } from "@tauri-apps/api/menu"; +import { ask, open } from "@tauri-apps/plugin-dialog"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; @@ -8,7 +12,9 @@ import { type ComponentProps, createEffect, createMemo, + createResource, createSignal, + For, onCleanup, onMount, Show, @@ -18,18 +24,19 @@ import toast from "solid-toast"; import Tooltip from "~/components/Tooltip"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; import { trackEvent } from "~/utils/analytics"; -import { commands } from "~/utils/tauri"; +import { commands, type RecordingMetaWithMetadata } from "~/utils/tauri"; import { initializeTitlebar } from "~/utils/titlebar-state"; +import IconLucideImport from "~icons/lucide/import"; import { applyCaptionResultToProject, getSelectedTranscriptionSettings, transcribeEditorCaptions, } from "./captions"; -import { useEditorContext } from "./context"; +import { serializeProjectConfiguration, useEditorContext } from "./context"; import OrganizationDropdown from "./OrganizationDropdown"; import PresetsDropdown from "./PresetsDropdown"; import ShareButton from "./ShareButton"; -import { EditorButton } from "./ui"; +import { Dialog, EditorButton, Input } from "./ui"; export type ResolutionOption = { label: string; @@ -50,6 +57,18 @@ export interface ExportEstimates { estimated_size_mb: number; } +type ImportableRecording = { + path: string; + meta: RecordingMetaWithMetadata; + thumbnailPath: string; +}; + +const normalizeImportPath = (path: string) => + path.replace(/\\/g, "/").replace(/\/+$/, ""); + +const recordingModeLabel = (mode: RecordingMetaWithMetadata["mode"]) => + mode === "studio" ? "Studio Mode" : "Instant Mode"; + export function Header() { const { editorInstance, @@ -66,18 +85,126 @@ export function Header() { setEditorState, } = useEditorContext(); + const [importingRecording, setImportingRecording] = createSignal(false); + const [importDialogOpen, setImportDialogOpen] = createSignal(false); + const [importSearch, setImportSearch] = createSignal(""); + const [recordings] = createResource(importDialogOpen, async (open) => { + if (!open) return []; + const result = await commands.listRecordings(); + return result.map(([path, meta]) => ({ + path, + meta, + thumbnailPath: `${path}/screenshots/display.jpg`, + })); + }); + let unlistenTitlebar: UnlistenFn | undefined; onMount(async () => { unlistenTitlebar = await initializeTitlebar(); }); onCleanup(() => unlistenTitlebar?.()); + createEffect(() => { + if (!importDialogOpen()) setImportSearch(""); + }); + const clearTimelineSelection = () => { if (!editorState.timeline.selection) return false; setEditorState("timeline", "selection", null); return true; }; + const selectedPath = (result: string | string[] | null) => + typeof result === "string" ? result : null; + + const importRecordingPath = async (sourcePath: string) => { + if (importingRecording()) return; + + clearTimelineSelection(); + setImportingRecording(true); + const toastId = toast.loading("Importing recording..."); + + try { + if (editorState.playing) { + await commands.stopPlayback(); + setEditorState("playing", false); + } + + await commands.setProjectConfig(serializeProjectConfiguration(project)); + const importedCount = await invoke( + "add_existing_recording_to_editor", + { sourcePath }, + ); + toast.success( + importedCount === 1 + ? "Recording imported" + : `${importedCount} recordings imported`, + { id: toastId }, + ); + window.location.reload(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`Failed to import recording: ${message}`, { id: toastId }); + } finally { + setImportingRecording(false); + } + }; + + const pickMp4Recording = async () => { + const path = selectedPath( + await open({ + filters: [{ name: "MP4 Video", extensions: ["mp4"] }], + multiple: false, + }), + ); + if (path) await importRecordingPath(path); + }; + + const openExistingRecordingImporter = () => { + if (importingRecording()) return; + clearTimelineSelection(); + setImportDialogOpen(true); + }; + + const openImportMenu = async (event: MouseEvent) => { + if (importingRecording()) return; + clearTimelineSelection(); + + const menu = await Menu.new({ + items: [ + await MenuItem.new({ + text: "Existing Cap Recording...", + action: openExistingRecordingImporter, + }), + await MenuItem.new({ + text: "MP4 Video...", + action: () => void pickMp4Recording(), + }), + ], + }); + + menu.popup(new LogicalPosition(event.clientX, event.clientY)); + }; + + const importableRecordings = createMemo(() => { + const currentPath = normalizeImportPath(editorInstance.path); + const query = importSearch().trim().toLowerCase(); + + return (recordings() ?? []).filter((recording) => { + if (normalizeImportPath(recording.path) === currentPath) return false; + if (recording.meta.status.status !== "Complete") return false; + if (recording.meta.mode !== "instant" && recording.meta.mode !== "studio") + return false; + if (!query) return true; + return recording.meta.pretty_name.toLowerCase().includes(query); + }); + }); + + const handleImportRecording = async (recording: ImportableRecording) => { + setImportDialogOpen(false); + await importRecordingPath(recording.path); + }; + const showCaptionsStale = createMemo( () => (editorState.captions.isStale || editorState.captions.isGenerating) && @@ -165,6 +292,23 @@ export function Header() { tooltipText="Open recording bundle" leftIcon={} /> + } + /> +
@@ -336,6 +480,157 @@ export function Header() { ); } +function ImportRecordingDialog(props: { + open: boolean; + search: string; + recordings: ImportableRecording[]; + isLoading: boolean; + isImporting: boolean; + onOpenChange: (open: boolean) => void; + onSearch: (value: string) => void; + onImport: (recording: ImportableRecording) => void; + onImportMp4: () => void; +}) { + return ( + + +
+ + Import recording + + + Newest to oldest + +
+
+ + props.onSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && props.search) { + event.preventDefault(); + props.onSearch(""); + } + }} + placeholder="Search recordings" + autoCapitalize="off" + autocorrect="off" + autocomplete="off" + spellcheck={false} + aria-label="Search recordings" + /> +
+ + Loading recordings... +
+ } + > + 0} + fallback={ +
+ No importable recordings found +
+ } + > +
    + + {(recording) => ( + props.onImport(recording)} + /> + )} + +
+
+ +
+ + }> + + + + ); +} + +function ImportRecordingItem(props: { + recording: ImportableRecording; + disabled: boolean; + onClick: () => void; +}) { + const [imageExists, setImageExists] = createSignal(true); + + return ( +
  • + +
  • + ); +} + const UploadIcon = (props: ComponentProps<"svg">) => { const { exportState } = useEditorContext(); return ( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 42e988ad317..8519d7fda66 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -116,6 +116,9 @@ async generateExportPreviewFast(frameTime: number, settings: ExportPreviewSettin async startVideoImport(sourcePath: string) : Promise { return await TAURI_INVOKE("start_video_import", { sourcePath }); }, +async addExistingRecordingToEditor(sourcePath: string) : Promise { + return await TAURI_INVOKE("add_existing_recording_to_editor", { sourcePath }); +}, async startImageImport(sourcePath: string) : Promise { return await TAURI_INVOKE("start_image_import", { sourcePath }); }, @@ -447,7 +450,6 @@ videoImportProgress: "video-import-progress" /** user-defined types **/ -export type AllGpusInfo = { gpus: GpuInfoDiag[]; primaryGpuIndex: number | null; isMultiGpuSystem: boolean; hasDiscreteGpu: boolean } export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null } export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" export type AppTheme = "system" | "light" | "dark" @@ -525,7 +527,6 @@ quality: number | null; */ fast: boolean | null } export type GlideDirection = "none" | "left" | "right" | "up" | "down" -export type GpuInfoDiag = { vendor: string; description: string; dedicatedVideoMemoryMb: number; adapterIndex: number; isSoftwareAdapter: boolean; isBasicRenderDriver: boolean; supportsHardwareEncoding: boolean } export type HapticPattern = "alignment" | "levelChange" | "generic" export type HapticPerformanceTime = "default" | "now" | "drawCompleted" export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean } @@ -543,6 +544,7 @@ export type KeyboardTrackSegment = { id: string; start: number; end: number; dis export type LogicalBounds = { position: LogicalPosition; size: LogicalSize } export type LogicalPosition = { x: number; y: number } export type LogicalSize = { width: number; height: number } +export type MacOSVersionInfo = { major: number; minor: number; patch: number; displayName: string; buildNumber: string; isAppleSilicon: boolean } export type MainWindowRecordingStartBehaviour = "close" | "minimise" export type MaskKeyframes = { position?: MaskVectorKeyframe[]; size?: MaskVectorKeyframe[]; intensity?: MaskScalarKeyframe[] } export type MaskKind = "sensitive" | "highlight" @@ -590,7 +592,6 @@ export type RecordingStatus = "pending" | "recording" export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" | "camera" export type RenderFrameEvent = { frame_number: number; fps: number; resolution_base: XY } -export type RenderingStatus = { isUsingSoftwareRendering: boolean; isUsingBasicRenderDriver: boolean; hardwareEncodingAvailable: boolean; warningMessage: string | null } export type RequestOpenRecordingPicker = { target_mode: RecordingTargetMode | null } export type RequestOpenSettings = { page: string } export type RequestScreenCapturePrewarm = { force?: boolean } @@ -618,7 +619,7 @@ export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingQuality = "compatibility" | "balanced" | "ultra" export type StudioRecordingStatus = { status: "InProgress" } | { status: "NeedsRemux" } | { status: "Failed"; error: string } | { status: "Complete" } -export type SystemDiagnostics = { windowsVersion: WindowsVersionInfo | null; gpuInfo: GpuInfoDiag | null; allGpus: AllGpusInfo | null; renderingStatus: RenderingStatus; availableEncoders: string[]; graphicsCaptureSupported: boolean; d3D11VideoProcessorAvailable: boolean } +export type SystemDiagnostics = { macosVersion: MacOSVersionInfo | null; availableEncoders: string[]; screenCaptureSupported: boolean; metalSupported: boolean; gpuName: string | null } export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TextSegment = { start: number; end: number; track?: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; fadeDuration?: number } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[] } @@ -638,7 +639,6 @@ export type WindowExclusion = { bundleIdentifier?: string | null; ownerName?: st export type WindowId = string export type WindowPosition = { x: number; y: number; displayId?: DisplayId | null } export type WindowUnderCursor = { id: WindowId; app_name: string; bounds: LogicalBounds } -export type WindowsVersionInfo = { major: number; minor: number; build: number; displayName: string; meetsRequirements: boolean; isWindows11: boolean } export type XY = { x: T; y: T } export type ZoomMode = "auto" | { manual: { x: number; y: number } } export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode; glideDirection?: GlideDirection; glideSpeed?: number; instantAnimation?: boolean; edgeSnapRatio?: number } From 530f81ef8faf62031f2145b53ec3ef079410a597 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 14:47:52 +0100 Subject: [PATCH 2/5] fix: improve rendering stability --- apps/desktop/src-tauri/src/export.rs | 7 +- crates/enc-ffmpeg/src/video/h264.rs | 40 ++++---- crates/frame-converter/src/d3d11.rs | 50 ++++++---- crates/rendering/src/layers/display.rs | 122 +++++++++++++++---------- crates/rendering/src/lib.rs | 25 ++++- crates/rendering/src/yuv_converter.rs | 30 ++++++ 6 files changed, 184 insertions(+), 90 deletions(-) diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 1f1b39d69d1..e75669a8d44 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -298,9 +298,10 @@ async fn run_out_of_process_export_attempt( } if mode.is_software_safe() { - command - .env("CAP_RENDER_FORCE_SOFTWARE_ADAPTER", "1") - .env("CAP_EXPORT_FORCE_SOFTWARE_ENCODER", "1"); + command.env("CAP_EXPORT_FORCE_SOFTWARE_ENCODER", "1"); + if cfg!(windows) { + command.env("CAP_RENDER_FORCE_SOFTWARE_ADAPTER", "1"); + } } configure_exporter_command(&mut command); diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 7f844ee35e3..f8065b7bd32 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -183,7 +183,7 @@ impl H264EncoderBuilder { ); } else { let is_high_throughput = - requires_software_encoder(&input_config, self.preset); + requires_software_encoder(&input_config, self.preset, self.is_export); if is_high_throughput { warn!( encoder = %codec_name, @@ -776,7 +776,11 @@ fn estimate_hw_encoder_max_fps(encoder_name: &str, width: u32, height: u32) -> f } } -fn requires_software_encoder(config: &VideoInfo, preset: H264Preset) -> bool { +fn requires_software_encoder(config: &VideoInfo, preset: H264Preset, is_export: bool) -> bool { + if is_export { + return false; + } + if preset == H264Preset::HighThroughput { return true; } @@ -848,11 +852,15 @@ fn get_default_encoder_priority(_config: &VideoInfo) -> &'static [&'static str] static ENCODER_PRIORITY_DEFAULT: &[&str] = &["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libx264"]; - match detect_primary_gpu().map(|info| info.vendor) { - Some(GpuVendor::Nvidia) => ENCODER_PRIORITY_NVIDIA, - Some(GpuVendor::Amd) => ENCODER_PRIORITY_AMD, - Some(GpuVendor::Intel) => ENCODER_PRIORITY_INTEL, - _ => ENCODER_PRIORITY_DEFAULT, + match detect_primary_gpu() { + Some(info) if !info.supports_hardware_encoding() => &["libx264"], + Some(info) => match info.vendor { + GpuVendor::Nvidia => ENCODER_PRIORITY_NVIDIA, + GpuVendor::Amd => ENCODER_PRIORITY_AMD, + GpuVendor::Intel => ENCODER_PRIORITY_INTEL, + _ => ENCODER_PRIORITY_DEFAULT, + }, + None => &["libx264"], } } @@ -866,12 +874,13 @@ fn get_encoder_priority_with_override( config: &VideoInfo, preset: H264Preset, override_priority: Option<&'static [&'static str]>, + is_export: bool, ) -> &'static [&'static str] { if force_software_encoder() { return &["libx264"]; } - if requires_software_encoder(config, preset) { + if requires_software_encoder(config, preset, is_export) { return &["libx264"]; } @@ -888,13 +897,9 @@ fn force_software_encoder() -> bool { } fn export_encoder_priority_override( - config: &VideoInfo, - preset: H264Preset, + _config: &VideoInfo, + _preset: H264Preset, ) -> Option<&'static [&'static str]> { - if requires_software_encoder(config, preset) { - return None; - } - #[cfg(target_os = "windows")] { use cap_frame_converter::{GpuVendor, detect_primary_gpu}; @@ -902,7 +907,10 @@ fn export_encoder_priority_override( static ENCODER_PRIORITY_AMD_EXPORT: &[&str] = &["h264_amf", "h264_mf", "h264_nvenc", "h264_qsv", "libx264"]; - if let Some(GpuVendor::Amd) = detect_primary_gpu().map(|info| info.vendor) { + if let Some(info) = detect_primary_gpu() + && info.supports_hardware_encoding() + && info.vendor == GpuVendor::Amd + { return Some(ENCODER_PRIORITY_AMD_EXPORT); } } @@ -931,7 +939,7 @@ fn get_codec_and_options( let encoder_priority = if crf.is_some() { &["libx264"] as &[&str] } else { - get_encoder_priority_with_override(config, preset, encoder_priority_override) + get_encoder_priority_with_override(config, preset, encoder_priority_override, is_export) }; let mut encoders = Vec::new(); diff --git a/crates/frame-converter/src/d3d11.rs b/crates/frame-converter/src/d3d11.rs index 1b8cb34c265..5a2abac0eea 100644 --- a/crates/frame-converter/src/d3d11.rs +++ b/crates/frame-converter/src/d3d11.rs @@ -51,6 +51,29 @@ pub enum GpuVendor { Unknown(u32), } +const NON_HARDWARE_ADAPTER_MARKERS: &[&str] = &[ + "parsec", + "displaylink", + "splashtop", + "synergy", + "virtual display", + "microsoft basic render", + "microsoft basic", + "warp", +]; + +fn matches_non_hardware_adapter_marker(description: &str) -> bool { + let description = description.to_ascii_lowercase(); + NON_HARDWARE_ADAPTER_MARKERS + .iter() + .any(|marker| description.contains(marker)) +} + +fn is_non_hardware_adapter(vendor_id: u32, description: &str) -> bool { + (vendor_id == 0x1414 && description.contains("Basic Render")) + || matches_non_hardware_adapter_marker(description) +} + impl GpuVendor { pub fn from_id(vendor_id: u32) -> Self { match vendor_id { @@ -85,12 +108,13 @@ impl GpuInfo { } pub fn is_warp(&self) -> bool { - self.description.contains("Microsoft Basic Render Driver") - || self.description.contains("WARP") + matches_non_hardware_adapter_marker(&self.description) } pub fn supports_hardware_encoding(&self) -> bool { - !self.is_software_adapter && !self.is_basic_render_driver() + !self.is_software_adapter + && !self.is_basic_render_driver() + && !matches_non_hardware_adapter_marker(&self.description) } } @@ -174,10 +198,7 @@ fn enumerate_all_gpus() -> Vec { .collect::>(), ); - let is_software = desc.VendorId == 0x1414 - && (description.contains("Basic Render") - || description.contains("WARP") - || description.contains("Microsoft Basic")); + let is_software = is_non_hardware_adapter(desc.VendorId, &description); let gpu_info = GpuInfo { vendor: GpuVendor::from_id(desc.VendorId), @@ -219,7 +240,10 @@ fn select_best_gpu(gpus: &[GpuInfo]) -> Option { return None; } - let hardware_gpus: Vec<&GpuInfo> = gpus.iter().filter(|g| !g.is_software_adapter).collect(); + let hardware_gpus: Vec<&GpuInfo> = gpus + .iter() + .filter(|g| g.supports_hardware_encoding()) + .collect(); if hardware_gpus.is_empty() { tracing::warn!("No hardware GPUs found, falling back to software adapter"); @@ -316,10 +340,7 @@ fn get_gpu_info(device: &ID3D11Device) -> Result { .collect::>(), ); - let is_software = desc.VendorId == 0x1414 - && (description.contains("Basic Render") - || description.contains("WARP") - || description.contains("Microsoft Basic")); + let is_software = is_non_hardware_adapter(desc.VendorId, &description); Ok(GpuInfo { vendor: GpuVendor::from_id(desc.VendorId), @@ -360,10 +381,7 @@ impl D3D11Converter { .collect::>(), ); - let is_software = desc.VendorId == 0x1414 - && (description.contains("Basic Render") - || description.contains("WARP") - || description.contains("Microsoft Basic")); + let is_software = is_non_hardware_adapter(desc.VendorId, &description); if !is_software { let vendor = GpuVendor::from_id(desc.VendorId); diff --git a/crates/rendering/src/layers/display.rs b/crates/rendering/src/layers/display.rs index bd5a367d28b..0e970b277c0 100644 --- a/crates/rendering/src/layers/display.rs +++ b/crates/rendering/src/layers/display.rs @@ -14,6 +14,24 @@ struct PendingTextureCopy { dst_texture_index: usize, } +fn uniforms_for_source_frame( + mut uniforms: CompositeVideoFrameUniforms, + base_size: XY, + source_size: XY, +) -> CompositeVideoFrameUniforms { + let scale_x = source_size.x as f32 / base_size.x.max(1) as f32; + let scale_y = source_size.y as f32 / base_size.y.max(1) as f32; + + uniforms.crop_bounds = [ + uniforms.crop_bounds[0] * scale_x, + uniforms.crop_bounds[1] * scale_y, + uniforms.crop_bounds[2] * scale_x, + uniforms.crop_bounds[3] * scale_y, + ]; + uniforms.frame_size = [source_size.x as f32, source_size.y as f32]; + uniforms +} + pub struct DisplayLayer { frame_textures: [wgpu::Texture; 2], frame_texture_views: [wgpu::TextureView; 2], @@ -149,6 +167,8 @@ impl DisplayLayer { let frame_data = screen_frame.data(); let actual_width = screen_frame.width(); let actual_height = screen_frame.height(); + let source_size = XY::new(actual_width, actual_height); + let uniforms = uniforms_for_source_frame(uniforms, frame_size, source_size); let format = screen_frame.format(); let current_recording_time = segment_frames.recording_time; @@ -159,14 +179,14 @@ impl DisplayLayer { if !skipped { let next_texture = 1 - self.current_texture; - if self.frame_textures[next_texture].width() != frame_size.x - || self.frame_textures[next_texture].height() != frame_size.y + if self.frame_textures[next_texture].width() != source_size.x + || self.frame_textures[next_texture].height() != source_size.y { self.frame_textures[next_texture] = CompositeVideoFramePipeline::create_frame_texture( device, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, ); self.frame_texture_views[next_texture] = self.frame_textures[next_texture].create_view(&Default::default()); @@ -180,7 +200,7 @@ impl DisplayLayer { let frame_uploaded = match format { PixelFormat::Rgba => { - let src_bytes_per_row = frame_size.x * 4; + let src_bytes_per_row = source_size.x * 4; queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -193,11 +213,11 @@ impl DisplayLayer { wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(src_bytes_per_row), - rows_per_image: Some(frame_size.y), + rows_per_image: Some(source_size.y), }, wgpu::Extent3d { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, depth_or_array_layers: 1, }, ); @@ -216,8 +236,8 @@ impl DisplayLayer { queue, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ); @@ -226,8 +246,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -250,8 +270,8 @@ impl DisplayLayer { queue, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ); @@ -260,8 +280,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -378,8 +398,8 @@ impl DisplayLayer { queue, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ) @@ -389,8 +409,8 @@ impl DisplayLayer { queue, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ) @@ -400,8 +420,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -429,8 +449,8 @@ impl DisplayLayer { y_data, u_data, v_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, screen_frame.y_stride(), screen_frame.uv_stride(), ) @@ -441,8 +461,8 @@ impl DisplayLayer { y_data, u_data, v_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, screen_frame.y_stride(), screen_frame.uv_stride(), ) @@ -452,8 +472,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -500,6 +520,8 @@ impl DisplayLayer { let actual_width = screen_frame.width(); let actual_height = screen_frame.height(); + let source_size = XY::new(actual_width, actual_height); + let uniforms = uniforms_for_source_frame(uniforms, frame_size, source_size); let format = screen_frame.format(); let current_recording_time = segment_frames.recording_time; @@ -510,14 +532,14 @@ impl DisplayLayer { if !skipped { let next_texture = 1 - self.current_texture; - if self.frame_textures[next_texture].width() != frame_size.x - || self.frame_textures[next_texture].height() != frame_size.y + if self.frame_textures[next_texture].width() != source_size.x + || self.frame_textures[next_texture].height() != source_size.y { self.frame_textures[next_texture] = CompositeVideoFramePipeline::create_frame_texture( device, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, ); self.frame_texture_views[next_texture] = self.frame_textures[next_texture].create_view(&Default::default()); @@ -532,7 +554,7 @@ impl DisplayLayer { let frame_uploaded = match format { PixelFormat::Rgba => { let frame_data = screen_frame.data(); - let src_bytes_per_row = frame_size.x * 4; + let src_bytes_per_row = source_size.x * 4; queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -545,11 +567,11 @@ impl DisplayLayer { wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(src_bytes_per_row), - rows_per_image: Some(frame_size.y), + rows_per_image: Some(source_size.y), }, wgpu::Extent3d { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, depth_or_array_layers: 1, }, ); @@ -691,8 +713,8 @@ impl DisplayLayer { encoder, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ); @@ -701,8 +723,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -725,8 +747,8 @@ impl DisplayLayer { queue, y_data, uv_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, y_stride, uv_stride, ); @@ -735,8 +757,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true @@ -764,8 +786,8 @@ impl DisplayLayer { y_data, u_data, v_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, screen_frame.y_stride(), screen_frame.uv_stride(), ) @@ -777,8 +799,8 @@ impl DisplayLayer { y_data, u_data, v_data, - frame_size.x, - frame_size.y, + source_size.x, + source_size.y, screen_frame.y_stride(), screen_frame.uv_stride(), ) @@ -788,8 +810,8 @@ impl DisplayLayer { Ok(_) => { if self.yuv_converter.output_texture().is_some() { self.pending_copy = Some(PendingTextureCopy { - width: frame_size.x, - height: frame_size.y, + width: source_size.x, + height: source_size.y, dst_texture_index: next_texture, }); true diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index b79db1557ea..227ca3cab37 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -100,12 +100,27 @@ impl Nv12RenderStartupBreakdownMs { } } +const NON_HARDWARE_WGPU_ADAPTER_MARKERS: &[&str] = &[ + "parsec", + "displaylink", + "splashtop", + "synergy", + "virtual display", + "microsoft basic render", + "microsoft basic", + "warp", +]; + pub fn is_software_wgpu_adapter(info: &wgpu::AdapterInfo) -> bool { - matches!(info.device_type, wgpu::DeviceType::Cpu) - || info - .name - .to_lowercase() - .contains("microsoft basic render driver") + matches!( + info.device_type, + wgpu::DeviceType::Cpu | wgpu::DeviceType::VirtualGpu + ) || { + let name = info.name.to_ascii_lowercase(); + NON_HARDWARE_WGPU_ADAPTER_MARKERS + .iter() + .any(|marker| name.contains(marker)) + } } fn force_software_wgpu_adapter() -> bool { diff --git a/crates/rendering/src/yuv_converter.rs b/crates/rendering/src/yuv_converter.rs index 2319781a857..c1750dbbfbf 100644 --- a/crates/rendering/src/yuv_converter.rs +++ b/crates/rendering/src/yuv_converter.rs @@ -25,6 +25,12 @@ pub enum YuvConversionError { expected: usize, actual: usize, }, + #[error("{plane} plane stride too small: expected at least {expected}, got {actual}")] + PlaneStrideTooSmall { + plane: &'static str, + expected: u32, + actual: u32, + }, #[error("{dimension} dimension ({value}) exceeds maximum allowed ({max})")] DimensionExceedsLimit { dimension: &'static str, @@ -51,6 +57,14 @@ fn upload_plane_with_stride( stride: u32, plane_name: &'static str, ) -> Result<(), YuvConversionError> { + if stride < width { + return Err(YuvConversionError::PlaneStrideTooSmall { + plane: plane_name, + expected: width, + actual: stride, + }); + } + let expected_data_size = (stride * height) as usize; if data.len() < expected_data_size { return Err(YuvConversionError::PlaneSizeMismatch { @@ -697,6 +711,14 @@ impl YuvToRgbaConverter { upload_plane_with_stride(queue, &self.y_texture, y_data, width, height, y_stride, "Y")?; let half_height = height / 2; + if uv_stride < width { + return Err(YuvConversionError::PlaneStrideTooSmall { + plane: "UV", + expected: width, + actual: uv_stride, + }); + } + let expected_uv_size = (uv_stride * half_height) as usize; if uv_data.len() < expected_uv_size { return Err(YuvConversionError::PlaneSizeMismatch { @@ -778,6 +800,14 @@ impl YuvToRgbaConverter { upload_plane_with_stride(queue, &self.y_texture, y_data, width, height, y_stride, "Y")?; let half_height = height / 2; + if uv_stride < width { + return Err(YuvConversionError::PlaneStrideTooSmall { + plane: "UV", + expected: width, + actual: uv_stride, + }); + } + let expected_uv_size = (uv_stride * half_height) as usize; if uv_data.len() < expected_uv_size { return Err(YuvConversionError::PlaneSizeMismatch { From ea36d45674eca51fbe7ce02c3d06d8d2a0172713 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 14:48:00 +0100 Subject: [PATCH 3/5] fix: avoid broken sccache setup --- scripts/setup.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index ca372d31e86..2c85fe42417 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -30,11 +30,17 @@ async function main() { let cargoConfigContents = BASE_CARGO_TOML; let cargoBuildContents = ""; const sccachePath = await findExecutable("sccache"); + const useSccache = env.CAP_USE_SCCACHE === "1"; - if (sccachePath) { + if (sccachePath && useSccache && (await canUseSccache(sccachePath))) { cargoBuildContents += `\n[build]\nrustc-wrapper = "${sccachePath.replaceAll("\\", "/")}"\n`; console.log(`Using sccache at ${sccachePath}`); - } else console.log("sccache not found, using rustc directly"); + } else if (!sccachePath) + console.log("sccache not found, using rustc directly"); + else if (!useSccache) + console.log( + `sccache found at ${sccachePath}, using rustc directly. Set CAP_USE_SCCACHE=1 to enable it.`, + ); if (process.platform === "darwin") { const NATIVE_DEPS_VERSION = "v0.25"; @@ -422,3 +428,51 @@ async function findExecutable(name) { .then(({ stdout }) => stdout.trim().split(/\r?\n/).find(Boolean) ?? null) .catch(() => null); } + +async function canUseSccache(sccachePath) { + const rustcPath = env.RUSTC || (await findExecutable("rustc")) || "rustc"; + const probeDir = await fs.mkdtemp(path.join(targetDir, "sccache-probe-")); + const probePath = path.join(probeDir, "lib.rs"); + + try { + await fs.writeFile(probePath, ""); + await execFile(sccachePath, [ + rustcPath, + probePath, + "--crate-name", + "___", + "--print=file-names", + "--crate-type", + "bin", + "--crate-type", + "rlib", + "--crate-type", + "dylib", + "--crate-type", + "cdylib", + "--crate-type", + "staticlib", + "--crate-type", + "proc-macro", + "--print=sysroot", + "--print=split-debuginfo", + "--print=crate-name", + "--print=cfg", + "-Wwarnings", + ]); + return true; + } catch (error) { + const stderr = typeof error.stderr === "string" ? error.stderr.trim() : ""; + const message = stderr || (error instanceof Error ? error.message : ""); + const detail = message.split(/\r?\n/).find(Boolean); + + if (detail) + console.log(`sccache at ${sccachePath} failed rustc probe: ${detail}`); + else console.log(`sccache at ${sccachePath} failed rustc probe`); + + console.log("Using rustc directly"); + return false; + } finally { + await fs.rm(probeDir, { recursive: true, force: true }); + } +} From 9f387f43e6a3034bdeb46bb1ab65914d0916591e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 15:14:03 +0100 Subject: [PATCH 4/5] Fix tauri call, h264 default, GPU check, sccache --- apps/desktop/src/routes/editor/Header.tsx | 8 +++----- crates/enc-ffmpeg/src/video/h264.rs | 2 +- crates/frame-converter/src/d3d11.rs | 3 ++- scripts/setup.js | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index eb6b10a3a70..c314b2dc642 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -1,6 +1,6 @@ import { Button } from "@cap/ui-solid"; import { Dialog as KDialog } from "@kobalte/core/dialog"; -import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; @@ -131,10 +131,8 @@ export function Header() { } await commands.setProjectConfig(serializeProjectConfiguration(project)); - const importedCount = await invoke( - "add_existing_recording_to_editor", - { sourcePath }, - ); + const importedCount = + await commands.addExistingRecordingToEditor(sourcePath); toast.success( importedCount === 1 ? "Recording imported" diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index f8065b7bd32..de347efe995 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -860,7 +860,7 @@ fn get_default_encoder_priority(_config: &VideoInfo) -> &'static [&'static str] GpuVendor::Intel => ENCODER_PRIORITY_INTEL, _ => ENCODER_PRIORITY_DEFAULT, }, - None => &["libx264"], + None => ENCODER_PRIORITY_DEFAULT, } } diff --git a/crates/frame-converter/src/d3d11.rs b/crates/frame-converter/src/d3d11.rs index 5a2abac0eea..863d57fdcb9 100644 --- a/crates/frame-converter/src/d3d11.rs +++ b/crates/frame-converter/src/d3d11.rs @@ -108,7 +108,8 @@ impl GpuInfo { } pub fn is_warp(&self) -> bool { - matches_non_hardware_adapter_marker(&self.description) + self.description.contains("Microsoft Basic Render Driver") + || self.description.contains("WARP") } pub fn supports_hardware_encoding(&self) -> bool { diff --git a/scripts/setup.js b/scripts/setup.js index 2c85fe42417..38ef03271e6 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -435,7 +435,7 @@ async function canUseSccache(sccachePath) { const probePath = path.join(probeDir, "lib.rs"); try { - await fs.writeFile(probePath, ""); + await fs.writeFile(probePath, "fn main() {}\n"); await execFile(sccachePath, [ rustcPath, probePath, @@ -452,8 +452,6 @@ async function canUseSccache(sccachePath) { "cdylib", "--crate-type", "staticlib", - "--crate-type", - "proc-macro", "--print=sysroot", "--print=split-debuginfo", "--print=crate-name", From 6ee6a24dbdec3022d0a68b2e1ad1166d066d32ec Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 15:31:14 +0100 Subject: [PATCH 5/5] fix: validate imported recording asset paths --- apps/desktop/src-tauri/src/import.rs | 402 ++++++++++++++++++++++----- 1 file changed, 338 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index f700459f680..a56ca7f11ca 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -17,7 +17,7 @@ use ffmpeg::{ format::{self as avformat}, }; use image::ImageEncoder; -use relative_path::RelativePathBuf; +use relative_path::{Component as RelativeComponent, RelativePathBuf}; use serde::Serialize; use specta::Type; use std::{ @@ -38,6 +38,9 @@ use crate::{ const VIDEO_IMPORT_EXTENSIONS: &[&str] = &["mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv"]; const IMAGE_IMPORT_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff"]; +const AUDIO_IMPORT_EXTENSIONS: &[&str] = &["ogg", "m4a", "mp3", "wav", "aac", "flac"]; +const KEYBOARD_IMPORT_EXTENSIONS: &[&str] = &["bin", "json"]; +const CURSOR_EVENTS_IMPORT_EXTENSIONS: &[&str] = &["json"]; const MAX_IMAGE_DIMENSION: u32 = 16_384; #[derive(Serialize, Type, Clone, Debug)] @@ -158,6 +161,89 @@ fn is_cap_project_path(path: &Path) -> bool { path.is_dir() && path.join("recording-meta.json").is_file() } +fn normalized_metadata_relative_path( + path: &RelativePathBuf, + asset_kind: &str, +) -> Result { + let normalized = path.as_str().replace('\\', "/"); + let path = RelativePathBuf::from(normalized); + let raw = path.as_str(); + if raw.is_empty() + || raw.starts_with('/') + || raw.contains(':') + || path + .components() + .any(|component| matches!(component, RelativeComponent::ParentDir)) + { + return Err(format!( + "Invalid {asset_kind} path in recording metadata: {raw}" + )); + } + + Ok(path) +} + +fn source_asset_path( + source_project_path: &Path, + source_relative_path: &RelativePathBuf, + asset_kind: &str, + allowed_extensions: &[&str], +) -> Result, String> { + let source_relative_path = normalized_metadata_relative_path(source_relative_path, asset_kind)?; + + if !has_supported_extension(Path::new(source_relative_path.as_str()), allowed_extensions) { + return Err(format!( + "Unsupported {asset_kind} file type: {}", + source_relative_path.as_str() + )); + } + + let source_path = source_relative_path.to_path(source_project_path); + if !source_path.is_file() { + return Ok(None); + } + + let source_root = source_project_path + .canonicalize() + .map_err(|e| format!("Failed to resolve source project path: {e}"))?; + let canonical_source_path = source_path + .canonicalize() + .map_err(|e| format!("Failed to resolve {asset_kind} path: {e}"))?; + + if !canonical_source_path.starts_with(&source_root) { + return Err(format!( + "{asset_kind} path escapes source project: {}", + source_relative_path.as_str() + )); + } + + Ok(Some(canonical_source_path)) +} + +fn required_source_asset_path( + source_project_path: &Path, + source_relative_path: &RelativePathBuf, + asset_kind: &str, + allowed_extensions: &[&str], +) -> Result { + source_asset_path( + source_project_path, + source_relative_path, + asset_kind, + allowed_extensions, + )? + .ok_or_else(|| { + format!( + "Missing {asset_kind} file: {}", + source_relative_path.to_path(source_project_path).display() + ) + }) +} + +fn legacy_cursor_relative_path(path: &str) -> Result { + normalized_metadata_relative_path(&RelativePathBuf::from(path), "cursor image") +} + fn editor_project_path_from_window(window: &Window) -> Result { let CapWindowId::Editor { id } = CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? @@ -240,6 +326,38 @@ fn full_timeline_for_segments( .collect() } +fn get_source_video_duration_secs( + source_meta: &RecordingMeta, + video: &VideoMeta, +) -> Result { + let source_path = required_source_asset_path( + &source_meta.project_path, + &video.path, + "video", + VIDEO_IMPORT_EXTENSIONS, + )?; + get_video_duration_secs(&source_path) +} + +fn full_timeline_for_source_segments( + source_meta: &RecordingMeta, + segments: &[MultipleSegment], +) -> Result, String> { + segments + .iter() + .enumerate() + .map(|(index, segment)| { + let duration = get_source_video_duration_secs(source_meta, &segment.display)?; + Ok(TimelineSegment { + recording_clip: index as u32, + timescale: 1.0, + start: 0.0, + end: duration, + }) + }) + .collect() +} + fn ensure_project_timeline<'a>( config: &'a mut ProjectConfiguration, project_path: &Path, @@ -385,13 +503,21 @@ fn copy_video_meta( name: &str, required: bool, ) -> Result, String> { - let source_path = source.path.to_path(source_project_path); - if !source_path.is_file() { + let Some(source_path) = source_asset_path( + source_project_path, + &source.path, + "video", + VIDEO_IMPORT_EXTENSIONS, + )? + else { if required { - return Err(format!("Missing video file: {}", source_path.display())); + return Err(format!( + "Missing video file: {}", + source.path.to_path(source_project_path).display() + )); } return Ok(None); - } + }; let can_decode = probe_video_can_decode(&source_path) .map_err(|e| format!("Cannot decode video {}: {e}", source_path.display()))?; @@ -419,10 +545,15 @@ fn copy_audio_meta( target_relative_dir: &str, name: &str, ) -> Result, String> { - let source_path = source.path.to_path(source_project_path); - if !source_path.is_file() { + let Some(source_path) = source_asset_path( + source_project_path, + &source.path, + "audio", + AUDIO_IMPORT_EXTENSIONS, + )? + else { return Ok(None); - } + }; let extension = relative_file_extension(&source.path, "ogg"); let target_relative_path = @@ -440,43 +571,57 @@ fn copy_keyboard_path( target_project_path: &Path, target_relative_dir: &str, ) -> Result, String> { - let explicit = source_segment.keyboard.as_ref().map(|path| { - ( - path.clone(), - relative_file_name(path, cap_project::KEYBOARD_EVENTS_FILE_NAME), - ) - }); + if let Some(source_relative_path) = &source_segment.keyboard { + let file_name = + relative_file_name(source_relative_path, cap_project::KEYBOARD_EVENTS_FILE_NAME); + let Some(source_path) = source_asset_path( + &source_meta.project_path, + source_relative_path, + "keyboard events", + KEYBOARD_IMPORT_EXTENSIONS, + )? + else { + return Ok(None); + }; - let implicit = || { - let display_dir = source_segment.display.path.parent()?; - for file_name in [ - cap_project::KEYBOARD_EVENTS_FILE_NAME, - cap_project::LEGACY_KEYBOARD_EVENTS_FILE_NAME, - ] { - let path = display_dir.join(file_name); - if path.to_path(&source_meta.project_path).is_file() { - return Some((path, file_name.to_string())); - } - } - None + let target_relative_path = RelativePathBuf::from(format!( + "{target_relative_dir}/{}", + sanitize_filename(&file_name) + )); + copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; + + return Ok(Some(target_relative_path)); }; - let Some((source_relative_path, file_name)) = explicit.or_else(implicit) else { + let Some(display_dir) = source_segment.display.path.parent() else { return Ok(None); }; - let source_path = source_relative_path.to_path(&source_meta.project_path); - if !source_path.is_file() { - return Ok(None); - } + for file_name in [ + cap_project::KEYBOARD_EVENTS_FILE_NAME, + cap_project::LEGACY_KEYBOARD_EVENTS_FILE_NAME, + ] { + let source_relative_path = display_dir.join(file_name); + let Some(source_path) = source_asset_path( + &source_meta.project_path, + &source_relative_path, + "keyboard events", + KEYBOARD_IMPORT_EXTENSIONS, + )? + else { + continue; + }; - let target_relative_path = RelativePathBuf::from(format!( - "{target_relative_dir}/{}", - sanitize_filename(&file_name) - )); - copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; + let target_relative_path = RelativePathBuf::from(format!( + "{target_relative_dir}/{}", + sanitize_filename(file_name) + )); + copy_file_to_relative_path(&source_path, target_project_path, &target_relative_path)?; - Ok(Some(target_relative_path)) + return Ok(Some(target_relative_path)); + } + + Ok(None) } fn normalize_cursors_to_correct(cursors: &mut Cursors) -> &mut HashMap { @@ -545,10 +690,15 @@ fn copy_source_cursor_images( match source_cursors { Cursors::Correct(source_map) => { for (source_id, cursor) in source_map { - let source_path = cursor.image_path.to_path(&source_meta.project_path); - if !source_path.is_file() { + let Some(source_path) = source_asset_path( + &source_meta.project_path, + &cursor.image_path, + "cursor image", + IMAGE_IMPORT_EXTENSIONS, + )? + else { continue; - } + }; let new_id = unique_cursor_id(target_cursors, import_token, source_id); let source_file_name = relative_file_name(&cursor.image_path, "cursor.png"); @@ -576,21 +726,19 @@ fn copy_source_cursor_images( } Cursors::Old(source_map) => { for (source_id, source_path) in source_map { - let source_path = PathBuf::from(source_path); - let source_path = if source_path.is_absolute() { - source_path - } else { - source_meta.project_path.join(source_path) - }; - if !source_path.is_file() { + let source_relative_path = legacy_cursor_relative_path(source_path)?; + let Some(source_path) = source_asset_path( + &source_meta.project_path, + &source_relative_path, + "cursor image", + IMAGE_IMPORT_EXTENSIONS, + )? + else { continue; - } + }; let new_id = unique_cursor_id(target_cursors, import_token, source_id); - let source_file_name = source_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("cursor.png"); + let source_file_name = relative_file_name(&source_relative_path, "cursor.png"); let target_file_name = unique_file_name(&target_cursor_dir, &format!("{new_id}-{source_file_name}")); let target_relative_path = @@ -625,10 +773,15 @@ fn copy_cursor_events_path( target_relative_dir: &str, cursor_id_map: &HashMap, ) -> Result, String> { - let source_path = source_relative_path.to_path(&source_meta.project_path); - if !source_path.is_file() { + let Some(source_path) = source_asset_path( + &source_meta.project_path, + source_relative_path, + "cursor events", + CURSOR_EVENTS_IMPORT_EXTENSIONS, + )? + else { return Ok(None); - } + }; let target_relative_path = RelativePathBuf::from(format!("{target_relative_dir}/cursor.json")); let target_path = target_relative_path.to_path(target_project_path); @@ -698,11 +851,11 @@ fn source_timeline_segments_for_import( ) -> Result, String> { let source_config = ProjectConfiguration::load(&source_meta.project_path).unwrap_or_default(); let Some(timeline) = source_config.timeline else { - return full_timeline_for_segments(&source_meta.project_path, source_segments); + return full_timeline_for_source_segments(source_meta, source_segments); }; if timeline.segments.is_empty() { - return full_timeline_for_segments(&source_meta.project_path, source_segments); + return full_timeline_for_source_segments(source_meta, source_segments); } let mut duration_cache = HashMap::new(); @@ -717,12 +870,7 @@ fn source_timeline_segments_for_import( let max_duration = if let Some(duration) = duration_cache.get(&source_index) { *duration } else { - let duration = get_video_duration_secs( - &source_segment - .display - .path - .to_path(&source_meta.project_path), - )?; + let duration = get_source_video_duration_secs(source_meta, &source_segment.display)?; duration_cache.insert(source_index, duration); duration }; @@ -760,7 +908,7 @@ fn source_timeline_segments_for_import( } if imported_segments.is_empty() { - full_timeline_for_segments(&source_meta.project_path, source_segments) + full_timeline_for_source_segments(source_meta, source_segments) } else { Ok(imported_segments) } @@ -1907,3 +2055,129 @@ pub async fn check_import_ready(project_path: PathBuf) -> Result { debug!("check_import_ready: all checks passed, returning true"); Ok(true) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_asset_path_allows_file_inside_source_project() { + let source_project = tempfile::tempdir().unwrap(); + let source_relative_path = RelativePathBuf::from("content/segments/segment-0/display.mp4"); + let source_path = source_relative_path.to_path(source_project.path()); + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::fs::write(&source_path, b"video").unwrap(); + + let resolved = source_asset_path( + source_project.path(), + &source_relative_path, + "video", + VIDEO_IMPORT_EXTENSIONS, + ) + .unwrap() + .unwrap(); + + assert_eq!(resolved, source_path.canonicalize().unwrap()); + } + + #[test] + fn source_asset_path_allows_backslash_separators() { + let source_project = tempfile::tempdir().unwrap(); + let source_relative_path = + RelativePathBuf::from("content\\segments\\segment-0\\display.mp4"); + let source_path = RelativePathBuf::from("content/segments/segment-0/display.mp4") + .to_path(source_project.path()); + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::fs::write(&source_path, b"video").unwrap(); + + let resolved = source_asset_path( + source_project.path(), + &source_relative_path, + "video", + VIDEO_IMPORT_EXTENSIONS, + ) + .unwrap() + .unwrap(); + + assert_eq!(resolved, source_path.canonicalize().unwrap()); + } + + #[test] + fn source_asset_path_rejects_parent_traversal() { + let source_project = tempfile::tempdir().unwrap(); + let source_relative_path = RelativePathBuf::from("../secret.mp4"); + + let error = source_asset_path( + source_project.path(), + &source_relative_path, + "video", + VIDEO_IMPORT_EXTENSIONS, + ) + .unwrap_err(); + + assert!(error.contains("Invalid video path")); + } + + #[test] + fn source_asset_path_rejects_absolute_path() { + let source_project = tempfile::tempdir().unwrap(); + let source_relative_path = RelativePathBuf::from("/tmp/secret.mp4"); + + let error = source_asset_path( + source_project.path(), + &source_relative_path, + "video", + VIDEO_IMPORT_EXTENSIONS, + ) + .unwrap_err(); + + assert!(error.contains("Invalid video path")); + } + + #[test] + fn legacy_cursor_relative_path_rejects_windows_absolute_path() { + let error = legacy_cursor_relative_path("C:\\Users\\me\\cursor.png").unwrap_err(); + + assert!(error.contains("Invalid cursor image path")); + } + + #[test] + fn source_asset_path_rejects_unsupported_extension() { + let source_project = tempfile::tempdir().unwrap(); + let source_relative_path = RelativePathBuf::from("content/segments/segment-0/display.txt"); + + let error = source_asset_path( + source_project.path(), + &source_relative_path, + "video", + VIDEO_IMPORT_EXTENSIONS, + ) + .unwrap_err(); + + assert!(error.contains("Unsupported video file type")); + } + + #[cfg(unix)] + #[test] + fn source_asset_path_rejects_symlink_escape() { + let source_project = tempfile::tempdir().unwrap(); + let external_dir = tempfile::tempdir().unwrap(); + let external_file = external_dir.path().join("cursor.png"); + std::fs::write(&external_file, b"cursor").unwrap(); + + let source_relative_path = RelativePathBuf::from("content/cursors/cursor.png"); + let source_path = source_relative_path.to_path(source_project.path()); + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&external_file, &source_path).unwrap(); + + let error = source_asset_path( + source_project.path(), + &source_relative_path, + "cursor image", + IMAGE_IMPORT_EXTENSIONS, + ) + .unwrap_err(); + + assert!(error.contains("cursor image path escapes source project")); + } +}