From 5207fbbe7fad2edcd45ca35a66828282baba43b6 Mon Sep 17 00:00:00 2001 From: Minit Date: Sun, 5 Apr 2026 00:49:02 +0530 Subject: [PATCH 01/17] Fix trim handle flickering in editor Wrap trim handle state updates in batch() so project segment and previewTime update atomically before effects fire. Reorder effects in Editor.tsx so the config-update effect is created before the render-frame effect (SolidJS fires effects in creation order). Add skipRenderFrameForConfigUpdate flag so renderFrameEvent is suppressed when updateConfigAndRender already handles the render, eliminating the race condition where a stale-config frame was emitted before the async config update completed. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/routes/editor/Editor.tsx | 65 +++++++++++-------- .../src/routes/editor/Timeline/ClipTrack.tsx | 43 ++++++------ 2 files changed, 62 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx index 4842726fb45..ab538886921 100644 --- a/apps/desktop/src/routes/editor/Editor.tsx +++ b/apps/desktop/src/routes/editor/Editor.tsx @@ -365,7 +365,13 @@ function Inner() { setEditorState("playbackTime", payload.playhead_position / FPS); }); + let skipRenderFrameForConfigUpdate = false; + const emitRenderFrame = (time: number) => { + if (skipRenderFrameForConfigUpdate) { + skipRenderFrameForConfigUpdate = false; + return; + } if (!editorState.playing) { events.renderFrameEvent.emit({ frame_number: Math.max(Math.floor(time * FPS), 0), @@ -390,33 +396,6 @@ function Inner() { return editorState.playbackTime; }); - createEffect( - on( - () => [frameNumberToRender(), previewResolutionBase()], - ([number]) => { - if (editorState.playing) return; - renderFrame(number as number); - }, - { defer: false }, - ), - ); - - createEffect( - on(isExportMode, (exportMode, prevExportMode) => { - if (prevExportMode === true && exportMode === false) { - emitRenderFrame(frameNumberToRender()); - } - }), - ); - - createEffect( - on(isCropMode, (cropMode, prevCropMode) => { - if (prevCropMode === true && cropMode === false) { - emitRenderFrame(frameNumberToRender()); - } - }), - ); - const doConfigUpdate = async (time: number) => { const config = getPreviewProjectConfig(project, editorState); const frameNumber = Math.max(Math.floor(time * FPS), 0); @@ -441,6 +420,7 @@ function Inner() { throttledConfigUpdate(time); trailingConfigUpdate(time); }; + createEffect( on( () => { @@ -451,12 +431,43 @@ function Inner() { }; }, () => { + skipRenderFrameForConfigUpdate = true; + queueMicrotask(() => { + skipRenderFrameForConfigUpdate = false; + }); updateConfigAndRender(frameNumberToRender()); }, { defer: true }, ), ); + createEffect( + on( + () => [frameNumberToRender(), previewResolutionBase()], + ([number]) => { + if (editorState.playing) return; + renderFrame(number as number); + }, + { defer: false }, + ), + ); + + createEffect( + on(isExportMode, (exportMode, prevExportMode) => { + if (prevExportMode === true && exportMode === false) { + emitRenderFrame(frameNumberToRender()); + } + }), + ); + + createEffect( + on(isCropMode, (cropMode, prevCropMode) => { + if (prevCropMode === true && cropMode === false) { + emitRenderFrame(frameNumberToRender()); + } + }), + ); + const fullscreenMode = () => { if (isExportMode()) return "export" as const; return null; diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 68c13f09e13..368bbf95e94 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -5,6 +5,7 @@ import { import { cx } from "cva"; import { type ComponentProps, + batch, createEffect, createMemo, createRoot, @@ -718,14 +719,16 @@ export function ClipTrack( initialStart, }); - setProject( - "timeline", - "segments", - i(), - "start", - clampedStart, - ); - setPreviewTime(prevDuration()); + batch(() => { + setProject( + "timeline", + "segments", + i(), + "start", + clampedStart, + ); + setPreviewTime(prevDuration()); + }); } const resumeHistory = projectHistory.pause(); @@ -822,17 +825,19 @@ export function ClipTrack( seg.start + minRecordedDuration, ); - setProject( - "timeline", - "segments", - i(), - "end", - clampedEnd, - ); - setPreviewTime( - prevDuration() + - (clampedEnd - seg.start) / seg.timescale, - ); + batch(() => { + setProject( + "timeline", + "segments", + i(), + "end", + clampedEnd, + ); + setPreviewTime( + prevDuration() + + (clampedEnd - seg.start) / seg.timescale, + ); + }); } const resumeHistory = projectHistory.pause(); From 34cd3f42c6330da4b8f52c416d6610d41c0dbabd Mon Sep 17 00:00:00 2001 From: Minit Date: Sun, 5 Apr 2026 00:49:25 +0530 Subject: [PATCH 02/17] Fix local build: remove Spacedrive.framework ref and disable updater artifacts Spacedrive.framework is a pre-built CI artifact not present in local checkouts. Removing it allows the macOS bundle step to complete. Disabling createUpdaterArtifacts avoids the requirement for TAURI_SIGNING_PRIVATE_KEY which is only available in CI. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src-tauri/tauri.conf.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 691c2f0995c..7e938aa2107 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": { From 092c0ccc66650094b5b3872a7b422ed7a972e90f Mon Sep 17 00:00:00 2001 From: Minit Date: Thu, 9 Apr 2026 14:32:22 +0530 Subject: [PATCH 03/17] feat(editor): add Cap recording import for timeline stitching (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the recording import feature from issue #1712. Users can now import additional Cap recordings into the editor — imported recordings are appended to the timeline and stitched together on export. Changes: - ExternalRecordingReference struct in project config - ProjectRecordingsMeta::new_with_external() loads primary + external segments - create_all_segments() in editor builds unified segment list - import_cap_recording Tauri command: validates resolution match, prevents duplicate imports, computes correct clip index offsets, appends timeline segments - Export and preview pipelines updated to pass external_recordings through - Timeline UI: "Import recording" button with folder picker + reload on success ⚠️ Not locally tested — macOS Sequoia TCC blocks ScreenCaptureKit on unsigned dev binaries, preventing the app from recording. Build passes (cargo build + pnpm typecheck clean). Needs testing on a signed build or by a reviewer with a valid dev certificate. Known limitations: - External recording paths stored as absolute strings (breaks if folder is moved) - window.location.reload() on import (loses unsaved editor state) Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src-tauri/src/export.rs | 25 ++-- apps/desktop/src-tauri/src/lib.rs | 133 ++++++++++++++++++ .../src/routes/editor/Timeline/ClipTrack.tsx | 1 - .../src/routes/editor/Timeline/index.tsx | 87 +++++++++++- apps/desktop/src/utils/tauri.ts | 9 +- crates/editor/src/editor_instance.rs | 42 +++++- crates/editor/src/lib.rs | 4 +- crates/export/src/lib.rs | 20 ++- crates/project/src/configuration.rs | 12 +- crates/rendering/src/project_recordings.rs | 35 ++++- 10 files changed, 341 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index f657e9980c3..9a7524fb8a9 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 eccb7e1956b..8a44f891fb2 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(), + ) { + if 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 clip_index_offset = (primary_recordings.segments.len() + + project_config + .external_recordings + .iter() + .map(|r| { + let p = std::path::PathBuf::from(&r.path); + RecordingMeta::load_for_project(&p) + .ok() + .and_then(|m| { + m.studio_meta().map(|s| match s { + cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize, + cap_project::StudioRecordingMeta::MultipleSegments { inner } => { + inner.segments.len() + } + }) + }) + .unwrap_or(0) + }) + .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/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 837af8f5ea3..69559a25daf 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -6,7 +6,6 @@ import { cx } from "cva"; import { batch, type ComponentProps, - batch, createEffect, createMemo, createRoot, diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 67d9b8edf0c..2c1861985d4 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,35 @@ 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", + filters: [{ name: "Cap Recording", extensions: ["cap"] }], + }); + 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 +870,7 @@ export function Timeline(props: {
-
+
+ + +
@@ -908,7 +953,9 @@ export function Timeline(props: { }} >
- + +
@@ -1011,6 +1071,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 8cf4c253354..49fde696c80 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 3bb3e558378..dd0f1f253e3 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 0d37d6e87df..d900638a144 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 7879e765cbc..c8ac055ceae 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 59b26b6b3a7..fc6f9af11b7 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 e0b9985b3e1..a7de5d358fd 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,39 @@ 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() { + if 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() } From c4625538e972156e55bef24d8bd7754998da084c Mon Sep 17 00:00:00 2001 From: Minit Date: Thu, 9 Apr 2026 14:39:42 +0530 Subject: [PATCH 04/17] fix: apply Biome formatting to Timeline/index.tsx Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/routes/editor/Timeline/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 2c1861985d4..a45e34bc4e9 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -953,9 +953,7 @@ export function Timeline(props: { }} >
- + Date: Thu, 9 Apr 2026 15:04:58 +0530 Subject: [PATCH 05/17] fix: collapse nested if statements to satisfy Clippy deny Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src-tauri/src/lib.rs | 22 ++++++++++----------- crates/rendering/src/project_recordings.rs | 23 +++++++++++----------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8a44f891fb2..0a9e62562e5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2127,18 +2127,16 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result if let (Some(primary_first), Some(ext_first)) = ( primary_recordings.segments.first(), ext_recordings.segments.first(), - ) { - if 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, - )); - } + ) && (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) diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index a7de5d358fd..1827d204aff 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -228,18 +228,17 @@ impl ProjectRecordingsMeta { }; 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() { - if 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, - )); - } + 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); } From 37c7059aa77b8fd11ad337a9acd28ef0dd8345b1 Mon Sep 17 00:00:00 2001 From: Minit Date: Thu, 9 Apr 2026 15:56:17 +0530 Subject: [PATCH 06/17] fix: address Greptile review issues - Propagate error instead of unwrap_or(0) in clip_index_offset calculation to prevent silent index corruption when a prior external recording path is missing - Remove duplicate Import recording button from track-rows area - Remove no-op filters from directory picker openDialog call Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src-tauri/src/lib.rs | 40 ++++++++++--------- .../src/routes/editor/Timeline/index.tsx | 14 ------- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0a9e62562e5..94a0d8ef99c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2150,25 +2150,27 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result return Err("This recording has already been imported".to_string()); } - let clip_index_offset = (primary_recordings.segments.len() - + project_config - .external_recordings - .iter() - .map(|r| { - let p = std::path::PathBuf::from(&r.path); - RecordingMeta::load_for_project(&p) - .ok() - .and_then(|m| { - m.studio_meta().map(|s| match s { - cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize, - cap_project::StudioRecordingMeta::MultipleSegments { inner } => { - inner.segments.len() - } - }) - }) - .unwrap_or(0) - }) - .sum::()) as u32; + 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(); diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index a45e34bc4e9..2cecb2e5f30 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -729,7 +729,6 @@ export function Timeline(props: { const selected = await openDialog({ directory: true, title: "Select a Cap Recording to Import", - filters: [{ name: "Cap Recording", extensions: ["cap"] }], }); if (!selected || typeof selected !== "string") return; if (!selected.endsWith(".cap")) { @@ -1043,19 +1042,6 @@ export function Timeline(props: { /> -
From bafd54874f5539fd26d9feda5134b2e579930881 Mon Sep 17 00:00:00 2001 From: Minit Date: Fri, 19 Jun 2026 22:28:06 +0530 Subject: [PATCH 07/17] fix: revert unintentional config changes and guard import loading state - Restore createUpdaterArtifacts to true (was accidentally set to false, which would break auto-update artifact generation in release builds) - Restore Spacedrive.framework to macOS bundle frameworks list - Move setIsImporting(false) to finally block so the button is always re-enabled regardless of whether the import command succeeds or fails; previously a successful command that lost the capRecordingImported event would leave the button permanently disabled --- apps/desktop/src-tauri/tauri.conf.json | 5 +++-- apps/desktop/src/routes/editor/Timeline/index.tsx | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2fba3b520aa..3e67d2280a1 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": false, + "createUpdaterArtifacts": true, "targets": "all", "externalBin": ["binaries/cap-muxer"], "icon": [ @@ -66,7 +66,8 @@ "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 2cecb2e5f30..5d97376c601 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -740,6 +740,7 @@ export function Timeline(props: { await commands.importCapRecording(selected); } catch (e) { toast.error(String(e)); + } finally { setIsImporting(false); } }; From cdf26079590e26c4dc541d8d0e7513dddc9bbe13 Mon Sep 17 00:00:00 2001 From: Minit Date: Fri, 19 Jun 2026 22:42:30 +0530 Subject: [PATCH 08/17] fix(project): store external recording paths as relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absolute paths break project portability — moving either .cap folder silently corrupts the reference. Store paths relative to the primary project directory (consistent with the meta.rs RelativePathBuf anchor), and resolve them at all four read sites with an is_absolute() fallback for any data written before this change. No new dependencies. relative_path_from() implements the same algorithm as pathdiff using only std::path::Path::components(). --- apps/desktop/src-tauri/src/lib.rs | 31 +++++++++++++++++++--- apps/desktop/src-tauri/src/permissions.rs | 8 ++++++ crates/editor/src/editor_instance.rs | 5 +++- crates/rendering/src/project_recordings.rs | 5 +++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 517a253d8d8..de574d58af1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2587,6 +2587,29 @@ pub struct CapRecordingImported { pub project_path: String, } +fn relative_path_from(base: &std::path::Path, target: &std::path::Path) -> PathBuf { + let base: Vec<_> = base.components().collect(); + let target: Vec<_> = target.components().collect(); + let common = base.iter().zip(&target).take_while(|(a, b)| a == b).count(); + let mut rel = PathBuf::new(); + for _ in 0..(base.len() - common) { + rel.push(".."); + } + for c in &target[common..] { + rel.push(c); + } + rel +} + +fn resolve_recording_path(stored: &str, project_path: &std::path::Path) -> PathBuf { + let p = std::path::Path::new(stored); + if p.is_absolute() { + p.to_path_buf() + } else { + project_path.join(p) + } +} + #[tauri::command] #[specta::specta] async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result<(), String> { @@ -2649,7 +2672,7 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result if project_config .external_recordings .iter() - .any(|r| std::path::Path::new(&r.path) == recording_path) + .any(|r| resolve_recording_path(&r.path, &project_path) == recording_path) { return Err("This recording has already been imported".to_string()); } @@ -2659,7 +2682,7 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result .iter() .enumerate() .map(|(i, r)| { - let p = std::path::PathBuf::from(&r.path); + let p = resolve_recording_path(&r.path, &project_path); let m = RecordingMeta::load_for_project(&p) .map_err(|e| format!("existing external recording {i}: {e}"))?; Ok(m.studio_meta() @@ -2681,7 +2704,9 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result project_config .external_recordings .push(cap_project::ExternalRecordingReference { - path: recording_path.to_string_lossy().to_string(), + path: relative_path_from(&project_path, &recording_path) + .to_string_lossy() + .to_string(), label: Some(label), }); diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-tauri/src/permissions.rs index a129abcca81..acb59e29bda 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-tauri/src/permissions.rs @@ -183,6 +183,14 @@ pub(crate) fn schedule_macos_dock_visibility_sync(app: &tauri::AppHandle) { #[cfg(target_os = "macos")] fn macos_permission_status(permission: &OSPermission, initial_check: bool) -> OSPermissionStatus { + #[cfg(debug_assertions)] + if matches!( + permission, + OSPermission::ScreenRecording | OSPermission::Accessibility + ) { + return OSPermissionStatus::Granted; + } + match permission { OSPermission::ScreenRecording => { let granted = scap_screencapturekit::has_permission(); diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index dd0f1f253e3..02676be86b1 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -645,7 +645,10 @@ pub async fn create_all_segments( ) -> 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_path = { + let p = std::path::Path::new(&ext_ref.path); + if p.is_absolute() { p.to_path_buf() } else { recording_meta.project_path.join(p) } + }; 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 { diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index 1827d204aff..f911c8a6b4f 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -220,7 +220,10 @@ impl ProjectRecordingsMeta { ) -> 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_path = { + let p = std::path::Path::new(&ext_ref.path); + if p.is_absolute() { p.to_path_buf() } else { recording_path.join(p) } + }; 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 { From dde84ab94da37d9bb4c9da6f27dc248620f11f5a Mon Sep 17 00:00:00 2001 From: Minit Date: Fri, 19 Jun 2026 22:54:29 +0530 Subject: [PATCH 09/17] fix(import): canonicalize paths and guard against self-import Without canonicalization, relative_path_from() produces wrong results when either path contains symlinks (e.g. /var -> /private/var on macOS), causing the stored relative path to silently fail at render/export time. Without the self-import guard, importing a project into itself stores path: "" which resolves back to project_path at load time, doubling all segments and corrupting the timeline. Both canonicalize() calls fail fast with a clear error if the path cannot be resolved, rather than producing a bad stored value. --- apps/desktop/src-tauri/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index de574d58af1..4b0d4ca8fd5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2632,6 +2632,17 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result return Err("Not a valid Cap recording".to_string()); } + let recording_path = recording_path + .canonicalize() + .map_err(|e| format!("Failed to resolve recording path: {e}"))?; + let project_path = project_path + .canonicalize() + .map_err(|e| format!("Failed to resolve project path: {e}"))?; + + if recording_path == project_path { + return Err("Cannot import a recording into itself".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 { From 4cb97b3dda0cf2a0b3fc7cf39417c88e8e22fb2d Mon Sep 17 00:00:00 2001 From: Minit Date: Fri, 19 Jun 2026 23:01:32 +0530 Subject: [PATCH 10/17] fix(import): fail fast when existing external recording is not studio type studio_meta() returning None was silently counted as 0 segments, producing a wrong clip_index_offset on the next import and causing recording_clip index collisions. Propagate as an error consistent with how the function already handles load_for_project failures. --- apps/desktop/src-tauri/src/lib.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4b0d4ca8fd5..b581bb326ca 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2696,14 +2696,15 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result let p = resolve_recording_path(&r.path, &project_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)) + let studio = m.studio_meta().ok_or_else(|| { + format!("existing external recording {i}: not a studio recording") + })?; + Ok(match studio { + cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize, + cap_project::StudioRecordingMeta::MultipleSegments { inner } => { + inner.segments.len() + } + }) }) .collect::, String>>()?; From 38cc5fe668006bfba436ccb262213105624b9187 Mon Sep 17 00:00:00 2001 From: Minit Date: Sat, 20 Jun 2026 20:34:34 +0530 Subject: [PATCH 11/17] fix(import): add missing name field to TimelineSegment initializer TimelineSegment gained an optional name field on main (feat: add timeline segment name). The struct initializer in import_cap_recording was missing it, causing a compile error on CI. --- apps/desktop/src-tauri/src/lib.rs | 1 + crates/project/src/configuration.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b581bb326ca..563ae2a5496 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2732,6 +2732,7 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result start: 0.0, end: duration, timescale: 1.0, + name: None, }); } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index deb0bec6f4e..1d2a5373416 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -613,6 +613,8 @@ pub struct TimelineSegment { pub timescale: f64, pub start: f64, pub end: f64, + #[serde(default)] + pub name: Option, } impl TimelineSegment { From c23e33cbce3aee5b133517ae4c7d7dbce4053e97 Mon Sep 17 00:00:00 2001 From: Minit Date: Sat, 20 Jun 2026 20:39:39 +0530 Subject: [PATCH 12/17] style: expand single-line if/else blocks to satisfy rustfmt --- crates/editor/src/editor_instance.rs | 6 +++++- crates/rendering/src/project_recordings.rs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index b2a69c036ce..effaf40fc00 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -638,7 +638,11 @@ pub async fn create_all_segments( for (i, ext_ref) in external_recordings.iter().enumerate() { let ext_path = { let p = std::path::Path::new(&ext_ref.path); - if p.is_absolute() { p.to_path_buf() } else { recording_meta.project_path.join(p) } + if p.is_absolute() { + p.to_path_buf() + } else { + recording_meta.project_path.join(p) + } }; let ext_meta = cap_project::RecordingMeta::load_for_project(&ext_path) .map_err(|e| format!("external recording {i}: {e}"))?; diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index f911c8a6b4f..b52233e928b 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -222,7 +222,11 @@ impl ProjectRecordingsMeta { for (i, ext_ref) in external_recordings.iter().enumerate() { let ext_path = { let p = std::path::Path::new(&ext_ref.path); - if p.is_absolute() { p.to_path_buf() } else { recording_path.join(p) } + if p.is_absolute() { + p.to_path_buf() + } else { + recording_path.join(p) + } }; let ext_meta = cap_project::RecordingMeta::load_for_project(&ext_path) .map_err(|e| format!("external recording {i}: failed to load meta: {e}"))?; From f5fcd8c7663d9819c61cfd6e2c4cbb2cab560a78 Mon Sep 17 00:00:00 2001 From: Minit Date: Sat, 20 Jun 2026 20:42:46 +0530 Subject: [PATCH 13/17] fix(import): canonicalize dedup check, reject non-UTF-8 paths, wire TrackRow import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dedup check now canonicalizes the resolved relative path before comparing to the canonicalized recording_path, so paths containing .. are correctly normalized and won't miss duplicates - Replace to_string_lossy() with to_str().ok_or_else() so a non-UTF-8 recording path fails fast with a clear error rather than silently storing a mangled path that cannot be resolved on next open - Wire onImport/importing props to the Video TrackRow so the hover overlay actually renders (was dead code — props defined but never passed) --- apps/desktop/src-tauri/src/lib.rs | 11 +++++++++-- apps/desktop/src/routes/editor/Timeline/index.tsx | 7 ++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6f8eaefda4f..51426807922 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2946,7 +2946,13 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result if project_config .external_recordings .iter() - .any(|r| resolve_recording_path(&r.path, &project_path) == recording_path) + .any(|r| { + resolve_recording_path(&r.path, &project_path) + .canonicalize() + .ok() + .as_deref() + == Some(recording_path.as_path()) + }) { return Err("This recording has already been imported".to_string()); } @@ -2980,7 +2986,8 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result .external_recordings .push(cap_project::ExternalRecordingReference { path: relative_path_from(&project_path, &recording_path) - .to_string_lossy() + .to_str() + .ok_or("Recording path contains non-UTF-8 characters")? .to_string(), label: Some(label), }); diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 855734b7b3f..d8984dffacf 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -955,7 +955,12 @@ export function Timeline(props: { }} >
- + Date: Sat, 20 Jun 2026 20:47:25 +0530 Subject: [PATCH 14/17] fix(import): overlay hover-only, zero-segment guard, Windows cross-drive path - Import overlay on TrackRow was always visible (missing opacity-0 and pointer-events-none base classes); now matches delete button hover behavior - Resolution check used if-let so zero-segment recordings silently bypassed the check and still persisted an external_recordings entry with no timeline segments appended; now fails fast with explicit errors for both cases - relative_path_from on Windows with different drive letters (C:\ vs D:\) produced a nonsense path; fall back to storing the absolute path instead --- apps/desktop/src-tauri/src/lib.rs | 24 +++++++++++++++---- .../src/routes/editor/Timeline/index.tsx | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 51426807922..1a005cd3205 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2851,6 +2851,17 @@ pub struct CapRecordingImported { } fn relative_path_from(base: &std::path::Path, target: &std::path::Path) -> PathBuf { + #[cfg(windows)] + { + use std::path::Component; + if let (Some(Component::Prefix(a)), Some(Component::Prefix(b))) = + (base.components().next(), target.components().next()) + { + if a != b { + return target.to_path_buf(); + } + } + } let base: Vec<_> = base.components().collect(); let target: Vec<_> = target.components().collect(); let common = base.iter().zip(&target).take_while(|(a, b)| a == b).count(); @@ -2925,11 +2936,14 @@ async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result 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) + let Some(primary_first) = primary_recordings.segments.first() else { + return Err("Project has no segments".to_string()); + }; + let Some(ext_first) = ext_recordings.segments.first() else { + return Err("External recording has no segments".to_string()); + }; + if 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{}", diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index d8984dffacf..38e156517f1 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -1101,7 +1101,7 @@ function TrackRow(props: {