diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index f657e9980c..9a7524fb8a 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -327,7 +327,7 @@ pub async fn generate_export_preview( settings: ExportPreviewSettings, ) -> Result { use base64::{Engine, engine::general_purpose::STANDARD}; - use cap_editor::create_segments; + use cap_editor::create_all_segments; use std::time::Instant; let recording_meta = RecordingMeta::load_for_project(&project_path) @@ -337,12 +337,16 @@ pub async fn generate_export_preview( return Err("Cannot preview non-studio recordings".to_string()); }; - let project_config = - export_project_config(recording_meta.project_config(), settings.cursor_only); + let source_project_config = recording_meta.project_config(); + let project_config = export_project_config(source_project_config.clone(), settings.cursor_only); let recordings = Arc::new( - ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta) - .map_err(|e| format!("Failed to load recordings: {e}"))?, + ProjectRecordingsMeta::new_with_external( + &recording_meta.project_path, + studio_meta, + &source_project_config.external_recordings, + ) + .map_err(|e| format!("Failed to load recordings: {e}"))?, ); let render_constants = Arc::new( @@ -355,9 +359,14 @@ pub async fn generate_export_preview( .map_err(|e| format!("Failed to create render constants: {e}"))?, ); - let segments = create_segments(&recording_meta, studio_meta, false) - .await - .map_err(|e| format!("Failed to create segments: {e}"))?; + let segments = create_all_segments( + &recording_meta, + studio_meta, + &source_project_config.external_recordings, + false, + ) + .await + .map_err(|e| format!("Failed to create segments: {e}"))?; let render_segments: Vec = segments .iter() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index eccb7e1956..94a0d8ef99 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2078,6 +2078,137 @@ async fn get_editor_project_path(window: Window) -> Result { Ok(path.clone()) } +#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)] +pub struct CapRecordingImported { + pub project_path: String, +} + +#[tauri::command] +#[specta::specta] +async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result<(), String> { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { + return Err("Invalid window".to_string()); + }; + + let project_path = { + let window_ids = EditorWindowIds::get(window.app_handle()); + let window_ids = window_ids.ids.lock().unwrap(); + let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else { + return Err("Editor instance not found".to_string()); + }; + path.clone() + }; + + if !recording_path.exists() || !recording_path.join("recording-meta.json").exists() { + return Err("Not a valid Cap recording".to_string()); + } + + let ext_meta = RecordingMeta::load_for_project(&recording_path) + .map_err(|e| format!("Failed to load recording meta: {e}"))?; + let RecordingMetaInner::Studio(ext_studio_meta) = &ext_meta.inner else { + return Err("External recording is not a studio recording".to_string()); + }; + + let primary_meta = RecordingMeta::load_for_project(&project_path) + .map_err(|e| format!("Failed to load project meta: {e}"))?; + let RecordingMetaInner::Studio(primary_studio_meta) = &primary_meta.inner else { + return Err("Project is not a studio recording".to_string()); + }; + + let primary_recordings = + cap_rendering::ProjectRecordingsMeta::new(&primary_meta.project_path, primary_studio_meta) + .map_err(|e| format!("Failed to load primary recordings: {e}"))?; + let ext_recordings = + cap_rendering::ProjectRecordingsMeta::new(&recording_path, ext_studio_meta) + .map_err(|e| format!("Failed to load external recordings: {e}"))?; + + if let (Some(primary_first), Some(ext_first)) = ( + primary_recordings.segments.first(), + ext_recordings.segments.first(), + ) && (ext_first.display.width != primary_first.display.width + || ext_first.display.height != primary_first.display.height) + { + return Err(format!( + "Recording resolution {}x{} does not match project resolution {}x{}", + ext_first.display.width, + ext_first.display.height, + primary_first.display.width, + primary_first.display.height, + )); + } + + let mut project_config = ProjectConfiguration::load(&project_path) + .map_err(|e| format!("Failed to load project config: {e}"))?; + + if project_config + .external_recordings + .iter() + .any(|r| std::path::Path::new(&r.path) == recording_path) + { + return Err("This recording has already been imported".to_string()); + } + + let ext_segment_counts = project_config + .external_recordings + .iter() + .enumerate() + .map(|(i, r)| { + let p = std::path::PathBuf::from(&r.path); + let m = RecordingMeta::load_for_project(&p) + .map_err(|e| format!("existing external recording {i}: {e}"))?; + Ok(m.studio_meta() + .map(|s| match s { + cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize, + cap_project::StudioRecordingMeta::MultipleSegments { inner } => { + inner.segments.len() + } + }) + .unwrap_or(0)) + }) + .collect::, String>>()?; + + let clip_index_offset = + (primary_recordings.segments.len() + ext_segment_counts.iter().sum::()) as u32; + + let label = ext_meta.pretty_name.clone(); + + project_config + .external_recordings + .push(cap_project::ExternalRecordingReference { + path: recording_path.to_string_lossy().to_string(), + label: Some(label), + }); + + let timeline = project_config.timeline.get_or_insert_with(Default::default); + + let ext_segment_count = ext_recordings.segments.len(); + for i in 0..ext_segment_count { + let duration = ext_recordings.segments[i].duration(); + timeline.segments.push(cap_project::TimelineSegment { + recording_clip: clip_index_offset + i as u32, + start: 0.0, + end: duration, + timescale: 1.0, + }); + } + + project_config + .write(&project_path) + .map_err(|e| format!("Failed to save project config: {e}"))?; + + EditorInstances::remove(window.clone()).await; + + CapRecordingImported { + project_path: project_path.to_string_lossy().to_string(), + } + .emit(&window) + .map_err(|e| format!("Failed to emit event: {e}"))?; + + Ok(()) +} + #[tauri::command] #[specta::specta] #[instrument(skip(editor))] @@ -3355,6 +3486,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { export::generate_export_preview_fast, import::start_video_import, import::check_import_ready, + import_cap_recording, copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, @@ -3461,6 +3593,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { hotkeys::OnEscapePress, upload::UploadProgressEvent, import::VideoImportProgress, + CapRecordingImported, SetCaptureAreaPending, DevicesUpdated, ]) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 691c2f0995..7e938aa210 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ }, "bundle": { "active": true, - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "targets": "all", "icon": [ "icons/32x32.png", @@ -64,8 +64,7 @@ "x": 480, "y": 140 } - }, - "frameworks": ["../../../target/native-deps/Spacedrive.framework"] + } }, "windows": { "nsis": { diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 67d9b8edf0..2cecb2e5f3 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -2,6 +2,7 @@ import { createElementBounds } from "@solid-primitives/bounds"; import { createEventListener } from "@solid-primitives/event-listener"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { platform } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { @@ -25,7 +26,7 @@ import "./styles.css"; import Tooltip from "~/components/Tooltip"; import { defaultCaptionSettings } from "~/store/captions"; import { defaultKeyboardSettings } from "~/store/keyboard"; -import { commands } from "~/utils/tauri"; +import { commands, events } from "~/utils/tauri"; import { applyCaptionResultToProject, getCaptionGenerationErrorMessage, @@ -722,6 +723,34 @@ export function Timeline(props: { } }; + const [isImporting, setIsImporting] = createSignal(false); + + const handleImportCapRecording = async () => { + const selected = await openDialog({ + directory: true, + title: "Select a Cap Recording to Import", + }); + if (!selected || typeof selected !== "string") return; + if (!selected.endsWith(".cap")) { + toast.error("Please select a .cap recording folder"); + return; + } + setIsImporting(true); + try { + await commands.importCapRecording(selected); + } catch (e) { + toast.error(String(e)); + setIsImporting(false); + } + }; + + const importedListenerPromise = events.capRecordingImported.listen(() => { + window.location.reload(); + }); + onCleanup(() => { + importedListenerPromise.then((unlisten) => unlisten()); + }); + const split = () => editorState.timeline.interactMode === "split"; const maskImage = () => { @@ -840,7 +869,7 @@ export function Timeline(props: {
-
+
+ + +
@@ -1011,6 +1055,8 @@ function TrackRow(props: { children: JSX.Element; onDelete?: () => void; onContextMenu?: (e: MouseEvent) => void; + onImport?: () => void; + importing?: boolean; }) { return (
+ + +
{props.children} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 8cf4c25335..49fde696c8 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -107,6 +107,9 @@ async startVideoImport(sourcePath: string) : Promise { async checkImportReady(projectPath: string) : Promise { return await TAURI_INVOKE("check_import_ready", { projectPath }); }, +async importCapRecording(recordingPath: string) : Promise { + return await TAURI_INVOKE("import_cap_recording", { recordingPath }); +}, async copyFileToPath(src: string, dst: string) : Promise { return await TAURI_INVOKE("copy_file_to_path", { src, dst }); }, @@ -365,6 +368,7 @@ async discardIncompleteRecording(projectPath: string) : Promise { export const events = __makeEvents__<{ audioInputLevelChange: AudioInputLevelChange, +capRecordingImported: CapRecordingImported, currentRecordingChanged: CurrentRecordingChanged, devicesUpdated: DevicesUpdated, downloadProgress: DownloadProgress, @@ -390,6 +394,7 @@ uploadProgressEvent: UploadProgressEvent, videoImportProgress: VideoImportProgress }>({ audioInputLevelChange: "audio-input-level-change", +capRecordingImported: "cap-recording-imported", currentRecordingChanged: "current-recording-changed", devicesUpdated: "devices-updated", downloadProgress: "download-progress", @@ -444,6 +449,7 @@ export type CameraShape = "square" | "source" export type CameraWithFormats = { deviceId: string; displayName: string; modelId: string | null; formats: CameraFormatInfo[]; bestFormat: CameraFormatInfo | null } export type CameraXPosition = "left" | "center" | "right" export type CameraYPosition = "top" | "bottom" +export type CapRecordingImported = { project_path: string } export type CaptionData = { segments: CaptionSegment[]; settings: CaptionSettings | null } export type CaptionSegment = { id: string; start: number; end: number; text: string; words?: CaptionWord[] } export type CaptionSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; italic: boolean; fontWeight: number; outline: boolean; outlineColor: string; exportWithSubtitles: boolean; highlightColor: string; fadeDuration: number; lingerDuration: number; wordTransitionDuration: number; activeWordHighlight: boolean } @@ -480,6 +486,7 @@ export type ExportEstimates = { duration_seconds: number; estimated_time_seconds export type ExportPreviewResult = { jpeg_base64: string; estimated_size_mb: number; actual_width: number; actual_height: number; frame_render_time_ms: number; total_frames: number } export type ExportPreviewSettings = { fps: number; resolution_base: XY; compression_bpp: number; cursor_only?: boolean } export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format: "Gif" } & GifExportSettings) | ({ format: "Mov" } & MovExportSettings) +export type ExternalRecordingReference = { path: string; label?: string | null } export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } @@ -541,7 +548,7 @@ export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } -export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline: TimelineConfiguration | null; captions: CaptionsData | null; keyboard: KeyboardData | null; clips: ClipConfiguration[]; annotations: Annotation[]; screenMotionBlur?: number; screenMovementSpring?: ScreenMovementSpring } +export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline: TimelineConfiguration | null; captions: CaptionsData | null; keyboard: KeyboardData | null; clips: ClipConfiguration[]; annotations: Annotation[]; screenMotionBlur?: number; screenMovementSpring?: ScreenMovementSpring; externalRecordings?: ExternalRecordingReference[] } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 3bb3e55837..dd0f1f253e 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -247,10 +247,14 @@ impl EditorInstance { } } - let recordings = Arc::new(ProjectRecordingsMeta::new( - &recording_meta.project_path, - meta.as_ref(), - )?); + let recordings = Arc::new( + ProjectRecordingsMeta::new_with_external( + &recording_meta.project_path, + meta.as_ref(), + &project.external_recordings, + ) + .map_err(|e| format!("Failed to load recordings: {e}"))?, + ); let render_constants = if let Some(shared) = shared_device { let rc = RenderVideoConstants::new_with_device( @@ -272,7 +276,13 @@ impl EditorInstance { Arc::new(rc) }; - let segments = create_segments(&recording_meta, meta.as_ref(), false).await?; + let segments = create_all_segments( + &recording_meta, + meta.as_ref(), + &project.external_recordings, + false, + ) + .await?; let layers_rx = editor::start_renderer_layers_creation(&render_constants); @@ -627,6 +637,28 @@ pub struct SegmentMedia { pub decoders: RecordingSegmentDecoders, } +pub async fn create_all_segments( + recording_meta: &RecordingMeta, + meta: &StudioRecordingMeta, + external_recordings: &[cap_project::ExternalRecordingReference], + force_ffmpeg: bool, +) -> Result, String> { + let mut all = create_segments(recording_meta, meta, force_ffmpeg).await?; + for (i, ext_ref) in external_recordings.iter().enumerate() { + let ext_path = std::path::PathBuf::from(&ext_ref.path); + let ext_meta = cap_project::RecordingMeta::load_for_project(&ext_path) + .map_err(|e| format!("external recording {i}: {e}"))?; + let cap_project::RecordingMetaInner::Studio(ext_studio_meta) = &ext_meta.inner else { + return Err(format!("external recording {i}: not a studio recording")); + }; + let ext_segments = create_segments(&ext_meta, ext_studio_meta.as_ref(), force_ffmpeg) + .await + .map_err(|e| format!("external recording {i}: {e}"))?; + all.extend(ext_segments); + } + Ok(all) +} + pub async fn create_segments( recording_meta: &RecordingMeta, meta: &StudioRecordingMeta, diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 0d37d6e87d..d900638a14 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -6,5 +6,7 @@ mod segments; pub use audio::AudioRenderer; pub use editor::EditorFrameOutput; -pub use editor_instance::{EditorInstance, EditorState, SegmentMedia, create_segments}; +pub use editor_instance::{ + EditorInstance, EditorState, SegmentMedia, create_all_segments, create_segments, +}; pub use segments::get_audio_segments; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 7879e765cb..c8ac055cea 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -89,8 +89,12 @@ impl ExporterBuilder { .ok_or(Error::NotStudioRecording)?; let recordings = Arc::new( - ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta) - .map_err(Error::RecordingsMeta)?, + ProjectRecordingsMeta::new_with_external( + &recording_meta.project_path, + studio_meta, + &project_config.external_recordings, + ) + .map_err(Error::RecordingsMeta)?, ); let render_constants = Arc::new( @@ -103,10 +107,14 @@ impl ExporterBuilder { .map_err(Error::RendererSetup)?, ); - let segments = - cap_editor::create_segments(&recording_meta, studio_meta, self.force_ffmpeg_decoder) - .await - .map_err(Error::MediaLoad)?; + let segments = cap_editor::create_all_segments( + &recording_meta, + studio_meta, + &project_config.external_recordings, + self.force_ffmpeg_decoder, + ) + .await + .map_err(Error::MediaLoad)?; let output_path = self .output_path diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 59b26b6b3a..fc6f9af11b 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -801,7 +801,7 @@ pub struct SceneSegment { pub mode: SceneMode, } -#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "camelCase")] pub struct TimelineConfiguration { pub segments: Vec, @@ -1169,6 +1169,14 @@ impl Annotation { } } +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct ExternalRecordingReference { + pub path: String, + #[serde(default)] + pub label: Option, +} + #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "camelCase", default)] pub struct ProjectConfiguration { @@ -1189,6 +1197,8 @@ pub struct ProjectConfiguration { pub screen_motion_blur: f32, #[serde(default)] pub screen_movement_spring: ScreenMovementSpring, + #[serde(default)] + pub external_recordings: Vec, } fn camera_config_needs_migration(value: &Value) -> bool { diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index e0b9985b3e..1827d204af 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -122,7 +122,7 @@ pub struct ProjectRecordingsMeta { } impl ProjectRecordingsMeta { - pub fn new(recording_path: &PathBuf, meta: &StudioRecordingMeta) -> Result { + pub fn new(recording_path: &Path, meta: &StudioRecordingMeta) -> Result { let segments = match &meta { StudioRecordingMeta::SingleSegment { segment: s } => { let display = Video::new(s.display.path.to_path(recording_path), 0.0) @@ -213,6 +213,38 @@ impl ProjectRecordingsMeta { Ok(Self { segments }) } + pub fn new_with_external( + recording_path: &Path, + meta: &StudioRecordingMeta, + external_recordings: &[cap_project::ExternalRecordingReference], + ) -> Result { + let mut this = Self::new(recording_path, meta)?; + for (i, ext_ref) in external_recordings.iter().enumerate() { + let ext_path = PathBuf::from(&ext_ref.path); + let ext_meta = cap_project::RecordingMeta::load_for_project(&ext_path) + .map_err(|e| format!("external recording {i}: failed to load meta: {e}"))?; + let cap_project::RecordingMetaInner::Studio(ext_studio_meta) = &ext_meta.inner else { + return Err(format!("external recording {i}: not a studio recording")); + }; + let primary = this.segments.first().ok_or("no primary segments")?; + let ext_recordings = Self::new(&ext_path, ext_studio_meta)?; + if let Some(ext_first) = ext_recordings.segments.first() + && (ext_first.display.width != primary.display.width + || ext_first.display.height != primary.display.height) + { + return Err(format!( + "external recording {i}: resolution {}x{} does not match primary {}x{}", + ext_first.display.width, + ext_first.display.height, + primary.display.width, + primary.display.height, + )); + } + this.segments.extend(ext_recordings.segments); + } + Ok(this) + } + pub fn duration(&self) -> f64 { self.segments.iter().map(|s| s.duration()).sum() }