diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index e01cadd024..b89cab0e1f 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -1464,7 +1464,7 @@ async fn generate_export_preview_inner( 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) @@ -1474,12 +1474,16 @@ async fn generate_export_preview_inner( 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( @@ -1499,9 +1503,14 @@ async fn generate_export_preview_inner( "Starting export preview" ); - let segments = create_segments(&recording_meta, studio_meta, force_ffmpeg) - .await - .map_err(|e| format!("Failed to create segments: {e}"))?; + let segments = create_all_segments( + &recording_meta, + studio_meta, + &source_project_config.external_recordings, + force_ffmpeg, + ) + .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 f6158a1bec..e816eea584 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2799,7 +2799,10 @@ async fn create_editor_instance(window: Window) -> Result Result { }; let window_ids = EditorWindowIds::get(window.app_handle()); - let window_ids = window_ids.ids.lock().unwrap(); + let window_ids = window_ids + .ids + .lock() + .map_err(|_| "Failed to lock editor window ids".to_string())?; let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else { return Err("Editor instance not found".to_string()); @@ -2845,6 +2851,198 @@ 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, +} + +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(); + 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> { + 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() + .map_err(|_| "Failed to lock editor window ids".to_string())?; + 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 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 { + 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}"))?; + + 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{}", + 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| { + 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()); + } + + let ext_segment_counts = project_config + .external_recordings + .iter() + .enumerate() + .map(|(i, r)| { + 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}"))?; + 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>>()?; + + let clip_index_offset = + (primary_recordings.segments.len() + ext_segment_counts.iter().sum::()) as u32; + + let label = ext_meta.pretty_name.clone(); + + project_config + .external_recordings + .push(cap_project::ExternalRecordingReference { + path: relative_path_from(&project_path, &recording_path) + .to_str() + .ok_or("Recording path contains non-UTF-8 characters")? + .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, + name: None, + }); + } + + 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_str() + .ok_or("Project path contains non-UTF-8 characters")? + .to_string(), + } + .emit(&window) + .map_err(|e| format!("Failed to emit event: {e}"))?; + + Ok(()) +} + #[tauri::command] #[specta::specta] #[instrument(skip(editor))] @@ -4320,6 +4518,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { import::add_existing_recording_to_editor, import::start_image_import, import::check_import_ready, + import_cap_recording, copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, @@ -4441,6 +4640,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/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index e24007e2bf..38e156517f 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, @@ -724,6 +725,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", + }); + 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)); + } finally { + setIsImporting(false); + } + }; + + const importedListenerPromise = events.capRecordingImported.listen(() => { + window.location.reload(); + }); + onCleanup(() => { + importedListenerPromise.then((unlisten) => unlisten()); + }); + const split = () => editorState.timeline.interactMode === "split"; const maskImage = () => { @@ -842,7 +872,7 @@ export function Timeline(props: {
-
+
+ + +
@@ -910,7 +955,12 @@ export function Timeline(props: { }} >
- + void; onContextMenu?: (e: MouseEvent) => void; + onImport?: () => void; + importing?: boolean; }) { return (
@@ -1047,6 +1099,25 @@ function TrackRow(props: { + + +
{props.children} diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index cfa04be05a..0672c1851d 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -232,6 +232,7 @@ export function normalizeProject( timeline, background: withCornerDefaults(config.background), camera: withCornerDefaults(config.camera), + captions: config.captions as unknown as EditorCaptionsData | null, }; } diff --git a/apps/desktop/src/store/captions.ts b/apps/desktop/src/store/captions.ts index 75e335c02e..720f81a0e3 100644 --- a/apps/desktop/src/store/captions.ts +++ b/apps/desktop/src/store/captions.ts @@ -236,9 +236,11 @@ function createCaptionsStore() { try { const captionsData = await commands.loadCaptions(videoPath); if (captionsData) { - const loadedSettings = captionsData.settings - ? { ...defaultCaptionSettings, ...captionsData.settings } - : { ...defaultCaptionSettings, enabled: true }; + const loadedSettings = ( + captionsData.settings + ? { ...defaultCaptionSettings, ...captionsData.settings } + : { ...defaultCaptionSettings, enabled: true } + ) as EditorCaptionSettings; setState((prev) => ({ ...prev, segments: captionsData.segments, diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b397b3ed66..8f007ebebf 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -146,6 +146,9 @@ async startImageImport(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 }); }, @@ -443,6 +446,7 @@ async listAutomationCapabilities() : Promise { export const events = __makeEvents__<{ audioInputLevelChange: AudioInputLevelChange, +capRecordingImported: CapRecordingImported, currentRecordingChanged: CurrentRecordingChanged, devicesUpdated: DevicesUpdated, downloadProgress: DownloadProgress, @@ -470,6 +474,7 @@ uploadProgressEvent: UploadProgressEvent, videoImportProgress: VideoImportProgress }>({ audioInputLevelChange: "audio-input-level-change", +capRecordingImported: "cap-recording-imported", currentRecordingChanged: "current-recording-changed", devicesUpdated: "devices-updated", downloadProgress: "download-progress", @@ -559,6 +564,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; manualPosition: XY | null; preset: string; animation: string; highlightStyle: string; uppercase: boolean } @@ -617,6 +623,7 @@ export type ExportPreviewResult = { jpeg_base64: string; estimated_size_mb: numb export type ExportPreviewSettings = { fps: number; resolution_base: XY; compression_bpp: number; cursor_only?: boolean } export type ExportProfile = { format: ExportFormat; fps?: number; resolutionBase?: XY; compression?: AutomationExportCompression | null; presetName?: string | null } 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" } @@ -682,7 +689,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/apps/web/__tests__/unit/caption-tracks.test.ts b/apps/web/__tests__/unit/caption-tracks.test.ts index 74b208b8d0..0ca61bf9bb 100644 --- a/apps/web/__tests__/unit/caption-tracks.test.ts +++ b/apps/web/__tests__/unit/caption-tracks.test.ts @@ -76,11 +76,11 @@ function createCueList(text: string): TextTrackCueList { function createVideo( textTracks: FakeTextTrackList, trackElements: FakeTrackElement[], -): HTMLVideoElement { +): FakeVideo & HTMLVideoElement { return new FakeVideo( textTracks, trackElements, - ) as unknown as HTMLVideoElement; + ) as unknown as FakeVideo & HTMLVideoElement; } describe("bindCaptionTrackCueText", () => { diff --git a/apps/web/__tests__/unit/developer-rate-limiter.test.ts b/apps/web/__tests__/unit/developer-rate-limiter.test.ts new file mode 100644 index 0000000000..87decd9204 --- /dev/null +++ b/apps/web/__tests__/unit/developer-rate-limiter.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Stub all module-level imports in utils.ts that fail in the node test +// environment. evictRateLimitEntries itself has no external dependencies. +vi.mock("@cap/database", () => ({ db: vi.fn() })); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@cap/database/schema", () => ({ + authApiKeys: {}, + developerApiKeys: {}, + developerAppDomains: {}, + developerApps: {}, + users: {}, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_WEB_URL: "http://localhost:3000" }, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn(), + eq: vi.fn(), + isNull: vi.fn(), +})); +vi.mock("@/lib/developer-key-hash", () => ({ hashKey: vi.fn() })); + +import { evictRateLimitEntries } from "../../app/api/utils"; + +const MAX_ENTRIES = 3; +const MAX_REQUESTS = 60; + +type Entry = { count: number; resetAt: number }; + +describe("evictRateLimitEntries", () => { + let now: number; + + beforeEach(() => { + now = Date.now(); + }); + + /** + * A — Phase 1 evicts non-blocked entries and preserves blocked ones. + * + * Three non-evictable (count > MAX_REQUESTS) + one evictable (count < MAX_REQUESTS). + * Only one evictable entry exists so the outcome is insertion-order independent. + * + * Regression caught: removing the count guard causes blocked entries to be evicted. + */ + it("evicts non-blocked entries first and preserves all blocked entries", () => { + const map = new Map([ + ["blocked-1", { count: 61, resetAt: now + 60_000 }], + ["blocked-2", { count: 99, resetAt: now + 60_000 }], + ["blocked-3", { count: 75, resetAt: now + 60_000 }], + ["innocent", { count: 10, resetAt: now + 60_000 }], + ]); + + evictRateLimitEntries(map, MAX_ENTRIES, MAX_REQUESTS); + + expect(map.size).toBe(MAX_ENTRIES); + expect(map.has("innocent")).toBe(false); + expect(map.has("blocked-1")).toBe(true); + expect(map.has("blocked-2")).toBe(true); + expect(map.has("blocked-3")).toBe(true); + }); + + /** + * B — count === MAX_REQUESTS is preserved by Phase 1. + * + * Block condition: count > MAX_REQUESTS (61+) + * Eviction predicate: count < MAX_REQUESTS (≤59) + * count=60 falls in neither: the client has exhausted their allowance and + * their next request will be blocked. Evicting them would reset the counter + * and grant 60 free requests mid-window — a rate limit bypass. + * + * "at-limit" is never a Phase 1 candidate regardless of insertion order + * because 60 < 60 is false. "evictable" (count=59) is the only safe target. + * + * Regression caught: changing < to <= would evict count=60 entries, + * making map.has("at-limit") false and failing this assertion. + */ + it("preserves entries at count === MAX_REQUESTS (allowance exhausted)", () => { + const map = new Map([ + ["blocked", { count: 61, resetAt: now + 60_000 }], + ["at-limit", { count: 60, resetAt: now + 60_000 }], + ["blocked-2", { count: 62, resetAt: now + 60_000 }], + ["evictable", { count: 59, resetAt: now + 60_000 }], + ]); + + evictRateLimitEntries(map, MAX_ENTRIES, MAX_REQUESTS); + + expect(map.size).toBe(MAX_ENTRIES); + expect(map.has("evictable")).toBe(false); + expect(map.has("at-limit")).toBe(true); + expect(map.has("blocked")).toBe(true); + expect(map.has("blocked-2")).toBe(true); + }); + + /** + * C — The original requestCounts.clear() regression cannot return. + * + * All entries are blocked so Phase 1 makes no deletions and Phase 2 fires, + * evicting exactly one entry. All survivors must still be blocked. + * + * Regression caught: + * clear() → size=0 → size assertion fails immediately. + * partial clear → count check fails on any survivor with count ≤ MAX_REQUESTS. + */ + it("does not clear all entries when the entire map is rate-limited", () => { + const map = new Map( + Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => [ + `blocked-${i}`, + { count: 99, resetAt: now + (i + 1) * 1_000 }, + ]), + ); + + evictRateLimitEntries(map, MAX_ENTRIES, MAX_REQUESTS); + + expect(map.size).toBe(MAX_ENTRIES); + for (const v of map.values()) { + expect(v.count).toBeGreaterThan(MAX_REQUESTS); + } + }); + + /** + * D — Phase 2 evicts by soonest resetAt, not by insertion order. + * + * "last" (latest resetAt) is placed first in the map. + * "soon" (soonest resetAt) is placed last in the map. + * Without the sort, Phase 2 would delete "last" (first in map). + * With the sort, Phase 2 deletes "soon" (smallest resetAt). + * + * Regression caught: removing .sort() → "last" evicted → map.has("last") fails. + */ + it("phase 2 evicts the soonest-expiring entry, not the insertion-order first", () => { + const map = new Map([ + ["last", { count: 99, resetAt: now + 60_000 }], + ["mid", { count: 99, resetAt: now + 30_000 }], + ["later", { count: 99, resetAt: now + 59_000 }], + ["soon", { count: 99, resetAt: now + 1_000 }], + ]); + + evictRateLimitEntries(map, MAX_ENTRIES, MAX_REQUESTS); + + expect(map.size).toBe(MAX_ENTRIES); + expect(map.has("soon")).toBe(false); + expect(map.has("last")).toBe(true); + }); + + /** + * E — Phase 2 does not run when Phase 1 already brought the map within bounds. + * + * "blocked-soon" has the smallest resetAt — it would be Phase 2's first + * eviction target if Phase 2 fired. Its survival proves Phase 2 did not run. + * + * Regression caught: removing the `if (map.size > maxEntries)` guard before + * Phase 2 → "blocked-soon" evicted → map.has("blocked-soon") fails. + */ + it("phase 2 does not run when phase 1 is sufficient", () => { + const map = new Map([ + ["blocked-soon", { count: 99, resetAt: now + 1_000 }], + ["blocked-mid", { count: 99, resetAt: now + 30_000 }], + ["blocked-late", { count: 99, resetAt: now + 59_000 }], + ["innocent", { count: 10, resetAt: now + 60_000 }], + ]); + + evictRateLimitEntries(map, MAX_ENTRIES, MAX_REQUESTS); + + expect(map.size).toBe(MAX_ENTRIES); + expect(map.has("innocent")).toBe(false); + // Phase 2 would have evicted this first (soonest expiry). + // Its presence confirms Phase 2 did not run. + expect(map.has("blocked-soon")).toBe(true); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 3cfcc63954..7673688460 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "@effect/rpc": "^0.71.0", "@effect/sql-mysql2": "^0.47.0", "@effect/workflow": "^0.11.3", + "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 68dbb7a278..effaf40fc0 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -237,10 +237,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( @@ -264,7 +268,13 @@ impl EditorInstance { let layers_rx = editor::start_renderer_layers_creation(&render_constants, &project); - 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::finish_renderer_layers_creation(layers_rx).await; let renderer = Arc::new(editor::Renderer::spawn( @@ -618,6 +628,35 @@ 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 = { + 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 { + 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) +} + #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct SegmentAudioTimingRepair { pub mic_offset_secs: f32, diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 3724a303cf..23da4ceb4f 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -10,7 +10,9 @@ pub use editor::{ EditorFrameOutput, Renderer, RendererHandle, finish_renderer_layers_creation, start_renderer_layers_creation, }; -pub use editor_instance::{EditorInstance, EditorState, SegmentMedia, create_segments}; +pub use editor_instance::{ + EditorInstance, EditorState, SegmentMedia, create_all_segments, create_segments, +}; pub use playback::{Playback, PlaybackEvent, PlaybackHandle, PlaybackStartError}; pub use segments::get_audio_segments; pub use telemetry::{ diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index c8d53e4c92..ff50eac93a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -94,8 +94,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)?, ); // A freshly recorded .cap has no timeline — only the editor creates one. Without it the @@ -143,10 +147,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 2b6f8174df..496a35487e 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -865,7 +865,7 @@ pub struct SceneSegment { pub transition_out: f64, } -#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "camelCase")] pub struct TimelineConfiguration { pub segments: Vec, @@ -1265,6 +1265,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)] #[serde(rename_all = "camelCase", default)] pub struct ProjectConfiguration { @@ -1285,6 +1293,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 { @@ -1315,6 +1325,7 @@ impl Default for ProjectConfiguration { hidden_text_segments: Default::default(), screen_motion_blur: Self::default_screen_motion_blur(), screen_movement_spring: Default::default(), + external_recordings: Default::default(), } } } diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index e0b9985b3e..b52233e928 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,45 @@ 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 = { + 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 { + return Err(format!("external recording {i}: not a studio recording")); + }; + let primary = this.segments.first().ok_or("no primary segments")?; + let ext_recordings = Self::new(&ext_path, ext_studio_meta)?; + if let Some(ext_first) = ext_recordings.segments.first() + && (ext_first.display.width != primary.display.width + || ext_first.display.height != primary.display.height) + { + return Err(format!( + "external recording {i}: resolution {}x{} does not match primary {}x{}", + ext_first.display.width, + ext_first.display.height, + primary.display.width, + primary.display.height, + )); + } + this.segments.extend(ext_recordings.segments); + } + Ok(this) + } + pub fn duration(&self) -> f64 { self.segments.iter().map(|s| s.duration()).sum() }