Skip to content

Commit 092c0cc

Browse files
MinitJainclaude
andcommitted
feat(editor): add Cap recording import for timeline stitching (#1712)
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 <noreply@anthropic.com>
1 parent 386ed05 commit 092c0cc

10 files changed

Lines changed: 341 additions & 27 deletions

File tree

apps/desktop/src-tauri/src/export.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ pub async fn generate_export_preview(
327327
settings: ExportPreviewSettings,
328328
) -> Result<ExportPreviewResult, String> {
329329
use base64::{Engine, engine::general_purpose::STANDARD};
330-
use cap_editor::create_segments;
330+
use cap_editor::create_all_segments;
331331
use std::time::Instant;
332332

333333
let recording_meta = RecordingMeta::load_for_project(&project_path)
@@ -337,12 +337,16 @@ pub async fn generate_export_preview(
337337
return Err("Cannot preview non-studio recordings".to_string());
338338
};
339339

340-
let project_config =
341-
export_project_config(recording_meta.project_config(), settings.cursor_only);
340+
let source_project_config = recording_meta.project_config();
341+
let project_config = export_project_config(source_project_config.clone(), settings.cursor_only);
342342

343343
let recordings = Arc::new(
344-
ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta)
345-
.map_err(|e| format!("Failed to load recordings: {e}"))?,
344+
ProjectRecordingsMeta::new_with_external(
345+
&recording_meta.project_path,
346+
studio_meta,
347+
&source_project_config.external_recordings,
348+
)
349+
.map_err(|e| format!("Failed to load recordings: {e}"))?,
346350
);
347351

348352
let render_constants = Arc::new(
@@ -355,9 +359,14 @@ pub async fn generate_export_preview(
355359
.map_err(|e| format!("Failed to create render constants: {e}"))?,
356360
);
357361

358-
let segments = create_segments(&recording_meta, studio_meta, false)
359-
.await
360-
.map_err(|e| format!("Failed to create segments: {e}"))?;
362+
let segments = create_all_segments(
363+
&recording_meta,
364+
studio_meta,
365+
&source_project_config.external_recordings,
366+
false,
367+
)
368+
.await
369+
.map_err(|e| format!("Failed to create segments: {e}"))?;
361370

362371
let render_segments: Vec<RenderSegment> = segments
363372
.iter()

apps/desktop/src-tauri/src/lib.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,6 +2078,137 @@ async fn get_editor_project_path(window: Window) -> Result<PathBuf, String> {
20782078
Ok(path.clone())
20792079
}
20802080

2081+
#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)]
2082+
pub struct CapRecordingImported {
2083+
pub project_path: String,
2084+
}
2085+
2086+
#[tauri::command]
2087+
#[specta::specta]
2088+
async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result<(), String> {
2089+
let CapWindowId::Editor { id } =
2090+
CapWindowId::from_str(window.label()).map_err(|e| e.to_string())?
2091+
else {
2092+
return Err("Invalid window".to_string());
2093+
};
2094+
2095+
let project_path = {
2096+
let window_ids = EditorWindowIds::get(window.app_handle());
2097+
let window_ids = window_ids.ids.lock().unwrap();
2098+
let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else {
2099+
return Err("Editor instance not found".to_string());
2100+
};
2101+
path.clone()
2102+
};
2103+
2104+
if !recording_path.exists() || !recording_path.join("recording-meta.json").exists() {
2105+
return Err("Not a valid Cap recording".to_string());
2106+
}
2107+
2108+
let ext_meta = RecordingMeta::load_for_project(&recording_path)
2109+
.map_err(|e| format!("Failed to load recording meta: {e}"))?;
2110+
let RecordingMetaInner::Studio(ext_studio_meta) = &ext_meta.inner else {
2111+
return Err("External recording is not a studio recording".to_string());
2112+
};
2113+
2114+
let primary_meta = RecordingMeta::load_for_project(&project_path)
2115+
.map_err(|e| format!("Failed to load project meta: {e}"))?;
2116+
let RecordingMetaInner::Studio(primary_studio_meta) = &primary_meta.inner else {
2117+
return Err("Project is not a studio recording".to_string());
2118+
};
2119+
2120+
let primary_recordings =
2121+
cap_rendering::ProjectRecordingsMeta::new(&primary_meta.project_path, primary_studio_meta)
2122+
.map_err(|e| format!("Failed to load primary recordings: {e}"))?;
2123+
let ext_recordings =
2124+
cap_rendering::ProjectRecordingsMeta::new(&recording_path, ext_studio_meta)
2125+
.map_err(|e| format!("Failed to load external recordings: {e}"))?;
2126+
2127+
if let (Some(primary_first), Some(ext_first)) = (
2128+
primary_recordings.segments.first(),
2129+
ext_recordings.segments.first(),
2130+
) {
2131+
if ext_first.display.width != primary_first.display.width
2132+
|| ext_first.display.height != primary_first.display.height
2133+
{
2134+
return Err(format!(
2135+
"Recording resolution {}x{} does not match project resolution {}x{}",
2136+
ext_first.display.width,
2137+
ext_first.display.height,
2138+
primary_first.display.width,
2139+
primary_first.display.height,
2140+
));
2141+
}
2142+
}
2143+
2144+
let mut project_config = ProjectConfiguration::load(&project_path)
2145+
.map_err(|e| format!("Failed to load project config: {e}"))?;
2146+
2147+
if project_config
2148+
.external_recordings
2149+
.iter()
2150+
.any(|r| std::path::Path::new(&r.path) == recording_path)
2151+
{
2152+
return Err("This recording has already been imported".to_string());
2153+
}
2154+
2155+
let clip_index_offset = (primary_recordings.segments.len()
2156+
+ project_config
2157+
.external_recordings
2158+
.iter()
2159+
.map(|r| {
2160+
let p = std::path::PathBuf::from(&r.path);
2161+
RecordingMeta::load_for_project(&p)
2162+
.ok()
2163+
.and_then(|m| {
2164+
m.studio_meta().map(|s| match s {
2165+
cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize,
2166+
cap_project::StudioRecordingMeta::MultipleSegments { inner } => {
2167+
inner.segments.len()
2168+
}
2169+
})
2170+
})
2171+
.unwrap_or(0)
2172+
})
2173+
.sum::<usize>()) as u32;
2174+
2175+
let label = ext_meta.pretty_name.clone();
2176+
2177+
project_config
2178+
.external_recordings
2179+
.push(cap_project::ExternalRecordingReference {
2180+
path: recording_path.to_string_lossy().to_string(),
2181+
label: Some(label),
2182+
});
2183+
2184+
let timeline = project_config.timeline.get_or_insert_with(Default::default);
2185+
2186+
let ext_segment_count = ext_recordings.segments.len();
2187+
for i in 0..ext_segment_count {
2188+
let duration = ext_recordings.segments[i].duration();
2189+
timeline.segments.push(cap_project::TimelineSegment {
2190+
recording_clip: clip_index_offset + i as u32,
2191+
start: 0.0,
2192+
end: duration,
2193+
timescale: 1.0,
2194+
});
2195+
}
2196+
2197+
project_config
2198+
.write(&project_path)
2199+
.map_err(|e| format!("Failed to save project config: {e}"))?;
2200+
2201+
EditorInstances::remove(window.clone()).await;
2202+
2203+
CapRecordingImported {
2204+
project_path: project_path.to_string_lossy().to_string(),
2205+
}
2206+
.emit(&window)
2207+
.map_err(|e| format!("Failed to emit event: {e}"))?;
2208+
2209+
Ok(())
2210+
}
2211+
20812212
#[tauri::command]
20822213
#[specta::specta]
20832214
#[instrument(skip(editor))]
@@ -3355,6 +3486,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
33553486
export::generate_export_preview_fast,
33563487
import::start_video_import,
33573488
import::check_import_ready,
3489+
import_cap_recording,
33583490
copy_file_to_path,
33593491
copy_video_to_clipboard,
33603492
copy_screenshot_to_clipboard,
@@ -3461,6 +3593,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
34613593
hotkeys::OnEscapePress,
34623594
upload::UploadProgressEvent,
34633595
import::VideoImportProgress,
3596+
CapRecordingImported,
34643597
SetCaptureAreaPending,
34653598
DevicesUpdated,
34663599
])

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { cx } from "cva";
66
import {
77
batch,
88
type ComponentProps,
9-
batch,
109
createEffect,
1110
createMemo,
1211
createRoot,

apps/desktop/src/routes/editor/Timeline/index.tsx

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createElementBounds } from "@solid-primitives/bounds";
22
import { createEventListener } from "@solid-primitives/event-listener";
33
import { LogicalPosition } from "@tauri-apps/api/dpi";
44
import { Menu, MenuItem } from "@tauri-apps/api/menu";
5+
import { open as openDialog } from "@tauri-apps/plugin-dialog";
56
import { platform } from "@tauri-apps/plugin-os";
67
import { cx } from "cva";
78
import {
@@ -25,7 +26,7 @@ import "./styles.css";
2526
import Tooltip from "~/components/Tooltip";
2627
import { defaultCaptionSettings } from "~/store/captions";
2728
import { defaultKeyboardSettings } from "~/store/keyboard";
28-
import { commands } from "~/utils/tauri";
29+
import { commands, events } from "~/utils/tauri";
2930
import {
3031
applyCaptionResultToProject,
3132
getCaptionGenerationErrorMessage,
@@ -722,6 +723,35 @@ export function Timeline(props: {
722723
}
723724
};
724725

726+
const [isImporting, setIsImporting] = createSignal(false);
727+
728+
const handleImportCapRecording = async () => {
729+
const selected = await openDialog({
730+
directory: true,
731+
title: "Select a Cap Recording to Import",
732+
filters: [{ name: "Cap Recording", extensions: ["cap"] }],
733+
});
734+
if (!selected || typeof selected !== "string") return;
735+
if (!selected.endsWith(".cap")) {
736+
toast.error("Please select a .cap recording folder");
737+
return;
738+
}
739+
setIsImporting(true);
740+
try {
741+
await commands.importCapRecording(selected);
742+
} catch (e) {
743+
toast.error(String(e));
744+
setIsImporting(false);
745+
}
746+
};
747+
748+
const importedListenerPromise = events.capRecordingImported.listen(() => {
749+
window.location.reload();
750+
});
751+
onCleanup(() => {
752+
importedListenerPromise.then((unlisten) => unlisten());
753+
});
754+
725755
const split = () => editorState.timeline.interactMode === "split";
726756

727757
const maskImage = () => {
@@ -840,14 +870,29 @@ export function Timeline(props: {
840870
<div class="absolute inset-0 flex items-end">
841871
<TimelineMarkings />
842872
</div>
843-
<div class="absolute bottom-0 z-30">
873+
<div class="absolute bottom-0 z-30 flex items-center gap-2">
844874
<Tooltip content="Add track">
845875
<TrackManager
846876
options={trackOptions()}
847877
onToggle={handleToggleTrack}
848878
onAdd={handleAddTrack}
849879
/>
850880
</Tooltip>
881+
<Tooltip content="Import Cap recording">
882+
<button
883+
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-500 text-white text-xs font-medium disabled:opacity-50"
884+
onClick={handleImportCapRecording}
885+
disabled={isImporting()}
886+
>
887+
<Show
888+
when={isImporting()}
889+
fallback={<IconLucidePlus class="size-3" />}
890+
>
891+
<IconLucideLoader2 class="size-3 animate-spin" />
892+
</Show>
893+
Import recording
894+
</button>
895+
</Tooltip>
851896
</div>
852897
</div>
853898
<Show when={!editorState.playing && editorState.previewTime}>
@@ -908,7 +953,9 @@ export function Timeline(props: {
908953
}}
909954
>
910955
<div class="flex flex-col gap-2 min-h-full">
911-
<TrackRow icon={trackIcons.clip}>
956+
<TrackRow
957+
icon={trackIcons.clip}
958+
>
912959
<ClipTrack
913960
ref={setTimelineRef}
914961
handleUpdatePlayhead={handleUpdatePlayhead}
@@ -998,6 +1045,19 @@ export function Timeline(props: {
9981045
/>
9991046
</TrackRow>
10001047
</Show>
1048+
<button
1049+
class="flex items-center gap-1.5 px-3 py-2 mt-1 rounded-xl bg-blue-500 text-white text-xs font-medium disabled:opacity-50 self-start"
1050+
onClick={handleImportCapRecording}
1051+
disabled={isImporting()}
1052+
>
1053+
<Show
1054+
when={isImporting()}
1055+
fallback={<IconLucidePlus class="size-3" />}
1056+
>
1057+
<IconLucideLoader2 class="size-3 animate-spin" />
1058+
</Show>
1059+
Import recording
1060+
</button>
10011061
</div>
10021062
</div>
10031063
</div>
@@ -1011,6 +1071,8 @@ function TrackRow(props: {
10111071
children: JSX.Element;
10121072
onDelete?: () => void;
10131073
onContextMenu?: (e: MouseEvent) => void;
1074+
onImport?: () => void;
1075+
importing?: boolean;
10141076
}) {
10151077
return (
10161078
<div
@@ -1039,6 +1101,25 @@ function TrackRow(props: {
10391101
<IconCapTrash class="size-4" />
10401102
</button>
10411103
</Show>
1104+
<Show when={props.onImport}>
1105+
<button
1106+
class="absolute inset-0 z-20 flex items-center justify-center rounded-xl border border-blue-400/70 bg-blue-500/90 text-white disabled:opacity-50"
1107+
onClick={(e) => {
1108+
e.stopPropagation();
1109+
props.onImport?.();
1110+
}}
1111+
onMouseDown={(e) => e.stopPropagation()}
1112+
disabled={props.importing}
1113+
title="Import Cap recording"
1114+
>
1115+
<Show
1116+
when={props.importing}
1117+
fallback={<IconLucidePlus class="size-4" />}
1118+
>
1119+
<IconLucideLoader2 class="size-4 animate-spin" />
1120+
</Show>
1121+
</button>
1122+
</Show>
10421123
</div>
10431124
<div class="flex-1 relative overflow-hidden min-w-0">
10441125
{props.children}

0 commit comments

Comments
 (0)