From 5207fbbe7fad2edcd45ca35a66828282baba43b6 Mon Sep 17 00:00:00 2001 From: Minit Date: Sun, 5 Apr 2026 00:49:02 +0530 Subject: [PATCH 1/6] 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 4842726fb4..ab53888692 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 68c13f09e1..368bbf95e9 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 2/6] 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 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": { From 092c0ccc66650094b5b3872a7b422ed7a972e90f Mon Sep 17 00:00:00 2001 From: Minit Date: Thu, 9 Apr 2026 14:32:22 +0530 Subject: [PATCH 3/6] 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 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..8a44f891fb 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 837af8f5ea..69559a25da 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 67d9b8edf0..2c1861985d 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 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..a7de5d358f 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 4/6] 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 2c1861985d..a45e34bc4e 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 5/6] 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 8a44f891fb..0a9e62562e 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 a7de5d358f..1827d204af 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 6/6] 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 0a9e62562e..94a0d8ef99 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 a45e34bc4e..2cecb2e5f3 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: { /> -