From d2bc7b26b9f8bbecf038df1e7616b4e53c673232 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:20:37 +0100 Subject: [PATCH 01/10] feat: wip for split view --- .../src/routes/editor/ConfigSidebar.tsx | 156 +++++---- .../src/routes/editor/SceneSegmentConfig.tsx | 314 ++++++++++++++++++ .../src/routes/editor/Timeline/SceneTrack.tsx | 4 + apps/desktop/src/utils/tauri.ts | 6 +- crates/project/src/configuration.rs | 38 +++ crates/rendering/src/lib.rs | 222 ++++++++++++- crates/rendering/src/scene.rs | 125 ++++++- packages/ui-solid/src/auto-imports.d.ts | 14 +- 8 files changed, 804 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/src/routes/editor/SceneSegmentConfig.tsx diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 1327530aa3..29114acb81 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -45,6 +45,7 @@ import { type CameraShape, commands, type SceneSegment, + type SplitViewSettings, type StereoMode, type TimelineSegment, type ZoomSegment, @@ -53,6 +54,7 @@ import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; +import { SceneSegmentConfig } from "./SceneSegmentConfig"; import { DEFAULT_GRADIENT_FROM, DEFAULT_GRADIENT_TO, @@ -2284,12 +2286,21 @@ function ClipSegmentConfig(props: { ); } -function SceneSegmentConfig(props: { +// SceneSegmentConfig moved to separate file for better organization +// The old implementation below is kept temporarily for reference +function OldSceneSegmentConfig(props: { segmentIndex: number; segment: SceneSegment; }) { const { setProject, setEditorState, projectActions } = useEditorContext(); + // Initialize split view settings if not present + const splitViewSettings = (): SplitViewSettings => props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + }; + return ( <>
@@ -2312,8 +2323,7 @@ function SceneSegmentConfig(props: {
}> - { setProject( @@ -2321,71 +2331,97 @@ function SceneSegmentConfig(props: { "sceneSegments", props.segmentIndex, "mode", - v as "default" | "cameraOnly" | "hideCamera", + v as "default" | "cameraOnly" | "hideCamera" | "splitView", ); }} + class="flex flex-col gap-3" > - -
- +
+ + + Default - - + + + + + + Split View + + + + + Camera Only - - + + + + + Hide Camera - - -
- -
+
+
+
-
-
-
-
-
- {props.segment.mode === "cameraOnly" - ? "Shows only the camera feed" - : props.segment.mode === "hideCamera" - ? "Shows only the screen recording" - : "Shows both screen and camera"} -
-
+
+
+ {props.segment.mode === "cameraOnly" + ? "Shows only the camera feed" + : props.segment.mode === "hideCamera" + ? "Shows only the screen recording" + : props.segment.mode === "splitView" + ? `Shows screen on ${splitViewSettings().cameraSide === "left" ? "right" : "left"}, camera on ${splitViewSettings().cameraSide}` + : "Shows both screen and camera"}
- - +
+ + + + }> +
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraSide: value as "left" | "right" }, + ); + }} + class="flex flex-row gap-2" + > + + + + Left + + + + + + Right + + + + + + + {/* Position controls are now handled in SceneSegmentConfig */} +
Please use the new scene segment controls
+
+ + + {/* Position controls are now handled in SceneSegmentConfig */} +
Please use the new scene segment controls
+
+
+
+
); } @@ -2455,3 +2491,5 @@ function hexToRgb(hex: string): [number, number, number] | null { if (!match) return null; return match.slice(1).map((c) => Number.parseInt(c, 16)) as any; } + +// Position control moved to SceneSegmentConfig.tsx for better organization diff --git a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx new file mode 100644 index 0000000000..03dd5a199e --- /dev/null +++ b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx @@ -0,0 +1,314 @@ +import { + RadioGroup as KRadioGroup, +} from "@kobalte/core/radio-group"; +import { createSignal, For, Show } from "solid-js"; +import { type SceneSegment, type SplitViewSettings } from "~/utils/tauri"; +import { EditorButton, Slider } from "./ui"; +import { useEditorContext } from "./context"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideMonitor from "~icons/lucide/monitor"; +import IconLucideLayout from "~icons/lucide/layout"; +import IconLucideVideo from "~icons/lucide/video"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideAlignLeft from "~icons/lucide/align-left"; +import IconLucideAlignRight from "~icons/lucide/align-right"; +import IconCapTrash from "~icons/iconoir/trash"; +import IconLucideSettings from "~icons/lucide/settings"; + +function SimplePositionControl(props: { + position: { x: number; y: number }; + onChange: (position: { x: number; y: number }) => void; + label: string; +}) { + const [isDragging, setIsDragging] = createSignal(false); + + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); + + const updatePosition = (clientX: number, clientY: number) => { + const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + props.onChange({ x, y }); + }; + + updatePosition(e.clientX, e.clientY); + + const handleMouseMove = (moveEvent: MouseEvent) => { + updatePosition(moveEvent.clientX, moveEvent.clientY); + }; + + const handleMouseUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }} + > + {/* Grid lines for reference */} +
+
+
+
+ + {/* Position indicator */} +
+ + {/* Label */} +
+ {props.label} +
+ + {/* Coordinates */} +
+ {(props.position.x * 100).toFixed(0)}%, {(props.position.y * 100).toFixed(0)}% +
+
+ ); +} + +export function SceneSegmentConfig(props: { + segmentIndex: number; + segment: SceneSegment; +}) { + const { setProject, setEditorState, projectActions } = useEditorContext(); + + // Initialize split view settings if not present + const splitViewSettings = (): SplitViewSettings => props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + cameraZoom: 1.0, + screenZoom: 1.0, + }; + + const layoutOptions = [ + { + value: "default", + label: "Default", + icon: , + description: "Screen with camera overlay" + }, + { + value: "splitView", + label: "Split View", + icon: , + description: "Side-by-side layout" + }, + { + value: "cameraOnly", + label: "Camera Only", + icon: , + description: "Full screen camera" + }, + { + value: "hideCamera", + label: "Hide Camera", + icon: , + description: "Screen recording only" + }, + ]; + + return ( +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + + { + projectActions.deleteSceneSegment(props.segmentIndex); + }} + leftIcon={} + > + Delete + +
+ +
+
+ + Camera Layout +
+ + { + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "mode", + v as "default" | "cameraOnly" | "hideCamera" | "splitView", + ); + }} + class="grid grid-cols-2 gap-2" + > + + {(option) => ( + + + +
+ {option.icon} + {option.label} +
+ {option.description} +
+
+ )} +
+
+
+ + +
+
+ + Split View Settings +
+ +
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraSide: value as "left" | "right" }, + ); + }} + class="grid grid-cols-2 gap-2" + > + + + + + Left + + + + + + + Right + + + +
+ +
+
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraPosition: pos }, + ); + }} + label="Camera" + /> +
+
+
+ + {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)}% +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraZoom: value }, + ); + }} + /> +
+
+ +
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenPosition: pos }, + ); + }} + label="Screen" + /> +
+
+
+ + {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)}% +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenZoom: value }, + ); + }} + /> +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx index 4c12bc27f2..2981e4aab6 100644 --- a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx @@ -59,6 +59,8 @@ export function SceneTrack(props: { return ; case "hideCamera": return ; + case "splitView": + return ; default: return ; } @@ -70,6 +72,8 @@ export function SceneTrack(props: { return "Camera Only"; case "hideCamera": return "Hide Camera"; + case "splitView": + return "Split View"; default: return "Default"; } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index c02d36db54..6066727c24 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -425,8 +425,8 @@ export type RequestNewScreenshot = null export type RequestOpenSettings = { page: string } export type RequestStartRecording = null export type S3UploadMeta = { id: string } -export type SceneMode = "default" | "cameraOnly" | "hideCamera" -export type SceneSegment = { start: number; end: number; mode?: SceneMode } +export type SceneMode = "default" | "cameraOnly" | "hideCamera" | "splitView" +export type SceneSegment = { start: number; end: number; mode?: SceneMode; splitViewSettings?: SplitViewSettings | null } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } @@ -434,6 +434,8 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | "Main" | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } +export type SplitViewSettings = { cameraPosition: XY; screenPosition: XY; cameraSide: SplitViewSide; cameraZoom?: number; screenZoom?: number } +export type SplitViewSide = "left" | "right" export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 432be71bf5..9f273800f4 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -464,6 +464,42 @@ pub enum SceneMode { Default, CameraOnly, HideCamera, + SplitView, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SplitViewSettings { + pub camera_position: XY, + pub screen_position: XY, + pub camera_side: SplitViewSide, + #[serde(default = "default_zoom")] + pub camera_zoom: f64, + #[serde(default = "default_zoom")] + pub screen_zoom: f64, +} + +fn default_zoom() -> f64 { + 1.0 +} + +impl Default for SplitViewSettings { + fn default() -> Self { + Self { + camera_position: XY { x: 0.5, y: 0.5 }, + screen_position: XY { x: 0.5, y: 0.5 }, + camera_side: SplitViewSide::Right, + camera_zoom: 1.0, + screen_zoom: 1.0, + } + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub enum SplitViewSide { + Left, + Right, } #[derive(Type, Serialize, Deserialize, Clone, Debug)] @@ -473,6 +509,8 @@ pub struct SceneSegment { pub end: f64, #[serde(default)] pub mode: SceneMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub split_view_settings: Option, } #[derive(Type, Serialize, Deserialize, Clone, Debug)] diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 52ac314afd..298c937c8f 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::Result; use cap_project::{ AspectRatio, CameraShape, CameraXPosition, CameraYPosition, Crop, CursorEvents, - ProjectConfiguration, RecordingMeta, StudioRecordingMeta, XY, + ProjectConfiguration, RecordingMeta, SplitViewSettings, SplitViewSide, StudioRecordingMeta, XY, }; use composite_frame::CompositeVideoFrameUniforms; use core::f64; @@ -342,6 +342,8 @@ pub struct ProjectUniforms { display: CompositeVideoFrameUniforms, camera: Option, camera_only: Option, + split_view_camera: Option, + split_view_display: Option, interpolated_cursor: Option, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, @@ -635,7 +637,7 @@ impl ProjectUniforms { .advanced_shadow .as_ref() .map_or(50.0, |s| s.blur), - opacity: scene.screen_opacity as f32, + opacity: scene.regular_screen_transition_opacity() as f32, _padding: [0.0; 3], } }; @@ -812,6 +814,188 @@ impl ProjectUniforms { } }); + // Calculate split view uniforms + let (split_view_camera, split_view_display) = if scene.is_split_view() || scene.is_transitioning_split_view() { + // Get split view settings from current or upcoming scene segment + // During transitions, we need to look for the segment we're transitioning into + let split_settings = project.timeline + .as_ref() + .and_then(|t| { + // First try to find the current segment + let current = t.scene_segments.iter() + .find(|s| frame_time as f64 >= s.start && (frame_time as f64) < s.end) + .and_then(|s| s.split_view_settings.as_ref()); + + // If not in a segment, look for the next segment we're transitioning into + // This handles the transition period before the segment starts + if current.is_none() && scene.is_transitioning_split_view() { + t.scene_segments.iter() + .find(|s| { + // Check if this is a split view segment we're transitioning into + matches!(s.mode, cap_project::SceneMode::SplitView) && + s.start > frame_time as f64 && + (s.start - frame_time as f64) < scene::SCENE_TRANSITION_DURATION + }) + .and_then(|s| s.split_view_settings.as_ref()) + } else { + current + } + }) + .cloned() + .unwrap_or_else(SplitViewSettings::default); + + let output_size_f32 = [output_size.0 as f32, output_size.1 as f32]; + let half_width = output_size_f32[0] / 2.0; + + // Determine which side camera and screen are on + let (camera_x_offset, screen_x_offset) = match split_settings.camera_side { + SplitViewSide::Left => (0.0, half_width), + SplitViewSide::Right => (half_width, 0.0), + }; + + // Display uniforms for screen + let split_display = { + let screen_size = options.screen_size; + let crop = Self::get_crop(&options, project); + let frame_size = [screen_size.x as f32, screen_size.y as f32]; + + // Calculate crop bounds to fill the half while maintaining aspect ratio + let crop_size = [crop.size.x as f32, crop.size.y as f32]; + let source_aspect = crop_size[0] / crop_size[1]; + let target_aspect = half_width / output_size_f32[1]; + + // Apply position adjustment (0.0 = full crop from one side, 1.0 = full crop from other side) + let position_factor = split_settings.screen_position.x as f32; + let zoom = split_settings.screen_zoom as f32; + + // Apply zoom by adjusting the visible area + let zoomed_crop_size = [crop_size[0] / zoom, crop_size[1] / zoom]; + + // We want to fill the entire half, so we crop the source + let (crop_x, crop_y, crop_width, crop_height) = if source_aspect > target_aspect { + // Source is wider - crop left and right + let visible_width = zoomed_crop_size[1] * target_aspect; + let max_crop_offset = crop_size[0] - visible_width; + let crop_offset = max_crop_offset * position_factor; + + // Center the zoomed area + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) * split_settings.screen_position.y as f32; + + ( + crop.position.x as f32 + crop_offset + zoom_offset_x, + crop.position.y as f32 + zoom_offset_y, + visible_width, + zoomed_crop_size[1] + ) + } else { + // Source is taller - crop top and bottom + let visible_height = zoomed_crop_size[0] / target_aspect; + let max_crop_offset = crop_size[1] - visible_height; + let crop_offset = max_crop_offset * (split_settings.screen_position.y as f32); + + // Center the zoomed area + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) * split_settings.screen_position.y as f32; + + ( + crop.position.x as f32 + zoom_offset_x, + crop.position.y as f32 + crop_offset + zoom_offset_y, + zoomed_crop_size[0], + visible_height + ) + }; + + Some(CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size, + crop_bounds: [crop_x, crop_y, crop_x + crop_width, crop_y + crop_height], + target_bounds: [screen_x_offset, 0.0, screen_x_offset + half_width, output_size_f32[1]], + target_size: [half_width, output_size_f32[1]], + rounding_px: 0.0, + mirror_x: 0.0, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: 0.0, + shadow_size: 0.0, + shadow_opacity: 0.0, + shadow_blur: 0.0, + opacity: (scene.split_view_transition_opacity() * scene.screen_opacity) as f32, + _padding: [0.0; 3], + }) + }; + + // Camera uniforms + let split_camera = options.camera_size.map(|camera_size| { + let frame_size = [camera_size.x as f32, camera_size.y as f32]; + let source_aspect = frame_size[0] / frame_size[1]; + let target_aspect = half_width / output_size_f32[1]; + + // Apply position adjustment for camera + let cam_pos_x = split_settings.camera_position.x as f32; + let cam_pos_y = split_settings.camera_position.y as f32; + let cam_zoom = split_settings.camera_zoom as f32; + + // Apply zoom by adjusting the visible area + let zoomed_frame_size = [frame_size[0] / cam_zoom, frame_size[1] / cam_zoom]; + + // Calculate crop bounds to fill the half while maintaining aspect ratio + let crop_bounds = if source_aspect > target_aspect { + // Camera is wider - crop left and right to fit + let visible_width = zoomed_frame_size[1] * target_aspect; + let max_crop_offset = frame_size[0] - visible_width; + let crop_x = max_crop_offset * cam_pos_x; + + // Center the zoomed area + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; + let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; + + [crop_x + zoom_offset_x, + zoom_offset_y, + crop_x + zoom_offset_x + visible_width, + zoom_offset_y + zoomed_frame_size[1]] + } else { + // Camera is taller - crop top and bottom to fit + let visible_height = zoomed_frame_size[0] / target_aspect; + let max_crop_offset = frame_size[1] - visible_height; + let crop_y = max_crop_offset * cam_pos_y; + + // Center the zoomed area + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; + let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; + + [zoom_offset_x, + crop_y + zoom_offset_y, + zoom_offset_x + zoomed_frame_size[0], + crop_y + zoom_offset_y + visible_height] + }; + + CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size, + crop_bounds, + target_bounds: [camera_x_offset, 0.0, camera_x_offset + half_width, output_size_f32[1]], + target_size: [half_width, output_size_f32[1]], + rounding_px: 0.0, + mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: 0.0, + shadow_size: 0.0, + shadow_opacity: 0.0, + shadow_blur: 0.0, + opacity: (scene.split_view_transition_opacity() * scene.camera_opacity) as f32, + _padding: [0.0; 3], + } + }); + + (split_camera, split_display) + } else { + (None, None) + }; + Self { output_size, cursor_size: project.cursor.size as f32, @@ -819,6 +1003,8 @@ impl ProjectUniforms { display, camera, camera_only, + split_view_camera, + split_view_display, project: project.clone(), zoom, scene, @@ -887,6 +1073,8 @@ pub struct RendererLayers { cursor: CursorLayer, camera: CameraLayer, camera_only: CameraLayer, + split_view_camera: CameraLayer, + split_view_display: DisplayLayer, #[allow(unused)] captions: CaptionsLayer, } @@ -900,6 +1088,8 @@ impl RendererLayers { cursor: CursorLayer::new(device), camera: CameraLayer::new(device), camera_only: CameraLayer::new(device), + split_view_camera: CameraLayer::new(device), + split_view_display: DisplayLayer::new(device), captions: CaptionsLayer::new(device, queue), } } @@ -964,6 +1154,27 @@ impl RendererLayers { })(), ); + // Prepare split view layers + self.split_view_display.prepare( + &constants.device, + &constants.queue, + segment_frames, + constants.options.screen_size, + uniforms.split_view_display.unwrap_or_default(), + ); + + self.split_view_camera.prepare( + &constants.device, + &constants.queue, + (|| { + Some(( + uniforms.split_view_camera?, + constants.options.camera_size?, + segment_frames.camera_frame.as_ref()?, + )) + })(), + ); + Ok(()) } @@ -1032,6 +1243,13 @@ impl RendererLayers { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.camera.render(&mut pass); } + + // Render split view when active or transitioning + if uniforms.scene.is_split_view() || uniforms.scene.is_transitioning_split_view() { + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); + self.split_view_display.render(&mut pass); + self.split_view_camera.render(&mut pass); + } } } diff --git a/crates/rendering/src/scene.rs b/crates/rendering/src/scene.rs index 94500b07d9..2667a75ff2 100644 --- a/crates/rendering/src/scene.rs +++ b/crates/rendering/src/scene.rs @@ -97,6 +97,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { // Small gap between same modes, no transition needed @@ -292,6 +293,7 @@ impl InterpolatedScene { SceneMode::Default => (1.0, 1.0, 1.0), SceneMode::CameraOnly => (1.0, 1.0, 1.0), SceneMode::HideCamera => (0.0, 1.0, 1.0), + SceneMode::SplitView => (1.0, 1.0, 1.0), } } @@ -304,23 +306,107 @@ impl InterpolatedScene { } pub fn should_render_screen(&self) -> bool { + // Don't render regular screen during split view (except during transitions) + if matches!(self.scene_mode, SceneMode::SplitView) && !self.is_transitioning_split_view() { + return false; + } + // Always render screen during split view transitions for cross-fade effect + if self.is_transitioning_split_view() { + return true; + } self.screen_opacity > 0.01 || self.screen_blur > 0.01 } + pub fn regular_screen_transition_opacity(&self) -> f64 { + // Handle transitions to/from CameraOnly + if matches!(self.to_mode, SceneMode::CameraOnly) + && !matches!(self.from_mode, SceneMode::CameraOnly) + { + // Keep screen visible underneath camera-only transition + // but fade it slightly to create the overlay effect + let fade = (1.0 - self.transition_progress * 0.3).max(0.7); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::CameraOnly) + && !matches!(self.to_mode, SceneMode::CameraOnly) + { + // Restore screen opacity as camera-only fades out + let fade = (0.7 + self.transition_progress * 0.3).min(1.0); + fade * self.screen_opacity + } + // Handle transitions to/from SplitView + else if matches!(self.to_mode, SceneMode::SplitView) + && !matches!(self.from_mode, SceneMode::SplitView) + { + // Keep screen visible underneath split view transition for cross-fade effect + // Similar to camera-only, fade slightly but keep visible + let fade = (1.0 - self.transition_progress * 0.3).max(0.7); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + // Restore screen opacity as split view fades out + let fade = (0.7 + self.transition_progress * 0.3).min(1.0); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 0.0 + } else { + self.screen_opacity + } + } + pub fn is_transitioning_camera_only(&self) -> bool { matches!(self.from_mode, SceneMode::CameraOnly) || matches!(self.to_mode, SceneMode::CameraOnly) } + pub fn is_split_view(&self) -> bool { + matches!(self.scene_mode, SceneMode::SplitView) + } + + pub fn is_transitioning_split_view(&self) -> bool { + matches!(self.from_mode, SceneMode::SplitView) + || matches!(self.to_mode, SceneMode::SplitView) + } + + pub fn split_view_transition_opacity(&self) -> f64 { + if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + 1.0 - self.transition_progress + } else if !matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + self.transition_progress + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 1.0 + } else { + 0.0 + } + } + pub fn camera_only_transition_opacity(&self) -> f64 { if matches!(self.from_mode, SceneMode::CameraOnly) && !matches!(self.to_mode, SceneMode::CameraOnly) { - 1.0 - self.transition_progress + // Maintain full opacity until late in transition to ensure coverage + if self.transition_progress < 0.7 { + 1.0 + } else { + ((1.0 - self.transition_progress) / 0.3).max(0.0) + } } else if !matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { - self.transition_progress + // Start fading in early to ensure no gaps + if self.transition_progress > 0.3 { + 1.0 + } else { + (self.transition_progress / 0.3).min(1.0) + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { @@ -331,20 +417,47 @@ impl InterpolatedScene { } pub fn regular_camera_transition_opacity(&self) -> f64 { + // Handle transitions to/from CameraOnly if matches!(self.to_mode, SceneMode::CameraOnly) && !matches!(self.from_mode, SceneMode::CameraOnly) { - let fast_fade = (1.0 - self.transition_progress * 1.5).max(0.0); - fast_fade * self.camera_opacity + // Fade out quickly but maintain full opacity initially to prevent background showing + if self.transition_progress < 0.3 { + self.camera_opacity + } else { + let fast_fade = ((1.0 - self.transition_progress) / 0.7).max(0.0); + fast_fade * self.camera_opacity + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && !matches!(self.to_mode, SceneMode::CameraOnly) { - let fast_fade = (self.transition_progress * 1.5).min(1.0); - fast_fade * self.camera_opacity + // Fade in quickly but ensure no gap + if self.transition_progress > 0.7 { + self.camera_opacity + } else { + let fast_fade = (self.transition_progress / 0.7).min(1.0); + fast_fade * self.camera_opacity + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { 0.0 + } + // Handle transitions to/from SplitView + else if matches!(self.to_mode, SceneMode::SplitView) + && !matches!(self.from_mode, SceneMode::SplitView) + { + // Fade out regular camera when transitioning to split view + (1.0 - self.transition_progress) * self.camera_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + // Fade in regular camera when transitioning from split view + self.transition_progress * self.camera_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 0.0 } else { self.camera_opacity } diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 424407d67e..ec5d224e78 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -21,7 +21,7 @@ declare global { const IconCapCorners: typeof import('~icons/cap/corners.jsx')['default'] const IconCapCrop: typeof import('~icons/cap/crop.jsx')['default'] const IconCapCursor: typeof import('~icons/cap/cursor.jsx')['default'] - const IconCapEditor: typeof import("~icons/cap/editor.jsx")["default"] + const IconCapEditor: typeof import('~icons/cap/editor.jsx')['default'] const IconCapEnlarge: typeof import('~icons/cap/enlarge.jsx')['default'] const IconCapFile: typeof import('~icons/cap/file.jsx')['default'] const IconCapFilmCut: typeof import('~icons/cap/film-cut.jsx')['default'] @@ -53,22 +53,24 @@ declare global { const IconCapSettings: typeof import('~icons/cap/settings.jsx')['default'] const IconCapShadow: typeof import('~icons/cap/shadow.jsx')['default'] const IconCapSquare: typeof import('~icons/cap/square.jsx')['default'] - const IconCapStopCircle: typeof import("~icons/cap/stop-circle.jsx")["default"] + const IconCapStopCircle: typeof import('~icons/cap/stop-circle.jsx')['default'] const IconCapTrash: typeof import('~icons/cap/trash.jsx')['default'] const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default'] - const IconCapUpload: typeof import("~icons/cap/upload.jsx")["default"] + const IconCapUpload: typeof import('~icons/cap/upload.jsx')['default'] const IconCapX: typeof import('~icons/cap/x.jsx')['default'] const IconCapZoomIn: typeof import('~icons/cap/zoom-in.jsx')['default'] const IconCapZoomOut: typeof import('~icons/cap/zoom-out.jsx')['default'] const IconHugeiconsEaseCurveControlPoints: typeof import('~icons/hugeicons/ease-curve-control-points.jsx')['default'] + const IconLucideAlignLeft: typeof import('~icons/lucide/align-left.jsx')['default'] + const IconLucideAlignRight: typeof import('~icons/lucide/align-right.jsx')['default'] const IconLucideAppWindowMac: typeof import('~icons/lucide/app-window-mac.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] - const IconLucideDatabase: typeof import("~icons/lucide/database.jsx")["default"] + const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] - const IconLucideEye: typeof import("~icons/lucide/eye.jsx")["default"] + const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] const IconLucideGift: typeof import('~icons/lucide/gift.jsx')['default'] @@ -85,7 +87,7 @@ declare global { const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] const IconLucideVideo: typeof import('~icons/lucide/video.jsx')['default'] const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default'] - const IconLucideVolumeX: typeof import("~icons/lucide/volume-x.jsx")["default"] + const IconLucideVolumeX: typeof import('~icons/lucide/volume-x.jsx')['default'] const IconMaterialSymbolsScreenshotFrame2Rounded: typeof import('~icons/material-symbols/screenshot-frame2-rounded.jsx')['default'] const IconMdiLoading: typeof import("~icons/mdi/loading.jsx")["default"] const IconMdiMonitor: typeof import('~icons/mdi/monitor.jsx')['default'] From d350f47165cd6ccf55bae0cf77ce4b780b85192d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:24:48 +0100 Subject: [PATCH 02/10] feat: Improved ux/ui in editor for scenes --- .../src/routes/editor/SceneSegmentConfig.tsx | 682 ++++++++++-------- apps/desktop/src/routes/editor/context.ts | 81 +++ apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/configuration.rs | 3 + crates/rendering/src/composite_frame.rs | 4 +- crates/rendering/src/lib.rs | 237 +++--- crates/rendering/src/scene.rs | 2 + .../src/shaders/composite-video-frame.wgsl | 41 +- 8 files changed, 657 insertions(+), 395 deletions(-) diff --git a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx index 03dd5a199e..af1522a8e8 100644 --- a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx +++ b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx @@ -1,10 +1,9 @@ -import { - RadioGroup as KRadioGroup, -} from "@kobalte/core/radio-group"; +import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; import { createSignal, For, Show } from "solid-js"; import { type SceneSegment, type SplitViewSettings } from "~/utils/tauri"; import { EditorButton, Slider } from "./ui"; import { useEditorContext } from "./context"; +import { Toggle } from "~/components/Toggle"; import IconLucideCheck from "~icons/lucide/check"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideLayout from "~icons/lucide/layout"; @@ -14,301 +13,402 @@ import IconLucideAlignLeft from "~icons/lucide/align-left"; import IconLucideAlignRight from "~icons/lucide/align-right"; import IconCapTrash from "~icons/iconoir/trash"; import IconLucideSettings from "~icons/lucide/settings"; +import IconLucideMaximize from "~icons/lucide/maximize"; +import IconLucideMinimize from "~icons/lucide/minimize"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideClipboardCopy from "~icons/lucide/clipboard-copy"; function SimplePositionControl(props: { - position: { x: number; y: number }; - onChange: (position: { x: number; y: number }) => void; - label: string; + position: { x: number; y: number }; + onChange: (position: { x: number; y: number }) => void; + label: string; }) { - const [isDragging, setIsDragging] = createSignal(false); - - return ( -
{ - const rect = e.currentTarget.getBoundingClientRect(); - setIsDragging(true); - - const updatePosition = (clientX: number, clientY: number) => { - const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); - props.onChange({ x, y }); - }; - - updatePosition(e.clientX, e.clientY); - - const handleMouseMove = (moveEvent: MouseEvent) => { - updatePosition(moveEvent.clientX, moveEvent.clientY); - }; - - const handleMouseUp = () => { - setIsDragging(false); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }} - > - {/* Grid lines for reference */} -
-
-
-
- - {/* Position indicator */} -
- - {/* Label */} -
- {props.label} -
- - {/* Coordinates */} -
- {(props.position.x * 100).toFixed(0)}%, {(props.position.y * 100).toFixed(0)}% -
-
- ); + const [isDragging, setIsDragging] = createSignal(false); + + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); + + const updatePosition = (clientX: number, clientY: number) => { + const x = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width) + ); + const y = Math.max( + 0, + Math.min(1, (clientY - rect.top) / rect.height) + ); + props.onChange({ x, y }); + }; + + updatePosition(e.clientX, e.clientY); + + const handleMouseMove = (moveEvent: MouseEvent) => { + updatePosition(moveEvent.clientX, moveEvent.clientY); + }; + + const handleMouseUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }} + > + {/* Grid lines for reference */} +
+
+
+
+ + {/* Position indicator */} +
+
+ ); } export function SceneSegmentConfig(props: { - segmentIndex: number; - segment: SceneSegment; + segmentIndex: number; + segment: SceneSegment; }) { - const { setProject, setEditorState, projectActions } = useEditorContext(); + const { setProject, setEditorState, projectActions, project, totalDuration } = useEditorContext(); + + // Initialize split view settings if not present + const splitViewSettings = (): SplitViewSettings => + props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + cameraZoom: 1.0, + screenZoom: 1.0, + fullscreen: false, + }; + + const layoutOptions = [ + { + value: "default", + label: "Default", + icon: , + description: "Screen with camera overlay", + }, + { + value: "splitView", + label: "Split View", + icon: , + description: "Side-by-side layout", + }, + { + value: "cameraOnly", + label: "Camera Only", + icon: , + description: "Full screen camera", + }, + { + value: "hideCamera", + label: "Hide Camera", + icon: , + description: "Screen recording only", + }, + ]; - // Initialize split view settings if not present - const splitViewSettings = (): SplitViewSettings => props.segment.splitViewSettings || { - cameraPosition: { x: 0.5, y: 0.5 }, - screenPosition: { x: 0.5, y: 0.5 }, - cameraSide: "right", - cameraZoom: 1.0, - screenZoom: 1.0, - }; + // Check if duplication is possible + const canDuplicate = () => { + const segmentDuration = props.segment.end - props.segment.start; + const newSegmentEnd = props.segment.end + segmentDuration; + + // Check if it would exceed timeline duration + if (newSegmentEnd > totalDuration()) { + return false; + } + + // Check for overlaps with other scene segments + const wouldOverlap = project.timeline?.sceneSegments?.some((s, i) => { + if (i === props.segmentIndex) return false; + return (props.segment.end < s.end && newSegmentEnd > s.start); + }); + + return !wouldOverlap; + }; - const layoutOptions = [ - { - value: "default", - label: "Default", - icon: , - description: "Screen with camera overlay" - }, - { - value: "splitView", - label: "Split View", - icon: , - description: "Side-by-side layout" - }, - { - value: "cameraOnly", - label: "Camera Only", - icon: , - description: "Full screen camera" - }, - { - value: "hideCamera", - label: "Hide Camera", - icon: , - description: "Screen recording only" - }, - ]; + return ( +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.duplicateSceneSegment(props.segmentIndex); + }} + leftIcon={} + disabled={!canDuplicate()} + title={!canDuplicate() ? "Not enough space in timeline" : undefined} + > + Duplicate + + { + projectActions.deleteSceneSegment(props.segmentIndex); + }} + leftIcon={} + class="text-red-11" + > + Delete + +
+
- return ( -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - - { - projectActions.deleteSceneSegment(props.segmentIndex); - }} - leftIcon={} - > - Delete - -
- -
-
- - Camera Layout -
- - { - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "mode", - v as "default" | "cameraOnly" | "hideCamera" | "splitView", - ); - }} - class="grid grid-cols-2 gap-2" - > - - {(option) => ( - - - -
- {option.icon} - {option.label} -
- {option.description} -
-
- )} -
-
-
- - -
-
- - Split View Settings -
- -
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, cameraSide: value as "left" | "right" }, - ); - }} - class="grid grid-cols-2 gap-2" - > - - - - - Left - - - - - - - Right - - - -
- -
-
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, cameraPosition: pos }, - ); - }} - label="Camera" - /> -
-
-
- - {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)}% -
- { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, cameraZoom: value }, - ); - }} - /> -
-
- -
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, screenPosition: pos }, - ); - }} - label="Screen" - /> -
-
-
- - {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)}% -
- { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, screenZoom: value }, - ); - }} - /> -
-
-
-
-
-
-
- ); -} \ No newline at end of file +
+
+ + Camera Layout +
+ + { + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "mode", + v as "default" | "cameraOnly" | "hideCamera" | "splitView" + ); + }} + class="grid grid-cols-2 gap-2" + > + + {(option) => ( + + + +
+ {option.icon} + + {option.label} + +
+ {option.description} +
+
+ )} +
+
+
+ + +
+
+ + + Split View Settings + +
+ +
+
+
+
+ + + Fill entire frame without padding + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, fullscreen: checked } + ); + }} + /> +
+
+ +
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { + ...currentSettings, + cameraSide: value as "left" | "right", + } + ); + }} + class="grid grid-cols-2 gap-2" + > + + + + + Left + + + + + + + Right + + + +
+ +
+
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraPosition: pos } + ); + }} + label="Camera" + /> +
+
+
+ + + {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraZoom: value } + ); + }} + /> +
+
+ +
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenPosition: pos } + ); + }} + label="Screen" + /> +
+
+
+ + + {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenZoom: value } + ); + }} + /> +
+
+
+
+
+
+ + i !== props.segmentIndex && s.mode === props.segment.mode + ) + }> +
+ { + projectActions.copySceneSettingsFromOriginal(props.segmentIndex); + }} + leftIcon={} + class="w-full" + > + Copy Settings from Original + +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index a504fc7a67..a54b75a954 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -159,6 +159,87 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( setEditorState("timeline", "selection", null); }); }, + duplicateSceneSegment: (segmentIndex: number) => { + if (!project.timeline?.sceneSegments?.[segmentIndex]) return; + const segment = project.timeline.sceneSegments[segmentIndex]; + const segmentDuration = segment.end - segment.start; + const newSegmentStart = segment.end; + const newSegmentEnd = newSegmentStart + segmentDuration; + + // Check if there's enough space in the timeline + const timelineDuration = totalDuration(); + if (newSegmentEnd > timelineDuration) { + // Not enough space for the duplicate + return; + } + + // Check if the new segment would overlap with any existing scene segments + const wouldOverlap = project.timeline.sceneSegments.some((s, i) => { + if (i === segmentIndex) return false; // Skip the original segment + return (newSegmentStart < s.end && newSegmentEnd > s.start); + }); + + if (wouldOverlap) { + // Would overlap with another segment + return; + } + + batch(() => { + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (!s) return; + // Insert duplicate right after the original + s.splice(segmentIndex + 1, 0, { + ...segment, + start: newSegmentStart, + end: newSegmentEnd, + // Deep clone split view settings if present + splitViewSettings: segment.splitViewSettings + ? { ...segment.splitViewSettings } + : undefined, + }); + }), + ); + // Select and click on the newly duplicated segment + setEditorState("timeline", "selection", { + type: "scene", + index: segmentIndex + 1, + }); + // Move playhead to the start of the new segment + setEditorState("playbackTime", newSegmentStart); + // Center the timeline view on the new segment + const currentZoom = editorState.timeline.transform.zoom; + const targetPosition = Math.max(0, newSegmentStart - currentZoom / 2); + editorState.timeline.transform.setPosition(targetPosition); + }); + }, + copySceneSettingsFromOriginal: (segmentIndex: number) => { + if (!project.timeline?.sceneSegments?.[segmentIndex]) return; + + // Find the first segment with the same mode + const currentSegment = project.timeline.sceneSegments[segmentIndex]; + const originalSegment = project.timeline.sceneSegments.find( + (s, i) => i !== segmentIndex && s.mode === currentSegment.mode + ); + + if (!originalSegment) return; + + setProject( + "timeline", + "sceneSegments", + segmentIndex, + produce((s) => { + if (!s) return; + // Copy settings based on mode + if (s.mode === "splitView" && originalSegment.splitViewSettings) { + s.splitViewSettings = { ...originalSegment.splitViewSettings }; + } + // Can add more mode-specific settings copying here in the future + }), + ); + }, }; createEffect( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 6066727c24..fda9684f26 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -434,7 +434,7 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | "Main" | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } -export type SplitViewSettings = { cameraPosition: XY; screenPosition: XY; cameraSide: SplitViewSide; cameraZoom?: number; screenZoom?: number } +export type SplitViewSettings = { cameraPosition: XY; screenPosition: XY; cameraSide: SplitViewSide; cameraZoom?: number; screenZoom?: number; fullscreen?: boolean } export type SplitViewSide = "left" | "right" export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } export type StereoMode = "stereo" | "monoL" | "monoR" diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 9f273800f4..71c8f8439c 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -477,6 +477,8 @@ pub struct SplitViewSettings { pub camera_zoom: f64, #[serde(default = "default_zoom")] pub screen_zoom: f64, + #[serde(default)] + pub fullscreen: bool, } fn default_zoom() -> f64 { @@ -491,6 +493,7 @@ impl Default for SplitViewSettings { camera_side: SplitViewSide::Right, camera_zoom: 1.0, screen_zoom: 1.0, + fullscreen: false, } } } diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index d57c44b954..2ca8d72a5e 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -26,7 +26,8 @@ pub struct CompositeVideoFrameUniforms { pub shadow_opacity: f32, pub shadow_blur: f32, pub opacity: f32, - pub _padding: [f32; 3], + pub rounding_mask: f32, // Bitmask for which corners to round: 0x1=TL, 0x2=TR, 0x4=BL, 0x8=BR + pub _padding: [f32; 2], } impl Default for CompositeVideoFrameUniforms { @@ -47,6 +48,7 @@ impl Default for CompositeVideoFrameUniforms { shadow_opacity: Default::default(), shadow_blur: Default::default(), opacity: 1.0, + rounding_mask: 15.0, // Default: all corners rounded (0xF = 0b1111) _padding: Default::default(), } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 298c937c8f..fcd839d2fe 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -638,7 +638,8 @@ impl ProjectUniforms { .as_ref() .map_or(50.0, |s| s.blur), opacity: scene.regular_screen_transition_opacity() as f32, - _padding: [0.0; 3], + rounding_mask: 15.0, + _padding: [0.0; 2], } }; @@ -747,7 +748,8 @@ impl ProjectUniforms { .as_ref() .map_or(50.0, |s| s.blur), opacity: scene.regular_camera_transition_opacity() as f32, - _padding: [0.0; 3], + rounding_mask: 15.0, + _padding: [0.0; 2], } }); @@ -776,16 +778,11 @@ impl ProjectUniforms { position[1] + size[1], ]; - // In camera-only mode, we ignore the camera shape setting (Square/Source) - // and just apply the minimum crop needed to fill the output aspect ratio. - // This prevents excessive zooming when shape is set to Square. let crop_bounds = if aspect > output_aspect { - // Camera is wider than output - crop left and right let visible_width = frame_size[1] * output_aspect; let crop_x = (frame_size[0] - visible_width) / 2.0; [crop_x, 0.0, frame_size[0] - crop_x, frame_size[1]] } else { - // Camera is taller than output - crop top and bottom let visible_height = frame_size[0] / output_aspect; let crop_y = (frame_size[1] - visible_height) / 2.0; [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] @@ -810,31 +807,32 @@ impl ProjectUniforms { shadow_opacity: 0.0, shadow_blur: 0.0, opacity: scene.camera_only_transition_opacity() as f32, - _padding: [0.0; 3], + rounding_mask: 15.0, + _padding: [0.0; 2], } }); - // Calculate split view uniforms - let (split_view_camera, split_view_display) = if scene.is_split_view() || scene.is_transitioning_split_view() { - // Get split view settings from current or upcoming scene segment - // During transitions, we need to look for the segment we're transitioning into - let split_settings = project.timeline + let (split_view_camera, split_view_display) = if scene.is_split_view() + || scene.is_transitioning_split_view() + { + let split_settings = project + .timeline .as_ref() .and_then(|t| { - // First try to find the current segment - let current = t.scene_segments.iter() + let current = t + .scene_segments + .iter() .find(|s| frame_time as f64 >= s.start && (frame_time as f64) < s.end) .and_then(|s| s.split_view_settings.as_ref()); - - // If not in a segment, look for the next segment we're transitioning into - // This handles the transition period before the segment starts + if current.is_none() && scene.is_transitioning_split_view() { - t.scene_segments.iter() + t.scene_segments + .iter() .find(|s| { - // Check if this is a split view segment we're transitioning into - matches!(s.mode, cap_project::SceneMode::SplitView) && - s.start > frame_time as f64 && - (s.start - frame_time as f64) < scene::SCENE_TRANSITION_DURATION + matches!(s.mode, cap_project::SceneMode::SplitView) + && s.start > frame_time as f64 + && (s.start - frame_time as f64) + < scene::SCENE_TRANSITION_DURATION }) .and_then(|s| s.split_view_settings.as_ref()) } else { @@ -843,76 +841,94 @@ impl ProjectUniforms { }) .cloned() .unwrap_or_else(SplitViewSettings::default); - + let output_size_f32 = [output_size.0 as f32, output_size.1 as f32]; - let half_width = output_size_f32[0] / 2.0; - - // Determine which side camera and screen are on + + let (split_width, split_height, split_x_offset, split_y_offset) = + if split_settings.fullscreen { + (output_size_f32[0], output_size_f32[1], 0.0, 0.0) + } else { + let display_offset = Self::display_offset(&options, project, resolution_base); + let display_size = Self::display_size(&options, project, resolution_base); + + let display_width = display_size.coord.x as f32; + let display_height = display_size.coord.y as f32; + let display_x = display_offset.coord.x as f32; + let display_y = display_offset.coord.y as f32; + + (display_width, display_height, display_x, display_y) + }; + + let half_width = split_width / 2.0; + let (camera_x_offset, screen_x_offset) = match split_settings.camera_side { - SplitViewSide::Left => (0.0, half_width), - SplitViewSide::Right => (half_width, 0.0), + SplitViewSide::Left => (split_x_offset, split_x_offset + half_width), + SplitViewSide::Right => (split_x_offset + half_width, split_x_offset), }; - // Display uniforms for screen let split_display = { let screen_size = options.screen_size; let crop = Self::get_crop(&options, project); let frame_size = [screen_size.x as f32, screen_size.y as f32]; - - // Calculate crop bounds to fill the half while maintaining aspect ratio + let crop_size = [crop.size.x as f32, crop.size.y as f32]; let source_aspect = crop_size[0] / crop_size[1]; - let target_aspect = half_width / output_size_f32[1]; - - // Apply position adjustment (0.0 = full crop from one side, 1.0 = full crop from other side) + let target_aspect = half_width / split_height; + let position_factor = split_settings.screen_position.x as f32; let zoom = split_settings.screen_zoom as f32; - - // Apply zoom by adjusting the visible area + let zoomed_crop_size = [crop_size[0] / zoom, crop_size[1] / zoom]; - - // We want to fill the entire half, so we crop the source + let (crop_x, crop_y, crop_width, crop_height) = if source_aspect > target_aspect { - // Source is wider - crop left and right let visible_width = zoomed_crop_size[1] * target_aspect; let max_crop_offset = crop_size[0] - visible_width; let crop_offset = max_crop_offset * position_factor; - - // Center the zoomed area + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; - let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) * split_settings.screen_position.y as f32; - + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) + * split_settings.screen_position.y as f32; + ( crop.position.x as f32 + crop_offset + zoom_offset_x, crop.position.y as f32 + zoom_offset_y, visible_width, - zoomed_crop_size[1] + zoomed_crop_size[1], ) } else { - // Source is taller - crop top and bottom let visible_height = zoomed_crop_size[0] / target_aspect; let max_crop_offset = crop_size[1] - visible_height; let crop_offset = max_crop_offset * (split_settings.screen_position.y as f32); - - // Center the zoomed area + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; - let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) * split_settings.screen_position.y as f32; - + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) + * split_settings.screen_position.y as f32; + ( crop.position.x as f32 + zoom_offset_x, crop.position.y as f32 + crop_offset + zoom_offset_y, zoomed_crop_size[0], - visible_height + visible_height, ) }; - + Some(CompositeVideoFrameUniforms { output_size: output_size_f32, frame_size, crop_bounds: [crop_x, crop_y, crop_x + crop_width, crop_y + crop_height], - target_bounds: [screen_x_offset, 0.0, screen_x_offset + half_width, output_size_f32[1]], - target_size: [half_width, output_size_f32[1]], - rounding_px: 0.0, + target_bounds: [ + screen_x_offset, + split_y_offset, + screen_x_offset + half_width, + split_y_offset + split_height, + ], + target_size: [half_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, mirror_x: 0.0, velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, @@ -922,62 +938,76 @@ impl ProjectUniforms { shadow_opacity: 0.0, shadow_blur: 0.0, opacity: (scene.split_view_transition_opacity() * scene.screen_opacity) as f32, - _padding: [0.0; 3], + rounding_mask: if split_settings.fullscreen { + 0.0 + } else { + match split_settings.camera_side { + SplitViewSide::Left => 6.0, + SplitViewSide::Right => 5.0, + } + }, + _padding: [0.0; 2], }) }; - // Camera uniforms let split_camera = options.camera_size.map(|camera_size| { let frame_size = [camera_size.x as f32, camera_size.y as f32]; let source_aspect = frame_size[0] / frame_size[1]; - let target_aspect = half_width / output_size_f32[1]; - - // Apply position adjustment for camera + let target_aspect = half_width / split_height; + let cam_pos_x = split_settings.camera_position.x as f32; let cam_pos_y = split_settings.camera_position.y as f32; let cam_zoom = split_settings.camera_zoom as f32; - - // Apply zoom by adjusting the visible area + let zoomed_frame_size = [frame_size[0] / cam_zoom, frame_size[1] / cam_zoom]; - - // Calculate crop bounds to fill the half while maintaining aspect ratio + let crop_bounds = if source_aspect > target_aspect { - // Camera is wider - crop left and right to fit let visible_width = zoomed_frame_size[1] * target_aspect; let max_crop_offset = frame_size[0] - visible_width; let crop_x = max_crop_offset * cam_pos_x; - - // Center the zoomed area + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; - - [crop_x + zoom_offset_x, - zoom_offset_y, - crop_x + zoom_offset_x + visible_width, - zoom_offset_y + zoomed_frame_size[1]] + + [ + crop_x + zoom_offset_x, + zoom_offset_y, + crop_x + zoom_offset_x + visible_width, + zoom_offset_y + zoomed_frame_size[1], + ] } else { - // Camera is taller - crop top and bottom to fit let visible_height = zoomed_frame_size[0] / target_aspect; let max_crop_offset = frame_size[1] - visible_height; let crop_y = max_crop_offset * cam_pos_y; - - // Center the zoomed area + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; - - [zoom_offset_x, - crop_y + zoom_offset_y, - zoom_offset_x + zoomed_frame_size[0], - crop_y + zoom_offset_y + visible_height] + + [ + zoom_offset_x, + crop_y + zoom_offset_y, + zoom_offset_x + zoomed_frame_size[0], + crop_y + zoom_offset_y + visible_height, + ] }; - + CompositeVideoFrameUniforms { output_size: output_size_f32, frame_size, crop_bounds, - target_bounds: [camera_x_offset, 0.0, camera_x_offset + half_width, output_size_f32[1]], - target_size: [half_width, output_size_f32[1]], - rounding_px: 0.0, + target_bounds: [ + camera_x_offset, + split_y_offset, + camera_x_offset + half_width, + split_y_offset + split_height, + ], + target_size: [half_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, @@ -987,7 +1017,15 @@ impl ProjectUniforms { shadow_opacity: 0.0, shadow_blur: 0.0, opacity: (scene.split_view_transition_opacity() * scene.camera_opacity) as f32, - _padding: [0.0; 3], + rounding_mask: if split_settings.fullscreen { + 0.0 + } else { + match split_settings.camera_side { + SplitViewSide::Left => 5.0, + SplitViewSide::Right => 10.0, + } + }, + _padding: [0.0; 2], } }); @@ -1154,7 +1192,6 @@ impl RendererLayers { })(), ); - // Prepare split view layers self.split_view_display.prepare( &constants.device, &constants.queue, @@ -1204,12 +1241,27 @@ impl RendererLayers { }; } - { + let split_view_fullscreen = uniforms + .split_view_display + .map(|u| u.target_bounds[0] < 1.0 && u.target_bounds[1] < 1.0) + .unwrap_or(false); + + // Only skip background when fully in split view, not during transitions + let skip_background = uniforms.scene.is_split_view() + && !uniforms.scene.is_transitioning_split_view() + && split_view_fullscreen; + + if !skip_background { let mut pass = render_pass!( session.current_texture_view(), wgpu::LoadOp::Clear(wgpu::Color::BLACK) ); self.background.render(&mut pass); + } else { + let _pass = render_pass!( + session.current_texture_view(), + wgpu::LoadOp::Clear(wgpu::Color::BLACK) + ); } if self.background_blur.blur_amount > 0.0 { @@ -1220,23 +1272,25 @@ impl RendererLayers { session.swap_textures(); } - if uniforms.scene.should_render_screen() { + // During split view transitions, render screen content for cross-fade effect + let should_render_regular_screen = uniforms.scene.should_render_screen() + && (!split_view_fullscreen || uniforms.scene.is_transitioning_split_view()); + + if should_render_regular_screen { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.display.render(&mut pass); } - if uniforms.scene.should_render_screen() { + if should_render_regular_screen { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.cursor.render(&mut pass); } - // Render camera-only layer when transitioning with CameraOnly mode if uniforms.scene.is_transitioning_camera_only() { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.camera_only.render(&mut pass); } - // Also render regular camera overlay during transitions when its opacity > 0 if uniforms.scene.should_render_camera() && uniforms.scene.regular_camera_transition_opacity() > 0.01 { @@ -1244,7 +1298,6 @@ impl RendererLayers { self.camera.render(&mut pass); } - // Render split view when active or transitioning if uniforms.scene.is_split_view() || uniforms.scene.is_transitioning_split_view() { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.split_view_display.render(&mut pass); diff --git a/crates/rendering/src/scene.rs b/crates/rendering/src/scene.rs index 2667a75ff2..f2e94f5b2f 100644 --- a/crates/rendering/src/scene.rs +++ b/crates/rendering/src/scene.rs @@ -126,6 +126,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { // Keep the current mode without transitioning @@ -174,6 +175,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { (prev_seg.mode.clone(), prev_seg.mode.clone(), 1.0) diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index a0ddd0bb3f..d94d0bdfc9 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -14,6 +14,7 @@ struct Uniforms { shadow_opacity: f32, shadow_blur: f32, opacity: f32, + rounding_mask: f32, // Bitmask: 1=TL, 2=TR, 4=BL, 8=BR }; @group(0) @binding(0) var uniforms: Uniforms; @@ -203,17 +204,37 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { } fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 { - let target_coord = abs(target_uv * uniforms.target_size - uniforms.target_size / 2.0); + let target_coord = target_uv * uniforms.target_size - uniforms.target_size / 2.0; + let abs_coord = abs(target_coord); let rounding_point = uniforms.target_size / 2.0 - uniforms.rounding_px; - let target_rounding_coord = target_coord - rounding_point; - - let distance = abs(length(target_rounding_coord)) - uniforms.rounding_px; - - let distance_blur = 1.0; - - if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 && distance >= -distance_blur/2.0 { - return vec4(0.0); - // return mix(current_color, vec4(0.0), min(distance / distance_blur + 0.5, 1.0)); + let target_rounding_coord = abs_coord - rounding_point; + + // Determine which corner we're in + let is_left = target_coord.x < 0.0; + let is_top = target_coord.y < 0.0; + + // Calculate corner mask bit (1=TL, 2=TR, 4=BL, 8=BR) + var corner_bit: f32 = 0.0; + if is_top && is_left { + corner_bit = 1.0; // Top-left + } else if is_top && !is_left { + corner_bit = 2.0; // Top-right + } else if !is_top && is_left { + corner_bit = 4.0; // Bottom-left + } else { + corner_bit = 8.0; // Bottom-right + } + + // Check if this corner should be rounded + let should_round = (u32(uniforms.rounding_mask) & u32(corner_bit)) != 0u; + + if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 && should_round { + let distance = abs(length(target_rounding_coord)) - uniforms.rounding_px; + let distance_blur = 1.0; + + if distance >= -distance_blur/2.0 { + return vec4(0.0); + } } return current_color; From 7693040e6f11e0f43bbcad6c03eb1246ed22495d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 00:02:04 +0100 Subject: [PATCH 03/10] feat: Shorten timeline indicator on drag --- .../src/routes/editor/Timeline/ClipTrack.tsx | 1297 +++++++++-------- .../src/routes/editor/Timeline/SceneTrack.tsx | 931 ++++++------ .../src/routes/editor/Timeline/ZoomTrack.tsx | 1003 +++++++------ 3 files changed, 1634 insertions(+), 1597 deletions(-) diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index ee132163d0..aa39d29ee3 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -1,18 +1,19 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - type ComponentProps, - createEffect, - createMemo, - createRoot, - For, - Match, - mergeProps, - Show, - Switch, + type ComponentProps, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Match, + mergeProps, + Show, + Switch, } from "solid-js"; import { produce } from "solid-js/store"; @@ -21,640 +22,682 @@ import { useEditorContext } from "../context"; import { useSegmentContext, useTimelineContext } from "./context"; import { getSectionMarker } from "./sectionMarker"; import { - SegmentContent, - SegmentHandle, - SegmentRoot, - TrackRoot, - useSegmentTranslateX, - useSegmentWidth, + SegmentContent, + SegmentHandle, + SegmentRoot, + TrackRoot, + useSegmentTranslateX, + useSegmentWidth, } from "./Track"; function WaveformCanvas(props: { - systemWaveform?: number[]; - micWaveform?: number[]; - segment: { start: number; end: number }; - secsPerPixel: number; + systemWaveform?: number[]; + micWaveform?: number[]; + segment: { start: number; end: number }; + secsPerPixel: number; }) { - const { project } = useEditorContext(); - - let canvas: HTMLCanvasElement | undefined; - const { width } = useSegmentContext(); - const { secsPerPixel } = useTimelineContext(); - - const render = ( - ctx: CanvasRenderingContext2D, - h: number, - waveform: number[], - color: string, - gain = 0, - ) => { - const maxAmplitude = h; - - // yellow please - ctx.fillStyle = color; - ctx.beginPath(); - - const step = 0.05 / secsPerPixel(); - - ctx.moveTo(0, h); - - const norm = (w: number) => { - const ww = Number.isFinite(w) ? w : -60; - return 1.0 - Math.max(ww + gain, -60) / -60; - }; - - for ( - let segmentTime = props.segment.start; - segmentTime <= props.segment.end + 0.1; - segmentTime += 0.1 - ) { - const index = Math.floor(segmentTime * 10); - const xTime = index / 10; - - const currentDb = - typeof waveform[index] === "number" ? waveform[index] : -60; - const amplitude = norm(currentDb) * maxAmplitude; - - const x = (xTime - props.segment.start) / secsPerPixel(); - const y = h - amplitude; - - const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); - const prevDb = - typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; - const prevAmplitude = norm(prevDb) * maxAmplitude; - const prevY = h - prevAmplitude; - - const cpX1 = prevX + step / 2; - const cpX2 = x - step / 2; - - ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); - } - - ctx.lineTo( - (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), - h, - ); - - ctx.closePath(); - ctx.fill(); - }; - - function renderWaveforms() { - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const w = width(); - if (w <= 0) return; - - const h = canvas.height; - canvas.width = w; - ctx.clearRect(0, 0, w, h); - - if (props.micWaveform) - render( - ctx, - h, - props.micWaveform, - "rgba(255,255,255,0.4)", - project.audio.micVolumeDb, - ); - - if (props.systemWaveform) - render( - ctx, - h, - props.systemWaveform, - "rgba(255,150,0,0.5)", - project.audio.systemVolumeDb, - ); - } - - createEffect(() => { - renderWaveforms(); - }); - - return ( - { - canvas = el; - renderWaveforms(); - }} - class="absolute inset-0 w-full h-full pointer-events-none" - height={52} - /> - ); + const { project } = useEditorContext(); + + let canvas: HTMLCanvasElement | undefined; + const { width } = useSegmentContext(); + const { secsPerPixel } = useTimelineContext(); + + const render = ( + ctx: CanvasRenderingContext2D, + h: number, + waveform: number[], + color: string, + gain = 0 + ) => { + const maxAmplitude = h; + + // yellow please + ctx.fillStyle = color; + ctx.beginPath(); + + const step = 0.05 / secsPerPixel(); + + ctx.moveTo(0, h); + + const norm = (w: number) => { + const ww = Number.isFinite(w) ? w : -60; + return 1.0 - Math.max(ww + gain, -60) / -60; + }; + + for ( + let segmentTime = props.segment.start; + segmentTime <= props.segment.end + 0.1; + segmentTime += 0.1 + ) { + const index = Math.floor(segmentTime * 10); + const xTime = index / 10; + + const currentDb = + typeof waveform[index] === "number" ? waveform[index] : -60; + const amplitude = norm(currentDb) * maxAmplitude; + + const x = (xTime - props.segment.start) / secsPerPixel(); + const y = h - amplitude; + + const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); + const prevDb = + typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; + const prevAmplitude = norm(prevDb) * maxAmplitude; + const prevY = h - prevAmplitude; + + const cpX1 = prevX + step / 2; + const cpX2 = x - step / 2; + + ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); + } + + ctx.lineTo( + (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), + h + ); + + ctx.closePath(); + ctx.fill(); + }; + + function renderWaveforms() { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = width(); + if (w <= 0) return; + + const h = canvas.height; + canvas.width = w; + ctx.clearRect(0, 0, w, h); + + if (props.micWaveform) + render( + ctx, + h, + props.micWaveform, + "rgba(255,255,255,0.4)", + project.audio.micVolumeDb + ); + + if (props.systemWaveform) + render( + ctx, + h, + props.systemWaveform, + "rgba(255,150,0,0.5)", + project.audio.systemVolumeDb + ); + } + + createEffect(() => { + renderWaveforms(); + }); + + return ( + { + canvas = el; + renderWaveforms(); + }} + class="absolute inset-0 w-full h-full pointer-events-none" + height={52} + /> + ); } export function ClipTrack( - props: Pick, "ref"> & { - handleUpdatePlayhead: (e: MouseEvent) => void; - }, + props: Pick, "ref"> & { + handleUpdatePlayhead: (e: MouseEvent) => void; + } ) { - const { - project, - setProject, - projectActions, - editorInstance, - projectHistory, - editorState, - setEditorState, - totalDuration, - micWaveforms, - systemAudioWaveforms, - metaQuery, - } = useEditorContext(); - - const { secsPerPixel, duration } = useTimelineContext(); - - const segments = (): Array => - project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; - - function onHandleReleased() { - const { transform } = editorState.timeline; - - if (transform.position + transform.zoom > totalDuration() + 4) { - transform.updateZoom(totalDuration(), editorState.previewTime!); - } - } - - const hasMultipleRecordingSegments = () => - editorInstance.recordings.segments.length > 1; - - return ( - - - {(segment, i) => { - const prevDuration = () => - segments() - .slice(0, i()) - .reduce((t, s) => t + (s.end - s.start) / s.timescale, 0); - - const relativeSegment = mergeProps(segment, () => ({ - start: prevDuration(), - end: segment.end - segment.start + prevDuration(), - })); - - const segmentX = useSegmentTranslateX(() => relativeSegment); - const segmentWidth = useSegmentWidth(() => relativeSegment); - - const segmentRecording = (s = i()) => - editorInstance.recordings.segments[ - segments()[s].recordingSegment ?? 0 - ]; - - const marker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "left", - })); - - const endMarker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "right", - })); - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "clip") return false; - - const segmentIndex = project.timeline?.segments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end, - ); - - return segmentIndex === selection.index; - }); - - const micWaveform = () => { - if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) - return; - - const idx = segment.recordingSegment ?? i(); - return micWaveforms()?.[idx] ?? []; - }; - - const systemAudioWaveform = () => { - if ( - project.audio.systemVolumeDb && - project.audio.systemVolumeDb <= -30 - ) - return; - - const idx = segment.recordingSegment ?? i(); - return systemAudioWaveforms()?.[idx] ?? []; - }; - - return ( - <> - - {(marker) => ( -
-
- - { - const m = marker(); - if (m.type === "single") return m.value; - })()} - > - {(marker) => ( -
- { - const m = marker(); - return m.type === "time" ? m.time : 0; - })()} - onClick={() => { - setProject( - "timeline", - "segments", - produce((s) => { - if (marker().type === "reset") { - s[i() - 1].end = s[i()].end; - s.splice(i(), 1); - } else { - s[i() - 1].end = s[i()].start; - } - }), - ); - }} - /> -
- )} -
- { - const m = marker(); - if ( - m.type === "dual" && - m.right && - m.right.type === "time" - ) - return m.right; - })()} - > - {(marker) => { - const markerValue = marker(); - return ( -
- { - setProject( - "timeline", - "segments", - i(), - "start", - 0, - ); - }} - /> -
- ); - }} -
-
-
- )} - - { - e.stopPropagation(); - - if (editorState.timeline.interactMode === "split") { - const rect = e.currentTarget.getBoundingClientRect(); - const fraction = (e.clientX - rect.left) / rect.width; - - const splitTime = fraction * (segment.end - segment.start); - - projectActions.splitClipSegment(prevDuration() + splitTime); - } else { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - // If there's only one segment, don't open the clip config panel - // since there's nothing to configure - just let the normal click behavior happen - const hasOnlyOneSegment = segments().length === 1; - - if (hasOnlyOneSegment) { - // Clear any existing selection (zoom, layout, etc.) when clicking on a clip - // This ensures the sidebar updates properly - setEditorState("timeline", "selection", null); - } else { - // When there are multiple segments, show the clip configuration - setEditorState("timeline", "selection", { - type: "clip", - index: i(), - }); - } - props.handleUpdatePlayhead(e); - }); - }); - } - }} - > - - - - - { - const start = segment.start; - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0, - ); - - const maxDuration = Math.min( - maxSegmentDuration, - availableTimelineDuration, - ); - - const prevSegment = segments()[i() - 1]; - const prevSegmentIsSameClip = - prevSegment?.recordingSegment !== undefined - ? prevSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newStart = - start + - (event.clientX - downEvent.clientX) * secsPerPixel(); - - setProject( - "timeline", - "segments", - i(), - "start", - Math.min( - Math.max( - newStart, - prevSegmentIsSameClip ? prevSegment.end : 0, - segment.end - maxDuration, - ), - segment.end - 1, - ), - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - onHandleReleased(); - }, - }); - }); - }} - /> - - {(() => { - const ctx = useSegmentContext(); - - return ( - 100}> -
- - {hasMultipleRecordingSegments() - ? `Clip ${segment.recordingSegment}` - : "Clip"} - -
- {" "} - {(segment.end - segment.start).toFixed(1)}s -
-
-
- ); - })()} -
- { - const end = segment.end; - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0, - ); - - const nextSegment = segments()[i() + 1]; - const nextSegmentIsSameClip = - nextSegment?.recordingSegment !== undefined - ? nextSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newEnd = - end + - (event.clientX - downEvent.clientX) * secsPerPixel(); - - setProject( - "timeline", - "segments", - i(), - "end", - Math.max( - Math.min( - newEnd, - segment.end + availableTimelineDuration, - nextSegmentIsSameClip - ? nextSegment.start - : maxSegmentDuration, - ), - segment.start + 1, - ), - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - onHandleReleased(); - }, - }); - }); - }} - /> -
- { - const m = endMarker(); - if (m?.type === "dual" && m.left && m.left.type === "time") - return m.left; - })()} - > - {(marker) => ( -
-
-
- { - const m = marker(); - return m.type === "time" ? m.time : 0; - })()} - class="-right-px absolute rounded-l-full !pr-1.5 rounded-tr-full" - onClick={() => { - setProject( - "timeline", - "segments", - i(), - "end", - segmentRecording().display.duration, - ); - }} - /> -
-
- )} - - - ); - }} - - - ); + const { + project, + setProject, + projectActions, + editorInstance, + projectHistory, + editorState, + setEditorState, + totalDuration, + micWaveforms, + systemAudioWaveforms, + } = useEditorContext(); + + const { secsPerPixel, duration } = useTimelineContext(); + + const [startResizePreview, setStartResizePreview] = createSignal<{ + index: number; + previewStart: number; + } | null>(null); + + const segments = (): Array => + project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; + + function onHandleReleased() { + const { transform } = editorState.timeline; + + if (transform.position + transform.zoom > totalDuration() + 4) { + transform.updateZoom( + totalDuration(), + editorState.previewTime ?? editorState.playbackTime + ); + } + } + + const hasMultipleRecordingSegments = () => + editorInstance.recordings.segments.length > 1; + + return ( + + + {(segment, i) => { + const prevDuration = () => + segments() + .slice(0, i()) + .reduce((t, s) => t + (s.end - s.start) / s.timescale, 0); + + const relativeSegment = mergeProps(segment, () => ({ + start: prevDuration(), + end: segment.end - segment.start + prevDuration(), + })); + + const segmentX = useSegmentTranslateX(() => relativeSegment); + const segmentWidth = useSegmentWidth(() => relativeSegment); + + const segmentRecording = (s = i()) => + editorInstance.recordings.segments[ + segments()[s].recordingSegment ?? 0 + ]; + + const marker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "left", + })); + + const endMarker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "right", + })); + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "clip") return false; + + const segmentIndex = project.timeline?.segments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end + ); + + return segmentIndex === selection.index; + }); + + const micWaveform = () => { + if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) + return; + + const idx = segment.recordingSegment ?? i(); + return micWaveforms()?.[idx] ?? []; + }; + + const systemAudioWaveform = () => { + if ( + project.audio.systemVolumeDb && + project.audio.systemVolumeDb <= -30 + ) + return; + + const idx = segment.recordingSegment ?? i(); + return systemAudioWaveforms()?.[idx] ?? []; + }; + + return ( + <> + + {(marker) => ( +
+
+ + { + const m = marker(); + if (m.type === "single") return m.value; + })()} + > + {(marker) => ( +
+ { + const m = marker(); + return m.type === "time" ? m.time : 0; + })()} + onClick={() => { + setProject( + "timeline", + "segments", + produce((s) => { + if (marker().type === "reset") { + s[i() - 1].end = s[i()].end; + s.splice(i(), 1); + } else { + s[i() - 1].end = s[i()].start; + } + }) + ); + }} + /> +
+ )} +
+ { + const m = marker(); + if ( + m.type === "dual" && + m.right && + m.right.type === "time" + ) + return m.right; + })()} + > + {(marker) => { + const markerValue = marker(); + return ( +
+ { + setProject( + "timeline", + "segments", + i(), + "start", + 0 + ); + }} + /> +
+ ); + }} +
+
+
+ )} + + { + e.stopPropagation(); + + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + + const splitTime = fraction * (segment.end - segment.start); + + projectActions.splitClipSegment(prevDuration() + splitTime); + } else { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + // If there's only one segment, don't open the clip config panel + // since there's nothing to configure - just let the normal click behavior happen + const hasOnlyOneSegment = segments().length === 1; + + if (hasOnlyOneSegment) { + // Clear any existing selection (zoom, layout, etc.) when clicking on a clip + // This ensures the sidebar updates properly + setEditorState("timeline", "selection", null); + } else { + // When there are multiple segments, show the clip configuration + setEditorState("timeline", "selection", { + type: "clip", + index: i(), + }); + } + props.handleUpdatePlayhead(e); + }); + }); + } + }} + > + + + + + { + const start = segment.start; + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0 + ); + + const maxDuration = Math.min( + maxSegmentDuration, + availableTimelineDuration + ); + + const prevSegment = segments()[i() - 1]; + const prevSegmentIsSameClip = + prevSegment?.recordingSegment !== undefined + ? prevSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newStart = + start + + (event.clientX - downEvent.clientX) * secsPerPixel(); + + const constrained = Math.min( + Math.max( + newStart, + prevSegmentIsSameClip ? prevSegment.end : 0, + segment.end - maxDuration + ), + segment.end - 1 + ); + + setStartResizePreview({ + index: i(), + previewStart: constrained, + }); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + const p = startResizePreview(); + if (p && p.index === i()) { + setProject( + "timeline", + "segments", + i(), + "start", + p.previewStart + ); + } + setStartResizePreview(null); + onHandleReleased(); + }, + }); + }); + }} + /> + { + const p = startResizePreview(); + return p && p.index === i(); + })()} + > + {() => { + const p = startResizePreview(); + const previewWidth = () => + (segment.end - (p?.previewStart ?? segment.start)) / + secsPerPixel(); + const leftOffset = () => + ((p?.previewStart ?? segment.start) - segment.start) / + secsPerPixel(); + return ( +
+ ); + }} + + + {(() => { + const ctx = useSegmentContext(); + + return ( + 100}> +
+ + {hasMultipleRecordingSegments() + ? `Clip ${segment.recordingSegment}` + : "Clip"} + +
+ {" "} + {(segment.end - segment.start).toFixed(1)}s +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0 + ); + + const nextSegment = segments()[i() + 1]; + const nextSegmentIsSameClip = + nextSegment?.recordingSegment !== undefined + ? nextSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newEnd = + end + + (event.clientX - downEvent.clientX) * secsPerPixel(); + + setProject( + "timeline", + "segments", + i(), + "end", + Math.max( + Math.min( + newEnd, + segment.end + availableTimelineDuration, + nextSegmentIsSameClip + ? nextSegment.start + : maxSegmentDuration + ), + segment.start + 1 + ) + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + onHandleReleased(); + }, + }); + }); + }} + /> + + { + const m = endMarker(); + if (m?.type === "dual" && m.left && m.left.type === "time") + return m.left; + })()} + > + {(marker) => ( +
+
+
+ { + const m = marker(); + return m.type === "time" ? m.time : 0; + })()} + class="-right-px absolute rounded-l-full !pr-1.5 rounded-tr-full" + onClick={() => { + setProject( + "timeline", + "segments", + i(), + "end", + segmentRecording().display.duration + ); + }} + /> +
+
+ )} + + + ); + }} + + + ); } function Markings(props: { segment: TimelineSegment; prevDuration: number }) { - const { editorState } = useEditorContext(); - const { secsPerPixel, markingResolution } = useTimelineContext(); - - const markings = () => { - const resolution = markingResolution(); - - const { transform } = editorState.timeline; - const visibleMin = - transform.position - props.prevDuration + props.segment.start; - const visibleMax = visibleMin + transform.zoom; - - const start = Math.floor(visibleMin / resolution); - - return Array.from( - { length: Math.ceil(visibleMax / resolution) - start }, - (_, i) => (start + i) * resolution, - ); - }; - - return ( - - {(marking) => ( -
- )} - - ); + const { editorState } = useEditorContext(); + const { secsPerPixel, markingResolution } = useTimelineContext(); + + const markings = () => { + const resolution = markingResolution(); + + const { transform } = editorState.timeline; + const visibleMin = + transform.position - props.prevDuration + props.segment.start; + const visibleMax = visibleMin + transform.zoom; + + const start = Math.floor(visibleMin / resolution); + + return Array.from( + { length: Math.ceil(visibleMax / resolution) - start }, + (_, i) => (start + i) * resolution + ); + }; + + return ( + + {(marking) => ( +
+ )} + + ); } function CutOffsetButton(props: { - value: number; - class?: string; - onClick?(): void; + value: number; + class?: string; + onClick?(): void; }) { - const formatTime = (t: number) => - t < 1 ? Math.round(t * 10) / 10 : Math.round(t); - - return ( - - ); + const formatTime = (t: number) => + t < 1 ? Math.round(t * 10) / 10 : Math.round(t); + + return ( + + ); } function useSectionMarker( - props: () => { - segments: TimelineSegment[]; - i: number; - position: "left" | "right"; - }, + props: () => { + segments: TimelineSegment[]; + i: number; + position: "left" | "right"; + } ) { - const { editorInstance } = useEditorContext(); + const { editorInstance } = useEditorContext(); - return () => getSectionMarker(props(), editorInstance.recordings.segments); + return () => getSectionMarker(props(), editorInstance.recordings.segments); } diff --git a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx index 2981e4aab6..5caad230a2 100644 --- a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx @@ -1,484 +1,481 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Show, + batch, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Show, } from "solid-js"; import { produce } from "solid-js/store"; import { useEditorContext } from "../context"; import { - useSegmentContext, - useTimelineContext, - useTrackContext, + useSegmentContext, + useTimelineContext, + useTrackContext, } from "./context"; import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; export type SceneSegmentDragState = - | { type: "idle" } - | { type: "movePending" } - | { type: "moving" }; + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; export function SceneTrack(props: { - onDragStateChanged: (v: SceneSegmentDragState) => void; - handleUpdatePlayhead: (e: MouseEvent) => void; + onDragStateChanged: (v: SceneSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; }) { - const { project, setProject, projectHistory, setEditorState, editorState } = - useEditorContext(); - - const { duration, secsPerPixel } = useTimelineContext(); - - const [hoveringSegment, setHoveringSegment] = createSignal(false); - const [hoveredTime, setHoveredTime] = createSignal(); - const [maxAvailableDuration, setMaxAvailableDuration] = - createSignal(3); - - // When we delete a segment that's being hovered, the onMouseLeave never fires - // because the element gets removed from the DOM. This leaves hoveringSegment stuck - // as true, which blocks the onMouseMove from setting hoveredTime, preventing - // users from creating new segments. This effect ensures we reset the hover state - // when all segments are deleted. - createEffect(() => { - const segments = project.timeline?.sceneSegments; - if (!segments || segments.length === 0) { - setHoveringSegment(false); - } - }); - - const getSceneIcon = (mode: string | undefined) => { - switch (mode) { - case "cameraOnly": - return ; - case "hideCamera": - return ; - case "splitView": - return ; - default: - return ; - } - }; - - const getSceneLabel = (mode: string | undefined) => { - switch (mode) { - case "cameraOnly": - return "Camera Only"; - case "hideCamera": - return "Hide Camera"; - case "splitView": - return "Split View"; - default: - return "Default"; - } - }; - - return ( - { - if (hoveringSegment()) { - setHoveredTime(undefined); - return; - } - - const bounds = e.target.getBoundingClientRect()!; - - let time = - (e.clientX - bounds.left) * secsPerPixel() + - editorState.timeline.transform.position; - - const segments = project.timeline?.sceneSegments || []; - const nextSegmentIndex = segments.findIndex((s) => time < s.start); - - let maxDuration = 3; // Default duration - - if (nextSegmentIndex !== -1) { - const nextSegment = segments[nextSegmentIndex]; - const prevSegmentIndex = nextSegmentIndex - 1; - - if (prevSegmentIndex >= 0) { - const prevSegment = segments[prevSegmentIndex]; - const gapStart = prevSegment.end; - const gapEnd = nextSegment.start; - const availableSpace = gapEnd - gapStart; - - if (availableSpace < 0.5) { - setHoveredTime(undefined); - return; - } - - if (time < gapStart) { - time = gapStart; - } - - maxDuration = Math.min(3, gapEnd - time); - } else { - // No previous segment, only next segment - maxDuration = Math.min(3, nextSegment.start - time); - } - - if (nextSegment.start - time < 0.5) { - setHoveredTime(undefined); - return; - } - } else if (segments.length > 0) { - const lastSegment = segments[segments.length - 1]; - if (time < lastSegment.end) { - time = lastSegment.end; - } - maxDuration = Math.min(3, duration() - time); - } else { - maxDuration = Math.min(3, duration() - time); - } - - if (maxDuration < 0.5) { - setHoveredTime(undefined); - return; - } - - setMaxAvailableDuration(maxDuration); - setHoveredTime(Math.min(time, duration() - maxDuration)); - }} - onMouseLeave={() => { - setHoveredTime(); - setMaxAvailableDuration(3); - }} - onMouseDown={(e) => { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - const time = hoveredTime(); - const maxDuration = maxAvailableDuration(); - if (time === undefined) return; - - e.stopPropagation(); - batch(() => { - setProject("timeline", "sceneSegments", (v) => v ?? []); - setProject( - "timeline", - "sceneSegments", - produce((sceneSegments) => { - sceneSegments ??= []; - - let index = sceneSegments.length; - - for (let i = sceneSegments.length - 1; i >= 0; i--) { - if (sceneSegments[i].start > time) { - index = i; - break; - } - } - - sceneSegments.splice(index, 0, { - start: time, - end: time + maxDuration, - mode: "cameraOnly", - }); - }), - ); - }); - }); - }); - }} - > - -
Click to add scene segment
-
- (Make the camera full screen, or hide it) -
-
- } - > - {(segment, i) => { - const { setTrackState } = useTrackContext(); - - const sceneSegments = () => project.timeline!.sceneSegments!; - - function createMouseDownDrag( - setup: () => T, - _update: (e: MouseEvent, v: T, initialMouseX: number) => void, - ) { - return (downEvent: MouseEvent) => { - downEvent.stopPropagation(); - - const initial = setup(); - - let moved = false; - let initialMouseX: null | number = null; - - setTrackState("draggingSegment", true); - - const resumeHistory = projectHistory.pause(); - - props.onDragStateChanged({ type: "movePending" }); - - function finish(e: MouseEvent) { - resumeHistory(); - if (!moved) { - e.stopPropagation(); - setEditorState("timeline", "selection", { - type: "scene", - index: i(), - }); - props.handleUpdatePlayhead(e); - } else { - setEditorState("timeline", "selection", { - type: "scene", - index: i(), - }); - } - props.onDragStateChanged({ type: "idle" }); - setTrackState("draggingSegment", false); - } - - function update(event: MouseEvent) { - if (Math.abs(event.clientX - downEvent.clientX) > 2) { - if (!moved) { - moved = true; - initialMouseX = event.clientX; - props.onDragStateChanged({ - type: "moving", - }); - } - } - - if (initialMouseX === null) return; - - _update(event, initial, initialMouseX); - } - - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: (e) => { - update(e); - }, - mouseup: (e) => { - update(e); - finish(e); - dispose(); - }, - }); - }); - }; - } - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "scene") return false; - - const segmentIndex = project.timeline?.sceneSegments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end, - ); - - return segmentIndex === selection.index; - }); - - return ( - { - setHoveringSegment(true); - }} - onMouseLeave={() => { - setHoveringSegment(false); - }} - > - { - const start = segment.start; - - let minValue = 0; - - const maxValue = segment.end - 1; - - for (let i = sceneSegments().length - 1; i >= 0; i--) { - const segment = sceneSegments()[i]!; - if (segment.end <= start) { - minValue = segment.end; - break; - } - } - - return { start, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newStart = - value.start + - (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "sceneSegments", - i(), - "start", - Math.min( - value.maxValue, - Math.max(value.minValue, newStart), - ), - ); - - setProject( - "timeline", - "sceneSegments", - produce((s) => { - if (s) { - s.sort((a, b) => a.start - b.start); - } - }), - ); - }, - )} - /> - { - const original = { ...segment }; - - const prevSegment = sceneSegments()[i() - 1]; - const nextSegment = sceneSegments()[i() + 1]; - - const minStart = prevSegment?.end ?? 0; - const maxEnd = nextSegment?.start ?? duration(); - - return { - original, - minStart, - maxEnd, - }; - }, - (e, value, initialMouseX) => { - const rawDelta = - (e.clientX - initialMouseX) * secsPerPixel(); - - const newStart = value.original.start + rawDelta; - const newEnd = value.original.end + rawDelta; - - let delta = rawDelta; - - if (newStart < value.minStart) - delta = value.minStart - value.original.start; - else if (newEnd > value.maxEnd) - delta = value.maxEnd - value.original.end; - - setProject("timeline", "sceneSegments", i(), { - start: value.original.start + delta, - end: value.original.end + delta, - }); - }, - )} - > - {(() => { - const ctx = useSegmentContext(); - - return ( - 80}> -
- Scene -
- {getSceneIcon(segment.mode)} - {ctx.width() > 120 && ( - - {getSceneLabel(segment.mode)} - - )} -
-
-
- ); - })()} -
- { - const end = segment.end; - - const minValue = segment.start + 1; - - let maxValue = duration(); - - for (let i = 0; i < sceneSegments().length; i++) { - const segment = sceneSegments()[i]!; - if (segment.start > end) { - maxValue = segment.start; - break; - } - } - - return { end, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newEnd = - value.end + (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "sceneSegments", - i(), - "end", - Math.min( - value.maxValue, - Math.max(value.minValue, newEnd), - ), - ); - - setProject( - "timeline", - "sceneSegments", - produce((s) => { - if (s) { - s.sort((a, b) => a.start - b.start); - } - }), - ); - }, - )} - /> -
- ); - }} -
- - {(time) => ( - - -

- + -

-
-
- )} -
- - ); + const { project, setProject, projectHistory, setEditorState, editorState } = + useEditorContext(); + + const { duration, secsPerPixel } = useTimelineContext(); + + const [hoveringSegment, setHoveringSegment] = createSignal(false); + const [hoveredTime, setHoveredTime] = createSignal(); + const [maxAvailableDuration, setMaxAvailableDuration] = + createSignal(3); + + // When we delete a segment that's being hovered, the onMouseLeave never fires + // because the element gets removed from the DOM. This leaves hoveringSegment stuck + // as true, which blocks the onMouseMove from setting hoveredTime, preventing + // users from creating new segments. This effect ensures we reset the hover state + // when all segments are deleted. + createEffect(() => { + const segments = project.timeline?.sceneSegments; + if (!segments || segments.length === 0) { + setHoveringSegment(false); + } + }); + + const getSceneIcon = (mode: string | undefined) => { + switch (mode) { + case "cameraOnly": + return ; + case "hideCamera": + return ; + case "splitView": + return ; + default: + return ; + } + }; + + const getSceneLabel = (mode: string | undefined) => { + switch (mode) { + case "cameraOnly": + return "Camera Only"; + case "hideCamera": + return "Hide Camera"; + case "splitView": + return "Split View"; + default: + return "Default"; + } + }; + + return ( + { + if (hoveringSegment()) { + setHoveredTime(undefined); + return; + } + + const bounds = e.target.getBoundingClientRect()!; + + let time = + (e.clientX - bounds.left) * secsPerPixel() + + editorState.timeline.transform.position; + + const segments = project.timeline?.sceneSegments || []; + const nextSegmentIndex = segments.findIndex((s) => time < s.start); + + let maxDuration = 3; // Default duration + + if (nextSegmentIndex !== -1) { + const nextSegment = segments[nextSegmentIndex]; + const prevSegmentIndex = nextSegmentIndex - 1; + + if (prevSegmentIndex >= 0) { + const prevSegment = segments[prevSegmentIndex]; + const gapStart = prevSegment.end; + const gapEnd = nextSegment.start; + const availableSpace = gapEnd - gapStart; + + if (availableSpace < 0.5) { + setHoveredTime(undefined); + return; + } + + if (time < gapStart) { + time = gapStart; + } + + maxDuration = Math.min(3, gapEnd - time); + } else { + // No previous segment, only next segment + maxDuration = Math.min(3, nextSegment.start - time); + } + + if (nextSegment.start - time < 0.5) { + setHoveredTime(undefined); + return; + } + } else if (segments.length > 0) { + const lastSegment = segments[segments.length - 1]; + if (time < lastSegment.end) { + time = lastSegment.end; + } + maxDuration = Math.min(3, duration() - time); + } else { + maxDuration = Math.min(3, duration() - time); + } + + if (maxDuration < 0.5) { + setHoveredTime(undefined); + return; + } + + setMaxAvailableDuration(maxDuration); + setHoveredTime(Math.min(time, duration() - maxDuration)); + }} + onMouseLeave={() => { + setHoveredTime(); + setMaxAvailableDuration(3); + }} + onMouseDown={(e) => { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + const time = hoveredTime(); + const maxDuration = maxAvailableDuration(); + if (time === undefined) return; + + e.stopPropagation(); + batch(() => { + setProject("timeline", "sceneSegments", (v) => v ?? []); + setProject( + "timeline", + "sceneSegments", + produce((sceneSegments) => { + sceneSegments ??= []; + + let index = sceneSegments.length; + + for (let i = sceneSegments.length - 1; i >= 0; i--) { + if (sceneSegments[i].start > time) { + index = i; + break; + } + } + + sceneSegments.splice(index, 0, { + start: time, + end: time + maxDuration, + mode: "cameraOnly", + }); + }) + ); + }); + }); + }); + }} + > + +
Click to add scene segment
+
+ (Make the camera full screen, or hide it) +
+
+ } + > + {(segment, i) => { + const { setTrackState } = useTrackContext(); + + const sceneSegments = () => project.timeline!.sceneSegments!; + + function createMouseDownDrag( + setup: () => T, + _update: (e: MouseEvent, v: T, initialMouseX: number) => void + ) { + return (downEvent: MouseEvent) => { + downEvent.stopPropagation(); + + const initial = setup(); + + let moved = false; + let initialMouseX: null | number = null; + + setTrackState("draggingSegment", true); + + const resumeHistory = projectHistory.pause(); + + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + setEditorState("timeline", "selection", { + type: "scene", + index: i(), + }); + props.handleUpdatePlayhead(e); + } else { + setEditorState("timeline", "selection", { + type: "scene", + index: i(), + }); + } + props.onDragStateChanged({ type: "idle" }); + setTrackState("draggingSegment", false); + } + + function update(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ + type: "moving", + }); + } + } + + if (initialMouseX === null) return; + + _update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => { + update(e); + }, + mouseup: (e) => { + update(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "scene") return false; + + const segmentIndex = project.timeline?.sceneSegments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end + ); + + return segmentIndex === selection.index; + }); + + return ( + { + setHoveringSegment(true); + }} + onMouseLeave={() => { + setHoveringSegment(false); + }} + > + { + const start = segment.start; + + let minValue = 0; + + const maxValue = segment.end - 1; + + for (let i = sceneSegments().length - 1; i >= 0; i--) { + const segment = sceneSegments()[i]!; + if (segment.end <= start) { + minValue = segment.end; + break; + } + } + + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newStart = + value.start + + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "sceneSegments", + i(), + "start", + Math.min( + value.maxValue, + Math.max(value.minValue, newStart) + ) + ); + + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (s) { + s.sort((a, b) => a.start - b.start); + } + }) + ); + } + )} + /> + { + const original = { ...segment }; + + const prevSegment = sceneSegments()[i() - 1]; + const nextSegment = sceneSegments()[i() + 1]; + + const minStart = prevSegment?.end ?? 0; + const maxEnd = nextSegment?.start ?? duration(); + + return { + original, + minStart, + maxEnd, + }; + }, + (e, value, initialMouseX) => { + const rawDelta = + (e.clientX - initialMouseX) * secsPerPixel(); + + const newStart = value.original.start + rawDelta; + const newEnd = value.original.end + rawDelta; + + let delta = rawDelta; + + if (newStart < value.minStart) + delta = value.minStart - value.original.start; + else if (newEnd > value.maxEnd) + delta = value.maxEnd - value.original.end; + + setProject("timeline", "sceneSegments", i(), { + start: value.original.start + delta, + end: value.original.end + delta, + }); + } + )} + > + {(() => { + const ctx = useSegmentContext(); + + return ( + 80}> +
+ Scene +
+ {getSceneIcon(segment.mode)} + {ctx.width() > 120 && ( + + {getSceneLabel(segment.mode)} + + )} +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + const minValue = segment.start + 1; + + let maxValue = duration(); + + for (let i = 0; i < sceneSegments().length; i++) { + const segment = sceneSegments()[i]!; + if (segment.start > end) { + maxValue = segment.start; + break; + } + } + + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newEnd = + value.end + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "sceneSegments", + i(), + "end", + Math.min(value.maxValue, Math.max(value.minValue, newEnd)) + ); + + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (s) { + s.sort((a, b) => a.start - b.start); + } + }) + ); + } + )} + /> +
+ ); + }} +
+ + {(time) => ( + + +

+ + +

+
+
+ )} +
+ + ); } diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index d38b5f6996..c674d092a5 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -1,521 +1,518 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { Menu } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Show, + batch, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Show, } from "solid-js"; import { produce } from "solid-js/store"; import { commands } from "~/utils/tauri"; import { useEditorContext } from "../context"; import { - useSegmentContext, - useTimelineContext, - useTrackContext, + useSegmentContext, + useTimelineContext, + useTrackContext, } from "./context"; import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; export type ZoomSegmentDragState = - | { type: "idle" } - | { type: "movePending" } - | { type: "moving" }; + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; export function ZoomTrack(props: { - onDragStateChanged: (v: ZoomSegmentDragState) => void; - handleUpdatePlayhead: (e: MouseEvent) => void; + onDragStateChanged: (v: ZoomSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; }) { - const { project, setProject, projectHistory, setEditorState, editorState } = - useEditorContext(); - - const { duration, secsPerPixel } = useTimelineContext(); - - const [hoveringSegment, setHoveringSegment] = createSignal(false); - const [hoveredTime, setHoveredTime] = createSignal(); - - // When we delete a segment that's being hovered, the onMouseLeave never fires - // because the element gets removed from the DOM. This leaves hoveringSegment stuck - // as true, which blocks the onMouseMove from setting hoveredTime, preventing - // users from creating new segments. This effect ensures we reset the hover state - // when all segments are deleted. - createEffect(() => { - const segments = project.timeline?.zoomSegments; - if (!segments || segments.length === 0) { - setHoveringSegment(false); - setHoveredTime(undefined); - } - }); - - const handleGenerateZoomSegments = async () => { - try { - const zoomSegments = await commands.generateZoomSegmentsFromClicks(); - setProject("timeline", "zoomSegments", zoomSegments); - } catch (error) { - console.error("Failed to generate zoom segments:", error); - } - }; - - return ( - { - if (!import.meta.env.DEV) return; - - e.preventDefault(); - const menu = await Menu.new({ - id: "zoom-track-options", - items: [ - { - id: "generateZoomSegments", - text: "Generate zoom segments from clicks", - action: handleGenerateZoomSegments, - }, - ], - }); - menu.popup(); - }} - onMouseMove={(e) => { - if (hoveringSegment()) { - setHoveredTime(undefined); - return; - } - - const bounds = e.currentTarget.getBoundingClientRect()!; - - let time = - (e.clientX - bounds.left) * secsPerPixel() + - editorState.timeline.transform.position; - - const nextSegmentIndex = project.timeline?.zoomSegments?.findIndex( - (s) => time < s.start, - ); - - if (nextSegmentIndex !== undefined) { - const prevSegmentIndex = nextSegmentIndex - 1; - - if (prevSegmentIndex === undefined) return; - - const nextSegment = - project.timeline?.zoomSegments?.[nextSegmentIndex]; - - if (prevSegmentIndex !== undefined && nextSegment) { - const prevSegment = - project.timeline?.zoomSegments?.[prevSegmentIndex]; - - if (prevSegment) { - const availableTime = nextSegment?.start - prevSegment?.end; - - if (availableTime < 1) return; - } - } - - if (nextSegment && nextSegment.start - time < 1) { - time = nextSegment.start - 1; - } - } - - setHoveredTime(Math.min(time, duration() - 1)); - }} - onMouseLeave={() => setHoveredTime()} - onMouseDown={(e) => { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - const time = hoveredTime(); - if (time === undefined) return; - - e.stopPropagation(); - batch(() => { - setProject("timeline", "zoomSegments", (v) => v ?? []); - setProject( - "timeline", - "zoomSegments", - produce((zoomSegments) => { - zoomSegments ??= []; - - let index = zoomSegments.length; - - for (let i = zoomSegments.length - 1; i >= 0; i--) { - if (zoomSegments[i].start > time) { - index = i; - break; - } - } - - zoomSegments.splice(index, 0, { - start: time, - end: time + 1, - amount: 1.5, - mode: { - manual: { - x: 0.5, - y: 0.5, - }, - }, - }); - }), - ); - }); - }); - }); - }} - > - -
Click to add zoom segment
-
- (Smoothly zoom in on important areas) -
-
- } - > - {(segment, i) => { - const { setTrackState } = useTrackContext(); - - const zoomPercentage = () => { - const amount = segment.amount; - return `${amount.toFixed(1)}x`; - }; - - const zoomSegments = () => project.timeline!.zoomSegments!; - - function createMouseDownDrag( - setup: () => T, - _update: (e: MouseEvent, v: T, initialMouseX: number) => void, - ) { - return (downEvent: MouseEvent) => { - downEvent.stopPropagation(); - - const initial = setup(); - - let moved = false; - let initialMouseX: null | number = null; - - setTrackState("draggingSegment", true); - - const resumeHistory = projectHistory.pause(); - - props.onDragStateChanged({ type: "movePending" }); - - function finish(e: MouseEvent) { - resumeHistory(); - if (!moved) { - e.stopPropagation(); - - const currentSelection = editorState.timeline.selection; - const segmentIndex = i(); - - // Handle multi-selection with Ctrl/Cmd+click - if (e.ctrlKey || e.metaKey) { - if (currentSelection?.type === "zoom") { - // Normalize to indices[] from either indices[] or legacy index - const baseIndices = - "indices" in currentSelection && - Array.isArray(currentSelection.indices) - ? currentSelection.indices - : "index" in currentSelection && - typeof currentSelection.index === "number" - ? [currentSelection.index] - : []; - - const exists = baseIndices.includes(segmentIndex); - const newIndices = exists - ? baseIndices.filter((idx) => idx !== segmentIndex) - : [...baseIndices, segmentIndex]; - - setEditorState("timeline", "selection", { - type: "zoom", - indices: newIndices, - }); - } else { - // Start new multi-selection - setEditorState("timeline", "selection", { - type: "zoom", - indices: [segmentIndex], - }); - } - } else { - setEditorState("timeline", "selection", { - type: "zoom", - indices: [segmentIndex], - }); - } - props.handleUpdatePlayhead(e); - } - props.onDragStateChanged({ type: "idle" }); - setTrackState("draggingSegment", false); - } - - function update(event: MouseEvent) { - if (Math.abs(event.clientX - downEvent.clientX) > 2) { - if (!moved) { - moved = true; - initialMouseX = event.clientX; - props.onDragStateChanged({ - type: "moving", - }); - } - } - - if (initialMouseX === null) return; - - _update(event, initial, initialMouseX); - } - - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: (e) => { - update(e); - }, - mouseup: (e) => { - update(e); - finish(e); - dispose(); - }, - }); - }); - }; - } - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "zoom") return false; - - const segmentIndex = project.timeline?.zoomSegments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end, - ); - - // Support both single selection (index) and multi-selection (indices) - if ( - "indices" in selection && - Array.isArray(selection.indices) && - segmentIndex !== undefined - ) { - return selection.indices.includes(segmentIndex); - } else if ( - "index" in selection && - typeof selection.index === "number" - ) { - return segmentIndex === selection.index; - } - - return false; - }); - - return ( - { - setHoveringSegment(true); - }} - onMouseLeave={() => { - setHoveringSegment(false); - }} - > - { - const start = segment.start; - - let minValue = 0; - - const maxValue = segment.end - 1; - - for (let i = zoomSegments().length - 1; i >= 0; i--) { - const segment = zoomSegments()[i]!; - if (segment.end <= start) { - minValue = segment.end; - break; - } - } - - return { start, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newStart = - value.start + - (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "zoomSegments", - i(), - "start", - Math.min( - value.maxValue, - Math.max(value.minValue, newStart), - ), - ); - - setProject( - "timeline", - "zoomSegments", - produce((s) => { - s.sort((a, b) => a.start - b.start); - }), - ); - }, - )} - /> - { - const original = { ...segment }; - - const prevSegment = zoomSegments()[i() - 1]; - const nextSegment = zoomSegments()[i() + 1]; - - const minStart = prevSegment?.end ?? 0; - const maxEnd = nextSegment?.start ?? duration(); - - return { - original, - minStart, - maxEnd, - }; - }, - (e, value, initialMouseX) => { - const rawDelta = - (e.clientX - initialMouseX) * secsPerPixel(); - - const newStart = value.original.start + rawDelta; - const newEnd = value.original.end + rawDelta; - - let delta = rawDelta; - - if (newStart < value.minStart) - delta = value.minStart - value.original.start; - else if (newEnd > value.maxEnd) - delta = value.maxEnd - value.original.end; - - setProject("timeline", "zoomSegments", i(), { - start: value.original.start + delta, - end: value.original.end + delta, - }); - }, - )} - > - {(() => { - const ctx = useSegmentContext(); - const width = ctx.width(); - - if (width < 40) { - // Very small - just show icon - return ( -
- -
- ); - } else if (width < 100) { - // Small - show icon and zoom amount - return ( -
- - {zoomPercentage()} -
- ); - } else { - // Large - show full content - return ( -
- Zoom -
- - {zoomPercentage()} -
-
- ); - } - })()} -
- { - const end = segment.end; - - const minValue = segment.start + 1; - - let maxValue = duration(); - - for (let i = 0; i < zoomSegments().length; i++) { - const segment = zoomSegments()[i]!; - if (segment.start > end) { - maxValue = segment.start; - break; - } - } - - return { end, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newEnd = - value.end + (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "zoomSegments", - i(), - "end", - Math.min( - value.maxValue, - Math.max(value.minValue, newEnd), - ), - ); - - setProject( - "timeline", - "zoomSegments", - produce((s) => { - s.sort((a, b) => a.start - b.start); - }), - ); - }, - )} - /> -
- ); - }} - - - {(time) => ( - - -

- + -

-
-
- )} -
- - ); + const { project, setProject, projectHistory, setEditorState, editorState } = + useEditorContext(); + + const { duration, secsPerPixel } = useTimelineContext(); + + const [hoveringSegment, setHoveringSegment] = createSignal(false); + const [hoveredTime, setHoveredTime] = createSignal(); + + // When we delete a segment that's being hovered, the onMouseLeave never fires + // because the element gets removed from the DOM. This leaves hoveringSegment stuck + // as true, which blocks the onMouseMove from setting hoveredTime, preventing + // users from creating new segments. This effect ensures we reset the hover state + // when all segments are deleted. + createEffect(() => { + const segments = project.timeline?.zoomSegments; + if (!segments || segments.length === 0) { + setHoveringSegment(false); + setHoveredTime(undefined); + } + }); + + const handleGenerateZoomSegments = async () => { + try { + const zoomSegments = await commands.generateZoomSegmentsFromClicks(); + setProject("timeline", "zoomSegments", zoomSegments); + } catch (error) { + console.error("Failed to generate zoom segments:", error); + } + }; + + return ( + { + if (!import.meta.env.DEV) return; + + e.preventDefault(); + const menu = await Menu.new({ + id: "zoom-track-options", + items: [ + { + id: "generateZoomSegments", + text: "Generate zoom segments from clicks", + action: handleGenerateZoomSegments, + }, + ], + }); + menu.popup(); + }} + onMouseMove={(e) => { + if (hoveringSegment()) { + setHoveredTime(undefined); + return; + } + + const bounds = e.currentTarget.getBoundingClientRect()!; + + let time = + (e.clientX - bounds.left) * secsPerPixel() + + editorState.timeline.transform.position; + + const nextSegmentIndex = project.timeline?.zoomSegments?.findIndex( + (s) => time < s.start + ); + + if (nextSegmentIndex !== undefined) { + const prevSegmentIndex = nextSegmentIndex - 1; + + if (prevSegmentIndex === undefined) return; + + const nextSegment = + project.timeline?.zoomSegments?.[nextSegmentIndex]; + + if (prevSegmentIndex !== undefined && nextSegment) { + const prevSegment = + project.timeline?.zoomSegments?.[prevSegmentIndex]; + + if (prevSegment) { + const availableTime = nextSegment?.start - prevSegment?.end; + + if (availableTime < 1) return; + } + } + + if (nextSegment && nextSegment.start - time < 1) { + time = nextSegment.start - 1; + } + } + + setHoveredTime(Math.min(time, duration() - 1)); + }} + onMouseLeave={() => setHoveredTime()} + onMouseDown={(e) => { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + const time = hoveredTime(); + if (time === undefined) return; + + e.stopPropagation(); + batch(() => { + setProject("timeline", "zoomSegments", (v) => v ?? []); + setProject( + "timeline", + "zoomSegments", + produce((zoomSegments) => { + zoomSegments ??= []; + + let index = zoomSegments.length; + + for (let i = zoomSegments.length - 1; i >= 0; i--) { + if (zoomSegments[i].start > time) { + index = i; + break; + } + } + + zoomSegments.splice(index, 0, { + start: time, + end: time + 1, + amount: 1.5, + mode: { + manual: { + x: 0.5, + y: 0.5, + }, + }, + }); + }) + ); + }); + }); + }); + }} + > + +
Click to add zoom segment
+
+ (Smoothly zoom in on important areas) +
+
+ } + > + {(segment, i) => { + const { setTrackState } = useTrackContext(); + + const zoomPercentage = () => { + const amount = segment.amount; + return `${amount.toFixed(1)}x`; + }; + + const zoomSegments = () => project.timeline!.zoomSegments!; + + function createMouseDownDrag( + setup: () => T, + _update: (e: MouseEvent, v: T, initialMouseX: number) => void + ) { + return (downEvent: MouseEvent) => { + downEvent.stopPropagation(); + + const initial = setup(); + + let moved = false; + let initialMouseX: null | number = null; + + setTrackState("draggingSegment", true); + + const resumeHistory = projectHistory.pause(); + + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + + const currentSelection = editorState.timeline.selection; + const segmentIndex = i(); + + // Handle multi-selection with Ctrl/Cmd+click + if (e.ctrlKey || e.metaKey) { + if (currentSelection?.type === "zoom") { + // Normalize to indices[] from either indices[] or legacy index + const baseIndices = + "indices" in currentSelection && + Array.isArray(currentSelection.indices) + ? currentSelection.indices + : "index" in currentSelection && + typeof currentSelection.index === "number" + ? [currentSelection.index] + : []; + + const exists = baseIndices.includes(segmentIndex); + const newIndices = exists + ? baseIndices.filter((idx) => idx !== segmentIndex) + : [...baseIndices, segmentIndex]; + + setEditorState("timeline", "selection", { + type: "zoom", + indices: newIndices, + }); + } else { + // Start new multi-selection + setEditorState("timeline", "selection", { + type: "zoom", + indices: [segmentIndex], + }); + } + } else { + setEditorState("timeline", "selection", { + type: "zoom", + indices: [segmentIndex], + }); + } + props.handleUpdatePlayhead(e); + } + props.onDragStateChanged({ type: "idle" }); + setTrackState("draggingSegment", false); + } + + function update(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ + type: "moving", + }); + } + } + + if (initialMouseX === null) return; + + _update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => { + update(e); + }, + mouseup: (e) => { + update(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "zoom") return false; + + const segmentIndex = project.timeline?.zoomSegments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end + ); + + // Support both single selection (index) and multi-selection (indices) + if ( + "indices" in selection && + Array.isArray(selection.indices) && + segmentIndex !== undefined + ) { + return selection.indices.includes(segmentIndex); + } else if ( + "index" in selection && + typeof selection.index === "number" + ) { + return segmentIndex === selection.index; + } + + return false; + }); + + return ( + { + setHoveringSegment(true); + }} + onMouseLeave={() => { + setHoveringSegment(false); + }} + > + { + const start = segment.start; + + let minValue = 0; + + const maxValue = segment.end - 1; + + for (let i = zoomSegments().length - 1; i >= 0; i--) { + const segment = zoomSegments()[i]!; + if (segment.end <= start) { + minValue = segment.end; + break; + } + } + + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newStart = + value.start + + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "zoomSegments", + i(), + "start", + Math.min( + value.maxValue, + Math.max(value.minValue, newStart) + ) + ); + + setProject( + "timeline", + "zoomSegments", + produce((s) => { + s.sort((a, b) => a.start - b.start); + }) + ); + } + )} + /> + { + const original = { ...segment }; + + const prevSegment = zoomSegments()[i() - 1]; + const nextSegment = zoomSegments()[i() + 1]; + + const minStart = prevSegment?.end ?? 0; + const maxEnd = nextSegment?.start ?? duration(); + + return { + original, + minStart, + maxEnd, + }; + }, + (e, value, initialMouseX) => { + const rawDelta = + (e.clientX - initialMouseX) * secsPerPixel(); + + const newStart = value.original.start + rawDelta; + const newEnd = value.original.end + rawDelta; + + let delta = rawDelta; + + if (newStart < value.minStart) + delta = value.minStart - value.original.start; + else if (newEnd > value.maxEnd) + delta = value.maxEnd - value.original.end; + + setProject("timeline", "zoomSegments", i(), { + start: value.original.start + delta, + end: value.original.end + delta, + }); + } + )} + > + {(() => { + const ctx = useSegmentContext(); + const width = ctx.width(); + + if (width < 40) { + // Very small - just show icon + return ( +
+ +
+ ); + } else if (width < 100) { + // Small - show icon and zoom amount + return ( +
+ + {zoomPercentage()} +
+ ); + } else { + // Large - show full content + return ( +
+ Zoom +
+ + {zoomPercentage()} +
+
+ ); + } + })()} +
+ { + const end = segment.end; + + const minValue = segment.start + 1; + + let maxValue = duration(); + + for (let i = 0; i < zoomSegments().length; i++) { + const segment = zoomSegments()[i]!; + if (segment.start > end) { + maxValue = segment.start; + break; + } + } + + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newEnd = + value.end + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "zoomSegments", + i(), + "end", + Math.min(value.maxValue, Math.max(value.minValue, newEnd)) + ); + + setProject( + "timeline", + "zoomSegments", + produce((s) => { + s.sort((a, b) => a.start - b.start); + }) + ); + } + )} + /> +
+ ); + }} + + + {(time) => ( + + +

+ + +

+
+
+ )} +
+ + ); } From fea6cb50477d1f93128fd33f37df4a4a4d2d535d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:18:03 +0100 Subject: [PATCH 04/10] feat: Remove screen shadow in split view --- crates/rendering/src/lib.rs | 50 +++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index fcd839d2fe..97cee487f9 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -621,7 +621,11 @@ impl ProjectUniforms { velocity_uv: velocity, motion_blur_amount: (motion_blur_amount + scene.screen_blur as f32 * 0.8).min(1.0), camera_motion_blur_amount: 0.0, - shadow: project.background.shadow, + shadow: if scene.is_split_view() || scene.is_transitioning_split_view() { + 0.0 + } else { + project.background.shadow + }, shadow_size: project .background .advanced_shadow @@ -933,10 +937,22 @@ impl ProjectUniforms { velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, camera_motion_blur_amount: 0.0, - shadow: 0.0, - shadow_size: 0.0, - shadow_opacity: 0.0, - shadow_blur: 0.0, + shadow: project.background.shadow, + shadow_size: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.size), + shadow_opacity: project + .background + .advanced_shadow + .as_ref() + .map_or(18.0, |s| s.opacity), + shadow_blur: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.blur), opacity: (scene.split_view_transition_opacity() * scene.screen_opacity) as f32, rounding_mask: if split_settings.fullscreen { 0.0 @@ -1012,10 +1028,22 @@ impl ProjectUniforms { velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, camera_motion_blur_amount: 0.0, - shadow: 0.0, - shadow_size: 0.0, - shadow_opacity: 0.0, - shadow_blur: 0.0, + shadow: project.camera.shadow, + shadow_size: project + .camera + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.size), + shadow_opacity: project + .camera + .advanced_shadow + .as_ref() + .map_or(18.0, |s| s.opacity), + shadow_blur: project + .camera + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.blur), opacity: (scene.split_view_transition_opacity() * scene.camera_opacity) as f32, rounding_mask: if split_settings.fullscreen { 0.0 @@ -1273,9 +1301,9 @@ impl RendererLayers { } // During split view transitions, render screen content for cross-fade effect - let should_render_regular_screen = uniforms.scene.should_render_screen() + let should_render_regular_screen = uniforms.scene.should_render_screen() && (!split_view_fullscreen || uniforms.scene.is_transitioning_split_view()); - + if should_render_regular_screen { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.display.render(&mut pass); From 8b811bb09ee858a0d91df5c5cad9b8690580204c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:35:05 +0100 Subject: [PATCH 05/10] feat: Add shadow layer (for sharing shadows) --- crates/rendering/src/layers/mod.rs | 2 + crates/rendering/src/layers/shadow.rs | 76 +++++++++++++++++++++++++ crates/rendering/src/lib.rs | 80 ++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 crates/rendering/src/layers/shadow.rs diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index 1469690f2a..8f81d2f8dd 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -4,6 +4,7 @@ mod camera; mod captions; mod cursor; mod display; +mod shadow; pub use background::*; pub use blur::*; @@ -11,3 +12,4 @@ pub use camera::*; pub use captions::*; pub use cursor::*; pub use display::*; +pub use shadow::*; diff --git a/crates/rendering/src/layers/shadow.rs b/crates/rendering/src/layers/shadow.rs new file mode 100644 index 0000000000..916addf201 --- /dev/null +++ b/crates/rendering/src/layers/shadow.rs @@ -0,0 +1,76 @@ +use wgpu::util::DeviceExt; + +use crate::composite_frame::{CompositeVideoFramePipeline, CompositeVideoFrameUniforms}; + +pub struct ShadowLayer { + frame_texture: wgpu::Texture, + frame_texture_view: wgpu::TextureView, + uniforms_buffer: wgpu::Buffer, + pipeline: CompositeVideoFramePipeline, + bind_group: Option, + hidden: bool, +} + +impl ShadowLayer { + pub fn new(device: &wgpu::Device) -> Self { + let frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1, 1); + let frame_texture_view = frame_texture.create_view(&Default::default()); + + let pipeline = CompositeVideoFramePipeline::new(device); + + let uniforms_buffer = device.create_buffer_init( + &(wgpu::util::BufferInitDescriptor { + label: Some("ShadowLayer Uniforms Buffer"), + contents: bytemuck::cast_slice(&[CompositeVideoFrameUniforms::default()]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }), + ); + + let bind_group = Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view)); + + Self { + frame_texture, + frame_texture_view, + uniforms_buffer, + pipeline, + bind_group, + hidden: true, + } + } + + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + uniforms: Option, + ) { + self.hidden = uniforms.is_none(); + + let Some(uniforms) = uniforms else { + return; + }; + + if self.frame_texture.width() != 1 || self.frame_texture.height() != 1 { + self.frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1, 1); + self.frame_texture_view = self.frame_texture.create_view(&Default::default()); + + self.bind_group = Some(self.pipeline.bind_group( + device, + &self.uniforms_buffer, + &self.frame_texture_view, + )); + } + + queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::cast_slice(&[uniforms])); + } + + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + if !self.hidden + && let Some(bind_group) = &self.bind_group + { + pass.set_pipeline(&self.pipeline.render_pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..4, 0..1); + } + } +} diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 97cee487f9..9ba6edfaef 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -12,6 +12,7 @@ use futures::FutureExt; use futures::future::OptionFuture; use layers::{ Background, BackgroundLayer, BlurLayer, CameraLayer, CaptionsLayer, CursorLayer, DisplayLayer, + ShadowLayer, }; use specta::Type; use spring_mass_damper::SpringMassDamperSimulationConfig; @@ -344,6 +345,7 @@ pub struct ProjectUniforms { camera_only: Option, split_view_camera: Option, split_view_display: Option, + split_view_shadow: Option, interpolated_cursor: Option, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, @@ -816,7 +818,7 @@ impl ProjectUniforms { } }); - let (split_view_camera, split_view_display) = if scene.is_split_view() + let (split_view_camera, split_view_display, split_view_shadow) = if scene.is_split_view() || scene.is_transitioning_split_view() { let split_settings = project @@ -937,7 +939,7 @@ impl ProjectUniforms { velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, camera_motion_blur_amount: 0.0, - shadow: project.background.shadow, + shadow: 0.0, shadow_size: project .background .advanced_shadow @@ -958,7 +960,7 @@ impl ProjectUniforms { 0.0 } else { match split_settings.camera_side { - SplitViewSide::Left => 6.0, + SplitViewSide::Left => 10.0, SplitViewSide::Right => 5.0, } }, @@ -1028,19 +1030,19 @@ impl ProjectUniforms { velocity_uv: [0.0, 0.0], motion_blur_amount: 0.0, camera_motion_blur_amount: 0.0, - shadow: project.camera.shadow, + shadow: 0.0, shadow_size: project - .camera + .background .advanced_shadow .as_ref() .map_or(50.0, |s| s.size), shadow_opacity: project - .camera + .background .advanced_shadow .as_ref() .map_or(18.0, |s| s.opacity), shadow_blur: project - .camera + .background .advanced_shadow .as_ref() .map_or(50.0, |s| s.blur), @@ -1057,9 +1059,56 @@ impl ProjectUniforms { } }); - (split_camera, split_display) + // Compose a unified shadow that wraps both halves + let split_view_shadow = if split_settings.fullscreen { + None + } else { + let crop = Self::get_crop(&options, project); + let crop_x = crop.position.x as f32; + let crop_y = crop.position.y as f32; + let crop_w = crop.size.x as f32; + let crop_h = crop.size.y as f32; + + let transition_factor = scene.split_view_transition_opacity() as f32; + let adv = &project.background.advanced_shadow; + let adv_size = adv.as_ref().map_or(50.0, |s| s.size); + let adv_opacity = adv.as_ref().map_or(18.0, |s| s.opacity); + let adv_blur = adv.as_ref().map_or(50.0, |s| s.blur); + + Some(CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size: [options.screen_size.x as f32, options.screen_size.y as f32], + crop_bounds: [crop_x, crop_y, crop_x + crop_w, crop_y + crop_h], + target_bounds: [ + split_x_offset, + split_y_offset, + split_x_offset + split_width, + split_y_offset + split_height, + ], + target_size: [split_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, + mirror_x: 0.0, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: project.background.shadow * transition_factor, + shadow_size: adv_size, + shadow_opacity: adv_opacity * transition_factor, + shadow_blur: adv_blur, + opacity: 0.0, + rounding_mask: if split_settings.fullscreen { 0.0 } else { 15.0 }, + _padding: [0.0; 2], + }) + }; + + (split_camera, split_display, split_view_shadow) } else { - (None, None) + (None, None, None) }; Self { @@ -1071,6 +1120,7 @@ impl ProjectUniforms { camera_only, split_view_camera, split_view_display, + split_view_shadow: split_view_shadow, project: project.clone(), zoom, scene, @@ -1141,6 +1191,7 @@ pub struct RendererLayers { camera_only: CameraLayer, split_view_camera: CameraLayer, split_view_display: DisplayLayer, + split_view_shadow: ShadowLayer, #[allow(unused)] captions: CaptionsLayer, } @@ -1156,6 +1207,7 @@ impl RendererLayers { camera_only: CameraLayer::new(device), split_view_camera: CameraLayer::new(device), split_view_display: DisplayLayer::new(device), + split_view_shadow: ShadowLayer::new(device), captions: CaptionsLayer::new(device, queue), } } @@ -1240,6 +1292,12 @@ impl RendererLayers { })(), ); + self.split_view_shadow.prepare( + &constants.device, + &constants.queue, + uniforms.split_view_shadow, + ); + Ok(()) } @@ -1327,6 +1385,10 @@ impl RendererLayers { } if uniforms.scene.is_split_view() || uniforms.scene.is_transitioning_split_view() { + if uniforms.split_view_shadow.is_some() { + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); + self.split_view_shadow.render(&mut pass); + } let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.split_view_display.render(&mut pass); self.split_view_camera.render(&mut pass); From 64edd90be0dc406d8d2d5e9e765b3032bc532896 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:42:17 +0100 Subject: [PATCH 06/10] cleanup --- .../src/routes/editor/ConfigSidebar.tsx | 24 +- .../src/routes/editor/SceneSegmentConfig.tsx | 761 +++++----- .../src/routes/editor/Timeline/ClipTrack.tsx | 1340 ++++++++--------- .../src/routes/editor/Timeline/SceneTrack.tsx | 931 ++++++------ .../src/routes/editor/Timeline/ZoomTrack.tsx | 1003 ++++++------ apps/desktop/src/routes/editor/context.ts | 20 +- crates/rendering/src/composite_frame.rs | 4 +- crates/rendering/src/lib.rs | 1 - 8 files changed, 2047 insertions(+), 2037 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 29114acb81..e28250f62e 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -54,12 +54,12 @@ import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; -import { SceneSegmentConfig } from "./SceneSegmentConfig"; import { DEFAULT_GRADIENT_FROM, DEFAULT_GRADIENT_TO, type RGBColor, } from "./projectConfig"; +import { SceneSegmentConfig } from "./SceneSegmentConfig"; import ShadowSettings from "./ShadowSettings"; import { TextInput } from "./TextInput"; import { @@ -2295,11 +2295,12 @@ function OldSceneSegmentConfig(props: { const { setProject, setEditorState, projectActions } = useEditorContext(); // Initialize split view settings if not present - const splitViewSettings = (): SplitViewSettings => props.segment.splitViewSettings || { - cameraPosition: { x: 0.5, y: 0.5 }, - screenPosition: { x: 0.5, y: 0.5 }, - cameraSide: "right", - }; + const splitViewSettings = (): SplitViewSettings => + props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + }; return ( <> @@ -2376,7 +2377,7 @@ function OldSceneSegmentConfig(props: {
- + }>
@@ -2390,7 +2391,10 @@ function OldSceneSegmentConfig(props: { "sceneSegments", props.segmentIndex, "splitViewSettings", - { ...currentSettings, cameraSide: value as "left" | "right" }, + { + ...currentSettings, + cameraSide: value as "left" | "right", + }, ); }} class="flex flex-row gap-2" @@ -2409,12 +2413,12 @@ function OldSceneSegmentConfig(props: { - + {/* Position controls are now handled in SceneSegmentConfig */}
Please use the new scene segment controls
- + {/* Position controls are now handled in SceneSegmentConfig */}
Please use the new scene segment controls
diff --git a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx index af1522a8e8..71af251044 100644 --- a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx +++ b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx @@ -1,414 +1,415 @@ import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; import { createSignal, For, Show } from "solid-js"; -import { type SceneSegment, type SplitViewSettings } from "~/utils/tauri"; -import { EditorButton, Slider } from "./ui"; -import { useEditorContext } from "./context"; import { Toggle } from "~/components/Toggle"; -import IconLucideCheck from "~icons/lucide/check"; -import IconLucideMonitor from "~icons/lucide/monitor"; -import IconLucideLayout from "~icons/lucide/layout"; -import IconLucideVideo from "~icons/lucide/video"; -import IconLucideEyeOff from "~icons/lucide/eye-off"; +import type { SceneSegment, SplitViewSettings } from "~/utils/tauri"; +import IconCapTrash from "~icons/iconoir/trash"; import IconLucideAlignLeft from "~icons/lucide/align-left"; import IconLucideAlignRight from "~icons/lucide/align-right"; -import IconCapTrash from "~icons/iconoir/trash"; -import IconLucideSettings from "~icons/lucide/settings"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideClipboardCopy from "~icons/lucide/clipboard-copy"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideLayout from "~icons/lucide/layout"; import IconLucideMaximize from "~icons/lucide/maximize"; import IconLucideMinimize from "~icons/lucide/minimize"; -import IconLucideCopy from "~icons/lucide/copy"; -import IconLucideClipboardCopy from "~icons/lucide/clipboard-copy"; +import IconLucideMonitor from "~icons/lucide/monitor"; +import IconLucideSettings from "~icons/lucide/settings"; +import IconLucideVideo from "~icons/lucide/video"; +import { useEditorContext } from "./context"; +import { EditorButton, Slider } from "./ui"; function SimplePositionControl(props: { - position: { x: number; y: number }; - onChange: (position: { x: number; y: number }) => void; - label: string; + position: { x: number; y: number }; + onChange: (position: { x: number; y: number }) => void; + label: string; }) { - const [isDragging, setIsDragging] = createSignal(false); + const [isDragging, setIsDragging] = createSignal(false); - return ( -
{ - const rect = e.currentTarget.getBoundingClientRect(); - setIsDragging(true); + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); - const updatePosition = (clientX: number, clientY: number) => { - const x = Math.max( - 0, - Math.min(1, (clientX - rect.left) / rect.width) - ); - const y = Math.max( - 0, - Math.min(1, (clientY - rect.top) / rect.height) - ); - props.onChange({ x, y }); - }; + const updatePosition = (clientX: number, clientY: number) => { + const x = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + const y = Math.max( + 0, + Math.min(1, (clientY - rect.top) / rect.height), + ); + props.onChange({ x, y }); + }; - updatePosition(e.clientX, e.clientY); + updatePosition(e.clientX, e.clientY); - const handleMouseMove = (moveEvent: MouseEvent) => { - updatePosition(moveEvent.clientX, moveEvent.clientY); - }; + const handleMouseMove = (moveEvent: MouseEvent) => { + updatePosition(moveEvent.clientX, moveEvent.clientY); + }; - const handleMouseUp = () => { - setIsDragging(false); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; + const handleMouseUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }} - > - {/* Grid lines for reference */} -
-
-
-
+ window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }} + > + {/* Grid lines for reference */} +
+
+
+
- {/* Position indicator */} -
-
- ); + {/* Position indicator */} +
+
+ ); } export function SceneSegmentConfig(props: { - segmentIndex: number; - segment: SceneSegment; + segmentIndex: number; + segment: SceneSegment; }) { - const { setProject, setEditorState, projectActions, project, totalDuration } = useEditorContext(); + const { setProject, setEditorState, projectActions, project, totalDuration } = + useEditorContext(); + + // Initialize split view settings if not present + const splitViewSettings = (): SplitViewSettings => + props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + cameraZoom: 1.0, + screenZoom: 1.0, + fullscreen: false, + }; + + const layoutOptions = [ + { + value: "default", + label: "Default", + icon: , + description: "Screen with camera overlay", + }, + { + value: "splitView", + label: "Split View", + icon: , + description: "Side-by-side layout", + }, + { + value: "cameraOnly", + label: "Camera Only", + icon: , + description: "Full screen camera", + }, + { + value: "hideCamera", + label: "Hide Camera", + icon: , + description: "Screen recording only", + }, + ]; + + // Check if duplication is possible + const canDuplicate = () => { + const segmentDuration = props.segment.end - props.segment.start; + const newSegmentEnd = props.segment.end + segmentDuration; - // Initialize split view settings if not present - const splitViewSettings = (): SplitViewSettings => - props.segment.splitViewSettings || { - cameraPosition: { x: 0.5, y: 0.5 }, - screenPosition: { x: 0.5, y: 0.5 }, - cameraSide: "right", - cameraZoom: 1.0, - screenZoom: 1.0, - fullscreen: false, - }; + // Check if it would exceed timeline duration + if (newSegmentEnd > totalDuration()) { + return false; + } - const layoutOptions = [ - { - value: "default", - label: "Default", - icon: , - description: "Screen with camera overlay", - }, - { - value: "splitView", - label: "Split View", - icon: , - description: "Side-by-side layout", - }, - { - value: "cameraOnly", - label: "Camera Only", - icon: , - description: "Full screen camera", - }, - { - value: "hideCamera", - label: "Hide Camera", - icon: , - description: "Screen recording only", - }, - ]; + // Check for overlaps with other scene segments + const wouldOverlap = project.timeline?.sceneSegments?.some((s, i) => { + if (i === props.segmentIndex) return false; + return props.segment.end < s.end && newSegmentEnd > s.start; + }); - // Check if duplication is possible - const canDuplicate = () => { - const segmentDuration = props.segment.end - props.segment.start; - const newSegmentEnd = props.segment.end + segmentDuration; - - // Check if it would exceed timeline duration - if (newSegmentEnd > totalDuration()) { - return false; - } - - // Check for overlaps with other scene segments - const wouldOverlap = project.timeline?.sceneSegments?.some((s, i) => { - if (i === props.segmentIndex) return false; - return (props.segment.end < s.end && newSegmentEnd > s.start); - }); - - return !wouldOverlap; - }; + return !wouldOverlap; + }; - return ( -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.duplicateSceneSegment(props.segmentIndex); - }} - leftIcon={} - disabled={!canDuplicate()} - title={!canDuplicate() ? "Not enough space in timeline" : undefined} - > - Duplicate - - { - projectActions.deleteSceneSegment(props.segmentIndex); - }} - leftIcon={} - class="text-red-11" - > - Delete - -
-
+ return ( +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.duplicateSceneSegment(props.segmentIndex); + }} + leftIcon={} + disabled={!canDuplicate()} + title={!canDuplicate() ? "Not enough space in timeline" : undefined} + > + Duplicate + + { + projectActions.deleteSceneSegment(props.segmentIndex); + }} + leftIcon={} + class="text-red-11" + > + Delete + +
+
-
-
- - Camera Layout -
+
+
+ + Camera Layout +
- { - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "mode", - v as "default" | "cameraOnly" | "hideCamera" | "splitView" - ); - }} - class="grid grid-cols-2 gap-2" - > - - {(option) => ( - - - -
- {option.icon} - - {option.label} - -
- {option.description} -
-
- )} -
-
-
+ { + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "mode", + v as "default" | "cameraOnly" | "hideCamera" | "splitView", + ); + }} + class="grid grid-cols-2 gap-2" + > + + {(option) => ( + + + +
+ {option.icon} + + {option.label} + +
+ {option.description} +
+
+ )} +
+
+
- -
-
- - - Split View Settings - -
+ +
+
+ + + Split View Settings + +
-
-
-
-
- - - Fill entire frame without padding - -
- { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, fullscreen: checked } - ); - }} - /> -
-
+
+
+
+
+ + + Fill entire frame without padding + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, fullscreen: checked }, + ); + }} + /> +
+
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { - ...currentSettings, - cameraSide: value as "left" | "right", - } - ); - }} - class="grid grid-cols-2 gap-2" - > - - - - - Left - - - - - - - Right - - - -
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { + ...currentSettings, + cameraSide: value as "left" | "right", + }, + ); + }} + class="grid grid-cols-2 gap-2" + > + + + + + Left + + + + + + + Right + + + +
-
-
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, cameraPosition: pos } - ); - }} - label="Camera" - /> -
-
-
- - - {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)} - % - -
- { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, cameraZoom: value } - ); - }} - /> -
-
+
+
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraPosition: pos }, + ); + }} + label="Camera" + /> +
+
+
+ + + {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraZoom: value }, + ); + }} + /> +
+
-
-
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, screenPosition: pos } - ); - }} - label="Screen" - /> -
-
-
- - - {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)} - % - -
- { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { ...currentSettings, screenZoom: value } - ); - }} - /> -
-
-
-
-
- +
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenPosition: pos }, + ); + }} + label="Screen" + /> +
+
+
+ + + {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenZoom: value }, + ); + }} + /> +
+
+
+
+
+
- i !== props.segmentIndex && s.mode === props.segment.mode - ) - }> -
- { - projectActions.copySceneSettingsFromOriginal(props.segmentIndex); - }} - leftIcon={} - class="w-full" - > - Copy Settings from Original - -
-
-
- ); + i !== props.segmentIndex && s.mode === props.segment.mode, + )} + > +
+ { + projectActions.copySceneSettingsFromOriginal(props.segmentIndex); + }} + leftIcon={} + class="w-full" + > + Copy Settings from Original + +
+
+
+ ); } diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index aa39d29ee3..de90e722bc 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -1,19 +1,19 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - type ComponentProps, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Match, - mergeProps, - Show, - Switch, + type ComponentProps, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Match, + mergeProps, + Show, + Switch, } from "solid-js"; import { produce } from "solid-js/store"; @@ -22,682 +22,682 @@ import { useEditorContext } from "../context"; import { useSegmentContext, useTimelineContext } from "./context"; import { getSectionMarker } from "./sectionMarker"; import { - SegmentContent, - SegmentHandle, - SegmentRoot, - TrackRoot, - useSegmentTranslateX, - useSegmentWidth, + SegmentContent, + SegmentHandle, + SegmentRoot, + TrackRoot, + useSegmentTranslateX, + useSegmentWidth, } from "./Track"; function WaveformCanvas(props: { - systemWaveform?: number[]; - micWaveform?: number[]; - segment: { start: number; end: number }; - secsPerPixel: number; + systemWaveform?: number[]; + micWaveform?: number[]; + segment: { start: number; end: number }; + secsPerPixel: number; }) { - const { project } = useEditorContext(); - - let canvas: HTMLCanvasElement | undefined; - const { width } = useSegmentContext(); - const { secsPerPixel } = useTimelineContext(); - - const render = ( - ctx: CanvasRenderingContext2D, - h: number, - waveform: number[], - color: string, - gain = 0 - ) => { - const maxAmplitude = h; - - // yellow please - ctx.fillStyle = color; - ctx.beginPath(); - - const step = 0.05 / secsPerPixel(); - - ctx.moveTo(0, h); - - const norm = (w: number) => { - const ww = Number.isFinite(w) ? w : -60; - return 1.0 - Math.max(ww + gain, -60) / -60; - }; - - for ( - let segmentTime = props.segment.start; - segmentTime <= props.segment.end + 0.1; - segmentTime += 0.1 - ) { - const index = Math.floor(segmentTime * 10); - const xTime = index / 10; - - const currentDb = - typeof waveform[index] === "number" ? waveform[index] : -60; - const amplitude = norm(currentDb) * maxAmplitude; - - const x = (xTime - props.segment.start) / secsPerPixel(); - const y = h - amplitude; - - const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); - const prevDb = - typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; - const prevAmplitude = norm(prevDb) * maxAmplitude; - const prevY = h - prevAmplitude; - - const cpX1 = prevX + step / 2; - const cpX2 = x - step / 2; - - ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); - } - - ctx.lineTo( - (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), - h - ); - - ctx.closePath(); - ctx.fill(); - }; - - function renderWaveforms() { - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const w = width(); - if (w <= 0) return; - - const h = canvas.height; - canvas.width = w; - ctx.clearRect(0, 0, w, h); - - if (props.micWaveform) - render( - ctx, - h, - props.micWaveform, - "rgba(255,255,255,0.4)", - project.audio.micVolumeDb - ); - - if (props.systemWaveform) - render( - ctx, - h, - props.systemWaveform, - "rgba(255,150,0,0.5)", - project.audio.systemVolumeDb - ); - } - - createEffect(() => { - renderWaveforms(); - }); - - return ( - { - canvas = el; - renderWaveforms(); - }} - class="absolute inset-0 w-full h-full pointer-events-none" - height={52} - /> - ); + const { project } = useEditorContext(); + + let canvas: HTMLCanvasElement | undefined; + const { width } = useSegmentContext(); + const { secsPerPixel } = useTimelineContext(); + + const render = ( + ctx: CanvasRenderingContext2D, + h: number, + waveform: number[], + color: string, + gain = 0, + ) => { + const maxAmplitude = h; + + // yellow please + ctx.fillStyle = color; + ctx.beginPath(); + + const step = 0.05 / secsPerPixel(); + + ctx.moveTo(0, h); + + const norm = (w: number) => { + const ww = Number.isFinite(w) ? w : -60; + return 1.0 - Math.max(ww + gain, -60) / -60; + }; + + for ( + let segmentTime = props.segment.start; + segmentTime <= props.segment.end + 0.1; + segmentTime += 0.1 + ) { + const index = Math.floor(segmentTime * 10); + const xTime = index / 10; + + const currentDb = + typeof waveform[index] === "number" ? waveform[index] : -60; + const amplitude = norm(currentDb) * maxAmplitude; + + const x = (xTime - props.segment.start) / secsPerPixel(); + const y = h - amplitude; + + const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); + const prevDb = + typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; + const prevAmplitude = norm(prevDb) * maxAmplitude; + const prevY = h - prevAmplitude; + + const cpX1 = prevX + step / 2; + const cpX2 = x - step / 2; + + ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); + } + + ctx.lineTo( + (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), + h, + ); + + ctx.closePath(); + ctx.fill(); + }; + + function renderWaveforms() { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = width(); + if (w <= 0) return; + + const h = canvas.height; + canvas.width = w; + ctx.clearRect(0, 0, w, h); + + if (props.micWaveform) + render( + ctx, + h, + props.micWaveform, + "rgba(255,255,255,0.4)", + project.audio.micVolumeDb, + ); + + if (props.systemWaveform) + render( + ctx, + h, + props.systemWaveform, + "rgba(255,150,0,0.5)", + project.audio.systemVolumeDb, + ); + } + + createEffect(() => { + renderWaveforms(); + }); + + return ( + { + canvas = el; + renderWaveforms(); + }} + class="absolute inset-0 w-full h-full pointer-events-none" + height={52} + /> + ); } export function ClipTrack( - props: Pick, "ref"> & { - handleUpdatePlayhead: (e: MouseEvent) => void; - } + props: Pick, "ref"> & { + handleUpdatePlayhead: (e: MouseEvent) => void; + }, ) { - const { - project, - setProject, - projectActions, - editorInstance, - projectHistory, - editorState, - setEditorState, - totalDuration, - micWaveforms, - systemAudioWaveforms, - } = useEditorContext(); - - const { secsPerPixel, duration } = useTimelineContext(); - - const [startResizePreview, setStartResizePreview] = createSignal<{ - index: number; - previewStart: number; - } | null>(null); - - const segments = (): Array => - project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; - - function onHandleReleased() { - const { transform } = editorState.timeline; - - if (transform.position + transform.zoom > totalDuration() + 4) { - transform.updateZoom( - totalDuration(), - editorState.previewTime ?? editorState.playbackTime - ); - } - } - - const hasMultipleRecordingSegments = () => - editorInstance.recordings.segments.length > 1; - - return ( - - - {(segment, i) => { - const prevDuration = () => - segments() - .slice(0, i()) - .reduce((t, s) => t + (s.end - s.start) / s.timescale, 0); - - const relativeSegment = mergeProps(segment, () => ({ - start: prevDuration(), - end: segment.end - segment.start + prevDuration(), - })); - - const segmentX = useSegmentTranslateX(() => relativeSegment); - const segmentWidth = useSegmentWidth(() => relativeSegment); - - const segmentRecording = (s = i()) => - editorInstance.recordings.segments[ - segments()[s].recordingSegment ?? 0 - ]; - - const marker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "left", - })); - - const endMarker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "right", - })); - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "clip") return false; - - const segmentIndex = project.timeline?.segments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end - ); - - return segmentIndex === selection.index; - }); - - const micWaveform = () => { - if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) - return; - - const idx = segment.recordingSegment ?? i(); - return micWaveforms()?.[idx] ?? []; - }; - - const systemAudioWaveform = () => { - if ( - project.audio.systemVolumeDb && - project.audio.systemVolumeDb <= -30 - ) - return; - - const idx = segment.recordingSegment ?? i(); - return systemAudioWaveforms()?.[idx] ?? []; - }; - - return ( - <> - - {(marker) => ( -
-
- - { - const m = marker(); - if (m.type === "single") return m.value; - })()} - > - {(marker) => ( -
- { - const m = marker(); - return m.type === "time" ? m.time : 0; - })()} - onClick={() => { - setProject( - "timeline", - "segments", - produce((s) => { - if (marker().type === "reset") { - s[i() - 1].end = s[i()].end; - s.splice(i(), 1); - } else { - s[i() - 1].end = s[i()].start; - } - }) - ); - }} - /> -
- )} -
- { - const m = marker(); - if ( - m.type === "dual" && - m.right && - m.right.type === "time" - ) - return m.right; - })()} - > - {(marker) => { - const markerValue = marker(); - return ( -
- { - setProject( - "timeline", - "segments", - i(), - "start", - 0 - ); - }} - /> -
- ); - }} -
-
-
- )} - - { - e.stopPropagation(); - - if (editorState.timeline.interactMode === "split") { - const rect = e.currentTarget.getBoundingClientRect(); - const fraction = (e.clientX - rect.left) / rect.width; - - const splitTime = fraction * (segment.end - segment.start); - - projectActions.splitClipSegment(prevDuration() + splitTime); - } else { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - // If there's only one segment, don't open the clip config panel - // since there's nothing to configure - just let the normal click behavior happen - const hasOnlyOneSegment = segments().length === 1; - - if (hasOnlyOneSegment) { - // Clear any existing selection (zoom, layout, etc.) when clicking on a clip - // This ensures the sidebar updates properly - setEditorState("timeline", "selection", null); - } else { - // When there are multiple segments, show the clip configuration - setEditorState("timeline", "selection", { - type: "clip", - index: i(), - }); - } - props.handleUpdatePlayhead(e); - }); - }); - } - }} - > - - - - - { - const start = segment.start; - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0 - ); - - const maxDuration = Math.min( - maxSegmentDuration, - availableTimelineDuration - ); - - const prevSegment = segments()[i() - 1]; - const prevSegmentIsSameClip = - prevSegment?.recordingSegment !== undefined - ? prevSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newStart = - start + - (event.clientX - downEvent.clientX) * secsPerPixel(); - - const constrained = Math.min( - Math.max( - newStart, - prevSegmentIsSameClip ? prevSegment.end : 0, - segment.end - maxDuration - ), - segment.end - 1 - ); - - setStartResizePreview({ - index: i(), - previewStart: constrained, - }); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - const p = startResizePreview(); - if (p && p.index === i()) { - setProject( - "timeline", - "segments", - i(), - "start", - p.previewStart - ); - } - setStartResizePreview(null); - onHandleReleased(); - }, - }); - }); - }} - /> - { - const p = startResizePreview(); - return p && p.index === i(); - })()} - > - {() => { - const p = startResizePreview(); - const previewWidth = () => - (segment.end - (p?.previewStart ?? segment.start)) / - secsPerPixel(); - const leftOffset = () => - ((p?.previewStart ?? segment.start) - segment.start) / - secsPerPixel(); - return ( -
- ); - }} - - - {(() => { - const ctx = useSegmentContext(); - - return ( - 100}> -
- - {hasMultipleRecordingSegments() - ? `Clip ${segment.recordingSegment}` - : "Clip"} - -
- {" "} - {(segment.end - segment.start).toFixed(1)}s -
-
-
- ); - })()} -
- { - const end = segment.end; - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0 - ); - - const nextSegment = segments()[i() + 1]; - const nextSegmentIsSameClip = - nextSegment?.recordingSegment !== undefined - ? nextSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newEnd = - end + - (event.clientX - downEvent.clientX) * secsPerPixel(); - - setProject( - "timeline", - "segments", - i(), - "end", - Math.max( - Math.min( - newEnd, - segment.end + availableTimelineDuration, - nextSegmentIsSameClip - ? nextSegment.start - : maxSegmentDuration - ), - segment.start + 1 - ) - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - onHandleReleased(); - }, - }); - }); - }} - /> - - { - const m = endMarker(); - if (m?.type === "dual" && m.left && m.left.type === "time") - return m.left; - })()} - > - {(marker) => ( -
-
-
- { - const m = marker(); - return m.type === "time" ? m.time : 0; - })()} - class="-right-px absolute rounded-l-full !pr-1.5 rounded-tr-full" - onClick={() => { - setProject( - "timeline", - "segments", - i(), - "end", - segmentRecording().display.duration - ); - }} - /> -
-
- )} - - - ); - }} - - - ); + const { + project, + setProject, + projectActions, + editorInstance, + projectHistory, + editorState, + setEditorState, + totalDuration, + micWaveforms, + systemAudioWaveforms, + } = useEditorContext(); + + const { secsPerPixel, duration } = useTimelineContext(); + + const [startResizePreview, setStartResizePreview] = createSignal<{ + index: number; + previewStart: number; + } | null>(null); + + const segments = (): Array => + project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; + + function onHandleReleased() { + const { transform } = editorState.timeline; + + if (transform.position + transform.zoom > totalDuration() + 4) { + transform.updateZoom( + totalDuration(), + editorState.previewTime ?? editorState.playbackTime, + ); + } + } + + const hasMultipleRecordingSegments = () => + editorInstance.recordings.segments.length > 1; + + return ( + + + {(segment, i) => { + const prevDuration = () => + segments() + .slice(0, i()) + .reduce((t, s) => t + (s.end - s.start) / s.timescale, 0); + + const relativeSegment = mergeProps(segment, () => ({ + start: prevDuration(), + end: segment.end - segment.start + prevDuration(), + })); + + const segmentX = useSegmentTranslateX(() => relativeSegment); + const segmentWidth = useSegmentWidth(() => relativeSegment); + + const segmentRecording = (s = i()) => + editorInstance.recordings.segments[ + segments()[s].recordingSegment ?? 0 + ]; + + const marker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "left", + })); + + const endMarker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "right", + })); + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "clip") return false; + + const segmentIndex = project.timeline?.segments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end, + ); + + return segmentIndex === selection.index; + }); + + const micWaveform = () => { + if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) + return; + + const idx = segment.recordingSegment ?? i(); + return micWaveforms()?.[idx] ?? []; + }; + + const systemAudioWaveform = () => { + if ( + project.audio.systemVolumeDb && + project.audio.systemVolumeDb <= -30 + ) + return; + + const idx = segment.recordingSegment ?? i(); + return systemAudioWaveforms()?.[idx] ?? []; + }; + + return ( + <> + + {(marker) => ( +
+
+ + { + const m = marker(); + if (m.type === "single") return m.value; + })()} + > + {(marker) => ( +
+ { + const m = marker(); + return m.type === "time" ? m.time : 0; + })()} + onClick={() => { + setProject( + "timeline", + "segments", + produce((s) => { + if (marker().type === "reset") { + s[i() - 1].end = s[i()].end; + s.splice(i(), 1); + } else { + s[i() - 1].end = s[i()].start; + } + }), + ); + }} + /> +
+ )} +
+ { + const m = marker(); + if ( + m.type === "dual" && + m.right && + m.right.type === "time" + ) + return m.right; + })()} + > + {(marker) => { + const markerValue = marker(); + return ( +
+ { + setProject( + "timeline", + "segments", + i(), + "start", + 0, + ); + }} + /> +
+ ); + }} +
+
+
+ )} + + { + e.stopPropagation(); + + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + + const splitTime = fraction * (segment.end - segment.start); + + projectActions.splitClipSegment(prevDuration() + splitTime); + } else { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + // If there's only one segment, don't open the clip config panel + // since there's nothing to configure - just let the normal click behavior happen + const hasOnlyOneSegment = segments().length === 1; + + if (hasOnlyOneSegment) { + // Clear any existing selection (zoom, layout, etc.) when clicking on a clip + // This ensures the sidebar updates properly + setEditorState("timeline", "selection", null); + } else { + // When there are multiple segments, show the clip configuration + setEditorState("timeline", "selection", { + type: "clip", + index: i(), + }); + } + props.handleUpdatePlayhead(e); + }); + }); + } + }} + > + + + + + { + const start = segment.start; + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0, + ); + + const maxDuration = Math.min( + maxSegmentDuration, + availableTimelineDuration, + ); + + const prevSegment = segments()[i() - 1]; + const prevSegmentIsSameClip = + prevSegment?.recordingSegment !== undefined + ? prevSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newStart = + start + + (event.clientX - downEvent.clientX) * secsPerPixel(); + + const constrained = Math.min( + Math.max( + newStart, + prevSegmentIsSameClip ? prevSegment.end : 0, + segment.end - maxDuration, + ), + segment.end - 1, + ); + + setStartResizePreview({ + index: i(), + previewStart: constrained, + }); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + const p = startResizePreview(); + if (p && p.index === i()) { + setProject( + "timeline", + "segments", + i(), + "start", + p.previewStart, + ); + } + setStartResizePreview(null); + onHandleReleased(); + }, + }); + }); + }} + /> + { + const p = startResizePreview(); + return p && p.index === i(); + })()} + > + {() => { + const p = startResizePreview(); + const previewWidth = () => + (segment.end - (p?.previewStart ?? segment.start)) / + secsPerPixel(); + const leftOffset = () => + ((p?.previewStart ?? segment.start) - segment.start) / + secsPerPixel(); + return ( +
+ ); + }} + + + {(() => { + const ctx = useSegmentContext(); + + return ( + 100}> +
+ + {hasMultipleRecordingSegments() + ? `Clip ${segment.recordingSegment}` + : "Clip"} + +
+ {" "} + {(segment.end - segment.start).toFixed(1)}s +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0, + ); + + const nextSegment = segments()[i() + 1]; + const nextSegmentIsSameClip = + nextSegment?.recordingSegment !== undefined + ? nextSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newEnd = + end + + (event.clientX - downEvent.clientX) * secsPerPixel(); + + setProject( + "timeline", + "segments", + i(), + "end", + Math.max( + Math.min( + newEnd, + segment.end + availableTimelineDuration, + nextSegmentIsSameClip + ? nextSegment.start + : maxSegmentDuration, + ), + segment.start + 1, + ), + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + onHandleReleased(); + }, + }); + }); + }} + /> + + { + const m = endMarker(); + if (m?.type === "dual" && m.left && m.left.type === "time") + return m.left; + })()} + > + {(marker) => ( +
+
+
+ { + const m = marker(); + return m.type === "time" ? m.time : 0; + })()} + class="-right-px absolute rounded-l-full !pr-1.5 rounded-tr-full" + onClick={() => { + setProject( + "timeline", + "segments", + i(), + "end", + segmentRecording().display.duration, + ); + }} + /> +
+
+ )} + + + ); + }} + + + ); } function Markings(props: { segment: TimelineSegment; prevDuration: number }) { - const { editorState } = useEditorContext(); - const { secsPerPixel, markingResolution } = useTimelineContext(); - - const markings = () => { - const resolution = markingResolution(); - - const { transform } = editorState.timeline; - const visibleMin = - transform.position - props.prevDuration + props.segment.start; - const visibleMax = visibleMin + transform.zoom; - - const start = Math.floor(visibleMin / resolution); - - return Array.from( - { length: Math.ceil(visibleMax / resolution) - start }, - (_, i) => (start + i) * resolution - ); - }; - - return ( - - {(marking) => ( -
- )} - - ); + const { editorState } = useEditorContext(); + const { secsPerPixel, markingResolution } = useTimelineContext(); + + const markings = () => { + const resolution = markingResolution(); + + const { transform } = editorState.timeline; + const visibleMin = + transform.position - props.prevDuration + props.segment.start; + const visibleMax = visibleMin + transform.zoom; + + const start = Math.floor(visibleMin / resolution); + + return Array.from( + { length: Math.ceil(visibleMax / resolution) - start }, + (_, i) => (start + i) * resolution, + ); + }; + + return ( + + {(marking) => ( +
+ )} + + ); } function CutOffsetButton(props: { - value: number; - class?: string; - onClick?(): void; + value: number; + class?: string; + onClick?(): void; }) { - const formatTime = (t: number) => - t < 1 ? Math.round(t * 10) / 10 : Math.round(t); - - return ( - - ); + const formatTime = (t: number) => + t < 1 ? Math.round(t * 10) / 10 : Math.round(t); + + return ( + + ); } function useSectionMarker( - props: () => { - segments: TimelineSegment[]; - i: number; - position: "left" | "right"; - } + props: () => { + segments: TimelineSegment[]; + i: number; + position: "left" | "right"; + }, ) { - const { editorInstance } = useEditorContext(); + const { editorInstance } = useEditorContext(); - return () => getSectionMarker(props(), editorInstance.recordings.segments); + return () => getSectionMarker(props(), editorInstance.recordings.segments); } diff --git a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx index 5caad230a2..2981e4aab6 100644 --- a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx @@ -1,481 +1,484 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Show, + batch, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Show, } from "solid-js"; import { produce } from "solid-js/store"; import { useEditorContext } from "../context"; import { - useSegmentContext, - useTimelineContext, - useTrackContext, + useSegmentContext, + useTimelineContext, + useTrackContext, } from "./context"; import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; export type SceneSegmentDragState = - | { type: "idle" } - | { type: "movePending" } - | { type: "moving" }; + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; export function SceneTrack(props: { - onDragStateChanged: (v: SceneSegmentDragState) => void; - handleUpdatePlayhead: (e: MouseEvent) => void; + onDragStateChanged: (v: SceneSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; }) { - const { project, setProject, projectHistory, setEditorState, editorState } = - useEditorContext(); - - const { duration, secsPerPixel } = useTimelineContext(); - - const [hoveringSegment, setHoveringSegment] = createSignal(false); - const [hoveredTime, setHoveredTime] = createSignal(); - const [maxAvailableDuration, setMaxAvailableDuration] = - createSignal(3); - - // When we delete a segment that's being hovered, the onMouseLeave never fires - // because the element gets removed from the DOM. This leaves hoveringSegment stuck - // as true, which blocks the onMouseMove from setting hoveredTime, preventing - // users from creating new segments. This effect ensures we reset the hover state - // when all segments are deleted. - createEffect(() => { - const segments = project.timeline?.sceneSegments; - if (!segments || segments.length === 0) { - setHoveringSegment(false); - } - }); - - const getSceneIcon = (mode: string | undefined) => { - switch (mode) { - case "cameraOnly": - return ; - case "hideCamera": - return ; - case "splitView": - return ; - default: - return ; - } - }; - - const getSceneLabel = (mode: string | undefined) => { - switch (mode) { - case "cameraOnly": - return "Camera Only"; - case "hideCamera": - return "Hide Camera"; - case "splitView": - return "Split View"; - default: - return "Default"; - } - }; - - return ( - { - if (hoveringSegment()) { - setHoveredTime(undefined); - return; - } - - const bounds = e.target.getBoundingClientRect()!; - - let time = - (e.clientX - bounds.left) * secsPerPixel() + - editorState.timeline.transform.position; - - const segments = project.timeline?.sceneSegments || []; - const nextSegmentIndex = segments.findIndex((s) => time < s.start); - - let maxDuration = 3; // Default duration - - if (nextSegmentIndex !== -1) { - const nextSegment = segments[nextSegmentIndex]; - const prevSegmentIndex = nextSegmentIndex - 1; - - if (prevSegmentIndex >= 0) { - const prevSegment = segments[prevSegmentIndex]; - const gapStart = prevSegment.end; - const gapEnd = nextSegment.start; - const availableSpace = gapEnd - gapStart; - - if (availableSpace < 0.5) { - setHoveredTime(undefined); - return; - } - - if (time < gapStart) { - time = gapStart; - } - - maxDuration = Math.min(3, gapEnd - time); - } else { - // No previous segment, only next segment - maxDuration = Math.min(3, nextSegment.start - time); - } - - if (nextSegment.start - time < 0.5) { - setHoveredTime(undefined); - return; - } - } else if (segments.length > 0) { - const lastSegment = segments[segments.length - 1]; - if (time < lastSegment.end) { - time = lastSegment.end; - } - maxDuration = Math.min(3, duration() - time); - } else { - maxDuration = Math.min(3, duration() - time); - } - - if (maxDuration < 0.5) { - setHoveredTime(undefined); - return; - } - - setMaxAvailableDuration(maxDuration); - setHoveredTime(Math.min(time, duration() - maxDuration)); - }} - onMouseLeave={() => { - setHoveredTime(); - setMaxAvailableDuration(3); - }} - onMouseDown={(e) => { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - const time = hoveredTime(); - const maxDuration = maxAvailableDuration(); - if (time === undefined) return; - - e.stopPropagation(); - batch(() => { - setProject("timeline", "sceneSegments", (v) => v ?? []); - setProject( - "timeline", - "sceneSegments", - produce((sceneSegments) => { - sceneSegments ??= []; - - let index = sceneSegments.length; - - for (let i = sceneSegments.length - 1; i >= 0; i--) { - if (sceneSegments[i].start > time) { - index = i; - break; - } - } - - sceneSegments.splice(index, 0, { - start: time, - end: time + maxDuration, - mode: "cameraOnly", - }); - }) - ); - }); - }); - }); - }} - > - -
Click to add scene segment
-
- (Make the camera full screen, or hide it) -
-
- } - > - {(segment, i) => { - const { setTrackState } = useTrackContext(); - - const sceneSegments = () => project.timeline!.sceneSegments!; - - function createMouseDownDrag( - setup: () => T, - _update: (e: MouseEvent, v: T, initialMouseX: number) => void - ) { - return (downEvent: MouseEvent) => { - downEvent.stopPropagation(); - - const initial = setup(); - - let moved = false; - let initialMouseX: null | number = null; - - setTrackState("draggingSegment", true); - - const resumeHistory = projectHistory.pause(); - - props.onDragStateChanged({ type: "movePending" }); - - function finish(e: MouseEvent) { - resumeHistory(); - if (!moved) { - e.stopPropagation(); - setEditorState("timeline", "selection", { - type: "scene", - index: i(), - }); - props.handleUpdatePlayhead(e); - } else { - setEditorState("timeline", "selection", { - type: "scene", - index: i(), - }); - } - props.onDragStateChanged({ type: "idle" }); - setTrackState("draggingSegment", false); - } - - function update(event: MouseEvent) { - if (Math.abs(event.clientX - downEvent.clientX) > 2) { - if (!moved) { - moved = true; - initialMouseX = event.clientX; - props.onDragStateChanged({ - type: "moving", - }); - } - } - - if (initialMouseX === null) return; - - _update(event, initial, initialMouseX); - } - - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: (e) => { - update(e); - }, - mouseup: (e) => { - update(e); - finish(e); - dispose(); - }, - }); - }); - }; - } - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "scene") return false; - - const segmentIndex = project.timeline?.sceneSegments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end - ); - - return segmentIndex === selection.index; - }); - - return ( - { - setHoveringSegment(true); - }} - onMouseLeave={() => { - setHoveringSegment(false); - }} - > - { - const start = segment.start; - - let minValue = 0; - - const maxValue = segment.end - 1; - - for (let i = sceneSegments().length - 1; i >= 0; i--) { - const segment = sceneSegments()[i]!; - if (segment.end <= start) { - minValue = segment.end; - break; - } - } - - return { start, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newStart = - value.start + - (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "sceneSegments", - i(), - "start", - Math.min( - value.maxValue, - Math.max(value.minValue, newStart) - ) - ); - - setProject( - "timeline", - "sceneSegments", - produce((s) => { - if (s) { - s.sort((a, b) => a.start - b.start); - } - }) - ); - } - )} - /> - { - const original = { ...segment }; - - const prevSegment = sceneSegments()[i() - 1]; - const nextSegment = sceneSegments()[i() + 1]; - - const minStart = prevSegment?.end ?? 0; - const maxEnd = nextSegment?.start ?? duration(); - - return { - original, - minStart, - maxEnd, - }; - }, - (e, value, initialMouseX) => { - const rawDelta = - (e.clientX - initialMouseX) * secsPerPixel(); - - const newStart = value.original.start + rawDelta; - const newEnd = value.original.end + rawDelta; - - let delta = rawDelta; - - if (newStart < value.minStart) - delta = value.minStart - value.original.start; - else if (newEnd > value.maxEnd) - delta = value.maxEnd - value.original.end; - - setProject("timeline", "sceneSegments", i(), { - start: value.original.start + delta, - end: value.original.end + delta, - }); - } - )} - > - {(() => { - const ctx = useSegmentContext(); - - return ( - 80}> -
- Scene -
- {getSceneIcon(segment.mode)} - {ctx.width() > 120 && ( - - {getSceneLabel(segment.mode)} - - )} -
-
-
- ); - })()} -
- { - const end = segment.end; - - const minValue = segment.start + 1; - - let maxValue = duration(); - - for (let i = 0; i < sceneSegments().length; i++) { - const segment = sceneSegments()[i]!; - if (segment.start > end) { - maxValue = segment.start; - break; - } - } - - return { end, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newEnd = - value.end + (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "sceneSegments", - i(), - "end", - Math.min(value.maxValue, Math.max(value.minValue, newEnd)) - ); - - setProject( - "timeline", - "sceneSegments", - produce((s) => { - if (s) { - s.sort((a, b) => a.start - b.start); - } - }) - ); - } - )} - /> -
- ); - }} -
- - {(time) => ( - - -

- + -

-
-
- )} -
- - ); + const { project, setProject, projectHistory, setEditorState, editorState } = + useEditorContext(); + + const { duration, secsPerPixel } = useTimelineContext(); + + const [hoveringSegment, setHoveringSegment] = createSignal(false); + const [hoveredTime, setHoveredTime] = createSignal(); + const [maxAvailableDuration, setMaxAvailableDuration] = + createSignal(3); + + // When we delete a segment that's being hovered, the onMouseLeave never fires + // because the element gets removed from the DOM. This leaves hoveringSegment stuck + // as true, which blocks the onMouseMove from setting hoveredTime, preventing + // users from creating new segments. This effect ensures we reset the hover state + // when all segments are deleted. + createEffect(() => { + const segments = project.timeline?.sceneSegments; + if (!segments || segments.length === 0) { + setHoveringSegment(false); + } + }); + + const getSceneIcon = (mode: string | undefined) => { + switch (mode) { + case "cameraOnly": + return ; + case "hideCamera": + return ; + case "splitView": + return ; + default: + return ; + } + }; + + const getSceneLabel = (mode: string | undefined) => { + switch (mode) { + case "cameraOnly": + return "Camera Only"; + case "hideCamera": + return "Hide Camera"; + case "splitView": + return "Split View"; + default: + return "Default"; + } + }; + + return ( + { + if (hoveringSegment()) { + setHoveredTime(undefined); + return; + } + + const bounds = e.target.getBoundingClientRect()!; + + let time = + (e.clientX - bounds.left) * secsPerPixel() + + editorState.timeline.transform.position; + + const segments = project.timeline?.sceneSegments || []; + const nextSegmentIndex = segments.findIndex((s) => time < s.start); + + let maxDuration = 3; // Default duration + + if (nextSegmentIndex !== -1) { + const nextSegment = segments[nextSegmentIndex]; + const prevSegmentIndex = nextSegmentIndex - 1; + + if (prevSegmentIndex >= 0) { + const prevSegment = segments[prevSegmentIndex]; + const gapStart = prevSegment.end; + const gapEnd = nextSegment.start; + const availableSpace = gapEnd - gapStart; + + if (availableSpace < 0.5) { + setHoveredTime(undefined); + return; + } + + if (time < gapStart) { + time = gapStart; + } + + maxDuration = Math.min(3, gapEnd - time); + } else { + // No previous segment, only next segment + maxDuration = Math.min(3, nextSegment.start - time); + } + + if (nextSegment.start - time < 0.5) { + setHoveredTime(undefined); + return; + } + } else if (segments.length > 0) { + const lastSegment = segments[segments.length - 1]; + if (time < lastSegment.end) { + time = lastSegment.end; + } + maxDuration = Math.min(3, duration() - time); + } else { + maxDuration = Math.min(3, duration() - time); + } + + if (maxDuration < 0.5) { + setHoveredTime(undefined); + return; + } + + setMaxAvailableDuration(maxDuration); + setHoveredTime(Math.min(time, duration() - maxDuration)); + }} + onMouseLeave={() => { + setHoveredTime(); + setMaxAvailableDuration(3); + }} + onMouseDown={(e) => { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + const time = hoveredTime(); + const maxDuration = maxAvailableDuration(); + if (time === undefined) return; + + e.stopPropagation(); + batch(() => { + setProject("timeline", "sceneSegments", (v) => v ?? []); + setProject( + "timeline", + "sceneSegments", + produce((sceneSegments) => { + sceneSegments ??= []; + + let index = sceneSegments.length; + + for (let i = sceneSegments.length - 1; i >= 0; i--) { + if (sceneSegments[i].start > time) { + index = i; + break; + } + } + + sceneSegments.splice(index, 0, { + start: time, + end: time + maxDuration, + mode: "cameraOnly", + }); + }), + ); + }); + }); + }); + }} + > + +
Click to add scene segment
+
+ (Make the camera full screen, or hide it) +
+
+ } + > + {(segment, i) => { + const { setTrackState } = useTrackContext(); + + const sceneSegments = () => project.timeline!.sceneSegments!; + + function createMouseDownDrag( + setup: () => T, + _update: (e: MouseEvent, v: T, initialMouseX: number) => void, + ) { + return (downEvent: MouseEvent) => { + downEvent.stopPropagation(); + + const initial = setup(); + + let moved = false; + let initialMouseX: null | number = null; + + setTrackState("draggingSegment", true); + + const resumeHistory = projectHistory.pause(); + + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + setEditorState("timeline", "selection", { + type: "scene", + index: i(), + }); + props.handleUpdatePlayhead(e); + } else { + setEditorState("timeline", "selection", { + type: "scene", + index: i(), + }); + } + props.onDragStateChanged({ type: "idle" }); + setTrackState("draggingSegment", false); + } + + function update(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ + type: "moving", + }); + } + } + + if (initialMouseX === null) return; + + _update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => { + update(e); + }, + mouseup: (e) => { + update(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "scene") return false; + + const segmentIndex = project.timeline?.sceneSegments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end, + ); + + return segmentIndex === selection.index; + }); + + return ( + { + setHoveringSegment(true); + }} + onMouseLeave={() => { + setHoveringSegment(false); + }} + > + { + const start = segment.start; + + let minValue = 0; + + const maxValue = segment.end - 1; + + for (let i = sceneSegments().length - 1; i >= 0; i--) { + const segment = sceneSegments()[i]!; + if (segment.end <= start) { + minValue = segment.end; + break; + } + } + + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newStart = + value.start + + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "sceneSegments", + i(), + "start", + Math.min( + value.maxValue, + Math.max(value.minValue, newStart), + ), + ); + + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (s) { + s.sort((a, b) => a.start - b.start); + } + }), + ); + }, + )} + /> + { + const original = { ...segment }; + + const prevSegment = sceneSegments()[i() - 1]; + const nextSegment = sceneSegments()[i() + 1]; + + const minStart = prevSegment?.end ?? 0; + const maxEnd = nextSegment?.start ?? duration(); + + return { + original, + minStart, + maxEnd, + }; + }, + (e, value, initialMouseX) => { + const rawDelta = + (e.clientX - initialMouseX) * secsPerPixel(); + + const newStart = value.original.start + rawDelta; + const newEnd = value.original.end + rawDelta; + + let delta = rawDelta; + + if (newStart < value.minStart) + delta = value.minStart - value.original.start; + else if (newEnd > value.maxEnd) + delta = value.maxEnd - value.original.end; + + setProject("timeline", "sceneSegments", i(), { + start: value.original.start + delta, + end: value.original.end + delta, + }); + }, + )} + > + {(() => { + const ctx = useSegmentContext(); + + return ( + 80}> +
+ Scene +
+ {getSceneIcon(segment.mode)} + {ctx.width() > 120 && ( + + {getSceneLabel(segment.mode)} + + )} +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + const minValue = segment.start + 1; + + let maxValue = duration(); + + for (let i = 0; i < sceneSegments().length; i++) { + const segment = sceneSegments()[i]!; + if (segment.start > end) { + maxValue = segment.start; + break; + } + } + + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newEnd = + value.end + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "sceneSegments", + i(), + "end", + Math.min( + value.maxValue, + Math.max(value.minValue, newEnd), + ), + ); + + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (s) { + s.sort((a, b) => a.start - b.start); + } + }), + ); + }, + )} + /> +
+ ); + }} +
+ + {(time) => ( + + +

+ + +

+
+
+ )} +
+ + ); } diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index c674d092a5..d38b5f6996 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -1,518 +1,521 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { Menu } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Show, + batch, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Show, } from "solid-js"; import { produce } from "solid-js/store"; import { commands } from "~/utils/tauri"; import { useEditorContext } from "../context"; import { - useSegmentContext, - useTimelineContext, - useTrackContext, + useSegmentContext, + useTimelineContext, + useTrackContext, } from "./context"; import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; export type ZoomSegmentDragState = - | { type: "idle" } - | { type: "movePending" } - | { type: "moving" }; + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; export function ZoomTrack(props: { - onDragStateChanged: (v: ZoomSegmentDragState) => void; - handleUpdatePlayhead: (e: MouseEvent) => void; + onDragStateChanged: (v: ZoomSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; }) { - const { project, setProject, projectHistory, setEditorState, editorState } = - useEditorContext(); - - const { duration, secsPerPixel } = useTimelineContext(); - - const [hoveringSegment, setHoveringSegment] = createSignal(false); - const [hoveredTime, setHoveredTime] = createSignal(); - - // When we delete a segment that's being hovered, the onMouseLeave never fires - // because the element gets removed from the DOM. This leaves hoveringSegment stuck - // as true, which blocks the onMouseMove from setting hoveredTime, preventing - // users from creating new segments. This effect ensures we reset the hover state - // when all segments are deleted. - createEffect(() => { - const segments = project.timeline?.zoomSegments; - if (!segments || segments.length === 0) { - setHoveringSegment(false); - setHoveredTime(undefined); - } - }); - - const handleGenerateZoomSegments = async () => { - try { - const zoomSegments = await commands.generateZoomSegmentsFromClicks(); - setProject("timeline", "zoomSegments", zoomSegments); - } catch (error) { - console.error("Failed to generate zoom segments:", error); - } - }; - - return ( - { - if (!import.meta.env.DEV) return; - - e.preventDefault(); - const menu = await Menu.new({ - id: "zoom-track-options", - items: [ - { - id: "generateZoomSegments", - text: "Generate zoom segments from clicks", - action: handleGenerateZoomSegments, - }, - ], - }); - menu.popup(); - }} - onMouseMove={(e) => { - if (hoveringSegment()) { - setHoveredTime(undefined); - return; - } - - const bounds = e.currentTarget.getBoundingClientRect()!; - - let time = - (e.clientX - bounds.left) * secsPerPixel() + - editorState.timeline.transform.position; - - const nextSegmentIndex = project.timeline?.zoomSegments?.findIndex( - (s) => time < s.start - ); - - if (nextSegmentIndex !== undefined) { - const prevSegmentIndex = nextSegmentIndex - 1; - - if (prevSegmentIndex === undefined) return; - - const nextSegment = - project.timeline?.zoomSegments?.[nextSegmentIndex]; - - if (prevSegmentIndex !== undefined && nextSegment) { - const prevSegment = - project.timeline?.zoomSegments?.[prevSegmentIndex]; - - if (prevSegment) { - const availableTime = nextSegment?.start - prevSegment?.end; - - if (availableTime < 1) return; - } - } - - if (nextSegment && nextSegment.start - time < 1) { - time = nextSegment.start - 1; - } - } - - setHoveredTime(Math.min(time, duration() - 1)); - }} - onMouseLeave={() => setHoveredTime()} - onMouseDown={(e) => { - createRoot((dispose) => { - createEventListener(e.currentTarget, "mouseup", (e) => { - dispose(); - - const time = hoveredTime(); - if (time === undefined) return; - - e.stopPropagation(); - batch(() => { - setProject("timeline", "zoomSegments", (v) => v ?? []); - setProject( - "timeline", - "zoomSegments", - produce((zoomSegments) => { - zoomSegments ??= []; - - let index = zoomSegments.length; - - for (let i = zoomSegments.length - 1; i >= 0; i--) { - if (zoomSegments[i].start > time) { - index = i; - break; - } - } - - zoomSegments.splice(index, 0, { - start: time, - end: time + 1, - amount: 1.5, - mode: { - manual: { - x: 0.5, - y: 0.5, - }, - }, - }); - }) - ); - }); - }); - }); - }} - > - -
Click to add zoom segment
-
- (Smoothly zoom in on important areas) -
-
- } - > - {(segment, i) => { - const { setTrackState } = useTrackContext(); - - const zoomPercentage = () => { - const amount = segment.amount; - return `${amount.toFixed(1)}x`; - }; - - const zoomSegments = () => project.timeline!.zoomSegments!; - - function createMouseDownDrag( - setup: () => T, - _update: (e: MouseEvent, v: T, initialMouseX: number) => void - ) { - return (downEvent: MouseEvent) => { - downEvent.stopPropagation(); - - const initial = setup(); - - let moved = false; - let initialMouseX: null | number = null; - - setTrackState("draggingSegment", true); - - const resumeHistory = projectHistory.pause(); - - props.onDragStateChanged({ type: "movePending" }); - - function finish(e: MouseEvent) { - resumeHistory(); - if (!moved) { - e.stopPropagation(); - - const currentSelection = editorState.timeline.selection; - const segmentIndex = i(); - - // Handle multi-selection with Ctrl/Cmd+click - if (e.ctrlKey || e.metaKey) { - if (currentSelection?.type === "zoom") { - // Normalize to indices[] from either indices[] or legacy index - const baseIndices = - "indices" in currentSelection && - Array.isArray(currentSelection.indices) - ? currentSelection.indices - : "index" in currentSelection && - typeof currentSelection.index === "number" - ? [currentSelection.index] - : []; - - const exists = baseIndices.includes(segmentIndex); - const newIndices = exists - ? baseIndices.filter((idx) => idx !== segmentIndex) - : [...baseIndices, segmentIndex]; - - setEditorState("timeline", "selection", { - type: "zoom", - indices: newIndices, - }); - } else { - // Start new multi-selection - setEditorState("timeline", "selection", { - type: "zoom", - indices: [segmentIndex], - }); - } - } else { - setEditorState("timeline", "selection", { - type: "zoom", - indices: [segmentIndex], - }); - } - props.handleUpdatePlayhead(e); - } - props.onDragStateChanged({ type: "idle" }); - setTrackState("draggingSegment", false); - } - - function update(event: MouseEvent) { - if (Math.abs(event.clientX - downEvent.clientX) > 2) { - if (!moved) { - moved = true; - initialMouseX = event.clientX; - props.onDragStateChanged({ - type: "moving", - }); - } - } - - if (initialMouseX === null) return; - - _update(event, initial, initialMouseX); - } - - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: (e) => { - update(e); - }, - mouseup: (e) => { - update(e); - finish(e); - dispose(); - }, - }); - }); - }; - } - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "zoom") return false; - - const segmentIndex = project.timeline?.zoomSegments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end - ); - - // Support both single selection (index) and multi-selection (indices) - if ( - "indices" in selection && - Array.isArray(selection.indices) && - segmentIndex !== undefined - ) { - return selection.indices.includes(segmentIndex); - } else if ( - "index" in selection && - typeof selection.index === "number" - ) { - return segmentIndex === selection.index; - } - - return false; - }); - - return ( - { - setHoveringSegment(true); - }} - onMouseLeave={() => { - setHoveringSegment(false); - }} - > - { - const start = segment.start; - - let minValue = 0; - - const maxValue = segment.end - 1; - - for (let i = zoomSegments().length - 1; i >= 0; i--) { - const segment = zoomSegments()[i]!; - if (segment.end <= start) { - minValue = segment.end; - break; - } - } - - return { start, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newStart = - value.start + - (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "zoomSegments", - i(), - "start", - Math.min( - value.maxValue, - Math.max(value.minValue, newStart) - ) - ); - - setProject( - "timeline", - "zoomSegments", - produce((s) => { - s.sort((a, b) => a.start - b.start); - }) - ); - } - )} - /> - { - const original = { ...segment }; - - const prevSegment = zoomSegments()[i() - 1]; - const nextSegment = zoomSegments()[i() + 1]; - - const minStart = prevSegment?.end ?? 0; - const maxEnd = nextSegment?.start ?? duration(); - - return { - original, - minStart, - maxEnd, - }; - }, - (e, value, initialMouseX) => { - const rawDelta = - (e.clientX - initialMouseX) * secsPerPixel(); - - const newStart = value.original.start + rawDelta; - const newEnd = value.original.end + rawDelta; - - let delta = rawDelta; - - if (newStart < value.minStart) - delta = value.minStart - value.original.start; - else if (newEnd > value.maxEnd) - delta = value.maxEnd - value.original.end; - - setProject("timeline", "zoomSegments", i(), { - start: value.original.start + delta, - end: value.original.end + delta, - }); - } - )} - > - {(() => { - const ctx = useSegmentContext(); - const width = ctx.width(); - - if (width < 40) { - // Very small - just show icon - return ( -
- -
- ); - } else if (width < 100) { - // Small - show icon and zoom amount - return ( -
- - {zoomPercentage()} -
- ); - } else { - // Large - show full content - return ( -
- Zoom -
- - {zoomPercentage()} -
-
- ); - } - })()} -
- { - const end = segment.end; - - const minValue = segment.start + 1; - - let maxValue = duration(); - - for (let i = 0; i < zoomSegments().length; i++) { - const segment = zoomSegments()[i]!; - if (segment.start > end) { - maxValue = segment.start; - break; - } - } - - return { end, minValue, maxValue }; - }, - (e, value, initialMouseX) => { - const newEnd = - value.end + (e.clientX - initialMouseX) * secsPerPixel(); - - setProject( - "timeline", - "zoomSegments", - i(), - "end", - Math.min(value.maxValue, Math.max(value.minValue, newEnd)) - ); - - setProject( - "timeline", - "zoomSegments", - produce((s) => { - s.sort((a, b) => a.start - b.start); - }) - ); - } - )} - /> -
- ); - }} - - - {(time) => ( - - -

- + -

-
-
- )} -
- - ); + const { project, setProject, projectHistory, setEditorState, editorState } = + useEditorContext(); + + const { duration, secsPerPixel } = useTimelineContext(); + + const [hoveringSegment, setHoveringSegment] = createSignal(false); + const [hoveredTime, setHoveredTime] = createSignal(); + + // When we delete a segment that's being hovered, the onMouseLeave never fires + // because the element gets removed from the DOM. This leaves hoveringSegment stuck + // as true, which blocks the onMouseMove from setting hoveredTime, preventing + // users from creating new segments. This effect ensures we reset the hover state + // when all segments are deleted. + createEffect(() => { + const segments = project.timeline?.zoomSegments; + if (!segments || segments.length === 0) { + setHoveringSegment(false); + setHoveredTime(undefined); + } + }); + + const handleGenerateZoomSegments = async () => { + try { + const zoomSegments = await commands.generateZoomSegmentsFromClicks(); + setProject("timeline", "zoomSegments", zoomSegments); + } catch (error) { + console.error("Failed to generate zoom segments:", error); + } + }; + + return ( + { + if (!import.meta.env.DEV) return; + + e.preventDefault(); + const menu = await Menu.new({ + id: "zoom-track-options", + items: [ + { + id: "generateZoomSegments", + text: "Generate zoom segments from clicks", + action: handleGenerateZoomSegments, + }, + ], + }); + menu.popup(); + }} + onMouseMove={(e) => { + if (hoveringSegment()) { + setHoveredTime(undefined); + return; + } + + const bounds = e.currentTarget.getBoundingClientRect()!; + + let time = + (e.clientX - bounds.left) * secsPerPixel() + + editorState.timeline.transform.position; + + const nextSegmentIndex = project.timeline?.zoomSegments?.findIndex( + (s) => time < s.start, + ); + + if (nextSegmentIndex !== undefined) { + const prevSegmentIndex = nextSegmentIndex - 1; + + if (prevSegmentIndex === undefined) return; + + const nextSegment = + project.timeline?.zoomSegments?.[nextSegmentIndex]; + + if (prevSegmentIndex !== undefined && nextSegment) { + const prevSegment = + project.timeline?.zoomSegments?.[prevSegmentIndex]; + + if (prevSegment) { + const availableTime = nextSegment?.start - prevSegment?.end; + + if (availableTime < 1) return; + } + } + + if (nextSegment && nextSegment.start - time < 1) { + time = nextSegment.start - 1; + } + } + + setHoveredTime(Math.min(time, duration() - 1)); + }} + onMouseLeave={() => setHoveredTime()} + onMouseDown={(e) => { + createRoot((dispose) => { + createEventListener(e.currentTarget, "mouseup", (e) => { + dispose(); + + const time = hoveredTime(); + if (time === undefined) return; + + e.stopPropagation(); + batch(() => { + setProject("timeline", "zoomSegments", (v) => v ?? []); + setProject( + "timeline", + "zoomSegments", + produce((zoomSegments) => { + zoomSegments ??= []; + + let index = zoomSegments.length; + + for (let i = zoomSegments.length - 1; i >= 0; i--) { + if (zoomSegments[i].start > time) { + index = i; + break; + } + } + + zoomSegments.splice(index, 0, { + start: time, + end: time + 1, + amount: 1.5, + mode: { + manual: { + x: 0.5, + y: 0.5, + }, + }, + }); + }), + ); + }); + }); + }); + }} + > + +
Click to add zoom segment
+
+ (Smoothly zoom in on important areas) +
+
+ } + > + {(segment, i) => { + const { setTrackState } = useTrackContext(); + + const zoomPercentage = () => { + const amount = segment.amount; + return `${amount.toFixed(1)}x`; + }; + + const zoomSegments = () => project.timeline!.zoomSegments!; + + function createMouseDownDrag( + setup: () => T, + _update: (e: MouseEvent, v: T, initialMouseX: number) => void, + ) { + return (downEvent: MouseEvent) => { + downEvent.stopPropagation(); + + const initial = setup(); + + let moved = false; + let initialMouseX: null | number = null; + + setTrackState("draggingSegment", true); + + const resumeHistory = projectHistory.pause(); + + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + + const currentSelection = editorState.timeline.selection; + const segmentIndex = i(); + + // Handle multi-selection with Ctrl/Cmd+click + if (e.ctrlKey || e.metaKey) { + if (currentSelection?.type === "zoom") { + // Normalize to indices[] from either indices[] or legacy index + const baseIndices = + "indices" in currentSelection && + Array.isArray(currentSelection.indices) + ? currentSelection.indices + : "index" in currentSelection && + typeof currentSelection.index === "number" + ? [currentSelection.index] + : []; + + const exists = baseIndices.includes(segmentIndex); + const newIndices = exists + ? baseIndices.filter((idx) => idx !== segmentIndex) + : [...baseIndices, segmentIndex]; + + setEditorState("timeline", "selection", { + type: "zoom", + indices: newIndices, + }); + } else { + // Start new multi-selection + setEditorState("timeline", "selection", { + type: "zoom", + indices: [segmentIndex], + }); + } + } else { + setEditorState("timeline", "selection", { + type: "zoom", + indices: [segmentIndex], + }); + } + props.handleUpdatePlayhead(e); + } + props.onDragStateChanged({ type: "idle" }); + setTrackState("draggingSegment", false); + } + + function update(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ + type: "moving", + }); + } + } + + if (initialMouseX === null) return; + + _update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => { + update(e); + }, + mouseup: (e) => { + update(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "zoom") return false; + + const segmentIndex = project.timeline?.zoomSegments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end, + ); + + // Support both single selection (index) and multi-selection (indices) + if ( + "indices" in selection && + Array.isArray(selection.indices) && + segmentIndex !== undefined + ) { + return selection.indices.includes(segmentIndex); + } else if ( + "index" in selection && + typeof selection.index === "number" + ) { + return segmentIndex === selection.index; + } + + return false; + }); + + return ( + { + setHoveringSegment(true); + }} + onMouseLeave={() => { + setHoveringSegment(false); + }} + > + { + const start = segment.start; + + let minValue = 0; + + const maxValue = segment.end - 1; + + for (let i = zoomSegments().length - 1; i >= 0; i--) { + const segment = zoomSegments()[i]!; + if (segment.end <= start) { + minValue = segment.end; + break; + } + } + + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newStart = + value.start + + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "zoomSegments", + i(), + "start", + Math.min( + value.maxValue, + Math.max(value.minValue, newStart), + ), + ); + + setProject( + "timeline", + "zoomSegments", + produce((s) => { + s.sort((a, b) => a.start - b.start); + }), + ); + }, + )} + /> + { + const original = { ...segment }; + + const prevSegment = zoomSegments()[i() - 1]; + const nextSegment = zoomSegments()[i() + 1]; + + const minStart = prevSegment?.end ?? 0; + const maxEnd = nextSegment?.start ?? duration(); + + return { + original, + minStart, + maxEnd, + }; + }, + (e, value, initialMouseX) => { + const rawDelta = + (e.clientX - initialMouseX) * secsPerPixel(); + + const newStart = value.original.start + rawDelta; + const newEnd = value.original.end + rawDelta; + + let delta = rawDelta; + + if (newStart < value.minStart) + delta = value.minStart - value.original.start; + else if (newEnd > value.maxEnd) + delta = value.maxEnd - value.original.end; + + setProject("timeline", "zoomSegments", i(), { + start: value.original.start + delta, + end: value.original.end + delta, + }); + }, + )} + > + {(() => { + const ctx = useSegmentContext(); + const width = ctx.width(); + + if (width < 40) { + // Very small - just show icon + return ( +
+ +
+ ); + } else if (width < 100) { + // Small - show icon and zoom amount + return ( +
+ + {zoomPercentage()} +
+ ); + } else { + // Large - show full content + return ( +
+ Zoom +
+ + {zoomPercentage()} +
+
+ ); + } + })()} +
+ { + const end = segment.end; + + const minValue = segment.start + 1; + + let maxValue = duration(); + + for (let i = 0; i < zoomSegments().length; i++) { + const segment = zoomSegments()[i]!; + if (segment.start > end) { + maxValue = segment.start; + break; + } + } + + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const newEnd = + value.end + (e.clientX - initialMouseX) * secsPerPixel(); + + setProject( + "timeline", + "zoomSegments", + i(), + "end", + Math.min( + value.maxValue, + Math.max(value.minValue, newEnd), + ), + ); + + setProject( + "timeline", + "zoomSegments", + produce((s) => { + s.sort((a, b) => a.start - b.start); + }), + ); + }, + )} + /> +
+ ); + }} + + + {(time) => ( + + +

+ + +

+
+
+ )} +
+ + ); } diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index a54b75a954..d77e0825c8 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -165,25 +165,25 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( const segmentDuration = segment.end - segment.start; const newSegmentStart = segment.end; const newSegmentEnd = newSegmentStart + segmentDuration; - + // Check if there's enough space in the timeline const timelineDuration = totalDuration(); if (newSegmentEnd > timelineDuration) { // Not enough space for the duplicate return; } - + // Check if the new segment would overlap with any existing scene segments const wouldOverlap = project.timeline.sceneSegments.some((s, i) => { if (i === segmentIndex) return false; // Skip the original segment - return (newSegmentStart < s.end && newSegmentEnd > s.start); + return newSegmentStart < s.end && newSegmentEnd > s.start; }); - + if (wouldOverlap) { // Would overlap with another segment return; } - + batch(() => { setProject( "timeline", @@ -196,7 +196,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( start: newSegmentStart, end: newSegmentEnd, // Deep clone split view settings if present - splitViewSettings: segment.splitViewSettings + splitViewSettings: segment.splitViewSettings ? { ...segment.splitViewSettings } : undefined, }); @@ -217,15 +217,15 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( }, copySceneSettingsFromOriginal: (segmentIndex: number) => { if (!project.timeline?.sceneSegments?.[segmentIndex]) return; - + // Find the first segment with the same mode const currentSegment = project.timeline.sceneSegments[segmentIndex]; const originalSegment = project.timeline.sceneSegments.find( - (s, i) => i !== segmentIndex && s.mode === currentSegment.mode + (s, i) => i !== segmentIndex && s.mode === currentSegment.mode, ); - + if (!originalSegment) return; - + setProject( "timeline", "sceneSegments", diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 2ca8d72a5e..fd1cddf0a5 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -26,7 +26,7 @@ pub struct CompositeVideoFrameUniforms { pub shadow_opacity: f32, pub shadow_blur: f32, pub opacity: f32, - pub rounding_mask: f32, // Bitmask for which corners to round: 0x1=TL, 0x2=TR, 0x4=BL, 0x8=BR + pub rounding_mask: f32, pub _padding: [f32; 2], } @@ -48,7 +48,7 @@ impl Default for CompositeVideoFrameUniforms { shadow_opacity: Default::default(), shadow_blur: Default::default(), opacity: 1.0, - rounding_mask: 15.0, // Default: all corners rounded (0xF = 0b1111) + rounding_mask: 15.0, _padding: Default::default(), } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 9ba6edfaef..ce2286d0ad 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1059,7 +1059,6 @@ impl ProjectUniforms { } }); - // Compose a unified shadow that wraps both halves let split_view_shadow = if split_settings.fullscreen { None } else { From beea2b32d2b0571846c790d95409237f67b39594 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:47:54 +0100 Subject: [PATCH 07/10] cleanup --- .../src/routes/editor/ConfigSidebar.tsx | 144 ------------------ 1 file changed, 144 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index e28250f62e..717da9495d 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -2286,150 +2286,6 @@ function ClipSegmentConfig(props: { ); } -// SceneSegmentConfig moved to separate file for better organization -// The old implementation below is kept temporarily for reference -function OldSceneSegmentConfig(props: { - segmentIndex: number; - segment: SceneSegment; -}) { - const { setProject, setEditorState, projectActions } = useEditorContext(); - - // Initialize split view settings if not present - const splitViewSettings = (): SplitViewSettings => - props.segment.splitViewSettings || { - cameraPosition: { x: 0.5, y: 0.5 }, - screenPosition: { x: 0.5, y: 0.5 }, - cameraSide: "right", - }; - - return ( - <> -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.deleteSceneSegment(props.segmentIndex); - }} - leftIcon={} - > - Delete - -
- }> - { - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "mode", - v as "default" | "cameraOnly" | "hideCamera" | "splitView", - ); - }} - class="flex flex-col gap-3" - > -
- - - - Default - - - - - - Split View - - - - - - Camera Only - - - - - - Hide Camera - - -
- -
-
- {props.segment.mode === "cameraOnly" - ? "Shows only the camera feed" - : props.segment.mode === "hideCamera" - ? "Shows only the screen recording" - : props.segment.mode === "splitView" - ? `Shows screen on ${splitViewSettings().cameraSide === "left" ? "right" : "left"}, camera on ${splitViewSettings().cameraSide}` - : "Shows both screen and camera"} -
-
-
-
- - - }> -
- - { - const currentSettings = splitViewSettings(); - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "splitViewSettings", - { - ...currentSettings, - cameraSide: value as "left" | "right", - }, - ); - }} - class="flex flex-row gap-2" - > - - - - Left - - - - - - Right - - - - - - - {/* Position controls are now handled in SceneSegmentConfig */} -
Please use the new scene segment controls
-
- - - {/* Position controls are now handled in SceneSegmentConfig */} -
Please use the new scene segment controls
-
-
-
-
- - ); -} - function RgbInput(props: { value: [number, number, number]; onChange: (value: [number, number, number]) => void; From 97fa47b16278028fa2031ec7143a352a714e08a4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:49:31 +0100 Subject: [PATCH 08/10] cleanup --- apps/desktop/src/routes/editor/context.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index d77e0825c8..d01ec4b883 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -166,21 +166,17 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( const newSegmentStart = segment.end; const newSegmentEnd = newSegmentStart + segmentDuration; - // Check if there's enough space in the timeline const timelineDuration = totalDuration(); if (newSegmentEnd > timelineDuration) { - // Not enough space for the duplicate return; } - // Check if the new segment would overlap with any existing scene segments const wouldOverlap = project.timeline.sceneSegments.some((s, i) => { if (i === segmentIndex) return false; // Skip the original segment return newSegmentStart < s.end && newSegmentEnd > s.start; }); if (wouldOverlap) { - // Would overlap with another segment return; } @@ -190,26 +186,21 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( "sceneSegments", produce((s) => { if (!s) return; - // Insert duplicate right after the original s.splice(segmentIndex + 1, 0, { ...segment, start: newSegmentStart, end: newSegmentEnd, - // Deep clone split view settings if present splitViewSettings: segment.splitViewSettings ? { ...segment.splitViewSettings } : undefined, }); }), ); - // Select and click on the newly duplicated segment setEditorState("timeline", "selection", { type: "scene", index: segmentIndex + 1, }); - // Move playhead to the start of the new segment setEditorState("playbackTime", newSegmentStart); - // Center the timeline view on the new segment const currentZoom = editorState.timeline.transform.zoom; const targetPosition = Math.max(0, newSegmentStart - currentZoom / 2); editorState.timeline.transform.setPosition(targetPosition); @@ -218,7 +209,6 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( copySceneSettingsFromOriginal: (segmentIndex: number) => { if (!project.timeline?.sceneSegments?.[segmentIndex]) return; - // Find the first segment with the same mode const currentSegment = project.timeline.sceneSegments[segmentIndex]; const originalSegment = project.timeline.sceneSegments.find( (s, i) => i !== segmentIndex && s.mode === currentSegment.mode, @@ -232,11 +222,9 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( segmentIndex, produce((s) => { if (!s) return; - // Copy settings based on mode if (s.mode === "splitView" && originalSegment.splitViewSettings) { s.splitViewSettings = { ...originalSegment.splitViewSettings }; } - // Can add more mode-specific settings copying here in the future }), ); }, @@ -419,7 +407,7 @@ function transformMeta({ pretty_name, ...rawMeta }: RecordingMeta) { throw new Error("Instant mode recordings cannot be edited"); } - let meta; + let meta = null; if ("segments" in rawMeta) { meta = { From b234bd2a48e82e56bb0bb9f79bc9e340e58dc2db Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:32:16 +0100 Subject: [PATCH 09/10] Revert "Merge branch 'main' into editor-feats" This reverts commit d40e92d3c01c06e67624d051d60bc4b8dd7732db, reversing changes made to 97fa47b16278028fa2031ec7143a352a714e08a4. --- .claude/settings.local.json | 11 - .github/workflows/ci.yml | 5 - .github/workflows/docker-build-web.yml | 1 + .github/workflows/publish.yml | 20 +- AGENTS.md | 46 - CLAUDE.md | 21 - Cargo.lock | 2159 +++----- Cargo.toml | 5 +- apps/cli/Cargo.toml | 3 - apps/cli/src/record.rs | 26 +- apps/desktop/package.json | 7 +- apps/desktop/src-tauri/Cargo.toml | 6 +- .../src-tauri/capabilities/default.json | 1 - apps/desktop/src-tauri/src/camera.rs | 21 +- apps/desktop/src-tauri/src/camera_legacy.rs | 8 +- .../desktop/src-tauri/src/deeplink_actions.rs | 4 +- apps/desktop/src-tauri/src/hotkeys.rs | 57 +- apps/desktop/src-tauri/src/lib.rs | 240 +- apps/desktop/src-tauri/src/main.rs | 43 +- .../src-tauri/src/platform/macos/mod.rs | 3 - .../platform/macos/sc_shareable_content.rs | 238 - apps/desktop/src-tauri/src/recording.rs | 577 +- .../src-tauri/src/recording_settings.rs | 62 - .../src-tauri/src/target_select_overlay.rs | 40 - apps/desktop/src-tauri/src/thumbnails/mac.rs | 378 -- apps/desktop/src-tauri/src/thumbnails/mod.rs | 149 - .../src-tauri/src/thumbnails/windows.rs | 279 - apps/desktop/src-tauri/src/tray.rs | 6 +- apps/desktop/src-tauri/src/upload.rs | 169 +- apps/desktop/src-tauri/src/windows.rs | 113 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- .../src/routes/(window-chrome)/(main).tsx | 95 +- .../(window-chrome)/new-main/CameraSelect.tsx | 2 +- .../new-main/MicrophoneSelect.tsx | 2 +- .../(window-chrome)/new-main/SystemAudio.tsx | 2 +- .../(window-chrome)/new-main/TargetCard.tsx | 218 - .../new-main/TargetDropdownButton.tsx | 44 - .../new-main/TargetMenuGrid.tsx | 192 - .../new-main/TargetTypeButton.tsx | 48 +- .../routes/(window-chrome)/new-main/index.tsx | 551 +- .../(window-chrome)/settings/experimental.tsx | 114 +- .../(window-chrome)/settings/general.tsx | 249 +- .../(window-chrome)/settings/hotkeys.tsx | 53 +- .../(window-chrome)/settings/license.tsx | 2 +- .../(window-chrome)/settings/recordings.tsx | 34 +- .../src/routes/(window-chrome)/setup.tsx | 176 +- .../src/routes/editor/ConfigSidebar.tsx | 4642 ++++++++--------- apps/desktop/src/routes/editor/Editor.tsx | 6 +- .../src/routes/editor/ExportDialog.tsx | 70 +- apps/desktop/src/routes/editor/Header.tsx | 94 +- .../desktop/src/routes/editor/ShareButton.tsx | 106 +- .../src/routes/editor/Timeline/ClipTrack.tsx | 54 +- .../src/routes/editor/Timeline/ZoomTrack.tsx | 6 - apps/desktop/src/routes/editor/ui.tsx | 2 +- .../src/routes/in-progress-recording.tsx | 39 +- .../desktop/src/routes/recordings-overlay.tsx | 106 +- .../src/routes/target-select-overlay.tsx | 93 +- apps/desktop/src/store.ts | 14 +- apps/desktop/src/styles/theme.css | 17 - apps/desktop/src/utils/createPresets.ts | 1 - apps/desktop/src/utils/queries.ts | 48 +- apps/desktop/src/utils/tauri.ts | 45 +- apps/discord-bot/package.json | 2 +- apps/tasks/package.json | 1 + .../actions/analytics/track-user-signed-up.ts | 53 - .../actions/billing/track-meta-purchase.ts | 124 - apps/web/actions/caps/share.ts | 3 +- apps/web/actions/folders/moveVideoToFolder.ts | 7 +- apps/web/actions/folders/updateFolder.ts | 5 +- apps/web/actions/organization/create-space.ts | 50 +- apps/web/actions/organization/delete-space.ts | 40 +- apps/web/actions/organization/update-space.ts | 24 +- .../organization/upload-organization-icon.ts | 49 +- .../actions/organization/upload-space-icon.ts | 31 +- apps/web/actions/organizations/add-videos.ts | 3 +- .../actions/organizations/remove-videos.ts | 3 +- .../web/actions/screenshots/get-screenshot.ts | 67 + apps/web/actions/spaces/add-videos.ts | 6 +- apps/web/actions/spaces/remove-videos.ts | 3 +- apps/web/actions/video/upload.ts | 55 +- apps/web/actions/videos/download.ts | 15 +- apps/web/actions/videos/edit-date.ts | 3 +- apps/web/actions/videos/edit-title.ts | 3 +- apps/web/actions/videos/edit-transcript.ts | 32 +- .../actions/videos/generate-ai-metadata.ts | 39 +- apps/web/actions/videos/get-og-image.tsx | 19 +- apps/web/actions/videos/get-status.ts | 11 +- apps/web/actions/videos/get-transcript.ts | 23 +- apps/web/actions/videos/get-user-videos.ts | 6 +- apps/web/actions/videos/new-comment.ts | 7 +- apps/web/actions/videos/password.ts | 15 +- apps/web/app/(org)/dashboard/Contexts.tsx | 17 +- .../_components/AnimatedIcons/Refer.tsx | 1 - .../dashboard/_components/DashboardInner.tsx | 376 +- .../(org)/dashboard/_components/MobileTab.tsx | 183 - .../dashboard/_components/Navbar/CapAIBox.tsx | 4 +- .../_components/Navbar/CapAIDialog.tsx | 4 +- .../dashboard/_components/Navbar/Desktop.tsx | 25 +- .../dashboard/_components/Navbar/Items.tsx | 93 +- .../dashboard/_components/Navbar/Mobile.tsx | 10 +- .../_components/Navbar/SpaceDialog.tsx | 2 +- .../_components/Navbar/SpacesList.tsx | 3 +- .../dashboard/_components/Navbar/Top.tsx | 402 -- .../(org)/dashboard/_components/actions.ts | 2 +- .../dashboard/admin/AdminDashboardClient.tsx | 110 + .../app/(org)/dashboard/admin/UserLookup.tsx | 64 + apps/web/app/(org)/dashboard/admin/actions.ts | 301 ++ .../(org)/dashboard/admin/dateRangeUtils.ts | 51 + .../web/app/(org)/dashboard/admin/loading.tsx | 40 + apps/web/app/(org)/dashboard/admin/page.tsx | 13 + apps/web/app/(org)/dashboard/caps/Caps.tsx | 87 +- .../(org)/dashboard/caps/UploadingContext.tsx | 103 +- .../caps/components/CapCard/CapCard.tsx | 341 +- .../caps/components/CapCard/CapCardButton.tsx | 41 - .../components/CapCard/CapCardButtons.tsx | 173 + .../dashboard/caps/components/Folder.tsx | 17 +- .../caps/components/FoldersDropdown.tsx | 2 +- .../caps/components/NewFolderDialog.tsx | 4 +- .../caps/components/PasswordDialog.tsx | 3 +- .../caps/components/SelectedCapsBar.tsx | 6 +- .../caps/components/SharingDialog.tsx | 5 +- .../caps/components/UploadCapButton.tsx | 735 ++- .../caps/components/UploadPlaceholderCard.tsx | 48 +- apps/web/app/(org)/dashboard/caps/page.tsx | 17 +- .../web/app/(org)/dashboard/dashboard-data.ts | 1 - .../folder/[id]/components/BreadcrumbItem.tsx | 4 +- .../[id]/components/ClientMyCapsLink.tsx | 5 +- .../[id]/components/FolderVideosSection.tsx | 56 +- .../[id]/components/SubfolderDialog.tsx | 2 +- .../components/UploadCapButtonWithFolder.tsx | 35 + .../app/(org)/dashboard/folder/[id]/page.tsx | 113 +- apps/web/app/(org)/dashboard/layout.tsx | 20 +- .../dashboard/settings/account/Settings.tsx | 77 +- .../dashboard/settings/account/server.ts | 66 - .../CustomDomainDialog/CustomDomainDialog.tsx | 2 +- .../CustomDomainDialog/DomainStep.tsx | 5 +- .../CustomDomainDialog/VerifyStep.tsx | 2 +- .../dashboard/settings/organization/page.tsx | 25 +- .../dashboard/spaces/[spaceId]/SharedCaps.tsx | 1 - .../dashboard/spaces/[spaceId]/actions.ts | 8 +- .../components/AddVideosDialogBase.tsx | 33 +- .../[spaceId]/components/SharedCapCard.tsx | 2 +- .../spaces/[spaceId]/components/VideoCard.tsx | 5 +- .../components/VirtualizedVideoGrid.tsx | 13 +- .../[spaceId]/folder/[folderId]/page.tsx | 129 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 156 +- .../(org)/dashboard/spaces/[spaceId]/utils.ts | 48 - apps/web/app/(org)/invite/[inviteId]/page.tsx | 8 +- apps/web/app/(org)/login/form.tsx | 43 +- apps/web/app/(org)/signup/form.tsx | 494 -- apps/web/app/(org)/signup/page.tsx | 29 - apps/web/app/(org)/verify-otp/form.tsx | 12 +- apps/web/app/(org)/verify-otp/page.tsx | 7 +- .../(seo)/free-screen-recorder/page.tsx | 36 - .../(site)/(seo)/screen-recorder-mac/page.tsx | 36 - .../(seo)/screen-recorder-windows/page.tsx | 36 - .../app/(site)/(seo)/screen-recorder/page.tsx | 5 - .../(seo)/screen-recording-software/page.tsx | 36 - .../(site)/(seo)/solutions/agencies/page.tsx | 132 - apps/web/app/(site)/Footer.tsx | 20 +- apps/web/app/(site)/Navbar.tsx | 126 +- apps/web/app/(site)/[slug]/page.tsx | 8 +- apps/web/app/(site)/blog/[slug]/page.tsx | 34 +- .../record-screen-mac-system-audio/page.tsx | 18 + apps/web/app/(site)/docs/[...slug]/page.tsx | 8 +- apps/web/app/(site)/docs/[slug]/page.tsx | 14 +- .../app/(site)/download/[platform]/route.ts | 3 +- .../features/instant-mode/InstantModePage.tsx | 309 -- .../app/(site)/features/instant-mode/page.tsx | 36 - .../features/studio-mode/StudioModePage.tsx | 309 -- .../app/(site)/features/studio-mode/page.tsx | 36 - .../{(seo) => }/loom-alternative/page.tsx | 0 apps/web/app/(site)/page.tsx | 2 +- .../solutions/daily-standup-software/page.tsx | 57 +- .../employee-onboarding-platform/page.tsx | 54 +- .../solutions/online-classroom-tools/page.tsx | 57 +- .../remote-team-collaboration/page.tsx | 0 apps/web/app/(site)/student-discount/page.tsx | 12 - .../tools/convert/[conversionPath]/page.tsx | 14 +- apps/web/app/Layout/GTag.tsx | 64 - apps/web/app/Layout/Intercom/Client.tsx | 3 +- apps/web/app/Layout/MetaPixel.tsx | 90 - apps/web/app/Layout/PosthogIdentify.tsx | 17 +- apps/web/app/Layout/PurchaseTracker.tsx | 50 - apps/web/app/Layout/devtoolsServer.ts | 42 - apps/web/app/Layout/features.ts | 38 - apps/web/app/Layout/providers.tsx | 88 +- .../app/api/desktop/[...route]/s3Config.ts | 3 +- .../web/app/api/desktop/[...route]/session.ts | 2 + apps/web/app/api/desktop/[...route]/video.ts | 223 +- apps/web/app/api/erpc/route.ts | 4 +- .../api/notifications/preferences/route.ts | 2 - apps/web/app/api/notifications/route.ts | 2 - apps/web/app/api/playlist/route.ts | 30 +- apps/web/app/api/releases/macos/route.ts | 52 + .../tauri/[version]/[target]/[arch]/route.ts | 11 +- apps/web/app/api/screenshot/route.ts | 112 + .../settings/billing/guest-checkout/route.ts | 2 +- .../api/settings/billing/subscribe/route.ts | 2 +- apps/web/app/api/settings/onboarding/route.ts | 4 + apps/web/app/api/thumbnail/route.ts | 75 +- .../app/api/upload/[...route]/multipart.ts | 249 +- apps/web/app/api/upload/[...route]/signed.ts | 80 +- apps/web/app/api/utils.ts | 6 +- apps/web/app/api/video/ai/route.ts | 3 +- apps/web/app/api/video/comment/route.ts | 6 +- apps/web/app/api/video/delete/route.ts | 6 +- apps/web/app/api/video/domain-info/route.ts | 14 +- apps/web/app/api/video/og/route.tsx | 3 +- apps/web/app/api/video/playlistUrl/route.ts | 93 + .../app/api/video/transcribe/status/route.ts | 5 +- .../[videoId]/retry-transcription/route.ts | 56 - apps/web/app/api/webhooks/stripe/route.ts | 27 +- .../[videoId]/_components/EmbedVideo.tsx | 15 +- .../[videoId]/_components/PasswordOverlay.tsx | 3 +- apps/web/app/embed/[videoId]/page.tsx | 39 +- apps/web/app/globals.css | 73 +- apps/web/app/layout.tsx | 8 +- apps/web/app/robots.ts | 4 +- apps/web/app/s/[videoId]/Share.tsx | 48 +- .../s/[videoId]/_components/AuthOverlay.tsx | 328 +- .../[videoId]/_components/CapVideoPlayer.tsx | 348 +- .../s/[videoId]/_components/CommentStamp.tsx | 85 - .../[videoId]/_components/HLSVideoPlayer.tsx | 245 +- .../app/s/[videoId]/_components/OtpForm.tsx | 203 - .../[videoId]/_components/PasswordOverlay.tsx | 3 +- .../[videoId]/_components/ProgressCircle.tsx | 132 - .../s/[videoId]/_components/ShareHeader.tsx | 42 +- .../s/[videoId]/_components/ShareVideo.tsx | 50 +- .../app/s/[videoId]/_components/Sidebar.tsx | 7 +- .../app/s/[videoId]/_components/Toolbar.tsx | 51 +- .../_components/tabs/Activity/Comment.tsx | 39 +- .../tabs/Activity/CommentInput.tsx | 6 +- .../_components/tabs/Activity/Comments.tsx | 46 +- .../_components/tabs/Activity/index.tsx | 12 +- .../[videoId]/_components/tabs/Transcript.tsx | 65 +- .../_components/video/media-player.tsx | 20 +- apps/web/app/s/[videoId]/page.tsx | 66 +- apps/web/app/utils/analytics.ts | 21 - apps/web/components/ReadyToGetStarted.tsx | 34 +- apps/web/components/VideoThumbnail.tsx | 43 +- apps/web/components/blog/AuthorByline.tsx | 44 - apps/web/components/blog/BlogTemplate.tsx | 170 +- apps/web/components/features/FeaturePage.tsx | 51 - apps/web/components/forms/NewOrganization.tsx | 2 +- apps/web/components/forms/server.ts | 49 +- .../web/components/icons/QuestionMarkIcon.tsx | 23 - apps/web/components/pages/AboutPage.tsx | 48 +- apps/web/components/pages/DownloadPage.tsx | 21 +- apps/web/components/pages/HomePage/Faq.tsx | 8 +- .../components/pages/HomePage/Features.tsx | 2 +- apps/web/components/pages/HomePage/Header.tsx | 19 +- .../pages/HomePage/HomePageSchema.tsx | 68 - .../pages/HomePage/Pricing/CommercialArt.tsx | 14 +- .../pages/HomePage/Pricing/CommercialCard.tsx | 128 +- .../pages/HomePage/Pricing/EnterpriseArt.tsx | 45 - .../pages/HomePage/Pricing/EnterpriseCard.tsx | 109 - .../pages/HomePage/Pricing/ProArt.tsx | 11 +- .../pages/HomePage/Pricing/ProCard.tsx | 113 +- .../pages/HomePage/RecordingModes.tsx | 18 +- apps/web/components/pages/HomePage/index.tsx | 2 - apps/web/components/pages/PricingPage.tsx | 53 +- .../components/pages/StudentDiscountPage.tsx | 329 -- apps/web/components/pages/UpdatesPage.tsx | 87 +- .../pages/_components/ComparePlans.tsx | 412 -- .../pages/_components/UpgradeToPro.tsx | 36 - .../web/components/pages/seo/AgenciesPage.tsx | 656 --- .../pages/seo/LoomAlternativePage.tsx | 68 +- .../pages/seo/RemoteTeamCollaborationPage.tsx | 67 +- .../pages/seo/ScreenRecorderPage.tsx | 5 +- apps/web/components/seo/ComparisonSlider.tsx | 296 -- apps/web/components/seo/SeoPageTemplate.tsx | 572 +- apps/web/components/seo/types.ts | 22 +- apps/web/components/ui/MobileMenu.tsx | 49 +- .../record-screen-mac-system-audio.tsx | 1 - ...cord-screen-system-audio-no-stereo-mix.tsx | 205 - .../blog/september-23-outage-deep-dive.mdx | 107 - apps/web/content/changelog/70.mdx | 16 - apps/web/content/changelog/71.mdx | 9 - apps/web/content/changelog/72.mdx | 9 - apps/web/data/homepage-copy.ts | 2 +- apps/web/hooks/use-transcript.ts | 7 +- apps/web/lib/EffectRuntime.ts | 7 +- apps/web/lib/Notification.ts | 41 +- apps/web/lib/features/index.ts | 6 - apps/web/lib/features/transform.ts | 93 - apps/web/lib/features/types.ts | 107 - apps/web/lib/folder.ts | 262 +- apps/web/lib/server.ts | 41 +- apps/web/lib/tracing-server.ts | 5 - apps/web/lib/tracing.ts | 2 +- apps/web/lib/transcribe.ts | 86 +- apps/web/middleware.ts | 3 +- apps/web/next.config.mjs | 23 +- apps/web/package.json | 48 +- apps/web/public/app/capdashboard.webp | Bin 1034988 -> 0 bytes apps/web/public/app/loomdashboard.webp | Bin 727364 -> 0 bytes .../public/blog/author/brendonovichdev.jpg | Bin 18417 -> 0 bytes apps/web/public/blog/author/richiemcilroy.jpg | Bin 26748 -> 0 bytes apps/web/public/blog/deep-dive.jpg | Bin 103996 -> 0 bytes apps/web/public/cap-logo.png | Bin 34024 -> 0 bytes apps/web/public/cap-team-film.jpeg | Bin 212443 -> 0 bytes apps/web/public/github.svg | 1 - apps/web/public/logos/logo-solo.svg | 5 - apps/web/public/logos/loom.svg | 3 - apps/web/public/rive/pricing.riv | Bin 67986 -> 61492 bytes apps/web/tsconfig.json | 2 - apps/web/utils/authors.ts | 30 - apps/web/utils/blog-registry.ts | 27 - apps/web/utils/blog.ts | 90 +- apps/web/utils/getBootstrapData.ts | 2 +- apps/web/utils/gradients.ts | 41 - apps/web/utils/public-env.tsx | 2 + apps/web/utils/s3.ts | 395 ++ apps/web/utils/web-schema.ts | 267 - apps/workflow-manager/package.json | 15 - apps/workflow-manager/src/index.ts | 23 - apps/workflow-runner/package.json | 23 - apps/workflow-runner/src/index.ts | 66 - biome.json | 5 +- crates/audio/src/renderer.rs | 28 +- crates/camera-directshow/Cargo.toml | 1 - crates/camera-directshow/examples/cli.rs | 4 +- crates/camera-directshow/src/lib.rs | 19 +- crates/camera-ffmpeg/examples/cli.rs | 2 +- crates/camera-ffmpeg/src/lib.rs | 2 +- crates/camera-ffmpeg/src/macos.rs | 6 +- crates/camera-ffmpeg/src/windows.rs | 27 +- crates/camera-mediafoundation/Cargo.toml | 2 - crates/camera-mediafoundation/src/lib.rs | 35 +- crates/camera-windows/examples/cli.rs | 7 +- crates/camera-windows/src/lib.rs | 12 +- crates/camera/src/lib.rs | 6 +- crates/camera/src/macos.rs | 4 +- crates/camera/src/windows.rs | 4 +- crates/cpal-ffmpeg/Cargo.toml | 1 + crates/cpal-ffmpeg/src/lib.rs | 3 +- crates/editor/src/audio.rs | 32 +- crates/editor/src/editor.rs | 2 +- crates/editor/src/editor_instance.rs | 8 +- crates/editor/src/playback.rs | 16 +- crates/editor/src/segments.rs | 2 - crates/enc-avfoundation/Cargo.toml | 1 + crates/enc-avfoundation/src/mp4.rs | 75 +- crates/enc-ffmpeg/src/audio/aac.rs | 201 +- crates/enc-ffmpeg/src/audio/audio_encoder.rs | 2 +- crates/enc-ffmpeg/src/audio/base.rs | 46 - .../src/audio/buffered_resampler.rs | 603 --- crates/enc-ffmpeg/src/audio/mod.rs | 2 - crates/enc-ffmpeg/src/audio/opus.rs | 202 +- crates/enc-ffmpeg/src/base.rs | 88 - crates/enc-ffmpeg/src/lib.rs | 1 - crates/enc-ffmpeg/src/mux/mp4.rs | 14 +- crates/enc-ffmpeg/src/mux/ogg.rs | 30 +- crates/enc-ffmpeg/src/video/h264.rs | 64 +- crates/enc-mediafoundation/examples/cli.rs | 101 +- crates/enc-mediafoundation/src/mft.rs | 25 +- crates/enc-mediafoundation/src/video/h264.rs | 258 +- crates/export/src/mp4.rs | 9 +- crates/ffmpeg-utils/Cargo.toml | 10 + crates/ffmpeg-utils/src/lib.rs | 34 + crates/media-info/Cargo.toml | 1 + crates/media-info/src/lib.rs | 31 +- crates/mediafoundation-ffmpeg/src/h264.rs | 2 +- crates/project/src/configuration.rs | 48 +- crates/project/src/cursor.rs | 243 +- crates/project/src/meta.rs | 49 +- crates/recording/Cargo.toml | 29 +- crates/recording/examples/recording-cli.rs | 77 +- crates/recording/examples/screen_capture.rs | 6 +- crates/recording/src/capture_pipeline.rs | 640 ++- crates/recording/src/cursor.rs | 28 +- crates/recording/src/feeds/camera.rs | 62 +- crates/recording/src/feeds/microphone.rs | 78 +- crates/recording/src/instant_recording.rs | 575 +- crates/recording/src/lib.rs | 21 +- crates/recording/src/output_pipeline/core.rs | 784 --- .../recording/src/output_pipeline/ffmpeg.rs | 145 - crates/recording/src/output_pipeline/macos.rs | 87 - crates/recording/src/output_pipeline/mod.rs | 15 - crates/recording/src/output_pipeline/win.rs | 234 - .../src/{ => pipeline}/audio_buffer.rs | 8 +- crates/recording/src/pipeline/builder.rs | 170 + crates/recording/src/pipeline/control.rs | 82 + crates/recording/src/pipeline/mod.rs | 50 + crates/recording/src/pipeline/task.rs | 19 + crates/recording/src/sources/audio_input.rs | 143 + crates/recording/src/sources/audio_mixer.rs | 664 +-- crates/recording/src/sources/camera.rs | 176 +- crates/recording/src/sources/microphone.rs | 50 - crates/recording/src/sources/mod.rs | 7 +- .../src/sources/screen_capture/macos.rs | 573 +- .../src/sources/screen_capture/mod.rs | 53 +- .../src/sources/screen_capture/windows.rs | 647 ++- crates/recording/src/studio_recording.rs | 1139 ++-- .../rendering-skia/src/bin/test_background.rs | 5 - .../rendering-skia/src/layers/background.rs | 51 +- crates/rendering-skia/src/layers/mod.rs | 1 - crates/rendering/src/composite_frame.rs | 16 +- crates/rendering/src/layers/display.rs | 3 +- crates/rendering/src/lib.rs | 289 +- crates/rendering/src/scene.rs | 44 +- .../src/shaders/composite-video-frame.wgsl | 32 +- crates/scap-cpal/src/lib.rs | 2 - crates/scap-direct3d/src/lib.rs | 17 +- crates/scap-ffmpeg/Cargo.toml | 1 + crates/scap-ffmpeg/src/cpal.rs | 3 +- crates/scap-targets/Cargo.toml | 4 - crates/scap-targets/src/platform/macos.rs | 67 +- crates/scap-targets/src/platform/mod.rs | 2 +- crates/scap-targets/src/platform/win.rs | 216 +- crates/timestamp/Cargo.toml | 20 - crates/timestamp/src/lib.rs | 126 - crates/timestamp/src/macos.rs | 73 - crates/timestamp/src/win.rs | 101 - infra/sst-env.d.ts | 9 +- infra/sst.config.ts | 260 +- package.json | 7 +- .../{auth-options.ts => auth-options.tsx} | 67 +- packages/database/auth/drizzle-adapter.ts | 5 +- packages/database/drizzle.config.ts | 1 - packages/database/index.ts | 3 +- packages/database/package.json | 26 +- packages/database/schema.ts | 76 +- packages/database/types/index.ts | 2 - packages/env/build.ts | 4 - packages/env/index.ts | 4 +- packages/env/package.json | 1 - packages/env/server.ts | 7 +- packages/local-docker/docker-compose.yml | 18 +- packages/ui-solid/src/Button.tsx | 4 +- packages/ui-solid/src/ProgressCircle.tsx | 112 - packages/ui-solid/src/auto-imports.d.ts | 1 - packages/ui-solid/src/index.tsx | 1 - packages/ui/package.json | 8 +- packages/ui/src/components/Button.tsx | 15 +- packages/ui/src/components/Select.tsx | 270 +- packages/ui/src/components/icons/Logo.tsx | 9 +- packages/utils/package.json | 12 +- packages/utils/src/helpers.ts | 7 + packages/utils/src/index.ts | 5 - packages/utils/src/index.tsx | 5 + packages/web-api-contract-effect/package.json | 4 +- packages/web-backend/package.json | 15 +- packages/web-backend/src/Auth.ts | 49 +- packages/web-backend/src/Database.ts | 30 +- .../web-backend/src/Folders/FoldersPolicy.ts | 4 +- .../web-backend/src/Folders/FoldersRpcs.ts | 2 +- packages/web-backend/src/Folders/index.ts | 11 +- packages/web-backend/src/Loom/ImportVideo.ts | 153 - packages/web-backend/src/Loom/index.ts | 1 - .../src/Organisations/OrganisationsPolicy.ts | 29 - .../src/Organisations/OrganisationsRepo.ts | 23 +- packages/web-backend/src/Rpcs.ts | 10 +- .../src/S3Buckets/S3BucketAccess.ts | 422 +- .../src/S3Buckets/S3BucketsRepo.ts | 23 +- packages/web-backend/src/S3Buckets/index.ts | 154 +- .../web-backend/src/Spaces/SpacesPolicy.ts | 29 - packages/web-backend/src/Spaces/SpacesRepo.ts | 52 +- packages/web-backend/src/Spaces/index.ts | 0 .../web-backend/src/Videos/VideosPolicy.ts | 12 +- packages/web-backend/src/Videos/VideosRepo.ts | 144 +- packages/web-backend/src/Videos/VideosRpcs.ts | 22 +- packages/web-backend/src/Videos/index.ts | 163 +- packages/web-backend/src/Workflows.ts | 31 - packages/web-backend/src/index.ts | 21 +- packages/web-backend/tsconfig.json | 7 +- packages/web-domain/package.json | 7 +- packages/web-domain/src/Authentication.ts | 5 +- packages/web-domain/src/Errors.ts | 2 +- packages/web-domain/src/Folder.ts | 7 +- packages/web-domain/src/Loom.ts | 43 - packages/web-domain/src/Policy.ts | 23 +- packages/web-domain/src/Rpcs.ts | 8 +- packages/web-domain/src/S3Bucket.ts | 4 +- packages/web-domain/src/Video.ts | 99 +- packages/web-domain/src/index.ts | 16 +- packages/web-domain/src/utils.ts | 5 - packages/web-domain/tsconfig.json | 3 +- pnpm-lock.yaml | 4359 +++++++--------- pnpm-workspace.yaml | 1 - scripts/env-cli.js | 2 +- scripts/orgid_backfill/index.ts | 207 - scripts/orgid_backfill/package.json | 13 - 484 files changed, 15446 insertions(+), 29486 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 AGENTS.md delete mode 100644 apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs delete mode 100644 apps/desktop/src-tauri/src/recording_settings.rs delete mode 100644 apps/desktop/src-tauri/src/thumbnails/mac.rs delete mode 100644 apps/desktop/src-tauri/src/thumbnails/mod.rs delete mode 100644 apps/desktop/src-tauri/src/thumbnails/windows.rs delete mode 100644 apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx delete mode 100644 apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx delete mode 100644 apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx delete mode 100644 apps/web/actions/analytics/track-user-signed-up.ts delete mode 100644 apps/web/actions/billing/track-meta-purchase.ts create mode 100644 apps/web/actions/screenshots/get-screenshot.ts delete mode 100644 apps/web/app/(org)/dashboard/_components/MobileTab.tsx delete mode 100644 apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx create mode 100644 apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx create mode 100644 apps/web/app/(org)/dashboard/admin/UserLookup.tsx create mode 100644 apps/web/app/(org)/dashboard/admin/actions.ts create mode 100644 apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts create mode 100644 apps/web/app/(org)/dashboard/admin/loading.tsx create mode 100644 apps/web/app/(org)/dashboard/admin/page.tsx delete mode 100644 apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButton.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButtons.tsx create mode 100644 apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx delete mode 100644 apps/web/app/(org)/dashboard/settings/account/server.ts delete mode 100644 apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts delete mode 100644 apps/web/app/(org)/signup/form.tsx delete mode 100644 apps/web/app/(org)/signup/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/free-screen-recorder/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/screen-recorder-mac/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/screen-recorder-windows/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/screen-recorder/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/screen-recording-software/page.tsx delete mode 100644 apps/web/app/(site)/(seo)/solutions/agencies/page.tsx create mode 100644 apps/web/app/(site)/blog/record-screen-mac-system-audio/page.tsx delete mode 100644 apps/web/app/(site)/features/instant-mode/InstantModePage.tsx delete mode 100644 apps/web/app/(site)/features/instant-mode/page.tsx delete mode 100644 apps/web/app/(site)/features/studio-mode/StudioModePage.tsx delete mode 100644 apps/web/app/(site)/features/studio-mode/page.tsx rename apps/web/app/(site)/{(seo) => }/loom-alternative/page.tsx (100%) rename apps/web/app/(site)/{(seo) => }/solutions/daily-standup-software/page.tsx (84%) rename apps/web/app/(site)/{(seo) => }/solutions/employee-onboarding-platform/page.tsx (84%) rename apps/web/app/(site)/{(seo) => }/solutions/online-classroom-tools/page.tsx (84%) rename apps/web/app/(site)/{(seo) => }/solutions/remote-team-collaboration/page.tsx (100%) delete mode 100644 apps/web/app/(site)/student-discount/page.tsx delete mode 100644 apps/web/app/Layout/GTag.tsx delete mode 100644 apps/web/app/Layout/MetaPixel.tsx delete mode 100644 apps/web/app/Layout/PurchaseTracker.tsx delete mode 100644 apps/web/app/Layout/devtoolsServer.ts delete mode 100644 apps/web/app/Layout/features.ts create mode 100644 apps/web/app/api/releases/macos/route.ts create mode 100644 apps/web/app/api/screenshot/route.ts create mode 100644 apps/web/app/api/video/playlistUrl/route.ts delete mode 100644 apps/web/app/api/videos/[videoId]/retry-transcription/route.ts delete mode 100644 apps/web/app/s/[videoId]/_components/CommentStamp.tsx delete mode 100644 apps/web/app/s/[videoId]/_components/OtpForm.tsx delete mode 100644 apps/web/app/s/[videoId]/_components/ProgressCircle.tsx delete mode 100644 apps/web/components/blog/AuthorByline.tsx delete mode 100644 apps/web/components/features/FeaturePage.tsx delete mode 100644 apps/web/components/icons/QuestionMarkIcon.tsx delete mode 100644 apps/web/components/pages/HomePage/HomePageSchema.tsx delete mode 100644 apps/web/components/pages/HomePage/Pricing/EnterpriseArt.tsx delete mode 100644 apps/web/components/pages/HomePage/Pricing/EnterpriseCard.tsx delete mode 100644 apps/web/components/pages/StudentDiscountPage.tsx delete mode 100644 apps/web/components/pages/_components/ComparePlans.tsx delete mode 100644 apps/web/components/pages/_components/UpgradeToPro.tsx delete mode 100644 apps/web/components/pages/seo/AgenciesPage.tsx delete mode 100644 apps/web/components/seo/ComparisonSlider.tsx delete mode 100644 apps/web/content/blog-content/windows-11-record-screen-system-audio-no-stereo-mix.tsx delete mode 100644 apps/web/content/blog/september-23-outage-deep-dive.mdx delete mode 100644 apps/web/content/changelog/70.mdx delete mode 100644 apps/web/content/changelog/71.mdx delete mode 100644 apps/web/content/changelog/72.mdx delete mode 100644 apps/web/lib/features/index.ts delete mode 100644 apps/web/lib/features/transform.ts delete mode 100644 apps/web/lib/features/types.ts delete mode 100644 apps/web/lib/tracing-server.ts delete mode 100644 apps/web/public/app/capdashboard.webp delete mode 100644 apps/web/public/app/loomdashboard.webp delete mode 100644 apps/web/public/blog/author/brendonovichdev.jpg delete mode 100644 apps/web/public/blog/author/richiemcilroy.jpg delete mode 100644 apps/web/public/blog/deep-dive.jpg delete mode 100644 apps/web/public/cap-logo.png delete mode 100644 apps/web/public/cap-team-film.jpeg delete mode 100644 apps/web/public/github.svg delete mode 100644 apps/web/public/logos/logo-solo.svg delete mode 100644 apps/web/public/logos/loom.svg delete mode 100644 apps/web/utils/authors.ts delete mode 100644 apps/web/utils/blog-registry.ts delete mode 100644 apps/web/utils/gradients.ts create mode 100644 apps/web/utils/s3.ts delete mode 100644 apps/web/utils/web-schema.ts delete mode 100644 apps/workflow-manager/package.json delete mode 100644 apps/workflow-manager/src/index.ts delete mode 100644 apps/workflow-runner/package.json delete mode 100644 apps/workflow-runner/src/index.ts delete mode 100644 crates/enc-ffmpeg/src/audio/base.rs delete mode 100644 crates/enc-ffmpeg/src/audio/buffered_resampler.rs delete mode 100644 crates/enc-ffmpeg/src/base.rs create mode 100644 crates/ffmpeg-utils/Cargo.toml create mode 100644 crates/ffmpeg-utils/src/lib.rs delete mode 100644 crates/recording/src/output_pipeline/core.rs delete mode 100644 crates/recording/src/output_pipeline/ffmpeg.rs delete mode 100644 crates/recording/src/output_pipeline/macos.rs delete mode 100644 crates/recording/src/output_pipeline/mod.rs delete mode 100644 crates/recording/src/output_pipeline/win.rs rename crates/recording/src/{ => pipeline}/audio_buffer.rs (89%) create mode 100644 crates/recording/src/pipeline/builder.rs create mode 100644 crates/recording/src/pipeline/control.rs create mode 100644 crates/recording/src/pipeline/mod.rs create mode 100644 crates/recording/src/pipeline/task.rs create mode 100644 crates/recording/src/sources/audio_input.rs delete mode 100644 crates/recording/src/sources/microphone.rs delete mode 100644 crates/timestamp/Cargo.toml delete mode 100644 crates/timestamp/src/lib.rs delete mode 100644 crates/timestamp/src/macos.rs delete mode 100644 crates/timestamp/src/win.rs rename packages/database/auth/{auth-options.ts => auth-options.tsx} (78%) delete mode 100644 packages/ui-solid/src/ProgressCircle.tsx delete mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/index.tsx delete mode 100644 packages/web-backend/src/Loom/ImportVideo.ts delete mode 100644 packages/web-backend/src/Loom/index.ts delete mode 100644 packages/web-backend/src/Organisations/OrganisationsPolicy.ts delete mode 100644 packages/web-backend/src/Spaces/SpacesPolicy.ts delete mode 100644 packages/web-backend/src/Spaces/index.ts delete mode 100644 packages/web-backend/src/Workflows.ts delete mode 100644 packages/web-domain/src/Loom.ts delete mode 100644 packages/web-domain/src/utils.ts delete mode 100644 scripts/orgid_backfill/index.ts delete mode 100644 scripts/orgid_backfill/package.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 083ec9bd45..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pnpm typecheck:*)", - "Bash(pnpm lint:*)", - "Bash(pnpm build:*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827c80e279..ebb490afcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,6 @@ jobs: - uses: ./.github/actions/setup-js - - run: pnpm web exec next typegen - - name: Typecheck run: pnpm typecheck @@ -76,8 +74,6 @@ jobs: uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - name: Check formatting run: cargo fmt --check @@ -97,7 +93,6 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.settings.target }} - components: clippy - name: Rust cache uses: swatinem/rust-cache@v2 diff --git a/.github/workflows/docker-build-web.yml b/.github/workflows/docker-build-web.yml index af6a883dd2..85dfa83197 100644 --- a/.github/workflows/docker-build-web.yml +++ b/.github/workflows/docker-build-web.yml @@ -38,6 +38,7 @@ jobs: echo "NEXT_PUBLIC_DOCKER_BUILD=true" >> .env echo "NEXT_PUBLIC_CAP_AWS_BUCKET=capso" >> .env echo "NEXT_PUBLIC_CAP_AWS_REGION=us-east-1" >> .env + cat .env - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0e1222fad..f4e7c3409b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -213,29 +213,17 @@ jobs: - name: Upload assets uses: crabnebula-dev/cloud-release@v0 with: - working-directory: apps/desktop command: release upload ${{ env.CN_APPLICATION }} "${{ needs.draft.outputs.version }}" --framework tauri api-key: ${{ secrets.CN_API_KEY }} - env: - TAURI_BUNDLE_PATH: ../.. - - - uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e - - - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'macOS' }} - shell: bash - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/Cap.dSYM - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'Windows' }} - shell: bash + if: ${{ matrix.settings.runner == 'macos-latest' }} env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + working-directory: target run: | - sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/${{ matrix.settings.target }}/release/cap_desktop.pdb + curl -sL https://sentry.io/get-cli/ | bash + sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} Cap.dSYM done: needs: [draft, build] diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2a7295118d..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,46 +0,0 @@ -# Repository Guidelines - -## Project Structure & Modules -- Turborepo monorepo: - - `apps/desktop` (Tauri v2 + SolidStart), `apps/web` (Next.js), `apps/cli` (Rust CLI). - - `packages/*` shared libs (e.g., `database`, `ui`, `ui-solid`, `utils`, `web-*`). - - `crates/*` Rust media/recording/rendering/camera crates. - - `scripts/*`, `infra/`, and `packages/local-docker/` for tooling and local services. - -## Build, Test, Develop -- Install: `pnpm install`; setup: `pnpm env-setup` then `pnpm cap-setup`. -- Dev: `pnpm dev` (web+desktop). Desktop only: `pnpm dev:desktop`. Web only: `pnpm dev:web` or `cd apps/web && pnpm dev`. -- Build: `pnpm build` (Turbo). Desktop release: `pnpm tauri:build`. -- DB: `pnpm db:generate` → `pnpm db:push` → `pnpm db:studio`. -- Docker: `pnpm docker:up | docker:stop | docker:clean`. -- Quality: `pnpm lint`, `pnpm format`, `pnpm typecheck`. Rust: `cargo build -p `, `cargo test -p `. - -## Coding Style & Naming -- TypeScript: 2‑space indent; Biome formats/lints (`pnpm format`). -- Rust: `rustfmt` + workspace clippy lints. -- Naming: files kebab‑case (`user-menu.tsx`); components PascalCase; Rust modules snake_case, crates kebab‑case. -- Runtime: Node 20, pnpm 10.x, Rust 1.88+, Docker for MySQL/MinIO. - -## Testing -- TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources. -- Rust: `cargo test` per crate; tests in `src` or `tests`. -- Prefer unit tests for logic and light smoke tests for flows; no strict coverage yet. - -## Commits & PRs -- Conventional style: `feat:`, `fix:`, `chore:`, `improve:`, `refactor:`, `docs:` (e.g., `fix: hide watermark for pro users`). -- PRs: clear description, linked issues, screenshots/GIFs for UI, env/migration notes. Keep scope tight and update docs when behavior changes. - -## Agent‑Specific Practices (inspired by CLAUDE.md) -- Do not start extra servers; use `pnpm dev:web` or `pnpm dev:desktop` as needed. -- Never edit auto‑generated files: `**/tauri.ts`, `**/queries.ts`, `apps/desktop/src-tauri/gen/**`. -- Prefer existing scripts and Turbo filters over ad‑hoc commands; clear `.turbo` only when necessary. -- Database flow: always `db:generate` → `db:push` before relying on new schema. -- Keep secrets out of VCS; configure via `.env` from `pnpm env-setup`. -- macOS note: desktop permissions (screen/mic) apply to the terminal running `pnpm dev:desktop`. - -## Effect Usage -- Next.js API routes in `apps/web/app/api/*` are built with `@effect/platform`'s `HttpApi` builder; copy the existing class/group/endpoint pattern instead of ad-hoc handlers. -- Acquire backend services (e.g., `Videos`, `S3Buckets`) inside `Effect.gen` blocks and wire them through `Layer.provide`/`HttpApiBuilder.group`, translating domain errors to `HttpApiError` variants. -- Convert the effectful API to a Next.js handler with `apiToHandler(ApiLive)` from `@/lib/server` and export the returned `handler`—avoid calling `runPromise` inside route files. -- On the server, run effects through `EffectRuntime.runPromise` from `@/lib/server`, typically after `provideOptionalAuth`, so cookies and per-request context are attached automatically. -- On the client, use `useEffectQuery`/`useEffectMutation` from `@/lib/EffectRuntime`; they already bind the managed runtime and tracing so you shouldn't call `EffectRuntime.run*` directly in components. diff --git a/CLAUDE.md b/CLAUDE.md index eb48022aaa..89cdb7d8cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,27 +335,6 @@ Minimize `useEffect` usage: compute during render, handle logic in event handler - Loading: Use static skeletons that mirror content; no bouncing animations. - Performance: Memoize expensive work; code-split naturally; use Next/Image for remote assets. -## Effect Patterns - -### Managed Runtimes -- `apps/web/lib/server.ts` builds a `ManagedRuntime` from `Layer.mergeAll` so database, S3, policy, and tracing services are available to every request. Always run server-side effects through `EffectRuntime.runPromise`/`runPromiseExit` from this module so cookie-derived context and `VideoPasswordAttachment` are attached automatically. -- `apps/web/lib/EffectRuntime.ts` exposes a browser runtime that merges the RPC client and tracing layers. Client code should lean on `useEffectQuery`, `useEffectMutation`, and `useRpcClient`; never call `ManagedRuntime.make` yourself inside components. - -### API Route Construction -- Next.js API folders under `apps/web/app/api/*` wrap Effect handlers with `@effect/platform`'s `HttpApi`/`HttpApiBuilder`. Follow the existing pattern: declare a contract class via `HttpApi.make`, configure groups/endpoints with `Schema`, and only export the `handler` returned by `apiToHandler(ApiLive)`. -- Inside `HttpApiBuilder.group` blocks, acquire services (e.g., `Videos`, `S3Buckets`) with `yield*` inside `Effect.gen`. Provide layers using `Layer.provide` rather than manual `provideService` calls so dependencies stay declarative. -- Map domain-level errors to transport errors with `HttpApiError.*`. Keep error translation exhaustive (`Effect.catchTags`, `Effect.tapErrorCause(Effect.logError)`) to preserve observability. -- Use `HttpAuthMiddleware` for required auth and `provideOptionalAuth` when guests are allowed. The middleware/utility already hydrate `CurrentUser`, so avoid duplicating session lookups in route handlers. -- Shared HTTP contracts that power the desktop app live in `packages/web-api-contract-effect`; update them alongside route changes to keep schemas in sync. - -### Server Components & Effects -- Server components that need Effect services should call `EffectRuntime.runPromise(effect.pipe(provideOptionalAuth))`. This keeps request cookies, tracing spans, and optional auth consistent with the API layer. -- Prefer lifting Drizzle queries or other async work into `Effect.gen` blocks and reusing domain services (`Videos`, `VideosPolicy`, etc.) rather than writing ad-hoc logic. - -### Client Integration -- React Query hooks should wrap Effect workflows with `useEffectQuery`/`useEffectMutation` from `apps/web/lib/EffectRuntime.ts`; these helpers surface Fail/Die causes consistently and plug into tracing/span metadata. -- When a mutation or query needs the RPC transport, resolve it through `useRpcClient()` and invoke the strongly-typed procedures exposed by `packages/web-domain` instead of reaching into fetch directly. - ## Desktop (Solid + Tauri) Patterns - Data fetching: `@tanstack/solid-query` for server state. - IPC: Call generated `commands` and `events` from `tauri_specta`. Listen directly to generated events and prefer the typed interfaces. diff --git a/Cargo.lock b/Cargo.lock index 0c4cd0f508..f86cdc3c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,158 +8,6 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.9.4", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-http" -version = "3.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44cceded2fb55f3c4b67068fa64962e2ca59614edc5b03167de9ff82ae803da0" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "base64 0.22.1", - "bitflags 2.9.4", - "bytes", - "bytestring", - "derive_more 2.0.1", - "encoding_rs", - "foldhash", - "futures-core", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.9.2", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-router" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio 1.0.4", - "socket2 0.5.10", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea" -dependencies = [ - "actix-codec", - "actix-http", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "bytes", - "bytestring", - "cfg-if", - "derive_more 2.0.1", - "encoding_rs", - "foldhash", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.5.10", - "time", - "tracing", - "url", -] - [[package]] name = "addr2line" version = "0.24.2" @@ -215,7 +63,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", "libc", ] @@ -230,6 +78,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -251,9 +105,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" dependencies = [ "anstyle", "anstyle-parse", @@ -281,22 +135,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -306,20 +160,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" dependencies = [ "futures", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +dependencies = [ + "backtrace", +] [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" dependencies = [ "derive_arbitrary", ] @@ -331,9 +188,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", - "image 0.25.8", + "image 0.25.6", "log", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", @@ -353,7 +210,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -438,20 +295,20 @@ dependencies = [ [[package]] name = "async-io" -version = "2.6.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" dependencies = [ - "autocfg", + "async-lock", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.0.8", "slab", - "windows-sys 0.61.0", + "windows-sys 0.60.2", ] [[package]] @@ -467,9 +324,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" dependencies = [ "async-channel", "async-io", @@ -480,7 +337,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.2", + "rustix 1.0.8", ] [[package]] @@ -491,14 +348,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" dependencies = [ "async-io", "async-lock", @@ -506,10 +363,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.0.8", "signal-hook-registry", "slab", - "windows-sys 0.61.0", + "windows-sys 0.60.2", ] [[package]] @@ -520,13 +377,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -580,9 +437,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +checksum = "2ea8ef51aced2b9191c08197f55450d830876d9933f8f48a429b354f1d496b42" dependencies = [ "arrayvec", ] @@ -599,7 +456,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-util", - "http 1.3.1", + "http", "http-body", "http-body-util", "hyper", @@ -634,7 +491,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.3.1", + "http", "http-body", "http-body-util", "mime", @@ -654,7 +511,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -684,12 +541,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" - [[package]] name = "bezier_easing" version = "0.1.1" @@ -703,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "annotate-snippets", - "bitflags 2.9.4", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -716,7 +567,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.106", + "syn 2.0.104", "which", ] @@ -726,7 +577,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -735,7 +586,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -744,7 +595,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -755,16 +606,16 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "bindgen" -version = "0.72.1" +version = "0.72.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "4f72209734318d0b619a5e0f5129918b848c416e122a3c4ce054e03cb87b726f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -773,7 +624,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -793,9 +644,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.3" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" [[package]] name = "bitflags" @@ -805,9 +656,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -848,7 +699,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", ] [[package]] @@ -895,7 +746,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -912,22 +763,22 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" +checksum = "441473f2b4b0459a68628c744bc61d23e730fb00128b841d30fa4bb3972257e4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -951,22 +802,13 @@ dependencies = [ "serde", ] -[[package]] -name = "bytestring" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" -dependencies = [ - "bytes", -] - [[package]] name = "cairo-rs" version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cairo-sys-rs", "glib", "libc", @@ -987,11 +829,11 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.0" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -1007,7 +849,6 @@ dependencies = [ "cap-recording", "cap-rendering", "cap-utils", - "cidre 0.11.0", "clap", "ffmpeg-next", "flume", @@ -1025,7 +866,7 @@ name = "cap-audio" version = "0.1.0" dependencies = [ "cidre 0.11.0", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", "tokio", ] @@ -1112,7 +953,8 @@ dependencies = [ name = "cap-cpal-ffmpeg" version = "0.1.0" dependencies = [ - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cap-ffmpeg-utils", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", ] @@ -1130,7 +972,7 @@ name = "cap-cursor-info" version = "0.0.0" dependencies = [ "hex", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "serde", "sha2", @@ -1141,7 +983,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.72-beta.1" +version = "0.3.67" dependencies = [ "anyhow", "axum", @@ -1164,7 +1006,7 @@ dependencies = [ "cocoa 0.26.1", "core-foundation 0.10.1", "core-graphics 0.24.0", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "device_query", "dirs", "dotenvy_macro", @@ -1173,23 +1015,22 @@ dependencies = [ "futures", "futures-intrusive", "global-hotkey", - "image 0.25.8", + "image 0.25.6", "kameo", "keyed_priority_queue", "lazy_static", "log", "md5", + "mp4", "nix 0.29.0", "objc", "objc2-app-kit", - "png 0.17.16", + "png", "rand 0.8.5", "relative-path", "reqwest", "rodio", "scap", - "scap-direct3d", - "scap-screencapturekit", "scap-targets", "sentry", "serde", @@ -1212,7 +1053,6 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-positioner", "tauri-plugin-process", - "tauri-plugin-sentry", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", @@ -1243,7 +1083,7 @@ dependencies = [ "cap-media-info", "cap-project", "cap-rendering", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", "flume", "futures", @@ -1260,6 +1100,7 @@ dependencies = [ name = "cap-enc-avfoundation" version = "0.1.0" dependencies = [ + "cap-ffmpeg-utils", "cap-media-info", "cidre 0.11.0", "ffmpeg-next", @@ -1320,7 +1161,7 @@ dependencies = [ "ffmpeg-next", "futures", "gifski", - "image 0.25.8", + "image 0.25.6", "imgref", "inquire", "mp4", @@ -1341,6 +1182,13 @@ dependencies = [ "inventory", ] +[[package]] +name = "cap-ffmpeg-utils" +version = "0.1.0" +dependencies = [ + "ffmpeg-next", +] + [[package]] name = "cap-flags" version = "0.1.0" @@ -1372,7 +1220,8 @@ dependencies = [ name = "cap-media-info" version = "0.1.0" dependencies = [ - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cap-ffmpeg-utils", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", "thiserror 1.0.69", ] @@ -1417,37 +1266,35 @@ dependencies = [ name = "cap-recording" version = "0.1.0" dependencies = [ - "anyhow", "cap-audio", "cap-camera", "cap-camera-ffmpeg", - "cap-camera-windows", "cap-cursor-capture", "cap-cursor-info", "cap-enc-avfoundation", "cap-enc-ffmpeg", "cap-enc-mediafoundation", "cap-fail", + "cap-ffmpeg-utils", "cap-flags", "cap-media", "cap-media-info", "cap-mediafoundation-ffmpeg", "cap-mediafoundation-utils", "cap-project", - "cap-timestamp", "cap-utils", "chrono", "cidre 0.11.0", "cocoa 0.26.1", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "device_query", "either", "ffmpeg-next", "flume", "futures", "hex", - "image 0.25.8", - "indexmap 2.11.4", + "image 0.25.6", + "indexmap 2.10.0", "inquire", "kameo", "objc", @@ -1492,7 +1339,7 @@ dependencies = [ "futures", "futures-intrusive", "glyphon", - "image 0.25.8", + "image 0.25.6", "log", "pretty_assertions", "reactive_graph", @@ -1521,15 +1368,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "cap-timestamp" -version = "0.1.0" -dependencies = [ - "cidre 0.11.0", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", - "windows 0.60.0", -] - [[package]] name = "cap-utils" version = "0.1.0" @@ -1577,7 +1415,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -1587,16 +1425,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.7", + "toml 0.9.5", ] [[package]] name = "cc" -version = "1.2.37" +version = "1.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2" dependencies = [ - "find-msvc-tools", "jobserver", "libc", "shlex", @@ -1640,15 +1477,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "cfg_aliases" @@ -1667,16 +1498,17 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ + "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -1723,9 +1555,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.47" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -1733,9 +1565,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -1745,14 +1577,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -1774,8 +1606,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afede46921767868c5c7f8f55202bdd8bec0bab6bc9605174200f45924f93c62" dependencies = [ "clipboard-win", - "image 0.25.8", - "objc2 0.6.2", + "image 0.25.6", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", "windows 0.59.0", @@ -1823,7 +1655,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "cocoa-foundation 0.2.1", "core-foundation 0.10.1", @@ -1853,7 +1685,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", @@ -1977,9 +1809,9 @@ dependencies = [ [[package]] name = "core-audio-types-rs" -version = "0.3.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebfc66b5ef3f159b3c4a6097e7c2a2e2cc71ec77fff7913e9895fae45f4c349" +checksum = "02f7359c779907f80443d2b2d1b5a61182abb6d8ffd43b6fcb87a27c327d845f" dependencies = [ "core-foundation 0.10.1", ] @@ -2029,7 +1861,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2042,7 +1874,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2055,7 +1887,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -2079,29 +1911,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.10.1", "libc", ] [[package]] name = "core-media-rs" -version = "0.3.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6592bdc3e2b3479140e8c64d419b62d613526c8b61136ca990cd0bae4377db4f" +checksum = "3e33a8804301de5fc0f705ea1cdea692233c08bdcee8e42abb258f5de3b9a5e7" dependencies = [ "core-audio-types-rs", "core-foundation 0.10.1", "core-utils-rs", "core-video-rs", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] name = "core-utils-rs" -version = "0.3.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791d9e8eb99cff8db5e2b6a5bb420b893476b7a064bb6889b2a691c1bb6d745c" +checksum = "068ec1aa07335261033bf610b2868ea9db05353468b72b0045ae469e00d26121" dependencies = [ "core-foundation 0.10.1", "four-char-code", @@ -2109,15 +1941,15 @@ dependencies = [ [[package]] name = "core-video-rs" -version = "0.3.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f1614e163a5d6fb972305fa14fc5c2989143da0426c213b74785b31e1fadb8" +checksum = "7559e93f816c05607068cb0b741d997a47709f4ba70b36a02d335f7136b30c46" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "core-utils-rs", "io-surface", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -2146,7 +1978,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" dependencies = [ - "bindgen 0.72.1", + "bindgen 0.72.0", ] [[package]] @@ -2155,7 +1987,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "fontdb 0.16.2", "log", "rangemap", @@ -2198,7 +2030,7 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca#3cc779a7b4ca51770211f1b7dc19f107978af707" +source = "git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf#f43d36e55494993bbbde3299af0c53e5cdf4d4cf" dependencies = [ "alsa", "core-foundation-sys", @@ -2226,30 +2058,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crash-context" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" -dependencies = [ - "cfg-if", - "libc", - "mach2", -] - -[[package]] -name = "crash-handler" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" -dependencies = [ - "cfg-if", - "crash-context", - "libc", - "mach2", - "parking_lot", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -2358,7 +2166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2368,7 +2176,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2392,7 +2200,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2403,7 +2211,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2420,19 +2228,19 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "data-url" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" [[package]] name = "dbus" -version = "0.9.9" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" dependencies = [ "libc", "libdbus-sys", - "windows-sys 0.59.0", + "winapi", ] [[package]] @@ -2445,21 +2253,11 @@ dependencies = [ "uuid", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" -version = "0.5.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", "serde", @@ -2473,7 +2271,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2486,28 +2284,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.106", -] - -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "unicode-xid", + "syn 2.0.104", ] [[package]] @@ -2558,7 +2335,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.0", + "windows-sys 0.60.2", ] [[package]] @@ -2573,10 +2350,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.1", ] [[package]] @@ -2587,7 +2364,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2619,7 +2396,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2666,9 +2443,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "downcast-rs" -version = "2.0.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" [[package]] name = "dpi" @@ -2721,7 +2498,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.7", + "toml 0.9.5", "vswhom", "winreg", ] @@ -2765,7 +2542,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2785,7 +2562,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -2796,23 +2573,22 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" dependencies = [ "serde", - "serde_core", "typeid", ] [[package]] name = "errno" -version = "0.3.14" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.60.2", ] [[package]] @@ -2882,26 +2658,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -2923,7 +2679,7 @@ name = "ffmpeg-next" version = "7.1.0" source = "git+https://github.com/CapSoftware/rust-ffmpeg?rev=49db1fede112#49db1fede1123029670728bd6e3fcbb07b0f5716" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "ffmpeg-sys-next", "libc", ] @@ -2954,22 +2710,16 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] -[[package]] -name = "find-msvc-tools" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" - [[package]] name = "findshlibs" version = "0.10.2" @@ -3102,7 +2852,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -3119,9 +2869,9 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -3228,7 +2978,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -3388,13 +3138,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + [[package]] name = "gethostname" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "rustix 1.1.2", + "rustix 1.0.8", "windows-targets 0.52.6", ] @@ -3432,7 +3192,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] @@ -3540,7 +3300,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "futures-channel", "futures-core", "futures-executor", @@ -3564,11 +3324,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -3583,9 +3343,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "global-hotkey" @@ -3595,11 +3355,11 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "once_cell", "serde", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows-sys 0.59.0", "x11rb", "xkeysym", @@ -3650,24 +3410,13 @@ dependencies = [ "system-deps", ] -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - [[package]] name = "gpu-alloc" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "gpu-alloc-types", ] @@ -3677,7 +3426,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", ] [[package]] @@ -3698,9 +3447,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "gpu-descriptor-types", - "hashbrown 0.15.5", + "hashbrown 0.15.4", ] [[package]] @@ -3709,7 +3458,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", ] [[package]] @@ -3761,7 +3510,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -3781,8 +3530,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.11.4", + "http", + "indexmap 2.10.0", "slab", "tokio", "tokio-util", @@ -3814,19 +3563,13 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "heck" version = "0.4.1" @@ -3874,7 +3617,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -3895,17 +3638,6 @@ dependencies = [ "match_token", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.3.1" @@ -3924,7 +3656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http", ] [[package]] @@ -3935,7 +3667,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http", "http-body", "pin-project-lite", ] @@ -3960,22 +3692,20 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ - "atomic-waker", "bytes", "futures-channel", - "futures-core", + "futures-util", "h2", - "http 1.3.1", + "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3987,7 +3717,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", + "http", "hyper", "hyper-util", "rustls", @@ -4016,23 +3746,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", "system-configuration", "tokio", "tower-service", @@ -4042,9 +3772,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -4052,7 +3782,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.0", + "windows-core 0.61.2", ] [[package]] @@ -4071,7 +3801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png 0.17.16", + "png", ] [[package]] @@ -4168,9 +3898,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.1.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", "smallvec", @@ -4200,16 +3930,16 @@ dependencies = [ "gif", "jpeg-decoder", "num-traits", - "png 0.17.16", + "png", "qoi", - "tiff 0.9.1", + "tiff", ] [[package]] name = "image" -version = "0.25.8" +version = "0.25.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", @@ -4217,23 +3947,22 @@ dependencies = [ "exr", "gif", "image-webp", - "moxcms", "num-traits", - "png 0.18.0", + "png", "qoi", "ravif", "rayon", "rgb", - "tiff 0.10.3", + "tiff", "zune-core", "zune-jpeg", ] [[package]] name = "image-webp" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b" dependencies = [ "byteorder-lite", "quick-error", @@ -4264,12 +3993,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - [[package]] name = "indexmap" version = "1.9.3" @@ -4283,14 +4006,13 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.4", "serde", - "serde_core", ] [[package]] @@ -4308,7 +4030,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "crossterm", "dyn-clone", "fuzzy-matcher", @@ -4327,14 +4049,14 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" dependencies = [ "rustversion", ] @@ -4353,11 +4075,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.10" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", "libc", ] @@ -4474,9 +4196,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ "getrandom 0.3.3", "libc", @@ -4493,9 +4215,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -4529,7 +4251,7 @@ version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41a73be96f616ca2784f597b5b6635582f5a7b3ba73b1dbe7afa5d9667955d39" dependencies = [ - "downcast-rs 2.0.2", + "downcast-rs 2.0.1", "dyn-clone", "futures", "kameo_macros", @@ -4548,7 +4270,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "uuid", ] @@ -4558,7 +4280,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "serde", "unicode-segmentation", ] @@ -4569,7 +4291,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.10.0", ] [[package]] @@ -4597,7 +4319,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.11.4", + "indexmap 2.10.0", "selectors", ] @@ -4612,12 +4334,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - [[package]] name = "lazy_static" version = "1.5.0" @@ -4647,9 +4363,9 @@ dependencies = [ [[package]] name = "lebe" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "lewton" @@ -4688,15 +4404,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libdbus-sys" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" dependencies = [ "pkg-config", ] @@ -4739,11 +4455,11 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "libc", "redox_syscall", ] @@ -4754,7 +4470,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cc", "convert_case 0.6.0", "cookie-factory", @@ -4778,9 +4494,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" dependencies = [ "zlib-rs", ] @@ -4793,9 +4509,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" @@ -4809,23 +4525,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - [[package]] name = "lock_api" version = "0.4.13" @@ -4850,9 +4549,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "loop9" @@ -4888,7 +4587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" dependencies = [ "cc", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-foundation 0.3.1", "time", ] @@ -4943,16 +4642,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", + "syn 2.0.104", ] [[package]] @@ -4991,9 +4681,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memmap2" -version = "0.9.8" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" +checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" dependencies = [ "libc", ] @@ -5013,7 +4703,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -5028,7 +4718,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -5053,75 +4743,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minidump-common" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" -dependencies = [ - "bitflags 2.9.4", - "debugid", - "num-derive", - "num-traits", - "range-map", - "scroll", - "smart-default", -] - -[[package]] -name = "minidump-writer" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" -dependencies = [ - "bitflags 2.9.4", - "byteorder", - "cfg-if", - "crash-context", - "goblin", - "libc", - "log", - "mach2", - "memmap2", - "memoffset", - "minidump-common", - "nix 0.28.0", - "procfs-core", - "scroll", - "tempfile", - "thiserror 1.0.69", -] - -[[package]] -name = "minidumper" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1" -dependencies = [ - "cfg-if", - "crash-context", - "libc", - "log", - "minidump-writer", - "parking_lot", - "polling", - "scroll", - "thiserror 1.0.69", - "uds", -] - -[[package]] -name = "minidumper-child" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4f23f835dbe67e44ddf884d3802ff549ca5948bf60e9fd70e9a13c96324d1" -dependencies = [ - "crash-handler", - "minidumper", - "thiserror 1.0.69", - "uuid", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5163,21 +4784,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] -[[package]] -name = "moxcms" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "mp4" version = "0.14.0" @@ -5202,14 +4812,14 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.1", "once_cell", - "png 0.17.16", + "png", "serde", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows-sys 0.60.2", ] @@ -5221,20 +4831,20 @@ checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.9.4", - "cfg_aliases 0.2.1", + "bitflags 2.9.1", + "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.5", + "hashbrown 0.15.4", "hexf-parse", - "indexmap 2.11.4", + "indexmap 2.10.0", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.16", + "thiserror 2.0.12", "unicode-ident", ] @@ -5276,7 +4886,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "jni-sys", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -5290,7 +4900,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "jni-sys", "log", "ndk-sys 0.6.0+11769913", @@ -5344,20 +4954,8 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", - "cfg_aliases 0.1.1", "libc", ] @@ -5367,9 +4965,9 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", ] @@ -5379,9 +4977,9 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] @@ -5433,11 +5031,12 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ - "windows-sys 0.52.0", + "overload", + "winapi", ] [[package]] @@ -5464,7 +5063,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -5524,10 +5123,10 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -5569,9 +5168,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -5583,10 +5182,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", @@ -5602,10 +5201,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e085a2e16c61dadbad7a808fc9d5b5f8472b1b825b53d529c9f64ccac78e722" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-avf-audio", "objc2-core-foundation", "objc2-core-graphics", @@ -5622,7 +5221,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -5632,8 +5231,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -5644,7 +5243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" dependencies = [ "dispatch2", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -5655,8 +5254,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", ] [[package]] @@ -5665,8 +5264,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -5676,9 +5275,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.1", ] [[package]] @@ -5687,9 +5286,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-core-foundation", "objc2-io-surface", ] @@ -5700,7 +5299,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -5710,9 +5309,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0b7afa6822e2fa20dfc88d10186b2432bf8560b5ed73ec9d31efd78277bc878" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -5725,8 +5324,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1989c3e76c7e978cab0ba9e6f4961cd00ed14ca21121444cc26877403bfb6303" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -5753,7 +5352,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5765,10 +5364,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", "libc", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -5778,8 +5377,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -5789,7 +5388,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -5799,7 +5398,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5811,8 +5410,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", ] @@ -5823,7 +5422,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5836,8 +5435,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -5847,8 +5446,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -5858,8 +5457,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.9.1", + "objc2 0.6.1", "objc2-core-foundation", "objc2-foundation 0.3.1", ] @@ -5870,9 +5469,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -5969,7 +5568,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -5986,7 +5585,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -6085,14 +5684,20 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", "objc2-foundation 0.3.1", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.12", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "pango" version = "0.18.3" @@ -6170,20 +5775,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" -version = "2.3.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "petgraph" @@ -6192,7 +5788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.11.4", + "indexmap 2.10.0", ] [[package]] @@ -6299,7 +5895,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -6365,7 +5961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.9.1", "libc", "libspa", "libspa-sys", @@ -6392,21 +5988,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" -version = "1.8.0" +version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", - "quick-xml 0.38.3", + "indexmap 2.10.0", + "quick-xml 0.38.1", "serde", "time", ] @@ -6424,31 +6014,18 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.9.4", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" -version = "3.11.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.0", + "rustix 1.0.8", + "windows-sys 0.60.2", ] [[package]] @@ -6468,9 +6045,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" dependencies = [ "zerovec", ] @@ -6514,12 +6091,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.37" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -6534,21 +6111,20 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_edit 0.20.7", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ - "toml_edit 0.23.6", + "toml_edit 0.22.27", ] [[package]] @@ -6578,26 +6154,16 @@ dependencies = [ [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" -dependencies = [ - "unicode-ident", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] -name = "procfs-core" -version = "0.16.0" +name = "proc-macro2" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ - "bitflags 2.9.4", - "hex", + "unicode-ident", ] [[package]] @@ -6616,7 +6182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -6635,15 +6201,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "pxfm" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" -dependencies = [ - "num-traits", -] - [[package]] name = "qoi" version = "0.4.1" @@ -6670,9 +6227,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" dependencies = [ "memchr", ] @@ -6684,14 +6241,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.0", - "thiserror 2.0.16", + "socket2", + "thiserror 2.0.12", "tokio", "tracing", "web-time", @@ -6712,7 +6269,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.12", "tinyvec", "tracing", "web-time", @@ -6724,10 +6281,10 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", - "socket2 0.6.0", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -6863,15 +6420,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" -[[package]] -name = "range-map" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f" -dependencies = [ - "num-traits", -] - [[package]] name = "rangemap" version = "1.6.0" @@ -6936,9 +6484,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" -version = "1.11.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", @@ -6946,9 +6494,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.13.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -6969,7 +6517,7 @@ dependencies = [ "rustc-hash 2.1.1", "send_wrapper", "slotmap", - "thiserror 2.0.16", + "thiserror 2.0.12", "web-sys", ] @@ -7001,7 +6549,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", ] [[package]] @@ -7012,7 +6560,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -7032,14 +6580,14 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "regex" -version = "1.11.2" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -7049,26 +6597,20 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" - [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relative-path" @@ -7093,9 +6635,9 @@ checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64 0.22.1", "bytes", @@ -7106,7 +6648,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.3.1", + "http", "http-body", "http-body-util", "hyper", @@ -7183,7 +6725,7 @@ dependencies = [ "gtk-sys", "js-sys", "log", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -7250,12 +6792,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rust-ini" -version = "0.21.3" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" dependencies = [ "cfg-if", "ordered-multimap", + "trim-in-place", ] [[package]] @@ -7291,7 +6834,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7300,15 +6843,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.0", + "linux-raw-sys 0.9.4", + "windows-sys 0.60.2", ] [[package]] @@ -7325,15 +6868,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -7346,9 +6880,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.6" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -7357,9 +6891,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "rustybuzz" @@ -7367,7 +6901,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "bytemuck", "libm", "smallvec", @@ -7384,7 +6918,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "bytemuck", "core_maths", "log", @@ -7426,7 +6960,7 @@ dependencies = [ "pipewire", "rand 0.8.5", "sysinfo", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows 0.58.0", "windows-capture", ] @@ -7435,7 +6969,7 @@ dependencies = [ name = "scap-cpal" version = "0.1.0" dependencies = [ - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "thiserror 1.0.69", ] @@ -7454,8 +6988,9 @@ dependencies = [ name = "scap-ffmpeg" version = "0.1.0" dependencies = [ + "cap-ffmpeg-utils", "cidre 0.11.0", - "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", + "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", "futures", "scap-cpal", @@ -7474,7 +7009,7 @@ dependencies = [ "clap", "futures", "inquire", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", "scap-targets", @@ -7493,18 +7028,17 @@ dependencies = [ "objc", "serde", "specta", - "tokio", "tracing", "windows 0.60.0", ] [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -7555,7 +7089,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -7586,33 +7120,13 @@ dependencies = [ "objc", ] -[[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -7621,9 +7135,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -7637,7 +7151,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more 0.99.20", + "derive_more", "fxhash", "log", "phf 0.8.0", @@ -7655,12 +7169,11 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" dependencies = [ "serde", - "serde_core", ] [[package]] @@ -7674,14 +7187,13 @@ dependencies = [ [[package]] name = "sentry" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" dependencies = [ "httpdate", "native-tls", "reqwest", - "sentry-actix", "sentry-anyhow", "sentry-backtrace", "sentry-contexts", @@ -7693,24 +7205,11 @@ dependencies = [ "ureq", ] -[[package]] -name = "sentry-actix" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5c675bdf6118764a8e265c3395c311b4d905d12866c92df52870c0223d2ffc1" -dependencies = [ - "actix-http", - "actix-web", - "bytes", - "futures-util", - "sentry-core", -] - [[package]] name = "sentry-anyhow" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b4523c2595d6730bfbe401e95a6423fe9cb16dc3b6046f340551591cffe723" +checksum = "d672bfd1ed4e90978435f3c0704edb71a7a9d86403657839d518cd6aa278aff5" dependencies = [ "anyhow", "sentry-backtrace", @@ -7719,20 +7218,21 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" dependencies = [ "backtrace", + "once_cell", "regex", "sentry-core", ] [[package]] name = "sentry-contexts" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" dependencies = [ "hostname", "libc", @@ -7744,55 +7244,44 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" dependencies = [ - "rand 0.9.2", + "once_cell", + "rand 0.8.5", "sentry-types", "serde", "serde_json", - "url", ] [[package]] name = "sentry-debug-images" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00950648aa0d371c7f57057434ad5671bd4c106390df7e7284739330786a01b6" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" dependencies = [ "findshlibs", + "once_cell", "sentry-core", ] [[package]] name = "sentry-panic" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" dependencies = [ "sentry-backtrace", "sentry-core", ] -[[package]] -name = "sentry-rust-minidump" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63964525bf74b16233dbcfb307e11485ebd8ff8f87f6ae212b07ca7937cd2db1" -dependencies = [ - "minidumper-child", - "sentry", - "thiserror 2.0.16", -] - [[package]] name = "sentry-tracing" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" dependencies = [ - "bitflags 2.9.4", "sentry-backtrace", "sentry-core", "tracing-core", @@ -7801,16 +7290,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.42.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" dependencies = [ "debugid", "hex", - "rand 0.9.2", + "rand 0.8.5", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 1.0.69", "time", "url", "uuid", @@ -7818,44 +7307,33 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.225" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ - "serde_core", "serde_derive", ] [[package]] name = "serde-untagged" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" dependencies = [ "erased-serde", "serde", - "serde_core", "typeid", ] -[[package]] -name = "serde_core" -version = "1.0.225" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" -dependencies = [ - "serde_derive", -] - [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -7866,31 +7344,29 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", "ryu", "serde", - "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.20" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", - "serde_core", ] [[package]] @@ -7901,7 +7377,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -7915,11 +7391,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -7944,7 +7420,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.10.0", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -7963,7 +7439,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -7985,7 +7461,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8137,7 +7613,7 @@ dependencies = [ "regex", "serde_json", "tar", - "toml 0.8.2", + "toml 0.8.23", ] [[package]] @@ -8146,7 +7622,7 @@ version = "0.86.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e372258f52414e04de007326fa497581617c9fa872a3225dca5e42212723c426" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "lazy_static", "skia-bindings", ] @@ -8163,9 +7639,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" [[package]] name = "slotmap" @@ -8182,33 +7658,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smart-default" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "smol_str" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.0" @@ -8226,7 +7681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", - "cfg_aliases 0.2.1", + "cfg_aliases", "core-graphics 0.24.0", "foreign-types 0.5.0", "js-sys", @@ -8289,7 +7744,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8328,7 +7783,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", ] [[package]] @@ -8411,7 +7866,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8423,7 +7878,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8532,9 +7987,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -8558,7 +8013,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8591,7 +8046,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8615,17 +8070,17 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.2", + "toml 0.8.23", "version-compare", ] [[package]] name = "tao" -version = "0.34.3" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" +checksum = "4daa814018fecdfb977b59a094df4bd43b42e8e21f88fddfc05807e6f46efaaf" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block2 0.6.1", "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -8643,7 +8098,7 @@ dependencies = [ "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", "once_cell", @@ -8667,7 +8122,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -8689,9 +8144,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.8.5" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +checksum = "5d545ccf7b60dcd44e07c6fb5aeb09140966f0aabd5d2aa14a6821df7bc99348" dependencies = [ "anyhow", "bytes", @@ -8703,15 +8158,15 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.3.1", + "http", "http-range", - "image 0.25.8", + "image 0.25.6", "jni", "libc", "log", "mime", "muda", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", "objc2-ui-kit", @@ -8731,7 +8186,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.12", "tokio", "tray-icon", "url", @@ -8744,9 +8199,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.4.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +checksum = "67945dbaf8920dbe3a1e56721a419a0c3d085254ab24cff5b9ad55e2b0016e0b" dependencies = [ "anyhow", "cargo_toml", @@ -8760,7 +8215,7 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.7", + "toml 0.9.5", "walkdir", ] @@ -8775,16 +8230,16 @@ dependencies = [ "ico", "json-patch", "plist", - "png 0.17.16", + "png", "proc-macro2", "quote", "semver", "serde", "serde_json", "sha2", - "syn 2.0.106", + "syn 2.0.104", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.12", "time", "url", "uuid", @@ -8800,7 +8255,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "tauri-codegen", "tauri-utils", ] @@ -8810,7 +8265,7 @@ name = "tauri-nspanel" version = "2.0.1" source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "cocoa 0.26.1", "core-foundation 0.10.1", @@ -8834,7 +8289,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.7", + "toml 0.9.5", "walkdir", ] @@ -8850,14 +8305,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] name = "tauri-plugin-deep-link" -version = "2.4.3" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd67112fb1131834c2a7398ffcba520dbbf62c17de3b10329acd1a3554b1a9bb" +checksum = "6d430110d4ee102a9b673d3c03ff48098c80fe8ca71ba1ff52d8a5919538a1a6" dependencies = [ "dunce", "plist", @@ -8867,7 +8322,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.12", "tracing", "url", "windows-registry", @@ -8876,9 +8331,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.4.0" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" +checksum = "0ee5a3c416dc59d7d9aa0de5490a82d6e201c67ffe97388979d77b69b08cda40" dependencies = [ "log", "raw-window-handle", @@ -8888,7 +8343,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.16", + "thiserror 2.0.12", "url", ] @@ -8909,8 +8364,8 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.16", - "toml 0.9.7", + "thiserror 2.0.12", + "toml 0.9.5", "url", ] @@ -8926,7 +8381,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -8938,7 +8393,7 @@ dependencies = [ "bytes", "cookie_store", "data-url", - "http 1.3.1", + "http", "regex", "reqwest", "schemars 0.8.22", @@ -8947,7 +8402,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.16", + "thiserror 2.0.12", "tokio", "url", "urlpattern", @@ -8967,7 +8422,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", "time", "url", ] @@ -9002,7 +8457,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", "url", "windows 0.61.3", "zbus", @@ -9014,7 +8469,7 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba" dependencies = [ - "gethostname", + "gethostname 1.0.2", "log", "os_info", "serde", @@ -9023,7 +8478,7 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -9038,7 +8493,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -9051,22 +8506,6 @@ dependencies = [ "tauri-plugin", ] -[[package]] -name = "tauri-plugin-sentry" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295" -dependencies = [ - "base64 0.22.1", - "schemars 0.8.22", - "sentry", - "sentry-rust-minidump", - "serde", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - [[package]] name = "tauri-plugin-shell" version = "2.3.1" @@ -9084,21 +8523,21 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", "tokio", ] [[package]] name = "tauri-plugin-single-instance" -version = "2.3.4" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb9cac815bf11c4a80fb498666bcdad66d65b89e3ae24669e47806febb76389c" +checksum = "236043404a4d1502ed7cce11a8ec88ea1e85597eec9887b4701bb10b66b13b6e" dependencies = [ "serde", "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.16", + "thiserror 2.0.12", "tracing", "windows-sys 0.60.2", "zbus", @@ -9115,7 +8554,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", "tokio", "tracing", ] @@ -9130,7 +8569,7 @@ dependencies = [ "dirs", "flate2", "futures-util", - "http 1.3.1", + "http", "infer", "log", "minisign-verify", @@ -9144,7 +8583,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.12", "time", "tokio", "url", @@ -9158,13 +8597,13 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "log", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.16", + "thiserror 2.0.12", ] [[package]] @@ -9176,16 +8615,16 @@ dependencies = [ "cookie", "dpi", "gtk", - "http 1.3.1", + "http", "jni", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", - "thiserror 2.0.16", + "thiserror 2.0.12", "url", "webkit2gtk", "webview2-com", @@ -9199,10 +8638,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" dependencies = [ "gtk", - "http 1.3.1", + "http", "jni", "log", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-foundation 0.3.1", "once_cell", @@ -9244,7 +8683,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -9260,7 +8699,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http 1.3.1", + "http", "infer", "json-patch", "kuchikiki", @@ -9277,8 +8716,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.16", - "toml 0.9.7", + "thiserror 2.0.12", + "toml 0.9.5", "url", "urlpattern", "uuid", @@ -9292,7 +8731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" dependencies = [ "embed-resource", - "toml 0.9.7", + "toml 0.9.5", ] [[package]] @@ -9302,22 +8741,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows 0.61.3", "windows-version", ] [[package]] name = "tempfile" -version = "3.22.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.1.2", - "windows-sys 0.61.0", + "rustix 1.0.8", + "windows-sys 0.59.0", ] [[package]] @@ -9351,11 +8790,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.12", ] [[package]] @@ -9366,18 +8805,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -9400,27 +8839,14 @@ dependencies = [ "weezl", ] -[[package]] -name = "tiff" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" -version = "0.3.43" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -9430,15 +8856,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -9464,7 +8890,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png 0.17.16", + "png", "tiny-skia-path", ] @@ -9491,9 +8917,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] @@ -9515,11 +8941,10 @@ dependencies = [ "io-uring", "libc", "mio 1.0.4", - "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", "tracing", "windows-sys 0.59.0", @@ -9533,7 +8958,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -9548,9 +8973,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ "rustls", "tokio", @@ -9595,47 +9020,47 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "0.9.7" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ - "indexmap 2.11.4", - "serde_core", - "serde_spanned 1.0.2", - "toml_datetime 0.7.2", + "indexmap 2.10.0", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow 0.7.12", ] [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -9644,50 +9069,56 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.6.3", + "indexmap 2.10.0", + "toml_datetime 0.6.11", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.11.4", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "indexmap 2.10.0", + "toml_datetime 0.6.11", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.7.2", - "toml_parser", - "winnow 0.7.13", + "indexmap 2.10.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.12", ] [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ - "winnow 0.7.13", + "winnow 0.7.12", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "tower" @@ -9711,10 +9142,10 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "bytes", "futures-util", - "http 1.3.1", + "http", "http-body", "iri-string", "pin-project-lite", @@ -9767,7 +9198,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -9793,18 +9224,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ - "matchers", "nu-ansi-term", - "once_cell", - "regex-automata", "sharded-slab", "smallvec", "thread_local", - "tracing", "tracing-core", "tracing-log", ] @@ -9819,15 +9246,15 @@ dependencies = [ "dirs", "libappindicator", "muda", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.1", "once_cell", - "png 0.17.16", + "png", "serde", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows-sys 0.59.0", ] @@ -9843,6 +9270,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + [[package]] name = "try-lock" version = "0.2.5" @@ -9879,7 +9312,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.3.1", + "http", "httparse", "log", "rand 0.8.5", @@ -9900,15 +9333,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "uds" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" -dependencies = [ - "libc", -] - [[package]] name = "uds_windows" version = "1.1.0" @@ -10008,9 +9432,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-linebreak" @@ -10054,12 +9478,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "untrusted" version = "0.9.0" @@ -10068,39 +9486,22 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00432f493971db5d8e47a65aeb3b02f8226b9b11f1450ff86bb772776ebadd70" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64 0.22.1", - "der", "log", "native-tls", - "percent-encoding", - "rustls-pemfile", - "rustls-pki-types", - "ureq-proto", - "utf-8", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b6cabebbecc4c45189ab06b52f956206cea7d8c8a20851c35a85cb169224cc" -dependencies = [ - "base64 0.22.1", - "http 1.3.1", - "httparse", - "log", + "once_cell", + "url", ] [[package]] name = "url" -version = "2.5.7" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -10167,9 +9568,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -10265,54 +9666,44 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" dependencies = [ - "wit-bindgen", + "wit-bindgen-rt", ] [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", - "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.103" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", @@ -10323,9 +9714,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10333,22 +9724,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] @@ -10374,7 +9765,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs 1.2.1", - "rustix 1.1.2", + "rustix 1.0.8", "scoped-tls", "smallvec", "wayland-sys", @@ -10386,8 +9777,8 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.4", - "rustix 1.1.2", + "bitflags 2.9.1", + "rustix 1.0.8", "wayland-backend", "wayland-scanner", ] @@ -10398,7 +9789,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -10410,7 +9801,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10441,9 +9832,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -10503,15 +9894,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.2" @@ -10543,7 +9925,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -10552,7 +9934,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.12", "windows 0.61.3", "windows-core 0.61.2", ] @@ -10570,10 +9952,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" dependencies = [ "arrayvec", - "bitflags 2.9.4", - "cfg_aliases 0.2.1", + "bitflags 2.9.1", + "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.15.4", "js-sys", "log", "naga", @@ -10600,11 +9982,11 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.9.4", - "cfg_aliases 0.2.1", + "bitflags 2.9.1", + "cfg_aliases", "document-features", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.15.4", + "indexmap 2.10.0", "log", "naga", "once_cell", @@ -10614,7 +9996,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.12", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -10659,18 +10041,18 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.9.4", + "bitflags 2.9.1", "block", "bytemuck", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "core-graphics-types 0.1.3", "glow", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", - "hashbrown 0.15.5", + "hashbrown 0.15.4", "js-sys", "khronos-egl", "libc", @@ -10688,7 +10070,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.12", "wasm-bindgen", "web-sys", "wgpu-types", @@ -10702,11 +10084,11 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.9.1", "bytemuck", "js-sys", "log", - "thiserror 2.0.16", + "thiserror 2.0.12", "web-sys", ] @@ -10770,11 +10152,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -10789,7 +10171,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -10856,7 +10238,7 @@ dependencies = [ "windows-collections 0.1.1", "windows-core 0.60.1", "windows-future 0.1.1", - "windows-link 0.1.3", + "windows-link", "windows-numerics 0.1.1", ] @@ -10869,7 +10251,7 @@ dependencies = [ "windows-collections 0.2.0", "windows-core 0.61.2", "windows-future 0.2.1", - "windows-link 0.1.3", + "windows-link", "windows-numerics 0.2.0", ] @@ -10881,7 +10263,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.16", + "thiserror 2.0.12", "windows 0.61.3", "windows-future 0.2.1", ] @@ -10957,7 +10339,7 @@ checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" dependencies = [ "windows-implement 0.59.0", "windows-interface 0.59.1", - "windows-link 0.1.3", + "windows-link", "windows-result 0.3.4", "windows-strings 0.3.1", ] @@ -10970,24 +10352,11 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", - "windows-link 0.1.3", + "windows-link", "windows-result 0.3.4", "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" -dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link 0.2.0", - "windows-result 0.4.0", - "windows-strings 0.5.0", -] - [[package]] name = "windows-future" version = "0.1.1" @@ -10995,7 +10364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" dependencies = [ "windows-core 0.60.1", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -11005,7 +10374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-link", "windows-threading", ] @@ -11017,7 +10386,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11028,7 +10397,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11039,7 +10408,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11050,7 +10419,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11061,7 +10430,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11070,12 +10439,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" -[[package]] -name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - [[package]] name = "windows-numerics" version = "0.1.1" @@ -11083,7 +10446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" dependencies = [ "windows-core 0.60.1", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -11093,7 +10456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -11102,7 +10465,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link 0.1.3", + "windows-link", "windows-result 0.3.4", "windows-strings 0.4.2", ] @@ -11131,16 +10494,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" -dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -11159,7 +10513,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -11168,16 +10522,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" -dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -11225,15 +10570,6 @@ dependencies = [ "windows-targets 0.53.3", ] -[[package]] -name = "windows-sys" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" -dependencies = [ - "windows-link 0.2.0", -] - [[package]] name = "windows-targets" version = "0.42.2" @@ -11286,7 +10622,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link 0.1.3", + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -11303,16 +10639,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] name = "windows-version" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -11515,9 +10851,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] @@ -11533,10 +10869,13 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.46.0" +name = "wit-bindgen-rt" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] [[package]] name = "wl-clipboard-rs" @@ -11549,7 +10888,7 @@ dependencies = [ "os_pipe", "rustix 0.38.44", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.12", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -11579,13 +10918,13 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http 1.3.1", + "http", "javascriptcore-rs", "jni", "kuchikiki", "libc", "ndk 0.9.0", - "objc2 0.6.2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -11597,7 +10936,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.16", + "thiserror 2.0.12", "url", "webkit2gtk", "webkit2gtk-sys", @@ -11631,20 +10970,20 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ - "gethostname", - "rustix 1.1.2", + "gethostname 0.4.3", + "rustix 0.38.44", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" [[package]] name = "xattr" @@ -11653,7 +10992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.0.8", ] [[package]] @@ -11721,7 +11060,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "synstructure", ] @@ -11737,9 +11076,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.11.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "67a073be99ace1adc48af593701c8015cd9817df372e14a1a6b0ee8f8bf043be" dependencies = [ "async-broadcast", "async-executor", @@ -11763,7 +11102,7 @@ dependencies = [ "tracing", "uds_windows", "windows-sys 0.60.2", - "winnow 0.7.13", + "winnow 0.7.12", "zbus_macros", "zbus_names", "zvariant", @@ -11771,14 +11110,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "0e80cd713a45a49859dcb648053f63265f4f2851b6420d47a958e5697c68b131" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "zbus_names", "zvariant", "zvariant_utils", @@ -11792,7 +11131,7 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow 0.7.13", + "winnow 0.7.12", "zvariant", ] @@ -11804,22 +11143,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] @@ -11839,7 +11178,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "synstructure", ] @@ -11862,9 +11201,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -11879,26 +11218,26 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", ] [[package]] name = "zip" -version = "4.6.1" +version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.11.4", + "indexmap 2.10.0", "memchr", ] [[package]] name = "zlib-rs" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" [[package]] name = "zune-core" @@ -11917,9 +11256,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" dependencies = [ "zune-core", ] @@ -11934,7 +11273,7 @@ dependencies = [ "enumflags2", "serde", "url", - "winnow 0.7.13", + "winnow 0.7.12", "zvariant_derive", "zvariant_utils", ] @@ -11945,10 +11284,10 @@ version = "5.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.104", "zvariant_utils", ] @@ -11961,6 +11300,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", - "winnow 0.7.13", + "syn 2.0.104", + "winnow 0.7.12", ] diff --git a/Cargo.toml b/Cargo.toml index dc023746e9..46cd816eaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["apps/cli", "apps/desktop/src-tauri", "crates/*"] anyhow = { version = "1.0.86" } # This includes a currently-unreleased fix that ensures the audio stream is actually # stopped and released on drop on macOS -cpal = { git = "https://github.com/CapSoftware/cpal", rev = "3cc779a7b4ca" } +cpal = { git = "https://github.com/RustAudio/cpal", rev = "f43d36e55494993bbbde3299af0c53e5cdf4d4cf" } ffmpeg = { package = "ffmpeg-next", git = "https://github.com/CapSoftware/rust-ffmpeg", rev = "49db1fede112" } tokio = { version = "1.39.3", features = [ "macros", @@ -34,7 +34,7 @@ nokhwa-bindings-macos = { git = "https://github.com/CapSoftware/nokhwa", rev = " wgpu = "25.0.0" flume = "0.11.0" thiserror = "1.0" -sentry = { version = "0.42.0", features = [ +sentry = { version = "0.34.0", features = [ "anyhow", "backtrace", "debug-images", @@ -67,7 +67,6 @@ unexpected_cfgs = "allow" [workspace.lints.clippy] dbg_macro = "deny" let_underscore_future = "deny" -unchecked_duration_subtraction = "deny" # Optimize for smaller binary size [profile.release] diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index d7c5bd16eb..18d52a2634 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -24,8 +24,5 @@ tracing.workspace = true tracing-subscriber = "0.3.19" flume.workspace = true -[target.'cfg(target_os = "macos")'.dependencies] -cidre = { workspace = true } - [lints] workspace = true diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index 0fe468cfd3..f05d588869 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -1,4 +1,4 @@ -use cap_recording::{screen_capture::ScreenCaptureTarget, studio_recording}; +use cap_recording::screen_capture::ScreenCaptureTarget; use clap::Args; use scap_targets::{DisplayId, WindowId}; use std::{env::current_dir, path::PathBuf}; @@ -58,15 +58,19 @@ impl RecordStart { .path .unwrap_or_else(|| current_dir().unwrap().join(format!("{id}.cap"))); - let actor = studio_recording::Actor::builder(path, target_info) - .with_system_audio(self.system_audio) - .with_custom_cursor(false) - .build( - #[cfg(target_os = "macos")] - cidre::sc::ShareableContent::current().await.unwrap(), - ) - .await - .map_err(|e| e.to_string())?; + let actor = cap_recording::spawn_studio_recording_actor( + id, + path, + cap_recording::RecordingBaseInputs { + capture_target: target_info, + capture_system_audio: self.system_audio, + mic_feed: None, + camera_feed: None, // camera.map(|c| Arc::new(Mutex::new(c))), + }, + false, + ) + .await + .map_err(|e| e.to_string())?; println!("Recording starting, press Enter to stop"); @@ -75,7 +79,7 @@ impl RecordStart { .await .unwrap(); - actor.stop().await.unwrap(); + actor.0.stop().await.unwrap(); Ok(()) } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 23aee2c67a..f7d299f51f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,7 +5,7 @@ "dev": "pnpm -w cap-setup && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri dev", "build:tauri": "dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri build", "preparescript": "node scripts/prepare.js", - "localdev": "dotenv -e ../../.env -- vinxi dev --port 3002", + "localdev": "dotenv -e ../../.env -- vinxi dev --port 3001", "build": "vinxi build", "tauri": "tauri" }, @@ -41,7 +41,7 @@ "@tauri-apps/api": "2.5.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-deep-link": "^2.4.1", - "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-dialog": "^2.3.2", "@tauri-apps/plugin-fs": "^2.4.1", "@tauri-apps/plugin-http": "^2.5.1", "@tauri-apps/plugin-notification": "^2.3.0", @@ -54,12 +54,11 @@ "@ts-rest/core": "^3.52.1", "@types/react-tooltip": "^4.2.4", "cva": "npm:class-variance-authority@^0.7.0", - "effect": "^3.17.14", + "effect": "^3.17.7", "mp4box": "^0.5.2", "posthog-js": "^1.215.3", "solid-js": "^1.9.3", "solid-markdown": "^2.0.13", - "solid-motionone": "^1.0.4", "solid-presence": "^0.1.8", "solid-toast": "^0.5.0", "solid-transition-group": "^0.2.3", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 2eaccbe05d..47489b27db 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.72-beta.1" +version = "0.3.67" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" @@ -53,6 +53,7 @@ specta-typescript = "0.0.7" tokio.workspace = true uuid = { version = "1.10.0", features = ["v4"] } image = "0.25.2" +mp4 = "0.14.0" futures-intrusive = "0.5.0" anyhow.workspace = true futures = { workspace = true } @@ -88,8 +89,6 @@ cap-flags = { path = "../../../crates/flags" } cap-recording = { path = "../../../crates/recording" } cap-export = { path = "../../../crates/export" } scap-targets = { path = "../../../crates/scap-targets" } -scap-screencapturekit = { path = "../../../crates/scap-screencapturekit" } -scap-direct3d = { path = "../../../crates/scap-direct3d" } flume.workspace = true tracing-subscriber = "0.3.19" @@ -103,7 +102,6 @@ tokio-util = "0.7.15" wgpu.workspace = true bytemuck = "1.23.1" kameo = "0.17.2" -tauri-plugin-sentry = "0.5.0" [target.'cfg(target_os = "macos")'.dependencies] core-graphics = "0.24.0" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 5991565da6..1871829812 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -41,7 +41,6 @@ "core:window:allow-set-theme", "core:window:allow-set-progress-bar", "core:window:allow-set-effects", - "core:window:allow-set-ignore-cursor-events", "core:webview:default", "core:webview:allow-create-webview-window", "core:app:allow-version", diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 4160fa2f57..5b6e6546a8 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -1,7 +1,7 @@ use anyhow::{Context, anyhow}; -use cap_recording::{ - FFmpegVideoFrame, - feeds::{self, camera::CameraFeed}, +use cap_recording::feeds::{ + self, + camera::{CameraFeed, RawCameraFrame}, }; use ffmpeg::{ format::{self, Pixel}, @@ -123,8 +123,7 @@ impl CameraPreviewManager { LocalSet::new().block_on( &rt, renderer.run(window, default_state, reconfigure_rx, camera_rx), - ); - info!("DONE"); + ) }); self.preview = Some(InitializedCameraPreview { reconfigure }); @@ -465,7 +464,7 @@ impl Renderer { window: WebviewWindow, default_state: CameraPreviewState, mut reconfigure: broadcast::Receiver, - camera_rx: flume::Receiver, + camera_rx: flume::Receiver, ) { let mut resampler_frame = Cached::default(); let Ok(mut scaler) = scaling::Context::get( @@ -496,7 +495,7 @@ impl Renderer { } { match event { Ok(frame) => { - let aspect_ratio = frame.inner.width() as f32 / frame.inner.height() as f32; + let aspect_ratio = frame.frame.width() as f32 / frame.frame.height() as f32; self.sync_ratio_uniform_and_resize_window_to_it(&window, &state, aspect_ratio); if let Ok(surface) = self.surface.get_current_texture().map_err(|err| { @@ -509,16 +508,16 @@ impl Renderer { .get_or_init((output_width, output_height), frame::Video::empty); scaler.cached( - frame.inner.format(), - frame.inner.width(), - frame.inner.height(), + frame.frame.format(), + frame.frame.width(), + frame.frame.height(), format::Pixel::RGBA, output_width, output_height, ffmpeg::software::scaling::flag::Flags::FAST_BILINEAR, ); - if let Err(err) = scaler.run(&frame.inner, resampler_frame) { + if let Err(err) = scaler.run(&frame.frame, resampler_frame) { error!("Error rescaling frame with ffmpeg: {err:?}"); continue; } diff --git a/apps/desktop/src-tauri/src/camera_legacy.rs b/apps/desktop/src-tauri/src/camera_legacy.rs index 683514e656..2aeaeaf3b0 100644 --- a/apps/desktop/src-tauri/src/camera_legacy.rs +++ b/apps/desktop/src-tauri/src/camera_legacy.rs @@ -1,11 +1,11 @@ -use cap_recording::FFmpegVideoFrame; +use cap_recording::feeds::camera::RawCameraFrame; use flume::Sender; use tokio_util::sync::CancellationToken; use crate::frame_ws::{WSFrame, create_frame_ws}; -pub async fn create_camera_preview_ws() -> (Sender, u16, CancellationToken) { - let (camera_tx, mut _camera_rx) = flume::bounded::(4); +pub async fn create_camera_preview_ws() -> (Sender, u16, CancellationToken) { + let (camera_tx, mut _camera_rx) = flume::bounded::(4); let (_camera_tx, camera_rx) = flume::bounded::(4); std::thread::spawn(move || { use ffmpeg::format::Pixel; @@ -13,7 +13,7 @@ pub async fn create_camera_preview_ws() -> (Sender, u16, Cance let mut converter: Option<(Pixel, ffmpeg::software::scaling::Context)> = None; while let Ok(raw_frame) = _camera_rx.recv() { - let mut frame = raw_frame.inner; + let mut frame = raw_frame.frame; if frame.format() != Pixel::RGBA || frame.width() > 1280 || frame.height() > 720 { let converter = match &mut converter { diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index aaf6a62b80..ff4f05a998 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -1,6 +1,4 @@ -use cap_recording::{ - RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, -}; +use cap_recording::{RecordingMode, feeds::camera::DeviceOrModelID, sources::ScreenCaptureTarget}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 4ba18473cb..6d16f9afa3 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -1,7 +1,4 @@ -use crate::{ - RequestOpenRecordingPicker, RequestStartRecording, recording, - recording_settings::RecordingTargetMode, windows::ShowCapWindow, -}; +use crate::{RequestStartRecording, recording, windows::ShowCapWindow}; use global_hotkey::HotKeyState; use serde::{Deserialize, Serialize}; use specta::Type; @@ -43,22 +40,14 @@ impl From for Shortcut { } } -#[derive(Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy, Debug)] +#[derive(Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy)] #[serde(rename_all = "camelCase")] #[allow(clippy::enum_variant_names)] pub enum HotkeyAction { - StartStudioRecording, - StartInstantRecording, + StartRecording, StopRecording, RestartRecording, // TakeScreenshot, - OpenRecordingPicker, - OpenRecordingPickerDisplay, - OpenRecordingPickerWindow, - OpenRecordingPickerArea, - // Needed for deserialization of deprecated actions - #[serde(other)] - Other, } #[derive(Serialize, Deserialize, Type, Default)] @@ -131,50 +120,14 @@ pub fn init(app: &AppHandle) { async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> { match action { - HotkeyAction::StartStudioRecording => { - let _ = RequestStartRecording { - mode: cap_recording::RecordingMode::Studio, - } - .emit(&app); - Ok(()) - } - HotkeyAction::StartInstantRecording => { - let _ = RequestStartRecording { - mode: cap_recording::RecordingMode::Instant, - } - .emit(&app); + HotkeyAction::StartRecording => { + let _ = RequestStartRecording.emit(&app); Ok(()) } HotkeyAction::StopRecording => recording::stop_recording(app.clone(), app.state()).await, HotkeyAction::RestartRecording => { recording::restart_recording(app.clone(), app.state()).await } - HotkeyAction::OpenRecordingPicker => { - let _ = RequestOpenRecordingPicker { target_mode: None }.emit(&app); - Ok(()) - } - HotkeyAction::OpenRecordingPickerDisplay => { - let _ = RequestOpenRecordingPicker { - target_mode: Some(RecordingTargetMode::Display), - } - .emit(&app); - Ok(()) - } - HotkeyAction::OpenRecordingPickerWindow => { - let _ = RequestOpenRecordingPicker { - target_mode: Some(RecordingTargetMode::Window), - } - .emit(&app); - Ok(()) - } - HotkeyAction::OpenRecordingPickerArea => { - let _ = RequestOpenRecordingPicker { - target_mode: Some(RecordingTargetMode::Area), - } - .emit(&app); - Ok(()) - } - HotkeyAction::Other => Ok(()), } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 74ca14e706..060bd494b6 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,9 +17,7 @@ mod permissions; mod platform; mod presets; mod recording; -mod recording_settings; mod target_select_overlay; -mod thumbnails; mod tray; mod upload; mod web_api; @@ -40,15 +38,15 @@ use cap_recording::{ camera::{CameraFeed, DeviceOrModelID}, microphone::{self, MicrophoneFeed}, }, - sources::screen_capture::ScreenCaptureTarget, + sources::ScreenCaptureTarget, }; use cap_rendering::{ProjectRecordingsMeta, RenderedFrame}; use clipboard_rs::common::RustImage; use clipboard_rs::{Clipboard, ClipboardContext}; use editor_window::{EditorInstances, WindowEditorInstance}; -use ffmpeg::ffi::AV_TIME_BASE; use general_settings::GeneralSettingsStore; use kameo::{Actor, actor::ActorRef}; +use mp4::Mp4Reader; use notifications::NotificationType; use png::{ColorType, Encoder}; use recording::InProgressRecording; @@ -57,7 +55,7 @@ use scap::{ capturer::Capturer, frame::{Frame, VideoFrame}, }; -use scap_targets::{Display, DisplayId, WindowId, bounds::LogicalBounds}; +use scap_targets::{DisplayId, WindowId, bounds::LogicalBounds}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -65,14 +63,14 @@ use std::{ collections::BTreeMap, fs::File, future::Future, - io::BufWriter, + io::{BufReader, BufWriter}, marker::PhantomData, path::{Path, PathBuf}, process::Command, str::FromStr, sync::Arc, }; -use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel}; +use tauri::{AppHandle, Manager, State, Window, WindowEvent}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::DialogExt; use tauri_plugin_global_shortcut::GlobalShortcutExt; @@ -80,19 +78,14 @@ use tauri_plugin_notification::{NotificationExt, PermissionState}; use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::ShellExt; use tauri_specta::Event; -#[cfg(target_os = "macos")] -use tokio::sync::Mutex; use tokio::sync::{RwLock, oneshot}; use tracing::{error, trace}; use upload::{S3UploadMeta, create_or_get_video, upload_image, upload_video}; use web_api::ManagerExt as WebManagerExt; use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; -use crate::{ - camera::CameraPreviewManager, - recording_settings::{RecordingSettingsStore, RecordingTargetMode}, -}; -use crate::{recording::start_recording, upload::build_video_meta}; +use crate::camera::CameraPreviewManager; +use crate::upload::build_video_meta; #[allow(clippy::large_enum_variant)] pub enum RecordingState { @@ -252,7 +245,8 @@ async fn set_camera_input( state: MutableState<'_, App>, id: Option, ) -> Result<(), String> { - let camera_feed = state.read().await.camera_feed.clone(); + let app = state.read().await; + let camera_feed = app.camera_feed.clone(); match id { None => { @@ -288,7 +282,7 @@ pub struct NewStudioRecordingAdded { path: PathBuf, } -#[derive(specta::Type, tauri_specta::Event, Debug, Clone, Serialize)] +#[derive(specta::Type, tauri_specta::Event, Debug, Clone)] pub struct RecordingDeleted { #[allow(unused)] path: PathBuf, @@ -306,29 +300,16 @@ pub struct RecordingStarted; pub struct RecordingStopped; #[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] -pub struct RequestStartRecording { - pub mode: RecordingMode, -} +pub struct RequestStartRecording; #[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] pub struct RequestNewScreenshot; -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] -pub struct RequestOpenRecordingPicker { - pub target_mode: Option, -} - #[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] pub struct RequestOpenSettings { page: String, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] -pub struct RequestScreenCapturePrewarm { - #[serde(default)] - pub force: bool, -} - #[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] pub struct NewNotification { title: String, @@ -430,6 +411,11 @@ async fn create_screenshot( println!("Creating screenshot: input={input:?}, output={output:?}, size={size:?}"); let result: Result<(), String> = tokio::task::spawn_blocking(move || -> Result<(), String> { + ffmpeg::init().map_err(|e| { + eprintln!("Failed to initialize ffmpeg: {e}"); + e.to_string() + })?; + let mut ictx = ffmpeg::format::input(&input).map_err(|e| { eprintln!("Failed to create input context: {e}"); e.to_string() @@ -588,11 +574,11 @@ async fn copy_file_to_path(app: AppHandle, src: String, dst: String) -> Result<( return Err(format!("Source file {src} does not exist")); } - if !is_screenshot && !is_gif && !is_valid_video(src_path) { + if !is_screenshot && !is_gif && !is_valid_mp4(src_path) { let mut attempts = 0; while attempts < 10 { std::thread::sleep(std::time::Duration::from_secs(1)); - if is_valid_video(src_path) { + if is_valid_mp4(src_path) { break; } attempts += 1; @@ -635,8 +621,8 @@ async fn copy_file_to_path(app: AppHandle, src: String, dst: String) -> Result<( continue; } - if !is_screenshot && !is_gif && !is_valid_video(std::path::Path::new(&dst)) { - last_error = Some("Destination file is not a valid".to_string()); + if !is_screenshot && !is_gif && !is_valid_mp4(std::path::Path::new(&dst)) { + last_error = Some("Destination file is not a valid MP4".to_string()); let _ = tokio::fs::remove_file(&dst).await; attempts += 1; tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; @@ -686,15 +672,16 @@ async fn copy_file_to_path(app: AppHandle, src: String, dst: String) -> Result<( Err(last_error.unwrap_or_else(|| "Maximum retry attempts exceeded".to_string())) } -pub fn is_valid_video(path: &std::path::Path) -> bool { - match ffmpeg::format::input(path) { - Ok(input_context) => { - // Check if we have at least one video stream - input_context - .streams() - .any(|stream| stream.parameters().medium() == ffmpeg::media::Type::Video) - } - Err(_) => false, +pub fn is_valid_mp4(path: &std::path::Path) -> bool { + if let Ok(file) = std::fs::File::open(path) { + let file_size = match file.metadata() { + Ok(metadata) => metadata.len(), + Err(_) => return false, + }; + let reader = std::io::BufReader::new(file); + Mp4Reader::read_header(reader, file_size).is_ok() + } else { + false } } @@ -880,19 +867,23 @@ async fn get_video_metadata(path: PathBuf) -> Result Result { - let input = - ffmpeg::format::input(&path).map_err(|e| format!("Failed to open video file: {e}"))?; - - let raw_duration = input.duration(); - if raw_duration <= 0 { - return Err(format!( - "Unknown or invalid duration for video file: {:?}", - path - )); - } + let reader = BufReader::new( + File::open(&path).map_err(|e| format!("Failed to open video file: {e}"))?, + ); + let file_size = path + .metadata() + .map_err(|e| format!("Failed to get file metadata: {e}"))? + .len(); + + let current_duration = match Mp4Reader::read_header(reader, file_size) { + Ok(mp4) => mp4.duration().as_secs_f64(), + Err(e) => { + println!("Failed to read MP4 header: {e}. Falling back to default duration."); + 0.0_f64 + } + }; - let duration = raw_duration as f64 / AV_TIME_BASE as f64; - Ok(duration) + Ok(current_duration) } let display_paths = match &recording_meta.inner { @@ -914,10 +905,7 @@ async fn get_video_metadata(path: PathBuf) -> Result Result { - let d = item?; - Ok(acc + d) - })?; + .sum::>()?; let (width, height) = (1920, 1080); let fps = 30; @@ -1036,7 +1024,7 @@ async fn list_audio_devices() -> Result, ()> { Ok(MicrophoneFeed::list().keys().cloned().collect()) } -#[derive(Serialize, Type, Debug, Clone)] +#[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)] pub struct UploadProgress { progress: f64, } @@ -1055,7 +1043,6 @@ async fn upload_exported_video( app: AppHandle, path: PathBuf, mode: UploadMode, - channel: Channel, ) -> Result { let Ok(Some(auth)) = AuthStore::get(&app) else { AuthStore::set(&app, None).map_err(|e| e.to_string())?; @@ -1077,7 +1064,7 @@ async fn upload_exported_video( return Ok(UploadResult::UpgradeRequired); } - channel.send(UploadProgress { progress: 0.0 }).ok(); + UploadProgress { progress: 0.0 }.emit(&app).ok(); let s3_config = async { let video_id = match mode { @@ -1116,12 +1103,11 @@ async fn upload_exported_video( Some(s3_config), Some(meta.project_path.join("screenshots/display.jpg")), Some(metadata), - Some(channel.clone()), ) .await { Ok(uploaded_video) => { - channel.send(UploadProgress { progress: 1.0 }).ok(); + UploadProgress { progress: 1.0 }.emit(&app).ok(); meta.sharing = Some(SharingMeta { link: uploaded_video.link.clone(), @@ -1692,27 +1678,6 @@ async fn get_system_audio_waveforms( Ok(out) } -#[tauri::command] -#[specta::specta] -async fn editor_delete_project( - app: tauri::AppHandle, - editor_instance: WindowEditorInstance, - window: tauri::Window, -) -> Result<(), String> { - let _ = window.close(); - - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - - let path = editor_instance.0.project_path.clone(); - drop(editor_instance); - - let _ = tokio::fs::remove_dir_all(&path).await; - - RecordingDeleted { path }.emit(&app); - - Ok(()) -} - // keep this async otherwise opening windows may hang on windows #[tauri::command] #[specta::specta] @@ -1843,12 +1808,6 @@ type LoggingHandle = tracing_subscriber::reload::Handle, #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run(recording_logging_handle: LoggingHandle) { - ffmpeg::init() - .map_err(|e| { - error!("Failed to initialize ffmpeg: {e}"); - }) - .ok(); - let tauri_context = tauri::generate_context!(); let specta_builder = tauri_specta::Builder::new() @@ -1864,8 +1823,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { recording::list_cameras, recording::list_capture_windows, recording::list_capture_displays, - recording::list_displays_with_thumbnails, - recording::list_windows_with_thumbnails, take_screenshot, list_audio_devices, close_recordings_overlay_window, @@ -1932,8 +1889,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { target_select_overlay::close_target_select_overlays, target_select_overlay::display_information, target_select_overlay::get_window_icon, - target_select_overlay::focus_window, - editor_delete_project ]) .events(tauri_specta::collect_events![ RecordingOptionsChanged, @@ -1945,18 +1900,17 @@ pub async fn run(recording_logging_handle: LoggingHandle) { RecordingStarted, RecordingStopped, RequestStartRecording, - RequestOpenRecordingPicker, RequestNewScreenshot, RequestOpenSettings, - RequestScreenCapturePrewarm, NewNotification, AuthenticationInvalid, audio_meter::AudioInputLevelChange, + UploadProgress, captions::DownloadProgress, recording::RecordingEvent, RecordingDeleted, target_select_overlay::TargetUnderCursor, - hotkeys::OnEscapePress, + hotkeys::OnEscapePress ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() @@ -1964,7 +1918,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .typ::() .typ::() .typ::() - .typ::() .typ::(); #[cfg(debug_assertions)] @@ -2017,13 +1970,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .map(PathBuf::from) else { let app = app.clone(); - tokio::spawn(async move { - ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await - }); + tokio::spawn(async move { ShowCapWindow::Main.show(&app).await }); return; }; @@ -2087,8 +2034,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); app.manage(EditorWindowIds::default()); - #[cfg(target_os = "macos")] - app.manage(crate::platform::ScreenCapturePrewarmer::default()); tokio::spawn({ let camera_feed = camera_feed.clone(); @@ -2164,11 +2109,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { } else { println!("Permissions granted, showing main window"); - let _ = ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await; + let _ = ShowCapWindow::Main.show(&app).await; } } }); @@ -2177,40 +2118,13 @@ pub async fn run(recording_logging_handle: LoggingHandle) { tray::create_tray(&app).unwrap(); - RequestStartRecording::listen_any_spawn(&app, async |event, app| { - let settings = RecordingSettingsStore::get(&app) - .ok() - .flatten() - .unwrap_or_default(); - - let _ = set_mic_input(app.state(), settings.mic_name).await; - let _ = set_camera_input(app.clone(), app.state(), settings.camera_id).await; - - let _ = start_recording( - app.clone(), - app.state(), - recording::StartRecordingInputs { - capture_target: settings.target.unwrap_or_else(|| { - ScreenCaptureTarget::Display { - id: Display::primary().id(), - } - }), - capture_system_audio: settings.system_audio, - mode: event.mode, - }, - ) - .await; - }); - - RequestOpenRecordingPicker::listen_any_spawn(&app, async |event, app| { - let _ = ShowCapWindow::Main { - init_target_mode: event.target_mode, + RequestNewScreenshot::listen_any_spawn(&app, |_, app| async move { + if let Err(e) = take_screenshot(app.clone(), app.state()).await { + eprintln!("Failed to take screenshot: {e}"); } - .show(&app) - .await; }); - RequestOpenSettings::listen_any_spawn(&app, async |payload, app| { + RequestOpenSettings::listen_any_spawn(&app, |payload, app| async move { let _ = ShowCapWindow::Settings { page: Some(payload.page), } @@ -2218,12 +2132,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .await; }); - #[cfg(target_os = "macos")] - RequestScreenCapturePrewarm::listen_any_spawn(&app, async |event, app| { - let prewarmer = app.state::(); - prewarmer.request(event.force).await; - }); - let app_handle = app.clone(); app.deep_link().on_open_url(move |event| { deeplink_actions::handle(&app_handle, event.urls()); @@ -2241,15 +2149,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { match window_id { CapWindowId::Main => { let app = app.clone(); - - for (id, window) in app.webview_windows() { - if let Ok(CapWindowId::TargetSelectOverlay { .. }) = - CapWindowId::from_str(&id) - { - let _ = window.close(); - } - } - tokio::spawn(async move { let state = app.state::>(); let app_state = &mut *state.write().await; @@ -2318,21 +2217,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) { } } #[cfg(target_os = "macos")] - WindowEvent::Focused(focused) => { - let window_id = CapWindowId::from_str(label); - - if matches!(window_id, Ok(CapWindowId::Upgrade)) { - for (label, window) in app.webview_windows() { - if let Ok(id) = CapWindowId::from_str(&label) - && matches!(id, CapWindowId::TargetSelectOverlay { .. }) - { - let _ = window.hide(); - } - } - } - - if *focused - && let Ok(window_id) = window_id + WindowEvent::Focused(focused) if *focused => { + if let Ok(window_id) = CapWindowId::from_str(label) && window_id.activates_dock() { app.set_activation_policy(tauri::ActivationPolicy::Regular) @@ -2374,11 +2260,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { } else { let handle = _handle.clone(); tokio::spawn(async move { - let _ = ShowCapWindow::Main { - init_target_mode: None, - } - .show(&handle) - .await; + let _ = ShowCapWindow::Main.show(&handle).await; }); } } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c99138f74f..fded2c5905 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -4,8 +4,6 @@ use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; -use dirs; -use tracing_appender; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; fn main() { @@ -14,8 +12,8 @@ fn main() { } // We have to hold onto the ClientInitGuard until the very end - let _sentry_guard = std::option_env!("CAP_DESKTOP_SENTRY_URL").map(|url| { - let sentry_client = sentry::init(( + let _guard = std::option_env!("CAP_DESKTOP_SENTRY_URL").map(|url| { + sentry::init(( url, sentry::ClientOptions { release: sentry::release_name!(), @@ -41,40 +39,11 @@ fn main() { })), ..Default::default() }, - )); - - // Caution! Everything before here runs in both app and crash reporter processes - let _guard = tauri_plugin_sentry::minidump::init(&sentry_client); - - (sentry_client, _guard) + )) }); let (layer, handle) = tracing_subscriber::reload::Layer::new(None::); - let logs_dir = { - #[cfg(target_os = "macos")] - let path = dirs::home_dir() - .unwrap() - .join("Library/Logs") - .join("so.cap.desktop"); - - #[cfg(not(target_os = "macos"))] - let path = dirs::data_local_dir() - .unwrap() - .join("so.cap.desktop") - .join("logs"); - - path - }; - - // Ensure logs directory exists - std::fs::create_dir_all(&logs_dir).unwrap_or_else(|e| { - eprintln!("Failed to create logs directory: {}", e); - }); - - let file_appender = tracing_appender::rolling::daily(&logs_dir, "cap-desktop.log"); - let (non_blocking, _logger_guard) = tracing_appender::non_blocking(file_appender); - let registry = tracing_subscriber::registry().with(tracing_subscriber::filter::filter_fn( (|v| v.target().starts_with("cap_")) as fn(&tracing::Metadata) -> bool, )); @@ -86,12 +55,6 @@ fn main() { .with_ansi(true) .with_target(true), ) - .with( - tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_target(true) - .with_writer(non_blocking), - ) .init(); #[cfg(debug_assertions)] diff --git a/apps/desktop/src-tauri/src/platform/macos/mod.rs b/apps/desktop/src-tauri/src/platform/macos/mod.rs index f6d5d47010..540744b2f4 100644 --- a/apps/desktop/src-tauri/src/platform/macos/mod.rs +++ b/apps/desktop/src-tauri/src/platform/macos/mod.rs @@ -11,9 +11,6 @@ // use objc::{class, msg_send, sel, sel_impl}; pub mod delegates; -mod sc_shareable_content; - -pub use sc_shareable_content::*; pub fn set_window_level(window: tauri::Window, level: objc2_app_kit::NSWindowLevel) { let c_window = window.clone(); diff --git a/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs b/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs deleted file mode 100644 index d0996cbd26..0000000000 --- a/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs +++ /dev/null @@ -1,238 +0,0 @@ -use cidre::{arc, ns, sc}; -use core_graphics::{display::CGDirectDisplayID, window::CGWindowID}; -use std::sync::Arc; -use std::{ - collections::HashMap, - sync::{OnceLock, RwLock}, - time::Instant, -}; -use tokio::sync::{Mutex, Notify}; -use tracing::{debug, info, trace}; - -#[derive(Default)] -struct CacheState { - cache: RwLock>, - warmup: Mutex>, -} - -type WarmupResult = Result<(), arc::R>; - -#[derive(Clone)] -struct WarmupTask { - notify: Arc, - result: Arc>>, -} - -static STATE: OnceLock = OnceLock::new(); - -fn state() -> &'static CacheState { - STATE.get_or_init(CacheState::default) -} - -pub async fn prewarm_shareable_content() -> Result<(), arc::R> { - if state().cache.read().unwrap().is_some() { - trace!("ScreenCaptureKit shareable content already warmed"); - return Ok(()); - } - - let warmup = { - let mut guard = state().warmup.lock().await; - if let Some(task) = guard.clone() { - trace!("Awaiting in-flight ScreenCaptureKit warmup"); - task - } else { - let task = WarmupTask { - notify: Arc::new(Notify::new()), - result: Arc::new(Mutex::new(None)), - }; - *guard = Some(task.clone()); - tokio::spawn(run_warmup(task.clone())); - task - } - }; - - warmup.notify.notified().await; - warmup - .result - .lock() - .await - .clone() - .expect("ScreenCaptureKit warmup task missing result") -} - -pub async fn get_shareable_content() --> Result>, arc::R> { - let lookup_start = Instant::now(); - - if let Some(content) = state() - .cache - .read() - .unwrap() - .as_ref() - .map(|v| v.content.retained()) - { - trace!( - elapsed_ms = lookup_start.elapsed().as_micros() as f64 / 1000.0, - "Resolved ScreenCaptureKit from warmed cache" - ); - return Ok(Some(content)); - } - - prewarm_shareable_content().await?; - - let content = state().cache.read().unwrap(); - trace!( - elapsed_ms = lookup_start.elapsed().as_micros() as f64 / 1000.0, - cache_hit = content.is_some(), - "Resolved ScreenCaptureKit after cache populate" - ); - Ok(content.as_ref().map(|v| v.content.retained())) -} - -async fn run_warmup(task: WarmupTask) { - let result = async { - let warm_start = Instant::now(); - debug!("Populating ScreenCaptureKit shareable content cache"); - - let content = sc::ShareableContent::current().await?; - let cache = ShareableContentCache::new(content); - let elapsed_ms = warm_start.elapsed().as_micros() as f64 / 1000.0; - - let mut guard = state().cache.write().unwrap(); - let replaced = guard.is_some(); - *guard = Some(cache); - - info!( - elapsed_ms, - replaced, "ScreenCaptureKit shareable content cache populated" - ); - Ok::<(), arc::R>(()) - } - .await; - - { - let mut res_guard = task.result.lock().await; - *res_guard = Some(result); - } - - task.notify.notify_waiters(); - - let mut guard = state().warmup.lock().await; - if let Some(current) = guard.as_ref() - && Arc::ptr_eq(¤t.notify, &task.notify) - { - *guard = None; - } -} - -#[derive(Debug)] -struct ShareableContentCache { - #[allow(dead_code)] - content: arc::R, - displays: HashMap>, - windows: HashMap>, -} - -unsafe impl Send for ShareableContentCache {} -unsafe impl Sync for ShareableContentCache {} - -impl ShareableContentCache { - fn new(content: arc::R) -> Self { - let displays = content - .displays() - .iter() - .map(|display| (display.display_id().0, display.retained())) - .collect(); - - let windows = content - .windows() - .iter() - .map(|window| (window.id(), window.retained())) - .collect(); - - Self { - content, - displays, - windows, - } - } - - fn display(&self, id: CGDirectDisplayID) -> Option> { - self.displays.get(&id).cloned() - } - - fn window(&self, id: CGWindowID) -> Option> { - self.windows.get(&id).cloned() - } -} - -pub(crate) struct ScreenCapturePrewarmer { - state: Mutex, -} - -impl Default for ScreenCapturePrewarmer { - fn default() -> Self { - Self { - state: Mutex::new(PrewarmState::Idle), - } - } -} - -impl ScreenCapturePrewarmer { - pub async fn request(&self, force: bool) { - let should_start = { - let mut state = self.state.lock().await; - - if force { - *state = PrewarmState::Idle; - } - - match *state { - PrewarmState::Idle => { - *state = PrewarmState::Warming; - true - } - PrewarmState::Warming => { - trace!("ScreenCaptureKit prewarm already in progress"); - false - } - PrewarmState::Warmed => { - if force { - *state = PrewarmState::Warming; - true - } else { - trace!("ScreenCaptureKit cache already warmed"); - false - } - } - } - }; - - if !should_start { - return; - } - - let warm_start = std::time::Instant::now(); - let result = crate::platform::prewarm_shareable_content().await; - - let mut state = self.state.lock().await; - match result { - Ok(()) => { - let elapsed_ms = warm_start.elapsed().as_micros() as f64 / 1000.0; - *state = PrewarmState::Warmed; - trace!(elapsed_ms, "ScreenCaptureKit cache warmed"); - } - Err(error) => { - *state = PrewarmState::Idle; - tracing::warn!(error = %error, "ScreenCaptureKit prewarm failed"); - } - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum PrewarmState { - Idle, - Warming, - Warmed, -} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index fe53716ddf..ad566014d5 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1,46 +1,34 @@ use cap_fail::fail; -use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS; use cap_project::{ - CursorClickEvent, CursorMoveEvent, Platform, ProjectConfiguration, RecordingMeta, - RecordingMetaInner, SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, - ZoomMode, ZoomSegment, cursor::CursorEvents, + CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, + SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode, + ZoomSegment, cursor::CursorEvents, }; use cap_recording::{ - RecordingError, RecordingMode, + CompletedStudioRecording, RecordingError, RecordingMode, StudioRecordingHandle, feeds::{camera, microphone}, - instant_recording, - sources::{ - screen_capture, - screen_capture::{CaptureDisplay, CaptureWindow, ScreenCaptureTarget}, - }, - studio_recording, + instant_recording::{CompletedInstantRecording, InstantRecordingHandle}, + sources::{CaptureDisplay, CaptureWindow, ScreenCaptureTarget, screen_capture}, }; use cap_rendering::ProjectRecordingsMeta; use cap_utils::{ensure_dir, spawn_actor}; use serde::Deserialize; use specta::Type; -use std::{ - collections::{HashMap, VecDeque}, - path::PathBuf, - str::FromStr, - sync::Arc, - time::Duration, -}; +use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration}; use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; use tauri_specta::Event; -use tracing::*; +use tracing::{error, info}; use crate::{ - App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, - RecordingStopped, VideoUploadInfo, + App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingStopped, + VideoUploadInfo, audio::AppSounds, auth::AuthStore, create_screenshot, general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour}, open_external_link, presets::PresetsStore, - thumbnails::*, upload::{ InstantMultipartUpload, build_video_meta, create_or_get_video, prepare_screenshot_upload, upload_video, @@ -52,7 +40,7 @@ use crate::{ pub enum InProgressRecording { Instant { target_name: String, - handle: instant_recording::ActorHandle, + handle: InstantRecordingHandle, progressive_upload: Option, video_upload_info: VideoUploadInfo, inputs: StartRecordingInputs, @@ -60,7 +48,7 @@ pub enum InProgressRecording { }, Studio { target_name: String, - handle: studio_recording::ActorHandle, + handle: StudioRecordingHandle, inputs: StartRecordingInputs, recording_dir: PathBuf, }, @@ -82,19 +70,17 @@ impl InProgressRecording { } pub async fn pause(&self) -> Result<(), RecordingError> { - todo!() - // match self { - // Self::Instant { handle, .. } => handle.pause().await, - // Self::Studio { handle, .. } => handle.pause().await, - // } + match self { + Self::Instant { handle, .. } => handle.pause().await, + Self::Studio { handle, .. } => handle.pause().await, + } } pub async fn resume(&self) -> Result<(), String> { - todo!() - // match self { - // Self::Instant { handle, .. } => handle.resume().await.map_err(|e| e.to_string()), - // Self::Studio { handle, .. } => handle.resume().await.map_err(|e| e.to_string()), - // } + match self { + Self::Instant { handle, .. } => handle.resume().await.map_err(|e| e.to_string()), + Self::Studio { handle, .. } => handle.resume().await.map_err(|e| e.to_string()), + } } pub fn recording_dir(&self) -> &PathBuf { @@ -104,7 +90,7 @@ impl InProgressRecording { } } - pub async fn stop(self) -> anyhow::Result { + pub async fn stop(self) -> Result { Ok(match self { Self::Instant { handle, @@ -129,21 +115,13 @@ impl InProgressRecording { }) } - pub fn done_fut(&self) -> cap_recording::DoneFut { + pub async fn cancel(self) -> Result<(), RecordingError> { match self { - Self::Instant { handle, .. } => handle.done_fut(), - Self::Studio { handle, .. } => handle.done_fut(), + Self::Instant { handle, .. } => handle.cancel().await, + Self::Studio { handle, .. } => handle.cancel().await, } } - pub async fn cancel(self) -> Result<(), RecordingError> { - todo!() - // match self { - // Self::Instant { handle, .. } => handle.cancel().await, - // Self::Studio { handle, .. } => handle.cancel().await, - // } - } - pub fn mode(&self) -> RecordingMode { match self { Self::Instant { .. } => RecordingMode::Instant, @@ -154,18 +132,25 @@ impl InProgressRecording { pub enum CompletedRecording { Instant { - recording: instant_recording::CompletedRecording, + recording: CompletedInstantRecording, target_name: String, progressive_upload: Option, video_upload_info: VideoUploadInfo, }, Studio { - recording: studio_recording::CompletedRecording, + recording: CompletedStudioRecording, target_name: String, }, } impl CompletedRecording { + pub fn id(&self) -> &String { + match self { + Self::Instant { recording, .. } => &recording.id, + Self::Studio { recording, .. } => &recording.id, + } + } + pub fn project_path(&self) -> &PathBuf { match self { Self::Instant { recording, .. } => &recording.project_path, @@ -205,26 +190,6 @@ pub fn list_cameras() -> Vec { cap_camera::list_cameras().collect() } -#[tauri::command] -#[specta::specta] -pub async fn list_displays_with_thumbnails() -> Result, String> { - tokio::task::spawn_blocking(|| { - tauri::async_runtime::block_on(collect_displays_with_thumbnails()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -#[specta::specta] -pub async fn list_windows_with_thumbnails() -> Result, String> { - tokio::task::spawn_blocking( - || tauri::async_runtime::block_on(collect_windows_with_thumbnails()), - ) - .await - .map_err(|e| e.to_string())? -} - #[derive(Deserialize, Type, Clone, Debug)] pub struct StartRecordingInputs { pub capture_target: ScreenCaptureTarget, @@ -250,10 +215,6 @@ pub async fn start_recording( state_mtx: MutableState<'_, App>, inputs: StartRecordingInputs, ) -> Result<(), String> { - if !matches!(state_mtx.read().await.recording_state, RecordingState::None) { - return Err("Recording already in progress".to_string()); - } - let id = uuid::Uuid::new_v4().to_string(); let general_settings = GeneralSettingsStore::get(&app).ok().flatten(); let general_settings = general_settings.as_ref(); @@ -396,7 +357,7 @@ pub async fn start_recording( ) }); - debug!("spawning start_recording actor"); + println!("spawning actor"); // done in spawn to catch panics just in case let spawn_actor_res = async { @@ -419,93 +380,74 @@ pub async fn start_recording( Err(SendError::HandlerError(camera::LockFeedError::NoInput)) => None, Err(e) => return Err(e.to_string()), }; - #[cfg(target_os = "macos")] - let shareable_content = crate::platform::get_shareable_content() - .await - .map_err(|e| format!("GetShareableContent: {e}"))? - .ok_or_else(|| format!("GetShareableContent/NotAvailable"))?; - let actor = match inputs.mode { + let base_inputs = cap_recording::RecordingBaseInputs { + capture_target: inputs.capture_target.clone(), + capture_system_audio: inputs.capture_system_audio, + mic_feed, + camera_feed, + }; + + let (actor, actor_done_rx) = match inputs.mode { RecordingMode::Studio => { - let mut builder = studio_recording::Actor::builder( + let (handle, actor_done_rx) = cap_recording::spawn_studio_recording_actor( + id.clone(), recording_dir.clone(), - inputs.capture_target.clone(), - ) - .with_system_audio(inputs.capture_system_audio) - .with_custom_cursor( + base_inputs, general_settings .map(|s| s.custom_cursor_capture) .unwrap_or_default(), - ); - - if let Some(camera_feed) = camera_feed { - builder = builder.with_camera_feed(camera_feed); - } - - if let Some(mic_feed) = mic_feed { - builder = builder.with_mic_feed(mic_feed); - } - - let handle = builder - .build( - #[cfg(target_os = "macos")] - shareable_content, - ) - .await - .map_err(|e| { - error!("Failed to spawn studio recording actor: {e}"); - e.to_string() - })?; - - InProgressRecording::Studio { - handle, - target_name, - inputs, - recording_dir: recording_dir.clone(), - } + ) + .await + .map_err(|e| { + error!("Failed to spawn studio recording actor: {e}"); + e.to_string() + })?; + + ( + InProgressRecording::Studio { + handle, + target_name, + inputs, + recording_dir: recording_dir.clone(), + }, + actor_done_rx, + ) } RecordingMode::Instant => { let Some(video_upload_info) = video_upload_info.clone() else { return Err("Video upload info not found".to_string()); }; - let mut builder = instant_recording::Actor::builder( - recording_dir.clone(), - inputs.capture_target.clone(), - ) - .with_system_audio(inputs.capture_system_audio); - - if let Some(mic_feed) = mic_feed { - builder = builder.with_mic_feed(mic_feed); - } - - let handle = builder - .build( - #[cfg(target_os = "macos")] - shareable_content, + let (handle, actor_done_rx) = + cap_recording::instant_recording::spawn_instant_recording_actor( + id.clone(), + recording_dir.clone(), + base_inputs, ) .await .map_err(|e| { - error!("Failed to spawn instant recording actor: {e}"); + error!("Failed to spawn studio recording actor: {e}"); e.to_string() })?; - InProgressRecording::Instant { - handle, - progressive_upload, - video_upload_info, - target_name, - inputs, - recording_dir: recording_dir.clone(), - } + ( + InProgressRecording::Instant { + handle, + progressive_upload, + video_upload_info, + target_name, + inputs, + recording_dir: recording_dir.clone(), + }, + actor_done_rx, + ) } }; - let done_fut = actor.done_fut(); - state.set_current_recording(actor); - Ok::<_, String>(done_fut) + Ok::<_, String>(actor_done_rx) } }) .await @@ -513,8 +455,8 @@ pub async fn start_recording( } .await; - let actor_done_fut = match spawn_actor_res { - Ok(fut) => fut, + let actor_done_rx = match spawn_actor_res { + Ok(rx) => rx, Err(e) => { let _ = RecordingEvent::Failed { error: e.clone() }.emit(&app); @@ -545,25 +487,22 @@ pub async fn start_recording( let state_mtx = Arc::clone(&state_mtx); async move { fail!("recording::wait_actor_done"); - let res = actor_done_fut.await; + let res = actor_done_rx.await; info!("recording wait actor done: {:?}", &res); match res { - Ok(()) => { + Ok(Ok(_)) => { let _ = finish_upload_tx.send(()); let _ = RecordingEvent::Stopped.emit(&app); } - Err(e) => { + Ok(Err(e)) => { let mut state = state_mtx.write().await; - let _ = RecordingEvent::Failed { - error: e.to_string(), - } - .emit(&app); + let _ = RecordingEvent::Failed { error: e.clone() }.emit(&app); let mut dialog = MessageDialogBuilder::new( app.dialog().clone(), "An error occurred".to_string(), - e.to_string(), + e, ) .kind(tauri_plugin_dialog::MessageDialogKind::Error); @@ -576,6 +515,10 @@ pub async fn start_recording( // this clears the current recording for us handle_recording_end(app, None, &mut state).await.ok(); } + // Actor hasn't errored, it's just finished + v => { + info!("recording actor ended: {v:?}"); + } } } }); @@ -635,7 +578,7 @@ pub async fn restart_recording(app: AppHandle, state: MutableState<'_, App>) -> let inputs = recording.inputs().clone(); - // let _ = recording.cancel().await; + let _ = recording.cancel().await; tokio::time::sleep(Duration::from_millis(1000)).await; @@ -665,7 +608,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R CurrentRecordingChanged.emit(&app).ok(); RecordingStopped {}.emit(&app).ok(); - // let _ = recording.cancel().await; + let _ = recording.cancel().await; std::fs::remove_dir_all(&recording_dir).ok(); @@ -691,11 +634,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R match settings.post_deletion_behaviour { PostDeletionBehaviour::DoNothing => {} PostDeletionBehaviour::ReopenRecordingWindow => { - let _ = ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await; + let _ = ShowCapWindow::Main.show(&app).await; } } } @@ -865,7 +804,6 @@ async fn handle_recording_finish( Some(video_upload_info.config.clone()), Some(display_screenshot.clone()), meta, - None, ) .await { @@ -952,167 +890,62 @@ async fn handle_recording_finish( /// around user interactions to highlight important moments. fn generate_zoom_segments_from_clicks_impl( mut clicks: Vec, - mut moves: Vec, - max_duration: f64, + recordings: &ProjectRecordingsMeta, ) -> Vec { - const STOP_PADDING_SECONDS: f64 = 0.8; - const CLICK_PRE_PADDING: f64 = 0.6; - const CLICK_POST_PADDING: f64 = 1.6; - const MOVEMENT_PRE_PADDING: f64 = 0.4; - const MOVEMENT_POST_PADDING: f64 = 1.2; - const MERGE_GAP_THRESHOLD: f64 = 0.6; - const MIN_SEGMENT_DURATION: f64 = 1.3; - const MOVEMENT_WINDOW_SECONDS: f64 = 1.2; - const MOVEMENT_EVENT_DISTANCE_THRESHOLD: f64 = 0.025; - const MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.1; - - if max_duration <= 0.0 { - return Vec::new(); - } + const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5; + const ZOOM_SEGMENT_BEFORE_CLICK_PADDING: f64 = 0.8; + const ZOOM_DURATION: f64 = 1.0; + const CLICK_GROUP_THRESHOLD: f64 = 0.6; // seconds + const MIN_SEGMENT_PADDING: f64 = 2.0; // minimum gap between segments - // We trim the tail of the recording to avoid using the final - // "stop recording" click as a zoom target. - let activity_end_limit = if max_duration > STOP_PADDING_SECONDS { - max_duration - STOP_PADDING_SECONDS - } else { - max_duration - }; - - if activity_end_limit <= f64::EPSILON { - return Vec::new(); - } + let max_duration = recordings.duration(); clicks.sort_by(|a, b| { a.time_ms .partial_cmp(&b.time_ms) .unwrap_or(std::cmp::Ordering::Equal) }); - moves.sort_by(|a, b| { - a.time_ms - .partial_cmp(&b.time_ms) - .unwrap_or(std::cmp::Ordering::Equal) - }); - // Remove trailing click-down events that are too close to the end. - while let Some(index) = clicks.iter().rposition(|c| c.down) { - let time_secs = clicks[index].time_ms / 1000.0; - if time_secs > activity_end_limit { - clicks.remove(index); - } else { - break; - } - } - - let mut intervals: Vec<(f64, f64)> = Vec::new(); + let mut segments = Vec::::new(); - for click in clicks.into_iter().filter(|c| c.down) { - let time = click.time_ms / 1000.0; - if time >= activity_end_limit { + // Generate segments around mouse clicks + for click in &clicks { + if !click.down { continue; } - let start = (time - CLICK_PRE_PADDING).max(0.0); - let end = (time + CLICK_POST_PADDING).min(activity_end_limit); - - if end > start { - intervals.push((start, end)); - } - } - - let mut last_move_by_cursor: HashMap = HashMap::new(); - let mut distance_window: VecDeque<(f64, f64)> = VecDeque::new(); - let mut window_distance = 0.0_f64; - - for mv in moves.iter() { - let time = mv.time_ms / 1000.0; - if time >= activity_end_limit { - break; - } - - let distance = if let Some((_, last_x, last_y)) = last_move_by_cursor.get(&mv.cursor_id) { - let dx = mv.x - last_x; - let dy = mv.y - last_y; - (dx * dx + dy * dy).sqrt() - } else { - 0.0 - }; - - last_move_by_cursor.insert(mv.cursor_id.clone(), (time, mv.x, mv.y)); - - if distance <= f64::EPSILON { - continue; - } - - distance_window.push_back((time, distance)); - window_distance += distance; - - while let Some(&(old_time, old_distance)) = distance_window.front() { - if time - old_time > MOVEMENT_WINDOW_SECONDS { - distance_window.pop_front(); - window_distance -= old_distance; - } else { - break; - } - } - - if window_distance < 0.0 { - window_distance = 0.0; - } - - let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD - || window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD; - - if !significant_movement { - continue; - } - - let start = (time - MOVEMENT_PRE_PADDING).max(0.0); - let end = (time + MOVEMENT_POST_PADDING).min(activity_end_limit); - - if end > start { - intervals.push((start, end)); - } - } - - if intervals.is_empty() { - return Vec::new(); - } + let time = click.time_ms / 1000.0; - intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let proposed_start = (time - ZOOM_SEGMENT_BEFORE_CLICK_PADDING).max(0.0); + let proposed_end = (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration); - let mut merged: Vec<(f64, f64)> = Vec::new(); - for interval in intervals { - if let Some(last) = merged.last_mut() { - if interval.0 <= last.1 + MERGE_GAP_THRESHOLD { - last.1 = last.1.max(interval.1); + if let Some(last) = segments.last_mut() { + // Merge if within group threshold OR if segments would be too close together + if time <= last.end + CLICK_GROUP_THRESHOLD + || proposed_start <= last.end + MIN_SEGMENT_PADDING + { + last.end = proposed_end; continue; } } - merged.push(interval); - } - merged - .into_iter() - .filter_map(|(start, end)| { - let duration = end - start; - if duration < MIN_SEGMENT_DURATION { - return None; - } - - Some(ZoomSegment { - start, - end, + if time < max_duration - ZOOM_DURATION { + segments.push(ZoomSegment { + start: proposed_start, + end: proposed_end, amount: 2.0, mode: ZoomMode::Auto, - }) - }) - .collect() + }); + } + } + + segments } /// Generates zoom segments based on mouse click events during recording. /// Used during the recording completion process. pub fn generate_zoom_segments_from_clicks( - recording: &studio_recording::CompletedRecording, + recording: &CompletedStudioRecording, recordings: &ProjectRecordingsMeta, ) -> Vec { // Build a temporary RecordingMeta so we can use the common implementation @@ -1137,39 +970,29 @@ pub fn generate_zoom_segments_for_project( return Vec::new(); }; - let mut all_clicks = Vec::new(); - let mut all_moves = Vec::new(); - - match studio_meta { + let all_events = match studio_meta { StudioRecordingMeta::SingleSegment { segment } => { if let Some(cursor_path) = &segment.cursor { - let mut events = CursorEvents::load_from_file(&recording_meta.path(cursor_path)) - .unwrap_or_default(); - let pointer_ids = studio_meta.pointer_cursor_ids(); - let pointer_ids_ref = (!pointer_ids.is_empty()).then_some(&pointer_ids); - events.stabilize_short_lived_cursor_shapes( - pointer_ids_ref, - SHORT_CURSOR_SHAPE_DEBOUNCE_MS, - ); - all_clicks = events.clicks; - all_moves = events.moves; - } - } - StudioRecordingMeta::MultipleSegments { inner, .. } => { - for segment in inner.segments.iter() { - let events = segment.cursor_events(recording_meta); - all_clicks.extend(events.clicks); - all_moves.extend(events.moves); + CursorEvents::load_from_file(&recording_meta.path(cursor_path)) + .unwrap_or_default() + .clicks + } else { + vec![] } } - } + StudioRecordingMeta::MultipleSegments { inner, .. } => inner + .segments + .iter() + .flat_map(|s| s.cursor_events(recording_meta).clicks) + .collect(), + }; - generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration()) + generate_zoom_segments_from_clicks_impl(all_events, recordings) } fn project_config_from_recording( app: &AppHandle, - completed_recording: &studio_recording::CompletedRecording, + completed_recording: &CompletedStudioRecording, recordings: &ProjectRecordingsMeta, default_config: Option, ) -> ProjectConfiguration { @@ -1177,110 +1000,26 @@ fn project_config_from_recording( .unwrap_or(None) .unwrap_or_default(); - let mut config = default_config.unwrap_or_default(); - - let timeline_segments = recordings - .segments - .iter() - .enumerate() - .map(|(i, segment)| TimelineSegment { - recording_segment: i as u32, - start: 0.0, - end: segment.duration(), - timescale: 1.0, - }) - .collect::>(); - - let zoom_segments = if settings.auto_zoom_on_clicks { - generate_zoom_segments_from_clicks(completed_recording, recordings) - } else { - Vec::new() - }; - - if !zoom_segments.is_empty() { - config.cursor.size = 200; - } - - config.timeline = Some(TimelineConfiguration { - segments: timeline_segments, - zoom_segments, - scene_segments: Vec::new(), - }); - - config -} - -#[cfg(test)] -mod tests { - use super::*; - - fn click_event(time_ms: f64) -> CursorClickEvent { - CursorClickEvent { - active_modifiers: vec![], - cursor_num: 0, - cursor_id: "default".to_string(), - time_ms, - down: true, - } - } - - fn move_event(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent { - CursorMoveEvent { - active_modifiers: vec![], - cursor_id: "default".to_string(), - time_ms, - x, - y, - } - } - - #[test] - fn skips_trailing_stop_click() { - let segments = - generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0); - - assert!( - segments.is_empty(), - "expected trailing stop click to be ignored" - ); - } - - #[test] - fn generates_segment_for_sustained_activity() { - let clicks = vec![click_event(1_200.0), click_event(4_200.0)]; - let moves = vec![ - move_event(1_500.0, 0.10, 0.12), - move_event(1_720.0, 0.42, 0.45), - move_event(1_940.0, 0.74, 0.78), - ]; - - let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0); - - assert!( - !segments.is_empty(), - "expected activity to produce zoom segments" - ); - let first = &segments[0]; - assert!(first.start < first.end); - assert!(first.end - first.start >= 1.3); - assert!(first.end <= 19.5); - } - - #[test] - fn ignores_cursor_jitter() { - let jitter_moves = (0..30) - .map(|i| { - let t = 1_000.0 + (i as f64) * 30.0; - let delta = (i as f64) * 0.0004; - move_event(t, 0.5 + delta, 0.5) - }) - .collect::>(); - - let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0); - - assert!( - segments.is_empty(), - "small jitter should not generate segments" - ); + ProjectConfiguration { + timeline: Some(TimelineConfiguration { + segments: recordings + .segments + .iter() + .enumerate() + .map(|(i, segment)| TimelineSegment { + recording_segment: i as u32, + start: 0.0, + end: segment.duration(), + timescale: 1.0, + }) + .collect(), + zoom_segments: if settings.auto_zoom_on_clicks { + generate_zoom_segments_from_clicks(completed_recording, recordings) + } else { + Vec::new() + }, + scene_segments: Vec::new(), + }), + ..default_config.unwrap_or_default() } } diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs deleted file mode 100644 index a611f25698..0000000000 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ /dev/null @@ -1,62 +0,0 @@ -use cap_recording::{ - RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, -}; -use serde_json::json; -use tauri::{AppHandle, Wry}; -use tauri_plugin_store::StoreExt; - -#[derive(serde::Serialize, serde::Deserialize, specta::Type, Debug, Clone, Copy)] -#[serde(rename_all = "camelCase")] -pub enum RecordingTargetMode { - Display, - Window, - Area, -} - -#[derive(serde::Serialize, serde::Deserialize, specta::Type, Debug, Clone, Default)] -#[serde(rename_all = "camelCase")] -pub struct RecordingSettingsStore { - pub target: Option, - pub mic_name: Option, - pub camera_id: Option, - pub mode: Option, - pub system_audio: bool, -} - -impl RecordingSettingsStore { - const KEY: &'static str = "recording_settings"; - - pub fn get(app: &AppHandle) -> Result, String> { - match app.store("store").map(|s| s.get(Self::KEY)) { - Ok(Some(store)) => { - // Handle potential deserialization errors gracefully - match serde_json::from_value(store) { - Ok(settings) => Ok(Some(settings)), - Err(e) => Err(format!("Failed to deserialize general settings store: {e}")), - } - } - _ => Ok(None), - } - } - - // i don't trust anyone to not overwrite the whole store lols - pub fn update(app: &AppHandle, update: impl FnOnce(&mut Self)) -> Result<(), String> { - let Ok(store) = app.store("store") else { - return Err("Store not found".to_string()); - }; - - let mut settings = Self::get(app)?.unwrap_or_default(); - update(&mut settings); - store.set(Self::KEY, json!(settings)); - store.save().map_err(|e| e.to_string()) - } - - fn save(&self, app: &AppHandle) -> Result<(), String> { - let Ok(store) = app.store("store") else { - return Err("Store not found".to_string()); - }; - - store.set(Self::KEY, json!(self)); - store.save().map_err(|e| e.to_string()) - } -} diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index c218bc5288..5cec112106 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -140,46 +140,6 @@ pub async fn display_information(display_id: &str) -> Result Result<(), String> { - let window = Window::from_id(&window_id).ok_or("Window not found")?; - - #[cfg(target_os = "macos")] - { - use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; - - let pid = window - .raw_handle() - .owner_pid() - .ok_or("Could not get window owner PID")?; - - if let Some(app) = - unsafe { NSRunningApplication::runningApplicationWithProcessIdentifier(pid) } - { - unsafe { - app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); - } - } - } - - #[cfg(target_os = "windows")] - { - use windows::Win32::UI::WindowsAndMessaging::{ - SW_RESTORE, SetForegroundWindow, ShowWindow, - }; - - let hwnd = window.raw_handle().inner(); - - unsafe { - ShowWindow(hwnd, SW_RESTORE); - SetForegroundWindow(hwnd); - } - } - - Ok(()) -} - // Windows doesn't have a proper concept of window z-index's so we implement them in userspace :( #[derive(Default)] pub struct WindowFocusManager { diff --git a/apps/desktop/src-tauri/src/thumbnails/mac.rs b/apps/desktop/src-tauri/src/thumbnails/mac.rs deleted file mode 100644 index 64918e26a4..0000000000 --- a/apps/desktop/src-tauri/src/thumbnails/mac.rs +++ /dev/null @@ -1,378 +0,0 @@ -use cidre::{arc, sc}; - -use crate::platform::get_shareable_content; - -use super::*; - -pub async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option { - let content = get_shareable_content().await.ok()??; - - let filter = display.raw_handle().as_content_filter(content).await?; - capture_thumbnail_from_filter(filter).await -} - -pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option { - let content = get_shareable_content().await.ok()??; - - let sc_window = window.raw_handle().as_sc(content).await?; - let filter = cidre::sc::ContentFilter::with_desktop_independent_window(&sc_window); - capture_thumbnail_from_filter(filter).await -} - -async fn capture_thumbnail_from_filter(filter: arc::R) -> Option { - use cidre::{cv, sc}; - use image::{ImageEncoder, RgbaImage, codecs::png::PngEncoder}; - use std::{io::Cursor, slice}; - - let mut config = sc::StreamCfg::new(); - config.set_width(THUMBNAIL_WIDTH as usize); - config.set_height(THUMBNAIL_HEIGHT as usize); - config.set_shows_cursor(false); - - let sample_buf = match unsafe { - sc::ScreenshotManager::capture_sample_buf(filter.as_ref(), &config) - } - .await - { - Ok(buf) => buf, - Err(err) => { - warn!(error = ?err, "Failed to capture sample buffer for thumbnail"); - return None; - } - }; - - let Some(image_buf) = sample_buf.image_buf() else { - warn!("Sample buffer missing image data"); - return None; - }; - let mut image_buf = image_buf.retained(); - - let width = image_buf.width(); - let height = image_buf.height(); - if width == 0 || height == 0 { - warn!( - width = width, - height = height, - "Captured thumbnail had empty dimensions" - ); - return None; - } - - let pixel_format = image_buf.pixel_format(); - - let lock = - match PixelBufferLock::new(image_buf.as_mut(), cv::pixel_buffer::LockFlags::READ_ONLY) { - Ok(lock) => lock, - Err(err) => { - warn!(error = ?err, "Failed to lock pixel buffer for thumbnail"); - return None; - } - }; - - let rgba_data = match pixel_format { - cv::PixelFormat::_32_BGRA - | cv::PixelFormat::_32_RGBA - | cv::PixelFormat::_32_ARGB - | cv::PixelFormat::_32_ABGR => { - convert_32bit_pixel_buffer(&lock, width, height, pixel_format)? - } - cv::PixelFormat::_420V => { - convert_nv12_pixel_buffer(&lock, width, height, Nv12Range::Video)? - } - other => { - warn!(?other, "Unsupported pixel format for thumbnail capture"); - return None; - } - }; - - let Some(img) = RgbaImage::from_raw(width as u32, height as u32, rgba_data) else { - warn!("Failed to construct RGBA image for thumbnail"); - return None; - }; - let thumbnail = normalize_thumbnail_dimensions(&img); - let mut png_data = Cursor::new(Vec::new()); - let encoder = PngEncoder::new(&mut png_data); - if let Err(err) = encoder.write_image( - thumbnail.as_raw(), - thumbnail.width(), - thumbnail.height(), - image::ColorType::Rgba8.into(), - ) { - warn!(error = ?err, "Failed to encode thumbnail as PNG"); - return None; - } - - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - png_data.into_inner(), - )) -} - -fn convert_32bit_pixel_buffer( - lock: &PixelBufferLock<'_>, - width: usize, - height: usize, - pixel_format: cidre::cv::PixelFormat, -) -> Option> { - let base_ptr = lock.base_address(); - if base_ptr.is_null() { - warn!("Pixel buffer base address was null"); - return None; - } - - let bytes_per_row = lock.bytes_per_row(); - let total_len = bytes_per_row.checked_mul(height)?; - let raw_data = unsafe { std::slice::from_raw_parts(base_ptr, total_len) }; - - let mut rgba_data = Vec::with_capacity(width * height * 4); - for y in 0..height { - let row_start = y * bytes_per_row; - let row_end = row_start + width * 4; - if row_end > raw_data.len() { - warn!( - row_start = row_start, - row_end = row_end, - raw_len = raw_data.len(), - "Row bounds exceeded raw data length during thumbnail capture", - ); - return None; - } - - let row = &raw_data[row_start..row_end]; - for chunk in row.chunks_exact(4) { - match pixel_format { - cidre::cv::PixelFormat::_32_BGRA => { - rgba_data.extend_from_slice(&[chunk[2], chunk[1], chunk[0], chunk[3]]) - } - cidre::cv::PixelFormat::_32_RGBA => rgba_data.extend_from_slice(chunk), - cidre::cv::PixelFormat::_32_ARGB => { - rgba_data.extend_from_slice(&[chunk[1], chunk[2], chunk[3], chunk[0]]) - } - cidre::cv::PixelFormat::_32_ABGR => { - rgba_data.extend_from_slice(&[chunk[3], chunk[2], chunk[1], chunk[0]]) - } - _ => unreachable!(), - } - } - } - - Some(rgba_data) -} - -#[derive(Copy, Clone)] -enum Nv12Range { - Video, - Full, -} - -fn convert_nv12_pixel_buffer( - lock: &PixelBufferLock<'_>, - width: usize, - height: usize, - range: Nv12Range, -) -> Option> { - let y_base = lock.base_address_of_plane(0); - let uv_base = lock.base_address_of_plane(1); - if y_base.is_null() || uv_base.is_null() { - warn!("NV12 plane base address was null"); - return None; - } - - let y_stride = lock.bytes_per_row_of_plane(0); - let uv_stride = lock.bytes_per_row_of_plane(1); - if y_stride == 0 || uv_stride == 0 { - warn!(y_stride, uv_stride, "NV12 plane bytes per row was zero"); - return None; - } - - let y_plane_height = lock.height_of_plane(0); - let uv_plane_height = lock.height_of_plane(1); - if y_plane_height < height || uv_plane_height < (height + 1) / 2 { - warn!( - y_plane_height, - uv_plane_height, - expected_y = height, - expected_uv = (height + 1) / 2, - "NV12 plane height smaller than expected", - ); - return None; - } - - let y_plane = unsafe { std::slice::from_raw_parts(y_base, y_stride * y_plane_height) }; - let uv_plane = unsafe { std::slice::from_raw_parts(uv_base, uv_stride * uv_plane_height) }; - - let mut rgba_data = vec![0u8; width * height * 4]; - - for y_idx in 0..height { - let y_row_start = y_idx * y_stride; - if y_row_start + width > y_plane.len() { - warn!( - y_row_start, - width, - y_plane_len = y_plane.len(), - "Y row exceeded plane length during conversion", - ); - return None; - } - let y_row = &y_plane[y_row_start..y_row_start + width]; - - let uv_row_start = (y_idx / 2) * uv_stride; - if uv_row_start + width > uv_plane.len() { - warn!( - uv_row_start, - width, - uv_plane_len = uv_plane.len(), - "UV row exceeded plane length during conversion", - ); - return None; - } - let uv_row = &uv_plane[uv_row_start..uv_row_start + width]; - - for x in 0..width { - let uv_index = (x / 2) * 2; - if uv_index + 1 >= uv_row.len() { - warn!( - uv_index, - uv_row_len = uv_row.len(), - "UV index out of bounds during conversion", - ); - return None; - } - - let y_val = y_row[x]; - let cb = uv_row[uv_index]; - let cr = uv_row[uv_index + 1]; - let (r, g, b) = ycbcr_to_rgb(y_val, cb, cr, range); - let out = (y_idx * width + x) * 4; - rgba_data[out] = r; - rgba_data[out + 1] = g; - rgba_data[out + 2] = b; - rgba_data[out + 3] = 255; - } - } - - Some(rgba_data) -} - -fn ycbcr_to_rgb(y: u8, cb: u8, cr: u8, range: Nv12Range) -> (u8, u8, u8) { - let y = y as f32; - let cb = cb as f32 - 128.0; - let cr = cr as f32 - 128.0; - - let (y_value, scale) = match range { - Nv12Range::Video => ((y - 16.0).max(0.0), 1.164383_f32), - Nv12Range::Full => (y, 1.0_f32), - }; - - let r = scale * y_value + 1.596027_f32 * cr; - let g = scale * y_value - 0.391762_f32 * cb - 0.812968_f32 * cr; - let b = scale * y_value + 2.017232_f32 * cb; - - (clamp_channel(r), clamp_channel(g), clamp_channel(b)) -} - -fn clamp_channel(value: f32) -> u8 { - value.max(0.0).min(255.0) as u8 -} - -struct PixelBufferLock<'a> { - buffer: &'a mut cidre::cv::PixelBuf, - flags: cidre::cv::pixel_buffer::LockFlags, -} - -impl<'a> PixelBufferLock<'a> { - fn new( - buffer: &'a mut cidre::cv::PixelBuf, - flags: cidre::cv::pixel_buffer::LockFlags, - ) -> cidre::os::Result { - unsafe { buffer.lock_base_addr(flags) }.result()?; - Ok(Self { buffer, flags }) - } - - fn base_address(&self) -> *const u8 { - unsafe { cv_pixel_buffer_get_base_address(self.buffer) as *const u8 } - } - - fn bytes_per_row(&self) -> usize { - unsafe { cv_pixel_buffer_get_bytes_per_row(self.buffer) } - } - - fn base_address_of_plane(&self, plane_index: usize) -> *const u8 { - unsafe { cv_pixel_buffer_get_base_address_of_plane(self.buffer, plane_index) as *const u8 } - } - - fn bytes_per_row_of_plane(&self, plane_index: usize) -> usize { - unsafe { cv_pixel_buffer_get_bytes_per_row_of_plane(self.buffer, plane_index) } - } - - fn height_of_plane(&self, plane_index: usize) -> usize { - unsafe { cv_pixel_buffer_get_height_of_plane(self.buffer, plane_index) } - } -} - -impl Drop for PixelBufferLock<'_> { - fn drop(&mut self) { - unsafe { - let _ = self.buffer.unlock_lock_base_addr(self.flags); - } - } -} - -unsafe fn cv_pixel_buffer_get_base_address(buffer: &cidre::cv::PixelBuf) -> *mut std::ffi::c_void { - unsafe extern "C" { - fn CVPixelBufferGetBaseAddress(pixel_buffer: &cidre::cv::PixelBuf) - -> *mut std::ffi::c_void; - } - - unsafe { CVPixelBufferGetBaseAddress(buffer) } -} - -unsafe fn cv_pixel_buffer_get_bytes_per_row(buffer: &cidre::cv::PixelBuf) -> usize { - unsafe extern "C" { - fn CVPixelBufferGetBytesPerRow(pixel_buffer: &cidre::cv::PixelBuf) -> usize; - } - - unsafe { CVPixelBufferGetBytesPerRow(buffer) } -} - -unsafe fn cv_pixel_buffer_get_base_address_of_plane( - buffer: &cidre::cv::PixelBuf, - plane_index: usize, -) -> *mut std::ffi::c_void { - unsafe extern "C" { - fn CVPixelBufferGetBaseAddressOfPlane( - pixel_buffer: &cidre::cv::PixelBuf, - plane_index: usize, - ) -> *mut std::ffi::c_void; - } - - unsafe { CVPixelBufferGetBaseAddressOfPlane(buffer, plane_index) } -} - -unsafe fn cv_pixel_buffer_get_bytes_per_row_of_plane( - buffer: &cidre::cv::PixelBuf, - plane_index: usize, -) -> usize { - unsafe extern "C" { - fn CVPixelBufferGetBytesPerRowOfPlane( - pixel_buffer: &cidre::cv::PixelBuf, - plane_index: usize, - ) -> usize; - } - - unsafe { CVPixelBufferGetBytesPerRowOfPlane(buffer, plane_index) } -} - -unsafe fn cv_pixel_buffer_get_height_of_plane( - buffer: &cidre::cv::PixelBuf, - plane_index: usize, -) -> usize { - unsafe extern "C" { - fn CVPixelBufferGetHeightOfPlane( - pixel_buffer: &cidre::cv::PixelBuf, - plane_index: usize, - ) -> usize; - } - - unsafe { CVPixelBufferGetHeightOfPlane(buffer, plane_index) } -} diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs deleted file mode 100644 index c4dbeb9dae..0000000000 --- a/apps/desktop/src-tauri/src/thumbnails/mod.rs +++ /dev/null @@ -1,149 +0,0 @@ -use cap_recording::sources::screen_capture::{list_displays, list_windows}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use tracing::*; - -#[cfg(windows)] -mod windows; -#[cfg(windows)] -pub use windows::*; - -#[cfg(target_os = "macos")] -mod mac; -#[cfg(target_os = "macos")] -pub use mac::*; - -const THUMBNAIL_WIDTH: u32 = 320; -const THUMBNAIL_HEIGHT: u32 = 180; - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct CaptureDisplayWithThumbnail { - pub id: scap_targets::DisplayId, - pub name: String, - pub refresh_rate: u32, - pub thumbnail: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct CaptureWindowWithThumbnail { - pub id: scap_targets::WindowId, - pub owner_name: String, - pub name: String, - pub bounds: scap_targets::bounds::LogicalBounds, - pub refresh_rate: u32, - pub thumbnail: Option, - pub app_icon: Option, -} - -pub fn normalize_thumbnail_dimensions(image: &image::RgbaImage) -> image::RgbaImage { - let width = image.width(); - let height = image.height(); - - if width == THUMBNAIL_WIDTH && height == THUMBNAIL_HEIGHT { - return image.clone(); - } - - if width == 0 || height == 0 { - return image::RgbaImage::from_pixel( - THUMBNAIL_WIDTH, - THUMBNAIL_HEIGHT, - image::Rgba([0, 0, 0, 0]), - ); - } - - let scale = (THUMBNAIL_WIDTH as f32 / width as f32) - .min(THUMBNAIL_HEIGHT as f32 / height as f32) - .max(f32::MIN_POSITIVE); - - let scaled_width = (width as f32 * scale) - .round() - .clamp(1.0, THUMBNAIL_WIDTH as f32) as u32; - let scaled_height = (height as f32 * scale) - .round() - .clamp(1.0, THUMBNAIL_HEIGHT as f32) as u32; - - let resized = image::imageops::resize( - image, - scaled_width.max(1), - scaled_height.max(1), - image::imageops::FilterType::Lanczos3, - ); - - let mut canvas = - image::RgbaImage::from_pixel(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, image::Rgba([0, 0, 0, 0])); - - let offset_x = (THUMBNAIL_WIDTH - scaled_width) / 2; - let offset_y = (THUMBNAIL_HEIGHT - scaled_height) / 2; - - image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64); - - canvas -} - -pub async fn collect_displays_with_thumbnails() -> Result, String> -{ - let displays = list_displays(); - - let mut results = Vec::new(); - for (capture_display, display) in displays { - let thumbnail = capture_display_thumbnail(&display).await; - results.push(CaptureDisplayWithThumbnail { - id: capture_display.id, - name: capture_display.name, - refresh_rate: capture_display.refresh_rate, - thumbnail, - }); - } - - Ok(results) -} - -pub async fn collect_windows_with_thumbnails() -> Result, String> { - let windows = list_windows(); - - debug!(window_count = windows.len(), "Collecting window thumbnails"); - let mut results = Vec::new(); - for (capture_window, window) in windows { - let thumbnail = capture_window_thumbnail(&window).await; - let app_icon = window.app_icon().and_then(|bytes| { - if bytes.is_empty() { - None - } else { - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - bytes, - )) - } - }); - - if thumbnail.is_none() { - warn!( - window_id = ?capture_window.id, - window_name = %capture_window.name, - owner_name = %capture_window.owner_name, - "Window thumbnail capture returned None", - ); - } else { - debug!( - window_id = ?capture_window.id, - window_name = %capture_window.name, - owner_name = %capture_window.owner_name, - "Captured window thumbnail", - ); - } - - results.push(CaptureWindowWithThumbnail { - id: capture_window.id, - name: capture_window.name, - owner_name: capture_window.owner_name, - bounds: capture_window.bounds, - refresh_rate: capture_window.refresh_rate, - thumbnail, - app_icon, - }); - } - - info!(windows = results.len(), "Collected window thumbnail data"); - - Ok(results) -} diff --git a/apps/desktop/src-tauri/src/thumbnails/windows.rs b/apps/desktop/src-tauri/src/thumbnails/windows.rs deleted file mode 100644 index ec6307b3da..0000000000 --- a/apps/desktop/src-tauri/src/thumbnails/windows.rs +++ /dev/null @@ -1,279 +0,0 @@ -use super::*; - -pub async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option { - use image::{ColorType, ImageEncoder, codecs::png::PngEncoder}; - use scap_direct3d::{Capturer, Settings}; - use std::io::Cursor; - - let item = display.raw_handle().try_as_capture_item().ok()?; - - let (tx, rx) = std::sync::mpsc::channel(); - - let settings = Settings { - is_cursor_capture_enabled: Some(false), - pixel_format: scap_direct3d::PixelFormat::R8G8B8A8Unorm, - ..Default::default() - }; - - let mut capturer = Capturer::new( - item, - settings.clone(), - move |frame| { - let _ = tx.send(frame); - Ok(()) - }, - || Ok(()), - None, - ) - .ok()?; - - capturer.start().ok()?; - - let frame = rx.recv_timeout(std::time::Duration::from_secs(2)).ok()?; - let _ = capturer.stop(); - - let width = frame.width(); - let height = frame.height(); - - if width == 0 || height == 0 { - return None; - } - - let frame_buffer = frame.as_buffer().ok()?; - let data = frame_buffer.data(); - let stride = frame_buffer.stride() as usize; - - let width_usize = width as usize; - let height_usize = height as usize; - - let Some(row_bytes) = width_usize.checked_mul(4) else { - warn!( - frame_width = width, - "Windows display thumbnail row size overflowed" - ); - return None; - }; - - if stride < row_bytes { - warn!( - frame_width = width, - frame_height = height, - stride, - expected_row_bytes = row_bytes, - "Windows display thumbnail stride smaller than row size" - ); - return None; - } - - let rows_before_last = height_usize.saturating_sub(1); - let Some(last_row_start) = rows_before_last.checked_mul(stride) else { - warn!( - frame_width = width, - frame_height = height, - stride, - "Windows display thumbnail row offset overflowed" - ); - return None; - }; - - let Some(required_len) = last_row_start.checked_add(row_bytes) else { - warn!( - frame_width = width, - frame_height = height, - stride, - required_row_bytes = row_bytes, - "Windows display thumbnail required length overflowed" - ); - return None; - }; - - if data.len() < required_len { - warn!( - frame_width = width, - frame_height = height, - stride, - frame_data_len = data.len(), - expected_len = required_len, - "Windows display thumbnail frame buffer missing pixel data" - ); - return None; - } - - let Some(rgba_capacity) = height_usize.checked_mul(row_bytes) else { - warn!( - frame_width = width, - frame_height = height, - total_row_bytes = row_bytes, - "Windows display thumbnail RGBA capacity overflowed" - ); - return None; - }; - - let mut rgba_data = Vec::with_capacity(rgba_capacity); - for y in 0..height_usize { - let row_start = y * stride; - let row_end = row_start + row_bytes; - rgba_data.extend_from_slice(&data[row_start..row_end]); - } - - let Some(img) = image::RgbaImage::from_raw(width, height, rgba_data) else { - warn!("Windows display thumbnail failed to construct RGBA image"); - return None; - }; - let thumbnail = normalize_thumbnail_dimensions(&img); - - let mut png_data = Cursor::new(Vec::new()); - let encoder = PngEncoder::new(&mut png_data); - encoder - .write_image( - thumbnail.as_raw(), - thumbnail.width(), - thumbnail.height(), - ColorType::Rgba8.into(), - ) - .ok()?; - - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - png_data.into_inner(), - )) -} - -pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option { - use image::{ColorType, ImageEncoder, codecs::png::PngEncoder}; - use scap_direct3d::{Capturer, Settings}; - use std::io::Cursor; - - let item = window.raw_handle().try_as_capture_item().ok()?; - - let (tx, rx) = std::sync::mpsc::channel(); - - let settings = Settings { - is_cursor_capture_enabled: Some(false), - pixel_format: scap_direct3d::PixelFormat::R8G8B8A8Unorm, - ..Default::default() - }; - - let mut capturer = Capturer::new( - item, - settings.clone(), - move |frame| { - let _ = tx.send(frame); - Ok(()) - }, - || Ok(()), - None, - ) - .ok()?; - - capturer.start().ok()?; - - let frame = rx.recv_timeout(std::time::Duration::from_secs(2)).ok()?; - let _ = capturer.stop(); - - let width = frame.width(); - let height = frame.height(); - - if width == 0 || height == 0 { - return None; - } - - let frame_buffer = frame.as_buffer().ok()?; - let data = frame_buffer.data(); - let stride = frame_buffer.stride() as usize; - - let width_usize = width as usize; - let height_usize = height as usize; - - let Some(row_bytes) = width_usize.checked_mul(4) else { - warn!( - frame_width = width, - "Windows window thumbnail row size overflowed" - ); - return None; - }; - - if stride < row_bytes { - warn!( - frame_width = width, - frame_height = height, - stride, - expected_row_bytes = row_bytes, - "Windows window thumbnail stride smaller than row size" - ); - return None; - } - - let rows_before_last = height_usize.saturating_sub(1); - let Some(last_row_start) = rows_before_last.checked_mul(stride) else { - warn!( - frame_width = width, - frame_height = height, - stride, - "Windows window thumbnail row offset overflowed" - ); - return None; - }; - - let Some(required_len) = last_row_start.checked_add(row_bytes) else { - warn!( - frame_width = width, - frame_height = height, - stride, - required_row_bytes = row_bytes, - "Windows window thumbnail required length overflowed" - ); - return None; - }; - - if data.len() < required_len { - warn!( - frame_width = width, - frame_height = height, - stride, - frame_data_len = data.len(), - expected_len = required_len, - "Windows window thumbnail frame buffer missing pixel data" - ); - return None; - } - - let Some(rgba_capacity) = height_usize.checked_mul(row_bytes) else { - warn!( - frame_width = width, - frame_height = height, - total_row_bytes = row_bytes, - "Windows window thumbnail RGBA capacity overflowed" - ); - return None; - }; - - let mut rgba_data = Vec::with_capacity(rgba_capacity); - for y in 0..height_usize { - let row_start = y * stride; - let row_end = row_start + row_bytes; - rgba_data.extend_from_slice(&data[row_start..row_end]); - } - - let Some(img) = image::RgbaImage::from_raw(width, height, rgba_data) else { - warn!("Windows window thumbnail failed to construct RGBA image"); - return None; - }; - let thumbnail = normalize_thumbnail_dimensions(&img); - - let mut png_data = Cursor::new(Vec::new()); - let encoder = PngEncoder::new(&mut png_data); - encoder - .write_image( - thumbnail.as_raw(), - thumbnail.width(), - thumbnail.height(), - ColorType::Rgba8.into(), - ) - .ok()?; - - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - png_data.into_inner(), - )) -} diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 98c5b29dbd..dced37786b 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -102,11 +102,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Ok(TrayItem::OpenCap) => { let app = app.clone(); tokio::spawn(async move { - let _ = ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await; + let _ = ShowCapWindow::Main.show(&app).await; }); } Ok(TrayItem::TakeScreenshot) => { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f6e9236972..f306140b9d 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -11,29 +11,22 @@ use image::codecs::jpeg::JpegEncoder; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; -use serde_json::json; use specta::Type; -use std::{ - path::PathBuf, - time::{Duration, Instant}, -}; -use tauri::{AppHandle, ipc::Channel}; +use std::path::PathBuf; +use std::time::Duration; +use tauri::AppHandle; use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_specta::Event; use tokio::io::{AsyncReadExt, AsyncSeekExt}; -use tokio::task::{self, JoinHandle}; +use tokio::task; use tokio::time::sleep; -use tracing::{debug, error, info, trace, warn}; +use tracing::{error, info, warn}; #[derive(Deserialize, Serialize, Clone, Type, Debug)] pub struct S3UploadMeta { id: String, } -#[derive(Deserialize, Clone, Debug)] -pub struct CreateErrorResponse { - error: String, -} - // fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result // where // D: Deserializer<'de>, @@ -112,102 +105,6 @@ pub struct UploadedImage { // pub config: S3UploadMeta, // } -pub struct UploadProgressUpdater { - video_state: Option, - app: AppHandle, - video_id: String, -} - -struct VideoProgressState { - uploaded: u64, - total: u64, - pending_task: Option>, - last_update_time: Instant, -} - -impl UploadProgressUpdater { - pub fn new(app: AppHandle, video_id: String) -> Self { - Self { - video_state: None, - app, - video_id, - } - } - - pub fn update(&mut self, uploaded: u64, total: u64) { - let should_send_immediately = { - let state = self.video_state.get_or_insert_with(|| VideoProgressState { - uploaded, - total, - pending_task: None, - last_update_time: Instant::now(), - }); - - // Cancel any pending task - if let Some(handle) = state.pending_task.take() { - handle.abort(); - } - - state.uploaded = uploaded; - state.total = total; - state.last_update_time = Instant::now(); - - // Send immediately if upload is complete - uploaded >= total - }; - - let app = self.app.clone(); - if should_send_immediately { - tokio::spawn({ - let video_id = self.video_id.clone(); - async move { - Self::send_api_update(&app, video_id, uploaded, total).await; - } - }); - - // Clear state since upload is complete - self.video_state = None; - } else { - // Schedule delayed update - let handle = { - let video_id = self.video_id.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(2)).await; - Self::send_api_update(&app, video_id, uploaded, total).await; - }) - }; - - if let Some(state) = &mut self.video_state { - state.pending_task = Some(handle); - } - } - } - - async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { - let response = app - .authed_api_request("/api/desktop/video/progress", |client, url| { - client - .post(url) - .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) - .json(&json!({ - "videoId": video_id, - "uploaded": uploaded, - "total": total, - "updatedAt": chrono::Utc::now().to_rfc3339() - })) - }) - .await; - - match response { - Ok(resp) if resp.status().is_success() => { - trace!("Progress update sent successfully"); - } - Ok(resp) => error!("Failed to send progress update: {}", resp.status()), - Err(err) => error!("Failed to send progress update: {err}"), - } - } -} - pub async fn upload_video( app: &AppHandle, video_id: String, @@ -215,7 +112,6 @@ pub async fn upload_video( existing_config: Option, screenshot_path: Option, meta: Option, - channel: Option>, ) -> Result { println!("Uploading video {video_id}..."); @@ -249,24 +145,20 @@ pub async fn upload_video( let reader_stream = tokio_util::io::ReaderStream::new(file); - let mut bytes_uploaded = 0u64; - let mut progress = UploadProgressUpdater::new(app.clone(), video_id); - - let progress_stream = reader_stream.inspect(move |chunk| { - if let Ok(chunk) = chunk { - bytes_uploaded += chunk.len() as u64; - } - - if bytes_uploaded > 0 { - if let Some(channel) = &channel { - channel - .send(UploadProgress { - progress: bytes_uploaded as f64 / total_size as f64, - }) - .ok(); + let mut bytes_uploaded = 0; + let progress_stream = reader_stream.inspect({ + let app = app.clone(); + move |chunk| { + if bytes_uploaded > 0 { + let _ = UploadProgress { + progress: bytes_uploaded as f64 / total_size as f64, + } + .emit(&app); } - progress.update(bytes_uploaded, total_size); + if let Ok(chunk) = chunk { + bytes_uploaded += chunk.len(); + } } }); @@ -424,21 +316,6 @@ pub async fn create_or_get_video( return Err("Failed to authenticate request; please log in again".into()); } - if response.status() != StatusCode::OK { - if let Ok(error) = response.json::().await { - if error.error == "upgrade_required" { - return Err( - "You must upgrade to Cap Pro to upload recordings over 5 minutes in length" - .into(), - ); - } - - return Err(format!("server error: {}", error.error)); - } - - return Err("Unknown error uploading video".into()); - } - let response_text = response .text() .await @@ -667,12 +544,13 @@ impl InstantMultipartUpload { let mut uploaded_parts = Vec::new(); let mut part_number = 1; let mut last_uploaded_position: u64 = 0; - let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + + println!("Starting multipart upload for {video_id}..."); // -------------------------------------------- // initiate the multipart upload // -------------------------------------------- - debug!("Initiating multipart upload for {video_id}..."); + println!("Initiating multipart upload for {video_id}..."); let initiate_response = match app .authed_api_request("/api/upload/multipart/initiate", |c, url| { c.post(url) @@ -776,7 +654,6 @@ impl InstantMultipartUpload { &mut part_number, &mut last_uploaded_position, new_data_size.min(CHUNK_SIZE), - &mut progress, ) .await { @@ -803,7 +680,6 @@ impl InstantMultipartUpload { &mut 1, &mut 0, uploaded_parts[0].size as u64, - &mut progress, ) .await .map_err(|err| format!("Failed to re-upload first chunk: {err}"))?; @@ -850,7 +726,6 @@ impl InstantMultipartUpload { part_number: &mut i32, last_uploaded_position: &mut u64, chunk_size: u64, - progress: &mut UploadProgressUpdater, ) -> Result { let file_size = match tokio::fs::metadata(file_path).await { Ok(metadata) => metadata.len(), @@ -963,8 +838,6 @@ impl InstantMultipartUpload { } }; - progress.update(expected_pos, file_size); - if !presign_response.status().is_success() { let status = presign_response.status(); let error_body = presign_response diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 7079cf7b93..dc5bda851d 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -16,15 +16,13 @@ use tauri::{ AppHandle, LogicalPosition, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, Wry, }; -use tauri_specta::Event; use tokio::sync::RwLock; -use tracing::{debug, error, warn}; +use tracing::{debug, error}; use crate::{ - App, ArcLock, RequestScreenCapturePrewarm, fake_window, + App, ArcLock, fake_window, general_settings::{AppTheme, GeneralSettingsStore}, permissions, - recording_settings::RecordingTargetMode, target_select_overlay::WindowFocusManager, }; @@ -179,29 +177,15 @@ impl CapWindowId { #[derive(Clone, Type, Deserialize)] pub enum ShowCapWindow { Setup, - Main { - init_target_mode: Option, - }, - Settings { - page: Option, - }, - Editor { - project_path: PathBuf, - }, + Main, + Settings { page: Option }, + Editor { project_path: PathBuf }, RecordingsOverlay, - WindowCaptureOccluder { - screen_id: DisplayId, - }, - TargetSelectOverlay { - display_id: DisplayId, - }, - CaptureArea { - screen_id: DisplayId, - }, + WindowCaptureOccluder { screen_id: DisplayId }, + TargetSelectOverlay { display_id: DisplayId }, + CaptureArea { screen_id: DisplayId }, Camera, - InProgressRecording { - countdown: Option, - }, + InProgressRecording { countdown: Option }, Upgrade, ModeSelect, } @@ -239,57 +223,35 @@ impl ShowCapWindow { .maximizable(false) .shadow(true) .build()?, - Self::Main { init_target_mode } => { - if !permissions::do_permissions_check(false).necessary_granted() { - return Box::pin(Self::Setup.show(app)).await; - } - - let new_recording_flow = GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|s| s.enable_new_recording_flow) - .unwrap_or_default(); - - let window = self - .window_builder(app, if new_recording_flow { "/new-main" } else { "/" }) - .resizable(false) - .maximized(false) - .maximizable(false) - .minimizable(false) - .always_on_top(true) - .visible_on_all_workspaces(true) - .content_protected(false) - .center() - .initialization_script(format!( - " - window.__CAP__ = window.__CAP__ ?? {{}}; - window.__CAP__.initialTargetMode = {} - ", - serde_json::to_string(init_target_mode) - .expect("Failed to serialize initial target mode") - )) - .build()?; - - if new_recording_flow { - #[cfg(target_os = "macos")] - crate::platform::set_window_level(window.as_ref().window(), 50); - } + Self::Main => { + if permissions::do_permissions_check(false).necessary_granted() { + let new_recording_flow = GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|s| s.enable_new_recording_flow) + .unwrap_or_default(); + + let window = self + .window_builder(app, if new_recording_flow { "/new-main" } else { "/" }) + .resizable(false) + .maximized(false) + .maximizable(false) + .minimizable(false) + .always_on_top(true) + .visible_on_all_workspaces(true) + .content_protected(true) + .center() + .build()?; - #[cfg(target_os = "macos")] - { - let app_handle = app.clone(); - tauri::async_runtime::spawn(async move { - let prewarmer = - app_handle.state::(); - prewarmer.request(false).await; - }); - - if let Err(error) = (RequestScreenCapturePrewarm { force: false }).emit(app) { - warn!(%error, "Failed to emit ScreenCaptureKit prewarm event"); + if new_recording_flow { + #[cfg(target_os = "macos")] + crate::platform::set_window_level(window.as_ref().window(), 50); } - } - window + window + } else { + Box::pin(Self::Setup.show(app)).await? + } } Self::TargetSelectOverlay { display_id } => { let Some(display) = scap_targets::Display::from_id(display_id) else { @@ -309,8 +271,7 @@ impl ShowCapWindow { .always_on_top(true) .visible_on_all_workspaces(true) .skip_taskbar(true) - .transparent(true) - .visible(false); + .transparent(true); #[cfg(target_os = "macos")] { @@ -739,7 +700,7 @@ impl ShowCapWindow { pub fn id(&self, app: &AppHandle) -> CapWindowId { match self { ShowCapWindow::Setup => CapWindowId::Setup, - ShowCapWindow::Main { .. } => CapWindowId::Main, + ShowCapWindow::Main => CapWindowId::Main, ShowCapWindow::Settings { .. } => CapWindowId::Settings, ShowCapWindow::Editor { project_path } => { let state = app.state::(); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 5f2a676ebd..214c68a747 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "mainBinaryName": "Cap - Development", "build": { "beforeDevCommand": "pnpm localdev", - "devUrl": "http://localhost:3002", + "devUrl": "http://localhost:3001", "beforeBuildCommand": "pnpm turbo build --filter @cap/desktop", "frontendDist": "../.output/public" }, diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 22c667683f..623da2b38e 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -40,7 +40,6 @@ import { type CaptureWindow, commands, events, - type RecordingMode, type ScreenCaptureTarget, } from "~/utils/tauri"; @@ -197,7 +196,7 @@ function Page() { }); const toggleRecording = createMutation(() => ({ - mutationFn: async (payload: { mode: RecordingMode }) => { + mutationFn: async () => { if (!isRecording()) { const capture_target = ((): ScreenCaptureTarget => { switch (rawOptions.captureTarget.variant) { @@ -240,7 +239,7 @@ function Page() { await commands.startRecording({ capture_target, - mode: payload.mode, + mode: rawOptions.mode, capture_system_audio: rawOptions.captureSystemAudio, }); } else await commands.stopRecording(); @@ -345,7 +344,7 @@ function Page() { } }} class={cx( - "text-[0.6rem] ml-2 rounded-lg px-1 py-0.5", + "text-[0.6rem] rounded-lg px-1 py-0.5", license.data?.type === "pro" ? "bg-[--blue-400] text-gray-1 dark:text-gray-12" : "bg-gray-3 cursor-pointer hover:bg-gray-5", @@ -487,68 +486,36 @@ function Page() { Instant Mode ) : ( - toggleRecording.mutate()} + class="flex flex-grow justify-center items-center" + > + {isRecording() ? ( + "Stop Recording" + ) : ( <> - Instant Mode recordings are limited -
to 5 mins,{" "} - + {rawOptions.mode === "instant" ? ( + + ) : ( + + )} + Start Recording - } - openDelay={0} - closeDelay={0} - disabled={ - !( - rawOptions.mode === "instant" && - auth.data?.plan?.upgraded === false - ) - } - > - -
+ )} + )}
diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index 83a28b7fa8..5b0676b83e 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -38,7 +38,7 @@ export default function CameraSelect(props: {
- ); -} - -function escapeRegExp(value: string) { - return value.replace(/[\^$*+?.()|[\]{}-]/g, "\\$&"); -} - -export function TargetCardSkeleton(props: { class?: string }) { - return ( -
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx deleted file mode 100644 index a14601d687..0000000000 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { PolymorphicProps } from "@kobalte/core/polymorphic"; -import { Polymorphic } from "@kobalte/core/polymorphic"; -import { cx } from "cva"; -import { splitProps, type ValidComponent } from "solid-js"; -import IconCapChevronDown from "~icons/cap/chevron-down"; - -type TargetDropdownButtonProps = PolymorphicProps< - T, - { - class?: string; - disabled?: boolean; - expanded?: boolean; - } ->; - -export default function TargetDropdownButton< - T extends ValidComponent = "button", ->(props: TargetDropdownButtonProps) { - const [local, rest] = splitProps(props, ["class", "expanded", "disabled"]); - - return ( - - - - ); -} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx deleted file mode 100644 index 34cd229125..0000000000 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { cx } from "cva"; -import { createMemo, For, Match, Switch } from "solid-js"; -import { Motion } from "solid-motionone"; -import type { - CaptureDisplayWithThumbnail, - CaptureWindowWithThumbnail, -} from "~/utils/tauri"; -import TargetCard, { TargetCardSkeleton } from "./TargetCard"; - -const DEFAULT_SKELETON_COUNT = 6; - -type BaseProps = { - targets?: T[]; - onSelect?: (target: T) => void; - isLoading?: boolean; - errorMessage?: string; - emptyMessage?: string; - disabled?: boolean; - skeletonCount?: number; - class?: string; - highlightQuery?: string; -}; - -type DisplayGridProps = BaseProps & { - variant: "display"; -}; - -type WindowGridProps = BaseProps & { - variant: "window"; -}; - -type TargetMenuGridProps = DisplayGridProps | WindowGridProps; - -export default function TargetMenuGrid(props: TargetMenuGridProps) { - const items = createMemo(() => props.targets ?? []); - const skeletonItems = createMemo(() => - Array.from({ length: props.skeletonCount ?? DEFAULT_SKELETON_COUNT }), - ); - const isEmpty = createMemo( - () => !props.isLoading && items().length === 0 && !props.errorMessage, - ); - - let containerRef: HTMLDivElement | undefined; - - const handleKeyDown = (event: KeyboardEvent) => { - const container = containerRef; - if (!container) return; - - const buttons = Array.from( - container.querySelectorAll( - "button[data-target-menu-card]:not(:disabled)", - ), - ); - if (!buttons.length) return; - - const currentTarget = event.currentTarget as HTMLButtonElement | null; - if (!currentTarget) return; - - const currentIndex = buttons.indexOf(currentTarget); - if (currentIndex === -1) return; - - const totalItems = buttons.length; - const columns = 2; - let nextIndex = currentIndex; - - switch (event.key) { - case "ArrowRight": - nextIndex = (currentIndex + 1) % totalItems; - event.preventDefault(); - break; - case "ArrowLeft": - nextIndex = (currentIndex - 1 + totalItems) % totalItems; - event.preventDefault(); - break; - case "ArrowDown": - nextIndex = Math.min(currentIndex + columns, totalItems - 1); - event.preventDefault(); - break; - case "ArrowUp": - nextIndex = Math.max(currentIndex - columns, 0); - event.preventDefault(); - break; - case "Home": - nextIndex = 0; - event.preventDefault(); - break; - case "End": - nextIndex = totalItems - 1; - event.preventDefault(); - break; - default: - return; - } - - const target = buttons[nextIndex]; - target?.focus(); - }; - - const defaultEmptyMessage = () => - props.variant === "display" ? "No displays found" : "No windows found"; - - return ( -
{ - containerRef = node ?? undefined; - }} - > - - -
-

{props.errorMessage}

-
-
- - - {() => } - - - -
- {props.emptyMessage ?? defaultEmptyMessage()} -
-
- 0}> - - - {(() => { - const displayProps = props as DisplayGridProps; - return ( - - {(item, index) => ( - - displayProps.onSelect?.(item)} - disabled={displayProps.disabled} - onKeyDown={handleKeyDown} - class="w-full" - data-target-menu-card="true" - highlightQuery={displayProps.highlightQuery} - /> - - )} - - ); - })()} - - - {(() => { - const windowProps = props as WindowGridProps; - return ( - - {(item, index) => ( - - windowProps.onSelect?.(item)} - disabled={windowProps.disabled} - onKeyDown={handleKeyDown} - class="w-full" - data-target-menu-card="true" - highlightQuery={windowProps.highlightQuery} - /> - - )} - - ); - })()} - - - -
-
- ); -} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx index 6664e1e402..a7c4e2ac30 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetTypeButton.tsx @@ -1,43 +1,31 @@ import { cx } from "cva"; -import { type Component, type ComponentProps, splitProps } from "solid-js"; - -type TargetTypeButtonProps = { - selected: boolean; - Component: Component>; - name: string; - disabled?: boolean; -} & ComponentProps<"button">; - -function TargetTypeButton(props: TargetTypeButtonProps) { - const [local, rest] = splitProps(props, [ - "selected", - "Component", - "name", - "disabled", - "class", - ]); +import type { Component, ComponentProps } from "solid-js"; +function TargetTypeButton( + props: { + selected: boolean; + Component: Component>; + name: string; + } & ComponentProps<"div">, +) { return ( - +

{props.name}

+
); } diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 8bc8e2f0b2..646c7655a9 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -18,8 +18,6 @@ import * as updater from "@tauri-apps/plugin-updater"; import { cx } from "cva"; import { createEffect, - createMemo, - createSignal, ErrorBoundary, onCleanup, onMount, @@ -27,38 +25,23 @@ import { Suspense, } from "solid-js"; import { reconcile } from "solid-js/store"; -import { Motion, Presence } from "solid-motionone"; -import { Transition } from "solid-transition-group"; import Tooltip from "~/components/Tooltip"; -import { Input } from "~/routes/editor/ui"; import { generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { createCameraMutation, - createCurrentRecordingQuery, createLicenseQuery, listAudioDevices, - listDisplaysWithThumbnails, listScreens, listVideoDevices, listWindows, - listWindowsWithThumbnails, } from "~/utils/queries"; import { type CameraInfo, - type CaptureDisplay, - type CaptureDisplayWithThumbnail, - type CaptureWindow, - type CaptureWindowWithThumbnail, commands, type DeviceOrModelID, type ScreenCaptureTarget, } from "~/utils/tauri"; -import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; -import IconLucideArrowLeft from "~icons/lucide/arrow-left"; -import IconLucideSearch from "~icons/lucide/search"; -import IconMaterialSymbolsScreenshotFrame2Rounded from "~icons/material-symbols/screenshot-frame-2-rounded"; -import IconMdiMonitor from "~icons/mdi/monitor"; import { WindowChromeHeader } from "../Context"; import { RecordingOptionsProvider, @@ -68,8 +51,6 @@ import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; import MicrophoneSelect from "./MicrophoneSelect"; import SystemAudio from "./SystemAudio"; -import TargetDropdownButton from "./TargetDropdownButton"; -import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; function getWindowSize() { @@ -88,179 +69,10 @@ const findCamera = (cameras: CameraInfo[], id: DeviceOrModelID) => { }); }; -type WindowListItem = Pick< - CaptureWindow, - "id" | "owner_name" | "name" | "bounds" | "refresh_rate" ->; - -const createWindowSignature = ( - list?: readonly WindowListItem[], -): string | undefined => { - if (!list) return undefined; - - return list - .map((item) => { - const { position, size } = item.bounds; - return [ - item.id, - item.owner_name, - item.name, - position.x, - position.y, - size.width, - size.height, - item.refresh_rate, - ].join(":"); - }) - .join("|"); -}; - -type DisplayListItem = Pick; - -const createDisplaySignature = ( - list?: readonly DisplayListItem[], -): string | undefined => { - if (!list) return undefined; - - return list - .map((item) => [item.id, item.name, item.refresh_rate].join(":")) - .join("|"); -}; - -type TargetMenuPanelProps = - | { - variant: "display"; - targets?: CaptureDisplayWithThumbnail[]; - onSelect: (target: CaptureDisplayWithThumbnail) => void; - } - | { - variant: "window"; - targets?: CaptureWindowWithThumbnail[]; - onSelect: (target: CaptureWindowWithThumbnail) => void; - }; - -type SharedTargetMenuProps = { - isLoading: boolean; - errorMessage?: string; - disabled: boolean; - onBack: () => void; -}; - -function TargetMenuPanel(props: TargetMenuPanelProps & SharedTargetMenuProps) { - const [search, setSearch] = createSignal(""); - const trimmedSearch = createMemo(() => search().trim()); - const normalizedQuery = createMemo(() => trimmedSearch().toLowerCase()); - const placeholder = - props.variant === "display" ? "Search displays" : "Search windows"; - const noResultsMessage = - props.variant === "display" - ? "No matching displays" - : "No matching windows"; - - const filteredDisplayTargets = createMemo( - () => { - if (props.variant !== "display") return []; - const query = normalizedQuery(); - const targets = props.targets ?? []; - if (!query) return targets; - - const matchesQuery = (value?: string | null) => - !!value && value.toLowerCase().includes(query); - - return targets.filter( - (target) => matchesQuery(target.name) || matchesQuery(target.id), - ); - }, - ); - - const filteredWindowTargets = createMemo(() => { - if (props.variant !== "window") return []; - const query = normalizedQuery(); - const targets = props.targets ?? []; - if (!query) return targets; - - const matchesQuery = (value?: string | null) => - !!value && value.toLowerCase().includes(query); - - return targets.filter( - (target) => - matchesQuery(target.name) || - matchesQuery(target.owner_name) || - matchesQuery(target.id), - ); - }); - - return ( -
-
-
props.onBack()} - class="flex gap-1 items-center rounded-md px-1.5 text-xs - text-gray-11 transition-opacity hover:opacity-70 hover:text-gray-12 - focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-1" - > - - Back -
-
- - setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && search()) { - event.preventDefault(); - setSearch(""); - } - }} - placeholder={placeholder} - autoCapitalize="off" - autocorrect="off" - autocomplete="off" - spellcheck={false} - aria-label={placeholder} - /> -
-
-
-
- {props.variant === "display" ? ( - - ) : ( - - )} -
-
-
- ); -} - export default function () { const generalSettings = generalSettingsStore.createQuery(); + // We do this on focus so the window doesn't get revealed when toggling the setting const navigate = useNavigate(); createEventListener(window, "focus", () => { if (generalSettings.data?.enableNewRecordingFlow === false) navigate("/"); @@ -300,106 +112,22 @@ function createUpdateCheck() { function Page() { const { rawOptions, setOptions } = useRecordingOptions(); - const currentRecording = createCurrentRecordingQuery(); - const isRecording = () => !!currentRecording.data; - - const [displayMenuOpen, setDisplayMenuOpen] = createSignal(false); - const [windowMenuOpen, setWindowMenuOpen] = createSignal(false); - const activeMenu = createMemo<"display" | "window" | null>(() => { - if (displayMenuOpen()) return "display"; - if (windowMenuOpen()) return "window"; - return null; - }); - const [hasOpenedDisplayMenu, setHasOpenedDisplayMenu] = createSignal(false); - const [hasOpenedWindowMenu, setHasOpenedWindowMenu] = createSignal(false); - - let displayTriggerRef: HTMLButtonElement | undefined; - let windowTriggerRef: HTMLButtonElement | undefined; - - const displayTargets = useQuery(() => ({ - ...listDisplaysWithThumbnails, - enabled: hasOpenedDisplayMenu(), - refetchInterval: false, - })); - - const windowTargets = useQuery(() => ({ - ...listWindowsWithThumbnails, - enabled: hasOpenedWindowMenu(), - refetchInterval: false, - })); - - const hasDisplayTargetsData = () => displayTargets.status === "success"; - const hasWindowTargetsData = () => windowTargets.status === "success"; - - const displayTargetsData = createMemo(() => - hasDisplayTargetsData() ? displayTargets.data : undefined, - ); - - const windowTargetsData = createMemo(() => - hasWindowTargetsData() ? windowTargets.data : undefined, - ); - - const displayMenuLoading = () => - !hasDisplayTargetsData() && - (displayTargets.status === "pending" || - displayTargets.fetchStatus === "fetching"); - const windowMenuLoading = () => - !hasWindowTargetsData() && - (windowTargets.status === "pending" || - windowTargets.fetchStatus === "fetching"); - - const displayErrorMessage = () => { - if (!displayTargets.error) return undefined; - return "Unable to load displays. Try using the Display button."; - }; - - const windowErrorMessage = () => { - if (!windowTargets.error) return undefined; - return "Unable to load windows. Try using the Window button."; - }; - - const selectDisplayTarget = (target: CaptureDisplayWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "display", id: target.id }), - ); - setOptions("targetMode", "display"); - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }; - - const selectWindowTarget = async (target: CaptureWindowWithThumbnail) => { - setOptions( - "captureTarget", - reconcile({ variant: "window", id: target.id }), - ); - setOptions("targetMode", "window"); - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - - try { - await commands.focusWindow(target.id); - } catch (error) { - console.error("Failed to focus window:", error); - } - }; - - createEffect(() => { - if (!isRecording()) return; - setDisplayMenuOpen(false); - setWindowMenuOpen(false); - }); createUpdateCheck(); onMount(async () => { - setOptions({ targetMode: (window as any).__CAP__.initialTargetMode }); + // We don't want the target select overlay on launch + setOptions({ targetMode: null }); + // Enforce window size with multiple safeguards const currentWindow = getCurrentWindow(); + // We resize the window on mount as the user could be switching to the new recording flow + // which has a differently sized window. const size = getWindowSize(); currentWindow.setSize(new LogicalSize(size.width, size.height)); + // Check size when app regains focus const unlistenFocus = currentWindow.onFocusChanged( ({ payload: focused }) => { if (focused) { @@ -410,6 +138,7 @@ function Page() { }, ); + // Listen for resize events const unlistenResize = currentWindow.onResized(() => { const size = getWindowSize(); @@ -435,48 +164,6 @@ function Page() { const cameras = useQuery(() => listVideoDevices); const mics = useQuery(() => listAudioDevices); - const windowListSignature = createMemo(() => - createWindowSignature(windows.data), - ); - const displayListSignature = createMemo(() => - createDisplaySignature(screens.data), - ); - const [windowThumbnailsSignature, setWindowThumbnailsSignature] = - createSignal(); - const [displayThumbnailsSignature, setDisplayThumbnailsSignature] = - createSignal(); - - createEffect(() => { - if (windowTargets.status !== "success") return; - const signature = createWindowSignature(windowTargets.data); - if (signature !== undefined) setWindowThumbnailsSignature(signature); - }); - - createEffect(() => { - if (displayTargets.status !== "success") return; - const signature = createDisplaySignature(displayTargets.data); - if (signature !== undefined) setDisplayThumbnailsSignature(signature); - }); - - // Refetch thumbnails only when the cheaper lists detect a change. - createEffect(() => { - if (!hasOpenedWindowMenu()) return; - const signature = windowListSignature(); - if (signature === undefined) return; - if (windowTargets.fetchStatus !== "idle") return; - if (windowThumbnailsSignature() === signature) return; - void windowTargets.refetch(); - }); - - createEffect(() => { - if (!hasOpenedDisplayMenu()) return; - const signature = displayListSignature(); - if (signature === undefined) return; - if (displayTargets.fetchStatus !== "idle") return; - if (displayThumbnailsSignature() === signature) return; - void displayTargets.refetch(); - }); - cameras.promise.then((cameras) => { if (rawOptions.cameraID && findCamera(cameras, rawOptions.cameraID)) { setOptions("cameraLabel", null); @@ -489,6 +176,8 @@ function Page() { } }); + // these options take the raw config values and combine them with the available options, + // allowing us to define fallbacks if the selected options aren't actually available const options = { screen: () => { let screen; @@ -545,6 +234,7 @@ function Page() { }, }; + // if target is window and no windows are available, switch to screen capture createEffect(() => { const target = options.target(); if (!target) return; @@ -580,138 +270,6 @@ function Page() { const signIn = createSignInMutation(); - const BaseControls = () => ( -
- { - if (!c) setCamera.mutate(null); - else if (c.model_id) setCamera.mutate({ ModelID: c.model_id }); - else setCamera.mutate({ DeviceID: c.device_id }); - }} - /> - setMicInput.mutate(v)} - /> - -
- ); - - const TargetSelectionHome = () => ( - -
-
- { - if (isRecording()) return; - setOptions("targetMode", (v) => - v === "display" ? null : "display", - ); - }} - name="Display" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - (displayTriggerRef = el)} - disabled={isRecording()} - expanded={displayMenuOpen()} - onClick={() => { - setDisplayMenuOpen((prev) => { - const next = !prev; - if (next) { - setWindowMenuOpen(false); - setHasOpenedDisplayMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose display" - /> -
-
- { - if (isRecording()) return; - setOptions("targetMode", (v) => - v === "window" ? null : "window", - ); - }} - name="Window" - class="flex-1 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0" - /> - (windowTriggerRef = el)} - disabled={isRecording()} - expanded={windowMenuOpen()} - onClick={() => { - setWindowMenuOpen((prev) => { - const next = !prev; - if (next) { - setDisplayMenuOpen(false); - setHasOpenedWindowMenu(true); - } - return next; - }); - }} - aria-haspopup="menu" - aria-label="Choose window" - /> -
- { - if (isRecording()) return; - setOptions("targetMode", (v) => (v === "area" ? null : "area")); - }} - name="Area" - /> -
- -
- ); - const startSignInCleanup = listen("start-sign-in", async () => { const abort = new AbortController(); for (const win of await getAllWebviewWindows()) { @@ -731,11 +289,7 @@ function Page() { onCleanup(() => startSignInCleanup.then((cb) => cb())); return ( -
+
- - }> - {(variant) => - variant === "display" ? ( - { - setDisplayMenuOpen(false); - displayTriggerRef?.focus(); - }} - /> - ) : ( - { - setWindowMenuOpen(false); - windowTriggerRef?.focus(); - }} - /> +
+ + setOptions("targetMode", (v) => + v === "display" ? null : "display", ) } - - + name="Display" + /> + + setOptions("targetMode", (v) => (v === "window" ? null : "window")) + } + name="Window" + /> + + setOptions("targetMode", (v) => (v === "area" ? null : "area")) + } + name="Area" + /> +
+
+ { + if (!c) setCamera.mutate(null); + else if (c.model_id) setCamera.mutate({ ModelID: c.model_id }); + else setCamera.mutate({ DeviceID: c.device_id }); + }} + /> + setMicInput.mutate(v)} + /> + +
); } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 1dc37e8f8a..d12b1d8b32 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -40,62 +40,64 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }; return ( -
-
-
-

- Experimental Features -

-

- These features are still in development and may not work as - expected. -

-
-
-

Recording Features

-
- - handleChange("custom_cursor_capture2", value) - } - /> - - handleChange("enableNativeCameraPreview", value) - } - /> - { - handleChange("autoZoomOnClicks", value); - // This is bad code, but I just want the UI to not jank and can't seem to find the issue. - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - /> - { - handleChange("enableNewRecordingFlow", value); - // This is bad code, but I just want the UI to not jank and can't seem to find the issue. - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - /> +
+
+
+
+

+ Experimental Features +

+

+ These features are still in development and may not work as + expected. +

+
+
+

Recording Features

+
+ + handleChange("custom_cursor_capture2", value) + } + /> + + handleChange("enableNativeCameraPreview", value) + } + /> + { + handleChange("autoZoomOnClicks", value); + // This is bad code, but I just want the UI to not jank and can't seem to find the issue. + setTimeout( + () => window.scrollTo({ top: 0, behavior: "instant" }), + 5, + ); + }} + /> + { + handleChange("enableNewRecordingFlow", value); + // This is bad code, but I just want the UI to not jank and can't seem to find the issue. + setTimeout( + () => window.scrollTo({ top: 0, behavior: "instant" }), + 5, + ); + }} + /> +
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 8b6b1f1e44..2c42de9178 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -372,138 +372,141 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }; return ( -
-
- { - setSettings("theme", newTheme); - generalSettingsStore.set({ theme: newTheme }); - }} - /> +
+
+
+ { + setSettings("theme", newTheme); + generalSettingsStore.set({ theme: newTheme }); + }} + /> - - {(group) => ( - -
-

- {group.title} -

-
- - {(item) => { - // Check OS compatibility - if ( - item.type === "toggle" && - item.os && - item.os !== ostype - ) { - return null; - } - - if (item.type === "toggle") { - return ( - - ); - } else if (item.type === "select") { + + {(group) => ( + +
+

+ {group.title} +

+
+ + {(item) => { + // Check OS compatibility if ( - item.label === "Main window recording start behaviour" - ) { - return renderRecordingSelect( - item.label, - item.description, - () => item.value, - item.onChange, - [ - { text: "Close", value: "close" }, - { text: "Minimise", value: "minimise" }, - ], - ); - } else if ( - item.label === "Studio recording finish behaviour" + item.type === "toggle" && + item.os && + item.os !== ostype ) { - return renderRecordingSelect( - item.label, - item.description, - () => item.value, - item.onChange, - [ - { text: "Open editor", value: "openEditor" }, - { - text: "Show in overlay", - value: "showOverlay", - }, - ], - ); - } else if (item.label === "Recording countdown") { - return renderRecordingSelect( - item.label, - item.description, - () => item.value, - item.onChange, - [ - { text: "Off", value: 0 }, - { text: "3 seconds", value: 3 }, - { text: "5 seconds", value: 5 }, - { text: "10 seconds", value: 10 }, - ], - ); - } else if ( - item.label === "After deleting recording behaviour" - ) { - return renderRecordingSelect( - item.label, - item.description, - () => item.value, - item.onChange, - [ - { text: "Do Nothing", value: "doNothing" }, - { - text: "Reopen Recording Window", - value: "reopenRecordingWindow", - }, - ], + return null; + } + + if (item.type === "toggle") { + return ( + ); + } else if (item.type === "select") { + if ( + item.label === + "Main window recording start behaviour" + ) { + return renderRecordingSelect( + item.label, + item.description, + () => item.value, + item.onChange, + [ + { text: "Close", value: "close" }, + { text: "Minimise", value: "minimise" }, + ], + ); + } else if ( + item.label === "Studio recording finish behaviour" + ) { + return renderRecordingSelect( + item.label, + item.description, + () => item.value, + item.onChange, + [ + { text: "Open editor", value: "openEditor" }, + { + text: "Show in overlay", + value: "showOverlay", + }, + ], + ); + } else if (item.label === "Recording countdown") { + return renderRecordingSelect( + item.label, + item.description, + () => item.value, + item.onChange, + [ + { text: "Off", value: 0 }, + { text: "3 seconds", value: 3 }, + { text: "5 seconds", value: 5 }, + { text: "10 seconds", value: 10 }, + ], + ); + } else if ( + item.label === "After deleting recording behaviour" + ) { + return renderRecordingSelect( + item.label, + item.description, + () => item.value, + item.onChange, + [ + { text: "Do Nothing", value: "doNothing" }, + { + text: "Reopen Recording Window", + value: "reopenRecordingWindow", + }, + ], + ); + } } - } - return null; - }} - + return null; + }} + +
-
- - )} - + + )} + - { - const url = new URL(v); - const origin = url.origin; + { + const url = new URL(v); + const origin = url.origin; - if ( - !(await confirm( - `Are you sure you want to change the server URL to '${origin}'? You will need to sign in again.`, - )) - ) - return; + if ( + !(await confirm( + `Are you sure you want to change the server URL to '${origin}'? You will need to sign in again.`, + )) + ) + return; - await authStore.set(undefined); - await commands.setServerUrl(origin); - handleChange("serverUrl", origin); - }} - /> + await authStore.set(undefined); + await commands.setServerUrl(origin); + handleChange("serverUrl", origin); + }} + /> +
); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx index 3337282972..19e9e62d91 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx @@ -11,7 +11,7 @@ import { Switch, } from "solid-js"; import { createStore } from "solid-js/store"; -import { generalSettingsStore, hotkeysStore } from "~/store"; +import { hotkeysStore } from "~/store"; import { commands, @@ -20,17 +20,12 @@ import { type HotkeysStore, } from "~/utils/tauri"; -const ACTION_TEXT = { - startStudioRecording: "Start studio recording", - startInstantRecording: "Start instant recording", - restartRecording: "Restart recording", - stopRecording: "Stop recording", +const ACTION_TEXT: Record = { + startRecording: "Start Recording", + stopRecording: "Stop Recording", + restartRecording: "Restart Recording", // takeScreenshot: "Take Screenshot", - openRecordingPicker: "Open recording picker", - openRecordingPickerDisplay: "Record display", - openRecordingPickerWindow: "Record window", - openRecordingPickerArea: "Record area", -} satisfies { [K in HotkeyAction]?: string }; +}; export default function () { const [store] = createResource(() => hotkeysStore.get()); @@ -44,7 +39,6 @@ export default function () { const MODIFIER_KEYS = new Set(["Meta", "Shift", "Control", "Alt"]); function Inner(props: { initialStore: HotkeysStore | null }) { - const generalSettings = generalSettingsStore.createQuery(); const [hotkeys, setHotkeys] = createStore<{ [K in HotkeyAction]?: Hotkey; }>(props.initialStore?.hotkeys ?? {}); @@ -77,32 +71,23 @@ function Inner(props: { initialStore: HotkeysStore | null }) { } }); - const actions = () => - [ - ...(generalSettings.data?.enableNewRecordingFlow - ? (["openRecordingPicker"] as const) - : (["startStudioRecording", "startInstantRecording"] as const)), - "stopRecording", - "restartRecording", - ...(generalSettings.data?.enableNewRecordingFlow - ? ([ - "openRecordingPickerDisplay", - "openRecordingPickerWindow", - "openRecordingPickerArea", - ] as const) - : []), - ] satisfies Array; + const actions = [ + "startRecording", + "stopRecording", + "restartRecording", + // "takeScreenshot", + ] as Array; return ( -
+
-

Shortcuts

-

- Configure system-wide keyboard shortcuts to control Cap +

Hotkeys

+

+ Configure keyboard shortcuts for common actions.

-
- +
+ {(item, idx) => { createEventListener(window, "click", () => { if (listening()?.action !== item()) return; @@ -197,7 +182,7 @@ function Inner(props: { initialStore: HotkeysStore | null }) {
- {idx !== actions().length - 1 && ( + {idx !== actions.length - 1 && (
)} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/license.tsx b/apps/desktop/src/routes/(window-chrome)/settings/license.tsx index b10212cfdb..18c8a7b3bb 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/license.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/license.tsx @@ -23,7 +23,7 @@ export default function Page() { const queryClient = useQueryClient(); return ( -
+
}>
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index e26e056e0a..ef7690c25c 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -1,4 +1,3 @@ -import { ProgressCircle } from "@cap/ui-solid"; import Tooltip from "@corvu/tooltip"; import { createMutation, @@ -6,7 +5,7 @@ import { queryOptions, useQueryClient, } from "@tanstack/solid-query"; -import { Channel, convertFileSrc } from "@tauri-apps/api/core"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { ask } from "@tauri-apps/plugin-dialog"; import { remove } from "@tauri-apps/plugin-fs"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; @@ -20,14 +19,10 @@ import { type ParentProps, Show, } from "solid-js"; + import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; -import { - commands, - events, - type RecordingMetaWithMode, - type UploadProgress, -} from "~/utils/tauri"; +import { commands, events, type RecordingMetaWithMode } from "~/utils/tauri"; type Recording = { meta: RecordingMetaWithMode; @@ -152,12 +147,7 @@ export default function Recordings() {
-
- -

- No {activeTab()} recordings -

-
+
    {(recording) => ( @@ -247,19 +237,13 @@ function RecordingItem(props: { {(_) => { - const [progress, setProgress] = createSignal(0); const reupload = createMutation(() => ({ - mutationFn: async () => { - setProgress(0); - return await commands.uploadExportedVideo( + mutationFn: () => { + return commands.uploadExportedVideo( props.recording.path, "Reupload", - new Channel((progress) => - setProgress(Math.round(progress.progress * 100)), - ), ); }, - onSettled: () => setProgress(0), })); return ( @@ -271,11 +255,7 @@ function RecordingItem(props: { onClick={() => reupload.mutate()} > {reupload.isPending ? ( - + ) : ( )} diff --git a/apps/desktop/src/routes/(window-chrome)/setup.tsx b/apps/desktop/src/routes/(window-chrome)/setup.tsx index 7b13a1a30b..79c519f5b7 100644 --- a/apps/desktop/src/routes/(window-chrome)/setup.tsx +++ b/apps/desktop/src/routes/(window-chrome)/setup.tsx @@ -83,99 +83,101 @@ export default function () { const handleContinue = () => { // Just proceed to the main window without saving mode to store - commands.showWindow({ Main: { init_target_mode: null } }).then(() => { + commands.showWindow("Main").then(() => { getCurrentWindow().close(); }); }; return ( -
    - {showStartup() && ( - { - showStartupActions.mutate(false); - }} - /> - )} - - -
    - -

    - Permissions Required -

    -

    Cap needs permissions to run properly.

    -
    - -
      - - {(permission) => { - const permissionCheck = () => check()?.[permission.key]; - - return ( - -
    • -
      - - {permission.name} Permission - - - {permission.description} - -
      - -
    • -
      - ); + <> +
      + {showStartup() && ( + { + showStartupActions.mutate(false); }} - -
    - - -
    - - -
    - -

    - Select Recording Mode -

    -

    Choose how you want to record with Cap.

    -
    + /> + )} + + +
    + +

    + Permissions Required +

    +

    Cap needs permissions to run properly.

    +
    -
    - -
    +
      + + {(permission) => { + const permissionCheck = () => check()?.[permission.key]; + + return ( + +
    • +
      + + {permission.name} Permission + + + {permission.description} + +
      + +
    • +
      + ); + }} +
      +
    + + +
    + + +
    + +

    + Select Recording Mode +

    +

    Choose how you want to record with Cap.

    +
    - -
    -
    +
    + +
    + + +
    +
+ ); } @@ -503,9 +505,7 @@ function Startup(props: { onClose: () => void }) { size="lg" onClick={async () => { handleStartupCompleted(); - await commands.showWindow({ - Main: { init_target_mode: null }, - }); + await commands.showWindow("Main"); getCurrentWindow().close(); }} > diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index e1962ca6e1..717da9495d 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -1,11 +1,10 @@ -import { NumberField } from "@kobalte/core"; import { - Collapsible, - Collapsible as KCollapsible, + Collapsible, + Collapsible as KCollapsible, } from "@kobalte/core/collapsible"; import { - RadioGroup as KRadioGroup, - RadioGroup, + RadioGroup as KRadioGroup, + RadioGroup, } from "@kobalte/core/radio-group"; import { Select as KSelect } from "@kobalte/core/select"; import { Tabs as KTabs } from "@kobalte/core/tabs"; @@ -18,21 +17,21 @@ import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createResource, - createRoot, - createSignal, - For, - Index, - on, - onMount, - Show, - Suspense, - type ValidComponent, + batch, + createEffect, + createMemo, + createResource, + createRoot, + createSignal, + For, + Index, + on, + onMount, + Show, + Suspense, + type ValidComponent, } from "solid-js"; -import { createStore, produce } from "solid-js/store"; +import { createStore } from "solid-js/store"; import { Dynamic } from "solid-js/web"; import toast from "solid-toast"; import colorBg from "~/assets/illustrations/color.webp"; @@ -42,2454 +41,2315 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { - type BackgroundSource, - type CameraShape, - type ClipOffsets, - commands, - type SceneSegment, - type SplitViewSettings, - type StereoMode, - type TimelineSegment, - type ZoomSegment, + type BackgroundSource, + type CameraShape, + commands, + type SceneSegment, + type SplitViewSettings, + type StereoMode, + type TimelineSegment, + type ZoomSegment, } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, - type RGBColor, + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, } from "./projectConfig"; -import { SceneSegmentConfig as SceneSegmentConfigComponent } from "./SceneSegmentConfig"; +import { SceneSegmentConfig } from "./SceneSegmentConfig"; import ShadowSettings from "./ShadowSettings"; import { TextInput } from "./TextInput"; import { - ComingSoonTooltip, - EditorButton, - Field, - MenuItem, - MenuItemList, - PopperContent, - Slider, - Subfield, - topSlideAnimateClasses, + ComingSoonTooltip, + EditorButton, + Field, + MenuItem, + MenuItemList, + PopperContent, + Slider, + Subfield, + topSlideAnimateClasses, } from "./ui"; const BACKGROUND_SOURCES = { - wallpaper: "Wallpaper", - image: "Image", - color: "Color", - gradient: "Gradient", + wallpaper: "Wallpaper", + image: "Image", + color: "Color", + gradient: "Gradient", } satisfies Record; const BACKGROUND_ICONS = { - wallpaper: imageBg, - image: transparentBg, - color: colorBg, - gradient: gradientBg, + wallpaper: imageBg, + image: transparentBg, + color: colorBg, + gradient: gradientBg, } satisfies Record; const BACKGROUND_SOURCES_LIST = [ - "wallpaper", - "image", - "color", - "gradient", + "wallpaper", + "image", + "color", + "gradient", ] satisfies Array; const BACKGROUND_COLORS = [ - "#FF0000", - "#FF4500", - "#FF8C00", - "#FFD700", - "#FFFF00", - "#ADFF2F", - "#32CD32", - "#008000", - "#00CED1", - "#4785FF", - "#0000FF", - "#4B0082", - "#800080", - "#A9A9A9", - "#FFFFFF", - "#000000", + "#FF0000", // Red + "#FF4500", // Orange-Red + "#FF8C00", // Orange + "#FFD700", // Gold + "#FFFF00", // Yellow + "#ADFF2F", // Green-Yellow + "#32CD32", // Lime Green + "#008000", // Green + "#00CED1", // Dark Turquoise + "#4785FF", // Dodger Blue + "#0000FF", // Blue + "#4B0082", // Indigo + "#800080", // Purple + "#A9A9A9", // Dark Gray + "#FFFFFF", // White + "#000000", // Black ]; const BACKGROUND_GRADIENTS = [ - { from: [15, 52, 67], to: [52, 232, 158] }, - { from: [34, 193, 195], to: [253, 187, 45] }, - { from: [29, 253, 251], to: [195, 29, 253] }, - { from: [69, 104, 220], to: [176, 106, 179] }, - { from: [106, 130, 251], to: [252, 92, 125] }, - { from: [131, 58, 180], to: [253, 29, 29] }, - { from: [249, 212, 35], to: [255, 78, 80] }, - { from: [255, 94, 0], to: [255, 42, 104] }, - { from: [255, 0, 150], to: [0, 204, 255] }, - { from: [0, 242, 96], to: [5, 117, 230] }, - { from: [238, 205, 163], to: [239, 98, 159] }, - { from: [44, 62, 80], to: [52, 152, 219] }, - { from: [168, 239, 255], to: [238, 205, 163] }, - { from: [74, 0, 224], to: [143, 0, 255] }, - { from: [252, 74, 26], to: [247, 183, 51] }, - { from: [0, 255, 255], to: [255, 20, 147] }, - { from: [255, 127, 0], to: [255, 255, 0] }, - { from: [255, 0, 255], to: [0, 255, 0] }, + { from: [15, 52, 67], to: [52, 232, 158] }, // Dark Blue to Teal + { from: [34, 193, 195], to: [253, 187, 45] }, // Turquoise to Golden Yellow + { from: [29, 253, 251], to: [195, 29, 253] }, // Cyan to Purple + { from: [69, 104, 220], to: [176, 106, 179] }, // Blue to Violet + { from: [106, 130, 251], to: [252, 92, 125] }, // Soft Blue to Pinkish Red + { from: [131, 58, 180], to: [253, 29, 29] }, // Purple to Red + { from: [249, 212, 35], to: [255, 78, 80] }, // Yellow to Coral Red + { from: [255, 94, 0], to: [255, 42, 104] }, // Orange to Reddish Pink + { from: [255, 0, 150], to: [0, 204, 255] }, // Pink to Sky Blue + { from: [0, 242, 96], to: [5, 117, 230] }, // Green to Blue + { from: [238, 205, 163], to: [239, 98, 159] }, // Peach to Soft Pink + { from: [44, 62, 80], to: [52, 152, 219] }, // Dark Gray Blue to Light Blue + { from: [168, 239, 255], to: [238, 205, 163] }, // Light Blue to Peach + { from: [74, 0, 224], to: [143, 0, 255] }, // Deep Blue to Bright Purple + { from: [252, 74, 26], to: [247, 183, 51] }, // Deep Orange to Soft Yellow + { from: [0, 255, 255], to: [255, 20, 147] }, // Cyan to Deep Pink + { from: [255, 127, 0], to: [255, 255, 0] }, // Orange to Yellow + { from: [255, 0, 255], to: [0, 255, 0] }, // Magenta to Green ] satisfies Array<{ from: RGBColor; to: RGBColor }>; const WALLPAPER_NAMES = [ - "macOS/sequoia-dark", - "macOS/sequoia-light", - "macOS/sonoma-clouds", - "macOS/sonoma-dark", - "macOS/sonoma-evening", - "macOS/sonoma-fromabove", - "macOS/sonoma-horizon", - "macOS/sonoma-light", - "macOS/sonoma-river", - "macOS/ventura-dark", - "macOS/ventura-semi-dark", - "macOS/ventura", - "blue/1", - "blue/2", - "blue/3", - "blue/4", - "blue/5", - "blue/6", - "purple/1", - "purple/2", - "purple/3", - "purple/4", - "purple/5", - "purple/6", - "dark/1", - "dark/2", - "dark/3", - "dark/4", - "dark/5", - "dark/6", - "orange/1", - "orange/2", - "orange/3", - "orange/4", - "orange/5", - "orange/6", - "orange/7", - "orange/8", - "orange/9", + // macOS wallpapers + "macOS/sequoia-dark", + "macOS/sequoia-light", + "macOS/sonoma-clouds", + "macOS/sonoma-dark", + "macOS/sonoma-evening", + "macOS/sonoma-fromabove", + "macOS/sonoma-horizon", + "macOS/sonoma-light", + "macOS/sonoma-river", + "macOS/ventura-dark", + "macOS/ventura-semi-dark", + "macOS/ventura", + // Blue wallpapers + "blue/1", + "blue/2", + "blue/3", + "blue/4", + "blue/5", + "blue/6", + // Purple wallpapers + "purple/1", + "purple/2", + "purple/3", + "purple/4", + "purple/5", + "purple/6", + // Dark wallpapers + "dark/1", + "dark/2", + "dark/3", + "dark/4", + "dark/5", + "dark/6", + // Orange wallpapers + "orange/1", + "orange/2", + "orange/3", + "orange/4", + "orange/5", + "orange/6", + "orange/7", + "orange/8", + "orange/9", ] as const; const STEREO_MODES = [ - { name: "Stereo", value: "stereo" }, - { name: "Mono L", value: "monoL" }, - { name: "Mono R", value: "monoR" }, + { name: "Stereo", value: "stereo" }, + { name: "Mono L", value: "monoL" }, + { name: "Mono R", value: "monoR" }, ] satisfies Array<{ name: string; value: StereoMode }>; const CAMERA_SHAPES = [ - { - name: "Square", - value: "square", - }, - { - name: "Source", - value: "source", - }, + { + name: "Square", + value: "square", + }, + { + name: "Source", + value: "source", + }, ] satisfies Array<{ name: string; value: CameraShape }>; const BACKGROUND_THEMES = { - macOS: "macOS", - dark: "Dark", - blue: "Blue", - purple: "Purple", - orange: "Orange", + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", }; const TAB_IDS = { - background: "background", - camera: "camera", - transcript: "transcript", - audio: "audio", - cursor: "cursor", - hotkeys: "hotkeys", + background: "background", + camera: "camera", + transcript: "transcript", + audio: "audio", + cursor: "cursor", + hotkeys: "hotkeys", } as const; export function ConfigSidebar() { - const { - project, - setProject, - setEditorState, - projectActions, - editorInstance, - editorState, - meta, - } = useEditorContext(); - - const [state, setState] = createStore({ - selectedTab: "background" as - | "background" - | "camera" - | "transcript" - | "audio" - | "cursor" - | "hotkeys" - | "captions", - }); - - let scrollRef!: HTMLDivElement; - - return ( - - - s.camera === null - ), - }, - { id: TAB_IDS.audio, icon: IconCapAudioOn }, - { - id: TAB_IDS.cursor, - icon: IconCapCursor, - disabled: !( - meta().type === "multiple" && (meta() as any).segments[0].cursor - ), - }, - window.FLAGS.captions && { - id: "captions" as const, - icon: IconCapMessageBubble, - }, - ].filter(Boolean)} - > - {(item) => ( - { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - } - setState("selectedTab", item.id); - scrollRef.scrollTo({ - top: 0, - }); - }} - disabled={item.disabled} - > -
- -
-
- )} -
- - - -
- - - -
- - - - } - > - - setProject("audio", "mute", v)} - /> - - {editorInstance.recordings.segments[0].mic?.channels === 2 && ( - - - options={STEREO_MODES} - optionValue="value" - optionTextValue="name" - value={STEREO_MODES.find( - (v) => v.value === project.audio.micStereoMode - )} - onChange={(v) => { - if (v) setProject("audio", "micStereoMode", v.value); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} - /> - - - - - )} - - {meta().hasMicrophone && ( - } - > - setProject("audio", "micVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - {meta().hasSystemAudio && ( - } - > - setProject("audio", "systemVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - - - } - value={ - { - setProject("cursor", "hide", !v); - }} - /> - } - /> - - }> - setProject("cursor", "size", v[0])} - minValue={20} - maxValue={300} - step={1} - /> - - - } - value={ - { - setProject("cursor", "raw", !value); - }} - /> - } - /> - -
- - setProject("cursor", "tension", v[0])} - minValue={1} - maxValue={500} - step={1} - /> - - - setProject("cursor", "friction", v[0])} - minValue={0} - maxValue={50} - step={0.1} - /> - - - setProject("cursor", "mass", v[0])} - minValue={0.1} - maxValue={10} - step={0.01} - /> - -
-
-
- } - value={ + const { + project, + setProject, + setEditorState, + projectActions, + editorInstance, + editorState, + meta, + } = useEditorContext(); + + const [state, setState] = createStore({ + selectedTab: "background" as + | "background" + | "camera" + | "transcript" + | "audio" + | "cursor" + | "hotkeys" + | "captions", + }); + + let scrollRef!: HTMLDivElement; + + return ( + + + s.camera === null, + ), + }, + { id: TAB_IDS.audio, icon: IconCapAudioOn }, + { + id: TAB_IDS.cursor, + icon: IconCapCursor, + disabled: !( + meta().type === "multiple" && (meta() as any).segments[0].cursor + ), + }, + window.FLAGS.captions && { + id: "captions" as const, + icon: IconCapMessageBubble, + }, + // { id: "hotkeys" as const, icon: IconCapHotkeys }, + ].filter(Boolean)} + > + {(item) => ( + { + setState("selectedTab", item.id); + scrollRef.scrollTo({ + top: 0, + }); + }} + disabled={item.disabled} + > +
+ +
+
+ )} +
+ + {/** Center the indicator with the icon */} + +
+ + +
+ + + + } + > + + setProject("audio", "mute", v)} + /> + + {editorInstance.recordings.segments[0].mic?.channels === 2 && ( + + + options={STEREO_MODES} + optionValue="value" + optionTextValue="name" + value={STEREO_MODES.find( + (v) => v.value === project.audio.micStereoMode, + )} + onChange={(v) => { + if (v) setProject("audio", "micStereoMode", v.value); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + + + )} + + {/* { - setProject("cursor", "useSvg" as any, value); - }} + checked={project.audio.mute} + onChange={(v) => setProject("audio", "mute", v)} /> - } - /> - - - - }> - - - - - - - - - - -
- - {(selection) => ( -
- - { - const zoomSelection = selection(); - if (zoomSelection.type !== "zoom") return; - - const segments = zoomSelection.indices - .map((index) => ({ - index, - segment: project.timeline?.zoomSegments?.[index], - })) - .filter( - (item): item is { index: number; segment: ZoomSegment } => - item.segment !== undefined - ); - - if (segments.length === 0) { - setEditorState("timeline", "selection", null); - return; - } - return { selection: zoomSelection, segments }; - })()} - > - {(value) => ( -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} zoom{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - projectActions.deleteZoomSegments( - value().segments.map((s) => s.index) - ); - }} - leftIcon={} - > - Delete - -
- - - {(item, index) => ( -
- -
- )} -
-
- } - > - - {(item) => ( -
- -
- )} -
-
-
- )} -
- { - const sceneSelection = selection(); - if (sceneSelection.type !== "scene") return; - - const segment = - project.timeline?.sceneSegments?.[sceneSelection.index]; - if (!segment) return; - - return { selection: sceneSelection, segment }; - })()} - > - {(value) => ( - - )} - - { - const clipSegment = selection(); - if (clipSegment.type !== "clip") return; - - const segment = - project.timeline?.segments?.[clipSegment.index]; - if (!segment) return; - - return { selection: clipSegment, segment }; - })()} - > - {(value) => ( - */} + + {/* + + + + */} + + {meta().hasMicrophone && ( + } + > + setProject("audio", "micVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} + {meta().hasSystemAudio && ( + } + > + setProject("audio", "systemVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} + + + } + value={ + { + setProject("cursor", "hide", !v); + }} + /> + } + /> + + }> + setProject("cursor", "size", v[0])} + minValue={20} + maxValue={300} + step={1} + /> + + + } + value={ + { + setProject("cursor", "raw", !value); + }} + /> + } + /> + + {/* if Content has padding or margin the animation doesn't look as good */} +
+ + setProject("cursor", "tension", v[0])} + minValue={1} + maxValue={500} + step={1} + /> + + + setProject("cursor", "friction", v[0])} + minValue={0} + maxValue={50} + step={0.1} + /> + + + setProject("cursor", "mass", v[0])} + minValue={0.1} + maxValue={10} + step={0.01} + /> + +
+
+
+ } + value={ + { + setProject("cursor", "useSvg" as any, value); + }} + /> + } + /> +
+ + {/* + setProject("cursor", "motionBlur", v[0])} + minValue={0} + maxValue={1} + step={0.001} + /> + */} + {/* }> + { + setProject( + "cursor", + "animationStyle", + value as CursorAnimationStyle + ); + }} + class="flex flex-col gap-2" + disabled + > + {( + Object.entries(CURSOR_ANIMATION_STYLES) as [ + CursorAnimationStyle, + string + ][] + ).map(([value, label]) => ( + + + - )} -
- -
- )} -
- - ); + + {label} + + + ))} + +
*/} +
+ + }> + + + + + + + + + + +
+ + {(selection) => ( +
+ + { + const zoomSelection = selection(); + if (zoomSelection.type !== "zoom") return; + + const segments = zoomSelection.indices + .map((index) => ({ + index, + segment: project.timeline?.zoomSegments?.[index], + })) + .filter( + (item): item is { index: number; segment: ZoomSegment } => + item.segment !== undefined, + ); + + if (segments.length === 0) { + setEditorState("timeline", "selection", null); + return; + } + return { selection: zoomSelection, segments }; + })()} + > + {(value) => ( +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {value().segments.length} zoom{" "} + {value().segments.length === 1 + ? "segment" + : "segments"}{" "} + selected + +
+ { + projectActions.deleteZoomSegments( + value().segments.map((s) => s.index), + ); + }} + leftIcon={} + > + Delete + +
+ + + {(item, index) => ( +
+ +
+ )} +
+
+ } + > + + {(item) => ( +
+ +
+ )} +
+
+
+ )} +
+ { + const sceneSelection = selection(); + if (sceneSelection.type !== "scene") return; + + const segment = + project.timeline?.sceneSegments?.[sceneSelection.index]; + if (!segment) return; + + return { selection: sceneSelection, segment }; + })()} + > + {(value) => ( + + )} + + { + const clipSegment = selection(); + if (clipSegment.type !== "clip") return; + + const segment = + project.timeline?.segments?.[clipSegment.index]; + if (!segment) return; + + return { selection: clipSegment, segment }; + })()} + > + {(value) => ( + + )} + + +
+ )} +
+
+ ); } function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject, projectHistory } = useEditorContext(); - - const [backgroundTab, setBackgroundTab] = - createSignal("macOS"); - - const [wallpapers] = createResource(async () => { - const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { - try { - const path = await resolveResource(`assets/backgrounds/${id}.jpg`); - return { id, path }; - } catch (err) { - return { id, path: null }; - } - }); - - const initialPaths = await Promise.all(visibleWallpaperPaths); - - return initialPaths - .filter((p) => p.path !== null) - .map(({ id, path }) => ({ - id, - url: convertFileSrc(path!), - rawPath: path!, - })); - }); - - const ensurePaddingForBackground = () => { - if (project.background.padding === 0) - setProject("background", "padding", 10); - }; - - onMount(async () => { - if ( - project.background.source.type === "wallpaper" || - project.background.source.type === "image" - ) { - const path = project.background.source.path; - - if (path) { - if (project.background.source.type === "wallpaper") { - if ( - WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) - ) { - const loadedWallpapers = wallpapers(); - if (!loadedWallpapers) return; - - const wallpaper = loadedWallpapers.find((w) => w.id === path); - if (!wallpaper?.url) return; - - const radioGroupOnChange = async (photoUrl: string) => { - try { - const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); - if (!wallpaper) return; - - const rawPath = decodeURIComponent( - photoUrl.replace("file://", "") - ); - - debouncedSetProject(rawPath); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }; - - await radioGroupOnChange(wallpaper.url); - } - } else if (project.background.source.type === "image") { - (async () => { - try { - const convertedPath = convertFileSrc(path); - await fetch(convertedPath, { method: "HEAD" }); - } catch (err) { - setProject("background", "source", { - type: "image", - path: null, - }); - } - })(); - } - } - } - }); - - const filteredWallpapers = createMemo(() => { - const currentTab = backgroundTab(); - return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; - }); - - const [scrollX, setScrollX] = createSignal(0); - const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); - - const [backgroundRef, setBackgroundRef] = createSignal(); - - createEventListenerMap( - () => backgroundRef() ?? [], - { - /** Handle background tabs overflowing to show fade */ - scroll: () => { - const el = backgroundRef(); - if (el) { - setScrollX(el.scrollLeft); - const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; - setReachedEndOfScroll(reachedEnd === 0); - } - }, - wheel: (e: WheelEvent) => { - const el = backgroundRef(); - if (el) { - e.preventDefault(); - el.scrollLeft += - Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - } - }, - }, - { passive: false } - ); - - let fileInput!: HTMLInputElement; - - const debouncedSetProject = (wallpaperPath: string) => { - const resumeHistory = projectHistory.pause(); - queueMicrotask(() => { - batch(() => { - setProject("background", "source", { - type: "wallpaper", - path: wallpaperPath, - } as const); - resumeHistory(); - }); - }); - }; - - const backgrounds: { - [K in BackgroundSource["type"]]: Extract; - } = { - wallpaper: { - type: "wallpaper", - path: null, - }, - image: { - type: "image", - path: null, - }, - color: { - type: "color", - value: DEFAULT_GRADIENT_FROM, - }, - gradient: { - type: "gradient", - from: DEFAULT_GRADIENT_FROM, - to: DEFAULT_GRADIENT_TO, - }, - }; - - const generalSettings = generalSettingsStore.createQuery(); - const hapticsEnabled = () => - generalSettings.data?.hapticsEnabled && ostype() === "macos"; - - return ( - - } name="Background Image"> - { - const tab = v as BackgroundSource["type"]; - ensurePaddingForBackground(); - switch (tab) { - case "image": { - setProject("background", "source", { - type: "image", - path: - project.background.source.type === "image" - ? project.background.source.path - : null, - }); - break; - } - case "color": { - setProject("background", "source", { - type: "color", - value: - project.background.source.type === "color" - ? project.background.source.value - : DEFAULT_GRADIENT_FROM, - }); - break; - } - case "gradient": { - setProject("background", "source", { - type: "gradient", - from: - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM, - to: - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO, - angle: - project.background.source.type === "gradient" - ? project.background.source.angle - : 90, - }); - break; - } - case "wallpaper": { - setProject("background", "source", { - type: "wallpaper", - path: - project.background.source.type === "wallpaper" - ? project.background.source.path - : null, - }); - break; - } - } - }} - > - - - {(item) => { - const el = (props?: object) => ( - -
- {(() => { - const getGradientBackground = () => { - const angle = - project.background.source.type === "gradient" - ? project.background.source.angle - : 90; - const fromColor = - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM; - const toColor = - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO; - - return ( -
- ); - }; - - const getColorBackground = () => { - const backgroundColor = - project.background.source.type === "color" - ? project.background.source.value - : hexToRgb(BACKGROUND_COLORS[9]); - - return ( -
- ); - }; - - const getImageBackground = () => { - let imageSrc: string = BACKGROUND_ICONS[item]; - - if ( - item === "image" && - project.background.source.type === "image" && - project.background.source.path - ) { - const convertedPath = convertFileSrc( - project.background.source.path - ); - if (convertedPath) { - imageSrc = convertedPath; - } - } else if ( - item === "wallpaper" && - project.background.source.type === "wallpaper" && - project.background.source.path - ) { - const selectedWallpaper = wallpapers()?.find((w) => - ( - project.background.source as { path?: string } - ).path?.includes(w.id) - ); - if (selectedWallpaper?.url) { - imageSrc = selectedWallpaper.url; - } - } - - return ( - {BACKGROUND_SOURCES[item]} + const { project, setProject, projectHistory } = useEditorContext(); + + // Background tabs + const [backgroundTab, setBackgroundTab] = + createSignal("macOS"); + + const [wallpapers] = createResource(async () => { + // Only load visible wallpapers initially + const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { + try { + const path = await resolveResource(`assets/backgrounds/${id}.jpg`); + return { id, path }; + } catch (err) { + return { id, path: null }; + } + }); + + // Load initial batch + const initialPaths = await Promise.all(visibleWallpaperPaths); + + return initialPaths + .filter((p) => p.path !== null) + .map(({ id, path }) => ({ + id, + url: convertFileSrc(path!), + rawPath: path!, + })); + }); + + // set padding if background is selected + const ensurePaddingForBackground = () => { + if (project.background.padding === 0) + setProject("background", "padding", 10); + }; + + // Validate background source path on mount + onMount(async () => { + if ( + project.background.source.type === "wallpaper" || + project.background.source.type === "image" + ) { + const path = project.background.source.path; + + if (path) { + if (project.background.source.type === "wallpaper") { + // If the path is just the wallpaper ID (e.g. "sequoia-dark"), get the full path + if ( + WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) + ) { + // Wait for wallpapers to load + const loadedWallpapers = wallpapers(); + if (!loadedWallpapers) return; + + // Find the wallpaper with matching ID + const wallpaper = loadedWallpapers.find((w) => w.id === path); + if (!wallpaper?.url) return; + + // Directly trigger the radio group's onChange handler + const radioGroupOnChange = async (photoUrl: string) => { + try { + const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + const rawPath = decodeURIComponent( + photoUrl.replace("file://", ""), + ); + + debouncedSetProject(rawPath); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }; + + await radioGroupOnChange(wallpaper.url); + } + } else if (project.background.source.type === "image") { + (async () => { + try { + const convertedPath = convertFileSrc(path); + await fetch(convertedPath, { method: "HEAD" }); + } catch (err) { + setProject("background", "source", { + type: "image", + path: null, + }); + } + })(); + } + } + } + }); + + const filteredWallpapers = createMemo(() => { + const currentTab = backgroundTab(); + return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; + }); + + const [scrollX, setScrollX] = createSignal(0); + const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); + + const [backgroundRef, setBackgroundRef] = createSignal(); + + createEventListenerMap( + () => backgroundRef() ?? [], + { + /** Handle background tabs overflowing to show fade */ + scroll: () => { + const el = backgroundRef(); + if (el) { + setScrollX(el.scrollLeft); + const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; + setReachedEndOfScroll(reachedEnd === 0); + } + }, + //Mouse wheel and touchpad support + wheel: (e: WheelEvent) => { + const el = backgroundRef(); + if (el) { + e.preventDefault(); + el.scrollLeft += + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + } + }, + }, + { passive: false }, + ); + + let fileInput!: HTMLInputElement; + + // Optimize the debounced set project function + const debouncedSetProject = (wallpaperPath: string) => { + const resumeHistory = projectHistory.pause(); + queueMicrotask(() => { + batch(() => { + setProject("background", "source", { + type: "wallpaper", + path: wallpaperPath, + } as const); + resumeHistory(); + }); + }); + }; + + const backgrounds: { + [K in BackgroundSource["type"]]: Extract; + } = { + wallpaper: { + type: "wallpaper", + path: null, + }, + image: { + type: "image", + path: null, + }, + color: { + type: "color", + value: DEFAULT_GRADIENT_FROM, + }, + gradient: { + type: "gradient", + from: DEFAULT_GRADIENT_FROM, + to: DEFAULT_GRADIENT_TO, + }, + }; + + const generalSettings = generalSettingsStore.createQuery(); + const hapticsEnabled = () => + generalSettings.data?.hapticsEnabled && ostype() === "macos"; + + return ( + + } name="Background Image"> + { + const tab = v as BackgroundSource["type"]; + ensurePaddingForBackground(); + switch (tab) { + case "image": { + setProject("background", "source", { + type: "image", + path: + project.background.source.type === "image" + ? project.background.source.path + : null, + }); + break; + } + case "color": { + setProject("background", "source", { + type: "color", + value: + project.background.source.type === "color" + ? project.background.source.value + : DEFAULT_GRADIENT_FROM, + }); + break; + } + case "gradient": { + setProject("background", "source", { + type: "gradient", + from: + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM, + to: + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO, + angle: + project.background.source.type === "gradient" + ? project.background.source.angle + : 90, + }); + break; + } + case "wallpaper": { + setProject("background", "source", { + type: "wallpaper", + path: + project.background.source.type === "wallpaper" + ? project.background.source.path + : null, + }); + break; + } + } + }} + > + + + {(item) => { + const el = (props?: object) => ( + +
+ {(() => { + const getGradientBackground = () => { + const angle = + project.background.source.type === "gradient" + ? project.background.source.angle + : 90; + const fromColor = + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM; + const toColor = + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO; + + return ( +
+ ); + }; + + const getColorBackground = () => { + const backgroundColor = + project.background.source.type === "color" + ? project.background.source.value + : hexToRgb(BACKGROUND_COLORS[9]); + + return ( +
+ ); + }; + + const getImageBackground = () => { + // Always start with the default icon + let imageSrc: string = BACKGROUND_ICONS[item]; + + // Only override for "image" if a valid path exists + if ( + item === "image" && + project.background.source.type === "image" && + project.background.source.path + ) { + const convertedPath = convertFileSrc( + project.background.source.path, + ); + // Only use converted path if it's valid + if (convertedPath) { + imageSrc = convertedPath; + } + } + // Only override for "wallpaper" if a valid wallpaper is found + else if ( + item === "wallpaper" && + project.background.source.type === "wallpaper" && + project.background.source.path + ) { + const selectedWallpaper = wallpapers()?.find((w) => + ( + project.background.source as { path?: string } + ).path?.includes(w.id), + ); + // Only use wallpaper URL if it exists + if (selectedWallpaper?.url) { + imageSrc = selectedWallpaper.url; + } + } + + return ( + {BACKGROUND_SOURCES[item]} + ); + }; + + switch (item) { + case "gradient": + return getGradientBackground(); + case "color": + return getColorBackground(); + case "image": + case "wallpaper": + return getImageBackground(); + default: + return null; + } + })()} + {BACKGROUND_SOURCES[item]} +
+ + ); + + return el({}); + }} + + + {/** Dashed divider */} +
+ + {/** Background Tabs */} + + 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent)`, + + "mask-image": `linear-gradient(to right, transparent, black ${ + scrollX() > 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent);`, + }} + > + + {([key, value]) => ( + <> + + setBackgroundTab( + key as keyof typeof BACKGROUND_THEMES, + ) + } + value={key} + class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" + > + {value} + + + )} + + + + {/** End of Background Tabs */} + + ( + project.background.source as { path?: string } + ).path?.includes(w.id), + )?.url ?? undefined) + : undefined + } + onChange={(photoUrl) => { + try { + const wallpaper = wallpapers()?.find( + (w) => w.url === photoUrl, + ); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + + debouncedSetProject(wallpaper.rawPath); + + ensurePaddingForBackground(); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }} + class="grid grid-cols-7 gap-2 h-auto" + > + +
+
+ Loading wallpapers... +
+
+ } + > + + {(photo) => ( + + + + Wallpaper option + + + )} + + + +
+ + {(photo) => ( + + + + Wallpaper option + + + )} + +
+
+
+
+
+
+ + fileInput.click()} + class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" + > + + + Click to select or drag and drop image + + + } + > + {(source) => ( +
+ Selected background +
+ +
+
+ )} +
+ { + const file = e.currentTarget.files?.[0]; + if (!file) return; + + /* + this is a Tauri bug in WebKit so we need to validate the file type manually + https://github.com/tauri-apps/tauri/issues/9158 + */ + const validExtensions = [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp", + ]; + const extension = file.name.split(".").pop()?.toLowerCase(); + if (!extension || !validExtensions.includes(extension)) { + toast.error("Invalid image file type"); + return; + } + + try { + const fileName = `bg-${Date.now()}-${file.name}`; + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const fullPath = `${await appDataDir()}/${fileName}`; + + await writeFile(fileName, uint8Array, { + baseDir: BaseDirectory.AppData, + }); + + setProject("background", "source", { + type: "image", + path: fullPath, + }); + } catch (err) { + toast.error("Failed to save image"); + } + }} + /> +
+ + +
+
+ { + setProject("background", "source", { + type: "color", + value, + }); + }} + /> +
+ +
+ + {(color) => ( +
- - ); - - return el({}); - }} - - -
- - - 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent)`, - - "mask-image": `linear-gradient(to right, transparent, black ${ - scrollX() > 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent);`, - }} - > - - {([key, value]) => ( - <> - - setBackgroundTab( - key as keyof typeof BACKGROUND_THEMES - ) - } - value={key} - class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" + }} > - {value} - - - )} - - - - - ( - project.background.source as { path?: string } - ).path?.includes(w.id) - )?.url ?? undefined - : undefined - } - onChange={(photoUrl) => { - try { - const wallpaper = wallpapers()?.find( - (w) => w.url === photoUrl - ); - if (!wallpaper) return; - - debouncedSetProject(wallpaper.rawPath); - - ensurePaddingForBackground(); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }} - class="grid grid-cols-7 gap-2 h-auto" - > - -
-
- Loading wallpapers... -
-
- } - > - - {(photo) => ( - - - - Wallpaper option - - - )} - - - -
- - {(photo) => ( - - - - Wallpaper option - - - )} - -
-
-
-
-
-
- - fileInput.click()} - class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" - > - - - Click to select or drag and drop image - - - } - > - {(source) => ( -
- Selected background -
- -
-
- )} -
- { - const file = e.currentTarget.files?.[0]; - if (!file) return; - - const validExtensions = [ - "jpg", - "jpeg", - "png", - "gif", - "webp", - "bmp", - ]; - const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension || !validExtensions.includes(extension)) { - toast.error("Invalid image file type"); - return; - } - - try { - const fileName = `bg-${Date.now()}-${file.name}`; - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - const fullPath = `${await appDataDir()}/${fileName}`; - - await writeFile(fileName, uint8Array, { - baseDir: BaseDirectory.AppData, - }); - - setProject("background", "source", { - type: "image", - path: fullPath, - }); - } catch (err) { - toast.error("Failed to save image"); - } - }} - /> -
- - -
-
- { - setProject("background", "source", { - type: "color", - value, - }); - }} - /> -
- -
- - {(color) => ( -
- - - - - {(source) => { - const max = 360; - - const { projectHistory } = useEditorContext(); - - const angle = () => source().angle ?? 90; - - return ( - <> -
-
- { - backgrounds.gradient.from = from; - setProject("background", "source", { - type: "gradient", - from, - }); - }} - /> - { - backgrounds.gradient.to = to; - setProject("background", "source", { - type: "gradient", - to, - }); - }} - /> -
{ - const start = angle(); - const resumeHistory = projectHistory.pause(); - - createRoot((dispose) => - createEventListenerMap(window, { - mouseup: () => dispose(), - mousemove: (moveEvent) => { - const rawNewAngle = - Math.round( - start + - (downEvent.clientY - moveEvent.clientY) - ) % max; - const newAngle = moveEvent.shiftKey - ? rawNewAngle - : Math.round(rawNewAngle / 45) * 45; - - if ( - !moveEvent.shiftKey && - hapticsEnabled() && - project.background.source.type === - "gradient" && - project.background.source.angle !== newAngle - ) { - commands.performHapticFeedback( - "Alignment", - "Now" - ); - } - - setProject("background", "source", { - type: "gradient", - angle: - newAngle < 0 ? newAngle + max : newAngle, - }); - }, - }) - ); - }} - > -
-
-
-
- - {(gradient) => ( -
- - ); - }} - - - - - - }> - setProject("background", "blur", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - -
- }> - setProject("background", "padding", v[0])} - minValue={0} - maxValue={40} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("background", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - } - value={ - { - const prev = project.background.border ?? { - enabled: false, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }; - - setProject("background", "border", { - ...prev, - enabled, - }); - }} - /> - } - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - width: v[0], - }) - } - minValue={1} - maxValue={20} - step={0.1} - formatTooltip="px" - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - color, - }) - } - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - opacity: v[0], - }) - } - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - - }> - { - batch(() => { - setProject("background", "shadow", v[0]); - if (v[0] > 0 && !project.background.advancedShadow) { - setProject("background", "advancedShadow", { - size: 50, - opacity: 18, - blur: 50, - }); - } - }); - }} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.background.advancedShadow?.opacity ?? 18], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.background.advancedShadow?.blur ?? 50], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} - /> - - - ); + + + + */} +
+ + + + + {(source) => { + const max = 360; + + const { projectHistory } = useEditorContext(); + + const angle = () => source().angle ?? 90; + + return ( + <> +
+
+ { + backgrounds.gradient.from = from; + setProject("background", "source", { + type: "gradient", + from, + }); + }} + /> + { + backgrounds.gradient.to = to; + setProject("background", "source", { + type: "gradient", + to, + }); + }} + /> +
{ + const start = angle(); + const resumeHistory = projectHistory.pause(); + + createRoot((dispose) => + createEventListenerMap(window, { + mouseup: () => dispose(), + mousemove: (moveEvent) => { + const rawNewAngle = + Math.round( + start + + (downEvent.clientY - moveEvent.clientY), + ) % max; + const newAngle = moveEvent.shiftKey + ? rawNewAngle + : Math.round(rawNewAngle / 45) * 45; + + if ( + !moveEvent.shiftKey && + hapticsEnabled() && + project.background.source.type === + "gradient" && + project.background.source.angle !== newAngle + ) { + commands.performHapticFeedback( + "Alignment", + "Now", + ); + } + + setProject("background", "source", { + type: "gradient", + angle: + newAngle < 0 ? newAngle + max : newAngle, + }); + }, + }), + ); + }} + > +
+
+
+
+ + {(gradient) => ( +
+ + ); + }} + + + + + + }> + setProject("background", "blur", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + {/** Dashed divider */} +
+ }> + setProject("background", "padding", v[0])} + minValue={0} + maxValue={40} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + }> + { + batch(() => { + setProject("background", "shadow", v[0]); + // Initialize advanced shadow settings if they don't exist and shadow is enabled + if (v[0] > 0 && !project.background.advancedShadow) { + setProject("background", "advancedShadow", { + size: 50, + opacity: 18, + blur: 50, + }); + } + }); + }} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.background.advancedShadow?.opacity ?? 18], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.background.advancedShadow?.blur ?? 50], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> + + {/* + }> + setProject("background", "inset", v[0])} + minValue={0} + maxValue={100} + /> + + */} + + ); } function CameraConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject } = useEditorContext(); - - return ( - - } name="Camera"> -
-
- - { - const [x, y] = v.split(":"); - setProject("camera", "position", { x, y } as any); - }} - class="mt-[0.75rem] rounded-[0.5rem] border border-gray-3 bg-gray-2 w-full h-[7.5rem] relative" - > - - {(item) => ( - - - setProject("camera", "position", item)} - > -
- - - )} - - -
- - setProject("camera", "hide", hide)} - /> - - + const { project, setProject } = useEditorContext(); + + return ( + + } name="Camera"> +
+
+ + { + const [x, y] = v.split(":"); + setProject("camera", "position", { x, y } as any); + }} + class="mt-[0.75rem] rounded-[0.5rem] border border-gray-3 bg-gray-2 w-full h-[7.5rem] relative" + > + + {(item) => ( + + + setProject("camera", "position", item)} + > +
+ + + )} + + +
+ + setProject("camera", "hide", hide)} + /> + + + setProject("camera", "mirror", mirror)} + /> + + + + options={CAMERA_SHAPES} + optionValue="value" + optionTextValue="name" + value={CAMERA_SHAPES.find( + (v) => v.value === project.camera.shape, + )} + onChange={(v) => { + if (v) setProject("camera", "shape", v.value); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + + + + {/* setProject("camera", "mirror", mirror)} + checked={project.camera.use_camera_aspect} + onChange={(v) => setProject("camera", "use_camera_aspect", v)} /> - - - - options={CAMERA_SHAPES} - optionValue="value" - optionTextValue="name" - value={CAMERA_SHAPES.find( - (v) => v.value === project.camera.shape - )} - onChange={(v) => { - if (v) setProject("camera", "shape", v.value); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} - /> - - - - -
- -
- }> - setProject("camera", "size", v[0])} - minValue={20} - maxValue={80} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("camera", "zoom_size", v[0])} - minValue={10} - maxValue={60} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("camera", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - }> -
- setProject("camera", "shadow", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.camera.advanced_shadow?.opacity ?? 18], - onChange: (v) => { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.camera.advanced_shadow?.blur ?? 50], - onChange: (v) => { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} - /> -
-
- - ); + */} +
+ + {/** Dashed divider */} +
+ }> + setProject("camera", "size", v[0])} + minValue={20} + maxValue={80} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("camera", "zoom_size", v[0])} + minValue={10} + maxValue={60} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("camera", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + }> +
+ setProject("camera", "shadow", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.camera.advanced_shadow?.opacity ?? 18], + onChange: (v) => { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.camera.advanced_shadow?.blur ?? 50], + onChange: (v) => { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> +
+
+ {/* + }> + setProject("camera", "shadow", v[0])} + minValue={0} + maxValue={100} + /> + + */} + + ); } function ZoomSegmentPreview(props: { - segmentIndex: number; - segment: ZoomSegment; + segmentIndex: number; + segment: ZoomSegment; }) { - const { project, editorInstance } = useEditorContext(); - - const start = createMemo(() => props.segment.start); - - const segmentIndex = createMemo(() => { - const st = start(); - const i = project.timeline?.segments.findIndex( - (s) => s.start <= st && s.end > st - ); - if (i === undefined || i === -1) return 0; - return i; - }); - - const relativeTime = createMemo(() => { - const st = start(); - const segment = project.timeline?.segments[segmentIndex()]; - if (!segment) return 0; - return Math.max(0, st - segment.start); - }); - - const video = document.createElement("video"); - createEffect(() => { - const path = convertFileSrc( - `${ - editorInstance.path - }/content/segments/segment-${segmentIndex()}/display.mp4` - ); - video.src = path; - video.preload = "auto"; - video.load(); - }); - - createEffect(() => { - const t = relativeTime(); - if (t === undefined) return; - - if (video.readyState >= 2) { - video.currentTime = t; - } else { - const handleCanPlay = () => { - video.currentTime = t; - video.removeEventListener("canplay", handleCanPlay); - }; - video.addEventListener("canplay", handleCanPlay); - } - }); - - const render = () => { - if (!canvasRef || video.readyState < 2) return; - - const ctx = canvasRef.getContext("2d"); - if (!ctx) return; - - ctx.imageSmoothingEnabled = false; - ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); - - const raw = editorInstance.recordings.segments[0].display; - const croppedPosition = project.background.crop?.position || { x: 0, y: 0 }; - const croppedSize = project.background.crop?.size || { - x: raw.width, - y: raw.height, - }; - - ctx.drawImage( - video, - croppedPosition.x, - croppedPosition.y, - croppedSize.x, - croppedSize.y, - 0, - 0, - canvasRef.width, - canvasRef.height - ); - }; - - const [loaded, setLoaded] = createSignal(false); - video.onloadeddata = () => { - setLoaded(true); - render(); - }; - video.onseeked = render; - video.onerror = () => { - setTimeout(() => video.load(), 100); - }; - - let canvasRef!: HTMLCanvasElement; - - return ( - <> -
-
- Zoom {props.segmentIndex + 1} -
-
- - -

- Loading... -

-
-
-
-
- -

{props.segment.amount.toFixed(1)}x

-
- - ); + const { project, editorInstance } = useEditorContext(); + + const start = createMemo(() => props.segment.start); + + const segmentIndex = createMemo(() => { + const st = start(); + const i = project.timeline?.segments.findIndex( + (s) => s.start <= st && s.end > st, + ); + if (i === undefined || i === -1) return 0; + return i; + }); + + const relativeTime = createMemo(() => { + const st = start(); + const segment = project.timeline?.segments[segmentIndex()]; + if (!segment) return 0; + return Math.max(0, st - segment.start); + }); + + const video = document.createElement("video"); + createEffect(() => { + const path = convertFileSrc( + `${ + editorInstance.path + }/content/segments/segment-${segmentIndex()}/display.mp4`, + ); + video.src = path; + video.preload = "auto"; + video.load(); + }); + + createEffect(() => { + const t = relativeTime(); + if (t === undefined) return; + + if (video.readyState >= 2) { + video.currentTime = t; + } else { + const handleCanPlay = () => { + video.currentTime = t; + video.removeEventListener("canplay", handleCanPlay); + }; + video.addEventListener("canplay", handleCanPlay); + } + }); + + const render = () => { + if (!canvasRef || video.readyState < 2) return; + + const ctx = canvasRef.getContext("2d"); + if (!ctx) return; + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); + + const raw = editorInstance.recordings.segments[0].display; + const croppedPosition = project.background.crop?.position || { x: 0, y: 0 }; + const croppedSize = project.background.crop?.size || { + x: raw.width, + y: raw.height, + }; + + ctx.drawImage( + video, + croppedPosition.x, + croppedPosition.y, + croppedSize.x, + croppedSize.y, + 0, + 0, + canvasRef.width, + canvasRef.height, + ); + }; + + const [loaded, setLoaded] = createSignal(false); + video.onloadeddata = () => { + setLoaded(true); + render(); + }; + video.onseeked = render; + video.onerror = () => { + setTimeout(() => video.load(), 100); + }; + + let canvasRef!: HTMLCanvasElement; + + return ( + <> +
+
+ Zoom {props.segmentIndex + 1} +
+
+ + +

+ Loading... +

+
+
+
+
+ +

{props.segment.amount.toFixed(1)}x

+
+ + ); } function ZoomSegmentConfig(props: { - segmentIndex: number; - segment: ZoomSegment; + segmentIndex: number; + segment: ZoomSegment; }) { - const generalSettings = generalSettingsStore.createQuery(); - const { - project, - setProject, - editorInstance, - setEditorState, - projectHistory, - } = useEditorContext(); - - const states = { - manual: - props.segment.mode === "auto" - ? { x: 0.5, y: 0.5 } - : props.segment.mode.manual, - }; - - return ( - <> - } - > - - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "amount", - v[0] - ) - } - minValue={1} - maxValue={4.5} - step={0.001} - formatTooltip="x" - /> - - }> - { - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "mode", - v === "auto" ? "auto" : { manual: states.manual } - ); - }} - > - - - Auto - - - Manual - - -
- - - - { - const m = props.segment.mode; - if (m === "auto") return; - - return m.manual; - })()} - > - {(mode) => { - const start = createMemo((prev) => { - if (projectHistory.isPaused()) return prev; - - return props.segment.start; - }, 0); - - const segmentIndex = createMemo((prev) => { - if (projectHistory.isPaused()) return prev; - - const st = start(); - const i = project.timeline?.segments.findIndex( - (s) => s.start <= st && s.end > st - ); - if (i === undefined || i === -1) return 0; - return i; - }, 0); - - const relativeTime = createMemo(() => { - const st = start(); - const segment = project.timeline?.segments[segmentIndex()]; - if (!segment) return 0; - return Math.max(0, st - segment.start); - }); - - const video = document.createElement("video"); - createEffect(() => { - const path = convertFileSrc( - `${ - editorInstance.path - }/content/segments/segment-${segmentIndex()}/display.mp4` - ); - video.src = path; - video.preload = "auto"; - video.load(); - }); - - createEffect(() => { - const t = relativeTime(); - if (t === undefined) return; - - if (video.readyState >= 2) { - video.currentTime = t; - } else { - const handleCanPlay = () => { - video.currentTime = t; - video.removeEventListener("canplay", handleCanPlay); - }; - video.addEventListener("canplay", handleCanPlay); - } - }); - - createEffect( - on( - () => { - croppedPosition(); - croppedSize(); - }, - () => { - if (loaded()) { - render(); - } - } - ) - ); - - const render = () => { - if (!canvasRef || video.readyState < 2) return; - - const ctx = canvasRef.getContext("2d"); - if (!ctx) return; - - ctx.imageSmoothingEnabled = false; - ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); - ctx.drawImage( - video, - croppedPosition().x, - croppedPosition().y, - croppedSize().x, - croppedSize().y, - 0, - 0, - canvasRef.width!, - canvasRef.height! - ); - }; - - const [loaded, setLoaded] = createSignal(false); - video.onloadeddata = () => { - setLoaded(true); - render(); - }; - video.onseeked = render; - - video.onerror = (e) => { - console.error("Failed to load video for zoom preview:", e); - setTimeout(() => { - video.load(); - }, 100); - }; - - let canvasRef!: HTMLCanvasElement; - - const [ref, setRef] = createSignal(); - const bounds = createElementBounds(ref); - const rawSize = () => { - const raw = editorInstance.recordings.segments[0].display; - return { x: raw.width, y: raw.height }; - }; - - const croppedPosition = () => { - const cropped = project.background.crop?.position; - if (cropped) return cropped; - - return { x: 0, y: 0 }; - }; - - const croppedSize = () => { - const cropped = project.background.crop?.size; - if (cropped) return cropped; - - return rawSize(); - }; - - const visualHeight = () => - (bounds.width! / croppedSize().x) * croppedSize().y; - - return ( -
{ - const bounds = - downEvent.currentTarget.getBoundingClientRect(); - - createRoot((dispose) => - createEventListenerMap(window, { - mouseup: () => dispose(), - mousemove: (moveEvent) => { - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "mode", - "manual", - { - x: Math.max( - Math.min( - (moveEvent.clientX - bounds.left) / - bounds.width, - 1 - ), - 0 - ), - y: Math.max( - Math.min( - (moveEvent.clientY - bounds.top) / - bounds.height, - 1 - ), - 0 - ), - } - ); - }, - }) - ); - }} - > -
-
-
-
- - -
-
- Loading preview... -
-
-
-
-
- ); - }} - - - - - - ); + const generalSettings = generalSettingsStore.createQuery(); + const { + project, + setProject, + editorInstance, + setEditorState, + projectHistory, + } = useEditorContext(); + + const states = { + manual: + props.segment.mode === "auto" + ? { x: 0.5, y: 0.5 } + : props.segment.mode.manual, + }; + + return ( + <> + } + > + + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "amount", + v[0], + ) + } + minValue={1} + maxValue={4.5} + step={0.001} + formatTooltip="x" + /> + + }> + { + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "mode", + v === "auto" ? "auto" : { manual: states.manual }, + ); + }} + > + + + Auto + + + Manual + + +
+ + + + { + const m = props.segment.mode; + if (m === "auto") return; + + return m.manual; + })()} + > + {(mode) => { + const start = createMemo((prev) => { + if (projectHistory.isPaused()) return prev; + + return props.segment.start; + }, 0); + + const segmentIndex = createMemo((prev) => { + if (projectHistory.isPaused()) return prev; + + const st = start(); + const i = project.timeline?.segments.findIndex( + (s) => s.start <= st && s.end > st, + ); + if (i === undefined || i === -1) return 0; + return i; + }, 0); + + // Calculate the time relative to the video segment + const relativeTime = createMemo(() => { + const st = start(); + const segment = project.timeline?.segments[segmentIndex()]; + if (!segment) return 0; + // The time within the actual video file + return Math.max(0, st - segment.start); + }); + + const video = document.createElement("video"); + createEffect(() => { + const path = convertFileSrc( + // TODO: this shouldn't be so hardcoded + `${ + editorInstance.path + }/content/segments/segment-${segmentIndex()}/display.mp4`, + ); + video.src = path; + video.preload = "auto"; + // Force reload if video fails to load + video.load(); + }); + + createEffect(() => { + const t = relativeTime(); + if (t === undefined) return; + + // Ensure video is ready before seeking + if (video.readyState >= 2) { + video.currentTime = t; + } else { + // Wait for video to be ready, then seek + const handleCanPlay = () => { + video.currentTime = t; + video.removeEventListener("canplay", handleCanPlay); + }; + video.addEventListener("canplay", handleCanPlay); + } + }); + + createEffect( + on( + () => { + croppedPosition(); + croppedSize(); + }, + () => { + if (loaded()) { + render(); + } + }, + ), + ); + + const render = () => { + if (!canvasRef || video.readyState < 2) return; + + const ctx = canvasRef.getContext("2d"); + if (!ctx) return; + + ctx.imageSmoothingEnabled = false; + // Clear canvas first + ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); + // Draw video frame + ctx.drawImage( + video, + croppedPosition().x, + croppedPosition().y, + croppedSize().x, + croppedSize().y, + 0, + 0, + canvasRef.width!, + canvasRef.height!, + ); + }; + + const [loaded, setLoaded] = createSignal(false); + video.onloadeddata = () => { + setLoaded(true); + render(); + }; + video.onseeked = render; + + // Add error handling + video.onerror = (e) => { + console.error("Failed to load video for zoom preview:", e); + // Try to reload after a short delay + setTimeout(() => { + video.load(); + }, 100); + }; + + let canvasRef!: HTMLCanvasElement; + + const [ref, setRef] = createSignal(); + const bounds = createElementBounds(ref); + const rawSize = () => { + const raw = editorInstance.recordings.segments[0].display; + return { x: raw.width, y: raw.height }; + }; + + const croppedPosition = () => { + const cropped = project.background.crop?.position; + if (cropped) return cropped; + + return { x: 0, y: 0 }; + }; + + const croppedSize = () => { + const cropped = project.background.crop?.size; + if (cropped) return cropped; + + return rawSize(); + }; + + const visualHeight = () => + (bounds.width! / croppedSize().x) * croppedSize().y; + + return ( +
{ + const bounds = + downEvent.currentTarget.getBoundingClientRect(); + + createRoot((dispose) => + createEventListenerMap(window, { + mouseup: () => dispose(), + mousemove: (moveEvent) => { + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "mode", + "manual", + { + x: Math.max( + Math.min( + (moveEvent.clientX - bounds.left) / + bounds.width, + 1, + ), + 0, + ), + y: Math.max( + Math.min( + (moveEvent.clientY - bounds.top) / + bounds.height, + 1, + ), + 0, + ), + }, + ); + }, + }), + ); + }} + > +
+
+
+
+ + +
+
+ Loading preview... +
+
+
+
+
+ ); + }} + + + + + + ); } function ClipSegmentConfig(props: { - segmentIndex: number; - segment: TimelineSegment; -}) { - const { setProject, setEditorState, project, projectActions, meta } = - useEditorContext(); - - const clipConfig = () => - project.clips?.find((c) => c.index === props.segmentIndex); - const offsets = () => clipConfig()?.offsets || {}; - - function setOffset(type: keyof ClipOffsets, offset: number) { - if (Number.isNaN(offset)) return; - - setProject( - produce((proj) => { - const clips = proj.clips ?? []; - let clip = clips.find( - (clip) => clip.index === (props.segment.recordingSegment ?? 0) - ); - if (!clip) { - clip = { index: 0, offsets: {} }; - clips.push(clip); - } - - clip.offsets[type] = offset / 1000; - }) - ); - } - - return ( - <> -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.deleteClipSegment(props.segmentIndex); - }} - disabled={ - ( - project.timeline?.segments.filter( - (s) => s.recordingSegment === props.segment.recordingSegment - ) ?? [] - ).length < 2 - } - leftIcon={} - > - Delete - -
- -
-

Clip Settings

-

- These settings apply to all segments for the current clip -

-
- - {meta().hasSystemAudio && ( - { - setOffset("system_audio", offset); - }} - /> - )} - {meta().hasMicrophone && ( - { - setOffset("mic", offset); - }} - /> - )} - {meta().hasCamera && ( - { - setOffset("camera", offset); - }} - /> - )} - - ); -} - -function SourceOffsetField(props: { - name: string; - value?: number; - onChange: (value: number) => void; + segmentIndex: number; + segment: TimelineSegment; }) { - const rawValue = () => Math.round((props.value ?? 0) * 1000); - - const [value, setValue] = createSignal(rawValue().toString()); - - return ( - -
-
- { - props.onChange(v); - }} - > - { - if (!rawValue() || value() === "" || Number.isNaN(rawValue())) { - setValue("0"); - props.onChange(0); - } - }} - class="w-[5rem] p-[0.375rem] border rounded-[0.5rem] bg-gray-1 focus-visible:outline-none" - /> - - ms -
-
- {[-100, -10, 10, 100].map((v) => ( - - ))} -
-
-
- ); + const { setProject, setEditorState, project, projectActions } = + useEditorContext(); + + return ( + <> +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.deleteClipSegment(props.segmentIndex); + }} + disabled={ + ( + project.timeline?.segments.filter( + (s) => s.recordingSegment === props.segment.recordingSegment, + ) ?? [] + ).length < 2 + } + leftIcon={} + > + Delete + +
+ + } /> + + + } + /> + + + ); } -function SceneSegmentConfig(props: { - segmentIndex: number; - segment: SceneSegment; -}) { - const { setProject, setEditorState, projectActions } = useEditorContext(); - - return ( - <> -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.deleteSceneSegment(props.segmentIndex); - }} - leftIcon={} - > - Delete - -
- }> - { - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "mode", - v as "default" | "cameraOnly" | "hideCamera" - ); - }} - > - -
- - Default - - - Camera Only - - - Hide Camera - - -
- -
- -
-
-
-
-
- {props.segment.mode === "cameraOnly" - ? "Shows only the camera feed" - : props.segment.mode === "hideCamera" - ? "Shows only the screen recording" - : "Shows both screen and camera"} -
-
-
- - - - - ); -} function RgbInput(props: { - value: [number, number, number]; - onChange: (value: [number, number, number]) => void; + value: [number, number, number]; + onChange: (value: [number, number, number]) => void; }) { - const [text, setText] = createWritableMemo(() => rgbToHex(props.value)); - let prevHex = rgbToHex(props.value); - - let colorInput!: HTMLInputElement; - - return ( -
-
- ); + const [text, setText] = createWritableMemo(() => rgbToHex(props.value)); + let prevHex = rgbToHex(props.value); + + let colorInput!: HTMLInputElement; + + return ( +
+
+ ); } function rgbToHex(rgb: [number, number, number]) { - return `#${rgb - .map((c) => c.toString(16).padStart(2, "0")) - .join("") - .toUpperCase()}`; + return `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`; } function hexToRgb(hex: string): [number, number, number] | null { - const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); - if (!match) return null; - return match.slice(1).map((c) => Number.parseInt(c, 16)) as any; + const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (!match) return null; + return match.slice(1).map((c) => Number.parseInt(c, 16)) as any; } + +// Position control moved to SceneSegmentConfig.tsx for better organization diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx index 6bdf7466d9..1895220872 100644 --- a/apps/desktop/src/routes/editor/Editor.tsx +++ b/apps/desktop/src/routes/editor/Editor.tsx @@ -114,11 +114,11 @@ function Inner() { <>
-
-
+
+
diff --git a/apps/desktop/src/routes/editor/ExportDialog.tsx b/apps/desktop/src/routes/editor/ExportDialog.tsx index 40d6a75c62..22faab6122 100644 --- a/apps/desktop/src/routes/editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/editor/ExportDialog.tsx @@ -6,7 +6,6 @@ import { createQuery, keepPreviousData, } from "@tanstack/solid-query"; -import { Channel } from "@tauri-apps/api/core"; import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { @@ -34,7 +33,6 @@ import { type ExportSettings, events, type FramesRendered, - type UploadProgress, } from "~/utils/tauri"; import { type RenderState, useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; @@ -47,8 +45,6 @@ import { topSlideAnimateClasses, } from "./ui"; -class SilentError extends Error {} - export const COMPRESSION_OPTIONS: Array<{ label: string; value: ExportCompression; @@ -321,50 +317,46 @@ export function ExportDialog() { if (!canShare.allowed) { if (canShare.reason === "upgrade_required") { await commands.showWindow("Upgrade"); - // The window takes a little to show and this prevents the user seeing it glitch - await new Promise((resolve) => setTimeout(resolve, 1000)); - throw new SilentError(); + throw new Error( + "Upgrade required to share recordings longer than 5 minutes", + ); } } - const uploadChannel = new Channel((progress) => { - console.log("Upload progress:", progress); + const unlisten = await events.uploadProgress.listen((event) => { + console.log("Upload progress event:", event.payload); setExportState( produce((state) => { if (state.type !== "uploading") return; - state.progress = Math.round(progress.progress * 100); + state.progress = Math.round(event.payload.progress * 100); }), ); }); - await exportWithSettings((progress) => - setExportState({ type: "rendering", progress }), - ); + try { + await exportWithSettings((progress) => + setExportState({ type: "rendering", progress }), + ); - setExportState({ type: "uploading", progress: 0 }); - - // Now proceed with upload - const result = meta().sharing - ? await commands.uploadExportedVideo( - projectPath, - "Reupload", - uploadChannel, - ) - : await commands.uploadExportedVideo( - projectPath, - { - Initial: { pre_created_video: null }, - }, - uploadChannel, - ); + setExportState({ type: "uploading", progress: 0 }); - if (result === "NotAuthenticated") - throw new Error("You need to sign in to share recordings"); - else if (result === "PlanCheckFailed") - throw new Error("Failed to verify your subscription status"); - else if (result === "UpgradeRequired") - throw new Error("This feature requires an upgraded plan"); + // Now proceed with upload + const result = meta().sharing + ? await commands.uploadExportedVideo(projectPath, "Reupload") + : await commands.uploadExportedVideo(projectPath, { + Initial: { pre_created_video: null }, + }); + + if (result === "NotAuthenticated") + throw new Error("You need to sign in to share recordings"); + else if (result === "PlanCheckFailed") + throw new Error("Failed to verify your subscription status"); + else if (result === "UpgradeRequired") + throw new Error("This feature requires an upgraded plan"); + } finally { + unlisten(); + } }, onSuccess: async () => { const d = dialog(); @@ -378,11 +370,9 @@ export function ExportDialog() { }, onError: (error) => { console.error(error); - if (!(error instanceof SilentError)) { - commands.globalMessageDialog( - error instanceof Error ? error.message : "Failed to upload recording", - ); - } + commands.globalMessageDialog( + error instanceof Error ? error.message : "Failed to upload recording", + ); setExportState(reconcile({ type: "idle" })); }, diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index dae7df9b75..82322c4825 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -52,8 +52,6 @@ export function Header() { exportState, setExportState, customDomain, - editorState, - setEditorState, } = useEditorContext(); let unlistenTitlebar: UnlistenFn | undefined; @@ -74,38 +72,25 @@ export function Header() { {ostype() === "macos" &&
} { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - + const currentWindow = getCurrentWindow(); + if (!editorInstance.path) return; if (!(await ask("Are you sure you want to delete this recording?"))) return; - - await commands.editorDeleteProject(); + await remove(editorInstance.path, { + recursive: true, + }); + events.recordingDeleted.emit({ path: editorInstance.path }); + await currentWindow.close(); }} - tooltipText={ - editorState.timeline.selection - ? "Close selection" - : "Delete recording" - } + tooltipText="Delete recording" leftIcon={} /> { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - console.log({ path: `${editorInstance.path}/` }); revealItemInDir(`${editorInstance.path}/`); }} - tooltipText={ - editorState.timeline.selection - ? "Close selection" - : "Open recording bundle" - } + tooltipText="Open recording bundle" leftIcon={} /> @@ -115,30 +100,14 @@ export function Header() {
{ - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - }} - tooltipText={ - editorState.timeline.selection ? "Close selection" : "Captions" - } + tooltipText="Captions" leftIcon={} - comingSoon={!editorState.timeline.selection} + comingSoon /> { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - }} - tooltipText={ - editorState.timeline.selection ? "Close selection" : "Performance" - } + tooltipText="Performance" leftIcon={} - comingSoon={!editorState.timeline.selection} + comingSoon />
@@ -157,35 +126,15 @@ export function Header() { )} > { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - projectHistory.undo(); - }} - disabled={ - !projectHistory.canUndo() && !editorState.timeline.selection - } - tooltipText={ - editorState.timeline.selection ? "Close selection" : "Undo" - } + onClick={() => projectHistory.undo()} + disabled={!projectHistory.canUndo()} + tooltipText="Undo" leftIcon={} /> { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - projectHistory.redo(); - }} - disabled={ - !projectHistory.canRedo() && !editorState.timeline.selection - } - tooltipText={ - editorState.timeline.selection ? "Close selection" : "Redo" - } + onClick={() => projectHistory.redo()} + disabled={!projectHistory.canRedo()} + tooltipText="Redo" leftIcon={} />
@@ -196,11 +145,6 @@ export function Header() { variant="dark" class="flex gap-1.5 justify-center h-[40px] w-full max-w-[100px]" onClick={() => { - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - return; - } - trackEvent("export_button_clicked"); if (exportState.type === "done") setExportState({ type: "idle" }); diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index fbd3883849..6f9b999166 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -1,14 +1,13 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; import { createMutation } from "@tanstack/solid-query"; -import { Channel } from "@tauri-apps/api/core"; import { createSignal, Show } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; import { createProgressBar } from "~/routes/editor/utils"; import { authStore } from "~/store"; import { exportVideo } from "~/utils/export"; -import { commands, events, type UploadProgress } from "~/utils/tauri"; +import { commands, events } from "~/utils/tauri"; import { useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; import { @@ -21,8 +20,7 @@ import { } from "./ui"; function ShareButton() { - const { editorInstance, meta, customDomain, editorState, setEditorState } = - useEditorContext(); + const { editorInstance, meta, customDomain } = useEditorContext(); const projectPath = editorInstance.path; const upload = createMutation(() => ({ @@ -53,72 +51,68 @@ function ShareButton() { } } - const uploadChannel = new Channel((progress) => { - console.log("Upload progress:", progress); + const unlisten = await events.uploadProgress.listen((event) => { + console.log("Upload progress event:", event.payload); setUploadState( produce((state) => { if (state.type !== "uploading") return; - state.progress = Math.round(progress.progress * 100); + state.progress = Math.round(event.payload.progress * 100); }), ); }); - setUploadState({ type: "starting" }); + try { + setUploadState({ type: "starting" }); - // Setup progress listener before starting upload + // Setup progress listener before starting upload - console.log("Starting actual upload..."); + console.log("Starting actual upload..."); - await exportVideo( - projectPath, - { - format: "Mp4", - fps: 30, - resolution_base: { - x: RESOLUTION_OPTIONS._1080p.width, - y: RESOLUTION_OPTIONS._1080p.height, + await exportVideo( + projectPath, + { + format: "Mp4", + fps: 30, + resolution_base: { + x: RESOLUTION_OPTIONS._1080p.width, + y: RESOLUTION_OPTIONS._1080p.height, + }, + compression: "Web", }, - compression: "Web", - }, - (msg) => { - setUploadState( - reconcile({ - type: "rendering", - renderedFrames: msg.renderedCount, - totalFrames: msg.totalFrames, - }), - ); - }, - ); + (msg) => { + setUploadState( + reconcile({ + type: "rendering", + renderedFrames: msg.renderedCount, + totalFrames: msg.totalFrames, + }), + ); + }, + ); - setUploadState({ type: "uploading", progress: 0 }); + setUploadState({ type: "uploading", progress: 0 }); - // Now proceed with upload - const result = meta().sharing - ? await commands.uploadExportedVideo( - projectPath, - "Reupload", - uploadChannel, - ) - : await commands.uploadExportedVideo( - projectPath, - { + // Now proceed with upload + const result = meta().sharing + ? await commands.uploadExportedVideo(projectPath, "Reupload") + : await commands.uploadExportedVideo(projectPath, { Initial: { pre_created_video: null }, - }, - uploadChannel, - ); + }); - if (result === "NotAuthenticated") { - throw new Error("You need to sign in to share recordings"); - } else if (result === "PlanCheckFailed") - throw new Error("Failed to verify your subscription status"); - else if (result === "UpgradeRequired") - throw new Error("This feature requires an upgraded plan"); + if (result === "NotAuthenticated") { + throw new Error("You need to sign in to share recordings"); + } else if (result === "PlanCheckFailed") + throw new Error("Failed to verify your subscription status"); + else if (result === "UpgradeRequired") + throw new Error("This feature requires an upgraded plan"); - setUploadState({ type: "link-copied" }); + setUploadState({ type: "link-copied" }); - return result; + return result; + } finally { + unlisten(); + } }, onError: (error) => { console.error(error); @@ -195,13 +189,7 @@ function ShareButton() { >
@@ -685,10 +670,13 @@ function CutOffsetButton(props: { class?: string; onClick?(): void; }) { + const formatTime = (t: number) => + t < 1 ? Math.round(t * 10) / 10 : Math.round(t); + return ( ); diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index 533cd56dee..d38b5f6996 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -57,12 +57,6 @@ export function ZoomTrack(props: { try { const zoomSegments = await commands.generateZoomSegmentsFromClicks(); setProject("timeline", "zoomSegments", zoomSegments); - if (zoomSegments.length > 0) { - const currentSize = project.cursor?.size ?? 0; - if (currentSize < 200) { - setProject("cursor", "size", 200); - } - } } catch (error) { console.error("Failed to generate zoom segments:", error); } diff --git a/apps/desktop/src/routes/editor/ui.tsx b/apps/desktop/src/routes/editor/ui.tsx index 83d8b4cafe..88d988a09b 100644 --- a/apps/desktop/src/routes/editor/ui.tsx +++ b/apps/desktop/src/routes/editor/ui.tsx @@ -151,8 +151,8 @@ export function Input(props: ComponentProps<"input">) { ); diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 17682f5da5..2bae70d878 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -9,11 +9,11 @@ import { createEffect, createMemo, createSignal, + onCleanup, Show, } from "solid-js"; import { createStore, produce } from "solid-js/store"; import createPresence from "solid-presence"; -import { authStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { createCurrentRecordingQuery, @@ -33,8 +33,6 @@ declare global { } } -const MAX_RECORDING_FOR_FREE = 5 * 60 * 1000; - export default function () { const [state, setState] = createSignal( window.COUNTDOWN === 0 @@ -49,7 +47,6 @@ export default function () { const [time, setTime] = createSignal(Date.now()); const currentRecording = createCurrentRecordingQuery(); const optionsQuery = createOptionsQuery(); - const auth = authStore.createQuery(); const audioLevel = createAudioInputLevel(); @@ -159,33 +156,6 @@ export default function () { return t; }; - const isMaxRecordingLimitEnabled = () => { - // Only enforce the limit on instant mode. - // We enforce it on studio mode when exporting. - return ( - optionsQuery.rawOptions.mode === "instant" && - // If the data is loaded and the user is not upgraded - auth.data?.plan?.upgraded === false - ); - }; - - let aborted = false; - createEffect(() => { - if ( - isMaxRecordingLimitEnabled() && - adjustedTime() > MAX_RECORDING_FOR_FREE && - !aborted - ) { - aborted = true; - stopRecording.mutate(); - } - }); - - const remainingRecordingTime = () => { - if (MAX_RECORDING_FOR_FREE < adjustedTime()) return 0; - return MAX_RECORDING_FOR_FREE - adjustedTime(); - }; - const [countdownRef, setCountdownRef] = createSignal( null, ); @@ -226,12 +196,7 @@ export default function () { > - - {formatTime(remainingRecordingTime() / 1000)} - + {formatTime(adjustedTime() / 1000)} diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index 01a7812889..b7a926a99a 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -3,7 +3,7 @@ import Tooltip from "@corvu/tooltip"; import { createElementBounds } from "@solid-primitives/bounds"; import { makePersisted } from "@solid-primitives/storage"; import { createMutation, createQuery } from "@tanstack/solid-query"; -import { Channel, convertFileSrc } from "@tauri-apps/api/core"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { cx } from "cva"; import { type Accessor, @@ -30,7 +30,6 @@ import { commands, events, type FramesRendered, - type UploadProgress, type UploadResult, } from "~/utils/tauri"; import IconLucideClock from "~icons/lucide/clock"; @@ -727,8 +726,8 @@ function createRecordingMutations( } } - const uploadChannel = new Channel((progress) => { - console.log("Upload progress:", progress); + const unlisten = await events.uploadProgress.listen((event) => { + console.log("Upload progress event:", event.payload); setActionState( produce((actionState) => { if ( @@ -737,65 +736,70 @@ function createRecordingMutations( ) return; - actionState.state.progress = Math.round(progress.progress * 100); + actionState.state.progress = Math.round( + event.payload.progress * 100, + ); }), ); }); - let res: UploadResult; - if (isRecording) { - setActionState({ - type: "upload", - state: { type: "rendering", state: { type: "starting" } }, - }); + try { + let res: UploadResult; + if (isRecording) { + setActionState({ + type: "upload", + state: { type: "rendering", state: { type: "starting" } }, + }); - const progress = createRenderProgressCallback("upload", setActionState); + const progress = createRenderProgressCallback( + "upload", + setActionState, + ); - await exportWithDefaultSettings(progress); + await exportWithDefaultSettings(progress); - // Show quick progress animation for existing video - setActionState( - produce((s) => { - if ( - s.type === "copy" && - s.state.type === "rendering" && - s.state.state.type === "rendering" - ) - s.state.state.renderedFrames = s.state.state.totalFrames; - }), - ); + // Show quick progress animation for existing video + setActionState( + produce((s) => { + if ( + s.type === "copy" && + s.state.type === "rendering" && + s.state.state.type === "rendering" + ) + s.state.state.renderedFrames = s.state.state.totalFrames; + }), + ); - setActionState({ - type: "upload", - state: { type: "uploading", progress: 0 }, - }); + setActionState({ + type: "upload", + state: { type: "uploading", progress: 0 }, + }); - res = await commands.uploadExportedVideo( - media.path, - { + res = await commands.uploadExportedVideo(media.path, { Initial: { pre_created_video: null }, - }, - uploadChannel, - ); - } else { - setActionState({ - type: "upload", - state: { type: "uploading", progress: 0 }, - }); + }); + } else { + setActionState({ + type: "upload", + state: { type: "uploading", progress: 0 }, + }); - res = await commands.uploadScreenshot(media.path); - } + res = await commands.uploadScreenshot(media.path); + } - switch (res) { - case "NotAuthenticated": - throw new Error("Not authenticated"); - case "PlanCheckFailed": - throw new Error("Plan check failed"); - case "UpgradeRequired": - onEvent("upgradeRequired"); - return; - default: - break; + switch (res) { + case "NotAuthenticated": + throw new Error("Not authenticated"); + case "PlanCheckFailed": + throw new Error("Plan check failed"); + case "UpgradeRequired": + onEvent("upgradeRequired"); + return; + default: + break; + } + } finally { + unlisten(); } setActionState({ type: "upload", state: { type: "link-copied" } }); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 1ceafbb712..57b50e11ce 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -31,24 +31,12 @@ import { type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; -import { - RecordingOptionsProvider, - useRecordingOptions, -} from "./(window-chrome)/OptionsContext"; const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; export default function () { - return ( - - - - ); -} - -function Inner() { const [params] = useSearchParams<{ displayId: DisplayId }>(); const { rawOptions, setOptions } = createOptionsQuery(); const [toggleModeSelect, setToggleModeSelect] = createSignal(false); @@ -64,48 +52,16 @@ function Inner() { }); onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub())); - const selectedWindow = createQuery(() => ({ - queryKey: ["selectedWindow", rawOptions.captureTarget], - queryFn: async () => { - if (rawOptions.captureTarget.variant !== "window") return null; - const windowId = rawOptions.captureTarget.id; - - const windows = await commands.listCaptureWindows(); - const window = windows.find((w) => w.id === windowId); - - if (!window) return null; - - return { - id: window.id, - app_name: window.owner_name || window.name || "Unknown", - bounds: window.bounds, - }; - }, - enabled: - rawOptions.captureTarget.variant === "window" && - rawOptions.targetMode === "window", - staleTime: 5 * 1000, - })); - - const windowToShow = () => { - const hoveredWindow = targetUnderCursor.window; - if (hoveredWindow) return hoveredWindow; - if (rawOptions.captureTarget.variant === "window") { - const selected = selectedWindow.data; - if (selected) return selected; - } - return hoveredWindow; - }; - const windowIcon = createQuery(() => ({ - queryKey: ["windowIcon", windowToShow()?.id], + queryKey: ["windowIcon", targetUnderCursor.window?.id], queryFn: async () => { - const window = windowToShow(); - if (!window?.id) return null; - return await commands.getWindowIcon(window.id.toString()); + if (!targetUnderCursor.window?.id) return null; + return await commands.getWindowIcon( + targetUnderCursor.window.id.toString(), + ); }, - enabled: !!windowToShow()?.id, - staleTime: 5 * 60 * 1000, + enabled: !!targetUnderCursor.window?.id, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes })); const displayInformation = createQuery(() => ({ @@ -209,19 +165,16 @@ function Inner() { setToggleModeSelect={setToggleModeSelect} target={{ variant: "display", id: params.displayId! }} /> -
)} - + {(windowUnderCursor) => (
Adjust recording area -
)} @@ -712,9 +662,6 @@ function Inner() { bounds, }} /> -
@@ -736,7 +683,7 @@ function RecordingControls(props: { setToggleModeSelect?: (value: boolean) => void; }) { const auth = authStore.createQuery(); - const { setOptions, rawOptions } = useRecordingOptions(); + const { rawOptions, setOptions } = createOptionsQuery(); const generalSetings = generalSettingsStore.createQuery(); @@ -869,26 +816,6 @@ function RecordingControls(props: { ); } -function ShowCapFreeWarning(props: { isInstantMode: boolean }) { - const auth = authStore.createQuery(); - - return ( - - -

- Instant Mode recordings are limited to 5 mins,{" "} - -

-
-
- ); -} - function ResizeHandle( props: Omit, "style"> & { style?: JSX.CSSProperties; diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index 2fd16a965c..f98c7db75f 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -2,12 +2,12 @@ import { createQuery } from "@tanstack/solid-query"; import { Store } from "@tauri-apps/plugin-store"; import { onCleanup } from "solid-js"; -import type { - AuthStore, - GeneralSettingsStore, - HotkeysStore, - PresetsStore, - RecordingSettingsStore, +import { + type AuthStore, + commands, + type GeneralSettingsStore, + type HotkeysStore, + type PresetsStore, } from "~/utils/tauri"; let _store: Promise | undefined; @@ -60,5 +60,3 @@ export const authStore = declareStore("auth"); export const hotkeysStore = declareStore("hotkeys"); export const generalSettingsStore = declareStore("general_settings"); -export const recordingSettingsStore = - declareStore("recording_settings"); diff --git a/apps/desktop/src/styles/theme.css b/apps/desktop/src/styles/theme.css index 4d98267241..4a432b801f 100644 --- a/apps/desktop/src/styles/theme.css +++ b/apps/desktop/src/styles/theme.css @@ -154,23 +154,6 @@ .gray-button-shadow { box-shadow: 0 1.5px 0 0 rgba(255, 255, 255, 0.4) inset; } - -.dark .gray-button-border { - @apply border-gray-2; -} - -.gray-button-border { - @apply border-gray-8; -} - -.dark .dark-button-border { - @apply border-gray-2; -} - -.dark-button-border { - @apply border-gray-12; -} - [data-transparent-window] { background: transparent !important; } diff --git a/apps/desktop/src/utils/createPresets.ts b/apps/desktop/src/utils/createPresets.ts index 9ba9943d33..2217a75262 100644 --- a/apps/desktop/src/utils/createPresets.ts +++ b/apps/desktop/src/utils/createPresets.ts @@ -28,7 +28,6 @@ export function createPresets() { const config = { ...preset.config }; // @ts-expect-error we reeeally don't want the timeline in the preset config.timeline = undefined; - config.clips = undefined; await updatePresets((store) => { store.presets.push({ name: preset.name, config }); diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index 93ad12177a..ceafcc1cb5 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -7,14 +7,10 @@ import { useQuery, } from "@tanstack/solid-query"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { createEffect, createMemo } from "solid-js"; +import { createMemo } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; -import { - authStore, - generalSettingsStore, - recordingSettingsStore, -} from "~/store"; +import { authStore, generalSettingsStore } from "~/store"; import { createQueryInvalidate } from "./events"; import { type CameraInfo, @@ -39,7 +35,7 @@ export const listWindows = queryOptions({ return w; }, reconcile: "id", - refetchInterval: false, + refetchInterval: 1000, }); export const listScreens = queryOptions({ @@ -49,30 +45,6 @@ export const listScreens = queryOptions({ refetchInterval: 1000, }); -export const listWindowsWithThumbnails = queryOptions({ - queryKey: ["capture", "windows-thumbnails"] as const, - queryFn: async () => { - const w = await commands.listWindowsWithThumbnails(); - - w.sort( - (a, b) => - a.owner_name.localeCompare(b.owner_name) || - a.name.localeCompare(b.name), - ); - - return w; - }, - reconcile: "id", - refetchInterval: false, -}); - -export const listDisplaysWithThumbnails = queryOptions({ - queryKey: ["capture", "displays-thumbnails"] as const, - queryFn: () => commands.listDisplaysWithThumbnails(), - reconcile: "id", - refetchInterval: 1000, -}); - const getCurrentRecording = queryOptions({ queryKey: ["currentRecording"] as const, queryFn: () => commands.getCurrentRecording().then((d) => d[0]), @@ -134,20 +106,14 @@ export function createOptionsQuery() { if (e.key === PERSIST_KEY) _setState(JSON.parse(e.newValue ?? "{}")); }); - createEffect(() => { - recordingSettingsStore.set({ - target: _state.captureTarget, - micName: _state.micName, - cameraId: _state.cameraID, - mode: _state.mode, - systemAudio: _state.captureSystemAudio, - }); - }); - const [state, setState] = makePersisted([_state, _setState], { name: PERSIST_KEY, }); + createEventListener(window, "storage", (e) => { + if (e.key === PERSIST_KEY) setState(JSON.parse(e.newValue ?? "{}")); + }); + return { rawOptions: state, setOptions: setState }; } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 11e44e4973..fda9684f26 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -38,12 +38,6 @@ async listCaptureWindows() : Promise { async listCaptureDisplays() : Promise { return await TAURI_INVOKE("list_capture_displays"); }, -async listDisplaysWithThumbnails() : Promise { - return await TAURI_INVOKE("list_displays_with_thumbnails"); -}, -async listWindowsWithThumbnails() : Promise { - return await TAURI_INVOKE("list_windows_with_thumbnails"); -}, async takeScreenshot() : Promise { return await TAURI_INVOKE("take_screenshot"); }, @@ -119,8 +113,8 @@ async doPermissionsCheck(initialCheck: boolean) : Promise { async requestPermission(permission: OSPermission) : Promise { await TAURI_INVOKE("request_permission", { permission }); }, -async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL) : Promise { - return await TAURI_INVOKE("upload_exported_video", { path, mode, channel }); +async uploadExportedVideo(path: string, mode: UploadMode) : Promise { + return await TAURI_INVOKE("upload_exported_video", { path, mode }); }, async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); @@ -268,12 +262,6 @@ async displayInformation(displayId: string) : Promise { }, async getWindowIcon(windowId: string) : Promise { return await TAURI_INVOKE("get_window_icon", { windowId }); -}, -async focusWindow(windowId: WindowId) : Promise { - return await TAURI_INVOKE("focus_window", { windowId }); -}, -async editorDeleteProject() : Promise { - return await TAURI_INVOKE("editor_delete_project"); } } @@ -297,11 +285,10 @@ recordingStarted: RecordingStarted, recordingStopped: RecordingStopped, renderFrameEvent: RenderFrameEvent, requestNewScreenshot: RequestNewScreenshot, -requestOpenRecordingPicker: RequestOpenRecordingPicker, requestOpenSettings: RequestOpenSettings, -requestScreenCapturePrewarm: RequestScreenCapturePrewarm, requestStartRecording: RequestStartRecording, -targetUnderCursor: TargetUnderCursor +targetUnderCursor: TargetUnderCursor, +uploadProgress: UploadProgress }>({ audioInputLevelChange: "audio-input-level-change", authenticationInvalid: "authentication-invalid", @@ -319,11 +306,10 @@ recordingStarted: "recording-started", recordingStopped: "recording-stopped", renderFrameEvent: "render-frame-event", requestNewScreenshot: "request-new-screenshot", -requestOpenRecordingPicker: "request-open-recording-picker", requestOpenSettings: "request-open-settings", -requestScreenCapturePrewarm: "request-screen-capture-prewarm", requestStartRecording: "request-start-recording", -targetUnderCursor: "target-under-cursor" +targetUnderCursor: "target-under-cursor", +uploadProgress: "upload-progress" }) /** user-defined constants **/ @@ -345,9 +331,8 @@ start_time?: number | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null } export type AuthenticationInvalid = null -export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } +export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } -export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoom_size: number | null; rounding?: number; shadow?: number; advanced_shadow?: ShadowConfiguration | null; shape?: CameraShape } export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } export type CameraPosition = { x: CameraXPosition; y: CameraYPosition } @@ -362,11 +347,7 @@ export type CaptionSegment = { id: string; start: number; end: number; text: str export type CaptionSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; bold: boolean; italic: boolean; outline: boolean; outlineColor: string; exportWithSubtitles: boolean } export type CaptionsData = { segments: CaptionSegment[]; settings: CaptionSettings } export type CaptureDisplay = { id: DisplayId; name: string; refresh_rate: number } -export type CaptureDisplayWithThumbnail = { id: DisplayId; name: string; refresh_rate: number; thumbnail: string | null } export type CaptureWindow = { id: WindowId; owner_name: string; name: string; bounds: LogicalBounds; refresh_rate: number } -export type CaptureWindowWithThumbnail = { id: WindowId; owner_name: string; name: string; bounds: LogicalBounds; refresh_rate: number; thumbnail: string | null; app_icon: string | null } -export type ClipConfiguration = { index: number; offsets: ClipOffsets } -export type ClipOffsets = { camera?: number; mic?: number; system_audio?: number } export type CommercialLicense = { licenseKey: string; expiryDate: number | null; refresh: number; activatedOn: number } export type Crop = { position: XY; size: XY } export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode } @@ -402,7 +383,7 @@ fast: boolean | null } export type HapticPattern = "Alignment" | "LevelChange" | "Generic" export type HapticPerformanceTime = "Default" | "Now" | "DrawCompleted" export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean } -export type HotkeyAction = "startStudioRecording" | "startInstantRecording" | "stopRecording" | "restartRecording" | "openRecordingPicker" | "openRecordingPickerDisplay" | "openRecordingPickerWindow" | "openRecordingPickerArea" | "other" +export type HotkeyAction = "startRecording" | "stopRecording" | "restartRecording" export type HotkeysConfiguration = { show: boolean } export type HotkeysStore = { hotkeys: { [key in HotkeyAction]: Hotkey } } export type InstantRecordingMeta = { fps: number; sample_rate: number | null } @@ -429,7 +410,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; clips?: ClipConfiguration[] } +export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline?: TimelineConfiguration | null; captions?: CaptionsData | null } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } @@ -437,16 +418,12 @@ export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { pla export type RecordingMetaWithMode = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null }) & { mode: RecordingMode } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null -export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } export type RecordingStarted = null export type RecordingStopped = null -export type RecordingTargetMode = "display" | "window" | "area" export type RenderFrameEvent = { frame_number: number; fps: number; resolution_base: XY } export type RequestNewScreenshot = null -export type RequestOpenRecordingPicker = { target_mode: RecordingTargetMode | null } export type RequestOpenSettings = { page: string } -export type RequestScreenCapturePrewarm = { force?: boolean } -export type RequestStartRecording = { mode: RecordingMode } +export type RequestStartRecording = null export type S3UploadMeta = { id: string } export type SceneMode = "default" | "cameraOnly" | "hideCamera" | "splitView" export type SceneSegment = { start: number; end: number; mode?: SceneMode; splitViewSettings?: SplitViewSettings | null } @@ -455,7 +432,7 @@ export type SegmentRecordings = { display: Video; camera: Video | null; mic: Aud export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } export type ShadowConfiguration = { size: number; opacity: number; blur: number } export type SharingMeta = { id: string; link: string } -export type ShowCapWindow = "Setup" | { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" +export type ShowCapWindow = "Setup" | "Main" | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } export type SplitViewSettings = { cameraPosition: XY; screenPosition: XY; cameraSide: SplitViewSide; cameraZoom?: number; screenZoom?: number; fullscreen?: boolean } export type SplitViewSide = "left" | "right" diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index f2b68587ca..96ddf0e7bc 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "deploy": "wrangler deploy", - "bot-dev": "wrangler dev", + "dev": "wrangler dev", "start": "wrangler dev", "test": "vitest", "cf-typegen": "wrangler types" diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 2010d34899..30ee20f4d8 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -5,6 +5,7 @@ "main": "src/index.ts", "scripts": { "start": "node dist/src/index.js", + "dev": "ts-node src/index.ts", "build": "tsc", "start:dist": "node dist/src/index.js", "test": "jest", diff --git a/apps/web/actions/analytics/track-user-signed-up.ts b/apps/web/actions/analytics/track-user-signed-up.ts deleted file mode 100644 index 808fbc52c4..0000000000 --- a/apps/web/actions/analytics/track-user-signed-up.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; -import { sql } from "drizzle-orm"; - -type UserPreferences = { - notifications?: { - pauseComments: boolean; - pauseReplies: boolean; - pauseViews: boolean; - pauseReactions: boolean; - }; - trackedEvents?: { - user_signed_up?: boolean; - }; -} | null; - -export async function checkAndMarkUserSignedUpTracked(): Promise<{ - shouldTrack: boolean; -}> { - const currentUser = await getCurrentUser(); - if (!currentUser) { - return { shouldTrack: false }; - } - - try { - const prefs = currentUser.preferences as UserPreferences; - const alreadyTracked = Boolean(prefs?.trackedEvents?.user_signed_up); - - if (alreadyTracked) { - return { shouldTrack: false }; - } - - const result = await db() - .update(users) - .set({ - preferences: sql`JSON_SET(COALESCE(${users.preferences}, JSON_OBJECT()), '$.trackedEvents.user_signed_up', true)`, - }) - .where( - sql`(${users.id} = ${currentUser.id}) AND (${users.created_at} >= CURRENT_DATE()) AND JSON_CONTAINS(COALESCE(${users.preferences}, JSON_OBJECT()), CAST(true AS JSON), '$.trackedEvents.user_signed_up') = 0`, - ); - - if (result.rowsAffected && result.rowsAffected > 0) { - return { shouldTrack: true }; - } - - return { shouldTrack: false }; - } catch { - return { shouldTrack: false }; - } -} diff --git a/apps/web/actions/billing/track-meta-purchase.ts b/apps/web/actions/billing/track-meta-purchase.ts deleted file mode 100644 index c90db45c9f..0000000000 --- a/apps/web/actions/billing/track-meta-purchase.ts +++ /dev/null @@ -1,124 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; -import { stripe } from "@cap/utils"; -import { eq } from "drizzle-orm"; -import type Stripe from "stripe"; - -export async function getPurchaseForMeta({ - sessionId, -}: { - sessionId?: string | null; -}): Promise<{ - shouldTrack: boolean; - value?: number; - currency?: string; - eventId?: string; -}> { - const user = await getCurrentUser(); - if (!user) { - return { shouldTrack: false }; - } - - let subscriptionId: string | undefined; - let customerId: string | undefined; - let eventId: string | undefined; - - try { - if (sessionId) { - const session = await stripe().checkout.sessions.retrieve(sessionId); - if (!session || session.mode !== "subscription") { - return { shouldTrack: false }; - } - - eventId = session.id; - customerId = (session.customer as string) || undefined; - - if ( - user.stripeCustomerId && - customerId && - user.stripeCustomerId !== customerId - ) { - return { shouldTrack: false }; - } - - if (!user.stripeCustomerId && customerId) { - const customer = await stripe().customers.retrieve(customerId); - let matches = false; - if ( - "metadata" in customer && - customer.metadata && - (customer.metadata as Record).userId === - user.id - ) { - matches = true; - } else if ( - "email" in customer && - customer.email && - customer.email === user.email - ) { - matches = true; - } - if (!matches) { - return { shouldTrack: false }; - } - await db() - .update(users) - .set({ stripeCustomerId: customer.id }) - .where(eq(users.id, user.id)); - } - - if (session.subscription) { - subscriptionId = String(session.subscription); - } - } - - if (!subscriptionId) { - if (user.stripeSubscriptionId) { - subscriptionId = user.stripeSubscriptionId; - } else if (user.stripeCustomerId) { - const subs = await stripe().subscriptions.list({ - customer: user.stripeCustomerId, - status: "all", - limit: 1, - }); - if (subs.data[0]) subscriptionId = subs.data[0].id; - } - } - - if (!subscriptionId) { - return { shouldTrack: false }; - } - - const subscription = await stripe().subscriptions.retrieve(subscriptionId); - const alreadyTracked = - (subscription.metadata && - (subscription.metadata as Record) - .meta_purchase_tracked === "true") || - false; - if (alreadyTracked) { - return { shouldTrack: false }; - } - - const currency = subscription.items.data[0]?.price?.currency?.toUpperCase(); - const amountCents = subscription.items.data.reduce((acc, item) => { - const unit = item.price?.unit_amount ?? 0; - const qty = item.quantity ?? 1; - return acc + unit * qty; - }, 0); - const value = amountCents / 100; - - await stripe().subscriptions.update(subscription.id, { - metadata: { - ...(subscription.metadata || {}), - meta_purchase_tracked: "true", - } as Stripe.MetadataParam, - }); - - return { shouldTrack: true, value, currency, eventId }; - } catch { - return { shouldTrack: false }; - } -} diff --git a/apps/web/actions/caps/share.ts b/apps/web/actions/caps/share.ts index de025712d1..c85d0dedf5 100644 --- a/apps/web/actions/caps/share.ts +++ b/apps/web/actions/caps/share.ts @@ -11,12 +11,11 @@ import { spaceVideos, videos, } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; interface ShareCapParams { - capId: Video.VideoId; + capId: string; spaceIds: string[]; public?: boolean; } diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 86a88fbbdd..469dc43e0f 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -3,7 +3,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders, spaceVideos, videos } from "@cap/database/schema"; -import type { Folder, Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -12,8 +11,8 @@ export async function moveVideoToFolder({ folderId, spaceId, }: { - videoId: Video.VideoId; - folderId: Folder.FolderId | null; + videoId: string; + folderId: string | null; spaceId?: string | null; }) { const user = await getCurrentUser(); @@ -24,7 +23,7 @@ export async function moveVideoToFolder({ // Get the current video to know its original folder const [currentVideo] = await db() - .select({ folderId: videos.folderId, id: videos.id }) + .select({ folderId: videos.folderId }) .from(videos) .where(eq(videos.id, videoId)); diff --git a/apps/web/actions/folders/updateFolder.ts b/apps/web/actions/folders/updateFolder.ts index ff1548f57b..c5ad958fb5 100644 --- a/apps/web/actions/folders/updateFolder.ts +++ b/apps/web/actions/folders/updateFolder.ts @@ -3,7 +3,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders } from "@cap/database/schema"; -import type { Folder } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -13,10 +12,10 @@ export async function updateFolder({ color, parentId, }: { - folderId: Folder.FolderId; + folderId: string; name?: string; color?: "normal" | "blue" | "red" | "yellow"; - parentId?: Folder.FolderId | null; + parentId?: string | null; }) { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 552344f1a4..fceff5a72f 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -5,12 +5,10 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId, nanoIdLength } from "@cap/database/helpers"; import { spaceMembers, spaces, users } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; import { and, eq, inArray } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; interface CreateSpaceResponse { success: boolean; @@ -91,29 +89,25 @@ export async function createSpace( user.activeOrganizationId }/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`; - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - yield* bucket.putObject( - fileKey, - yield* Effect.promise(() => iconFile.bytes()), - { contentType: iconFile.type }, - ); - - // Construct the icon URL - if (serverEnv().CAP_AWS_BUCKET_URL) { - // If a custom bucket URL is defined, use it - iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - // For custom endpoints like MinIO - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - // Default AWS S3 URL format - iconUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } - }).pipe(runPromise); + const bucket = await createBucketProvider(); + + await bucket.putObject(fileKey, await iconFile.bytes(), { + contentType: iconFile.type, + }); + + // Construct the icon URL + if (serverEnv().CAP_AWS_BUCKET_URL) { + // If a custom bucket URL is defined, use it + iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; + } else if (serverEnv().CAP_AWS_ENDPOINT) { + // For custom endpoints like MinIO + iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.name}/${fileKey}`; + } else { + // Default AWS S3 URL format + iconUrl = `https://${bucket.name}.s3.${ + serverEnv().CAP_AWS_REGION || "us-east-1" + }.amazonaws.com/${fileKey}`; + } } catch (error) { console.error("Error uploading space icon:", error); return { @@ -172,9 +166,7 @@ export async function createSpace( if (!userId) return null; // Creator is always Owner, others are Member const role = - email.toLowerCase() === creatorEmail - ? ("Admin" as const) - : ("member" as const); + email.toLowerCase() === creatorEmail ? "Admin" : "Member"; return { id: uuidv4().substring(0, nanoIdLength), spaceId, diff --git a/apps/web/actions/organization/delete-space.ts b/apps/web/actions/organization/delete-space.ts index 066222621b..37c6588ad4 100644 --- a/apps/web/actions/organization/delete-space.ts +++ b/apps/web/actions/organization/delete-space.ts @@ -8,11 +8,9 @@ import { spaces, spaceVideos, } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; interface DeleteSpaceResponse { success: boolean; @@ -69,27 +67,25 @@ export async function deleteSpace( // 4. Delete space icons from S3 try { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - const listedObjects = yield* bucket.listObjects({ - prefix: `organizations/${user.activeOrganizationId}/spaces/${spaceId}/`, - }); - - if (listedObjects.Contents?.length) { - yield* bucket.deleteObjects( - listedObjects.Contents.map((content) => ({ - Key: content.Key, - })), - ); - - console.log( - `Deleted ${listedObjects.Contents.length} objects for space ${spaceId}`, - ); - } - }).pipe(runPromise); + const bucketProvider = await createBucketProvider(); // List all objects with the space prefix + + const listedObjects = await bucketProvider.listObjects({ + prefix: `organizations/${user.activeOrganizationId}/spaces/${spaceId}/`, + }); + + if (listedObjects.Contents?.length) { + await bucketProvider.deleteObjects( + listedObjects.Contents.map((content) => ({ + Key: content.Key, + })), + ); + + console.log( + `Deleted ${listedObjects.Contents.length} objects for space ${spaceId}`, + ); + } } catch (error) { console.error("Error deleting space icons from S3:", error); // Continue with space deletion even if S3 deletion fails diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index ab6237d0bd..3c7ede3feb 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -4,12 +4,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoIdLength } from "@cap/database/helpers"; import { spaceMembers, spaces } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; import { and, eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; import { uploadSpaceIcon } from "./upload-space-icon"; export async function updateSpace(formData: FormData) { @@ -50,18 +48,14 @@ export async function updateSpace(formData: FormData) { // Remove icon from S3 and set iconUrl to null const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id)); const space = spaceArr[0]; - if (space?.iconUrl) { - const key = space.iconUrl.match(/organizations\/.+/)?.[0]; - - if (key) { - try { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - yield* bucket.deleteObject(key); - }).pipe(runPromise); - } catch (e) { - console.warn("Failed to delete old space icon from S3", e); - } + if (space && space.iconUrl) { + try { + const bucketProvider = await createBucketProvider(); + const prevKeyMatch = space.iconUrl.match(/organizations\/.+/); + if (prevKeyMatch && prevKeyMatch[0]) + await bucketProvider.deleteObject(prevKeyMatch[0]); + } catch (e) { + console.warn("Failed to delete old space icon from S3", e); } } await db().update(spaces).set({ iconUrl: null }).where(eq(spaces.id, id)); diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts index 485418da7c..2c143a75c9 100644 --- a/apps/web/actions/organization/upload-organization-icon.ts +++ b/apps/web/actions/organization/upload-organization-icon.ts @@ -4,14 +4,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; import DOMPurify from "dompurify"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { JSDOM } from "jsdom"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; export async function uploadOrganizationIcon( formData: FormData, @@ -58,30 +56,27 @@ export async function uploadOrganizationIcon( try { const sanitizedFile = await sanitizeFile(file); - let iconUrl: string | undefined; - - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - yield* bucket.putObject( - fileKey, - yield* Effect.promise(() => sanitizedFile.bytes()), - { contentType: file.type }, - ); - // Construct the icon URL - if (serverEnv().CAP_AWS_BUCKET_URL) { - // If a custom bucket URL is defined, use it - iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - // For custom endpoints like MinIO - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - // Default AWS S3 URL format - iconUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } - }).pipe(runPromise); + + const bucket = await createBucketProvider(); + + await bucket.putObject(fileKey, await sanitizedFile.bytes(), { + contentType: file.type, + }); + + // Construct the icon URL + let iconUrl; + if (serverEnv().CAP_AWS_BUCKET_URL) { + // If a custom bucket URL is defined, use it + iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; + } else if (serverEnv().CAP_AWS_ENDPOINT) { + // For custom endpoints like MinIO + iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.name}/${fileKey}`; + } else { + // Default AWS S3 URL format + iconUrl = `https://${bucket.name}.s3.${ + serverEnv().CAP_AWS_REGION || "us-east-1" + }.amazonaws.com/${fileKey}`; + } // Update organization with new icon URL await db() diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index e56c92b5a6..e3a93464c1 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -4,12 +4,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { spaces } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; export async function uploadSpaceIcon(formData: FormData, spaceId: string) { const user = await getCurrentUser(); @@ -54,18 +52,16 @@ export async function uploadSpaceIcon(formData: FormData, spaceId: string) { space.organizationId }/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`; - const [bucket] = await S3Buckets.getBucketAccess(Option.none()).pipe( - runPromise, - ); + const bucket = await createBucketProvider(); try { // Remove previous icon if exists if (space.iconUrl) { // Try to extract the previous S3 key from the URL - const key = space.iconUrl.match(/organizations\/.+/)?.[0]; - if (key) { + const prevKeyMatch = space.iconUrl.match(/organizations\/.+/); + if (prevKeyMatch && prevKeyMatch[0]) { try { - await bucket.deleteObject(key).pipe(runPromise); + await bucket.deleteObject(prevKeyMatch[0]); } catch (e) { // Log and continue console.warn("Failed to delete old space icon from S3", e); @@ -75,23 +71,18 @@ export async function uploadSpaceIcon(formData: FormData, spaceId: string) { const sanitizedFile = await sanitizeFile(file); - await bucket - .putObject( - fileKey, - Effect.promise(() => sanitizedFile.bytes()), - { contentType: file.type }, - ) - .pipe(runPromise); - - let iconUrl: string | undefined; + await bucket.putObject(fileKey, await sanitizedFile.bytes(), { + contentType: file.type, + }); // Construct the icon URL + let iconUrl; if (serverEnv().CAP_AWS_BUCKET_URL) { iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; } else if (serverEnv().CAP_AWS_ENDPOINT) { - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; + iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.name}/${fileKey}`; } else { - iconUrl = `https://${bucket.bucketName}.s3.${ + iconUrl = `https://${bucket.name}.s3.${ serverEnv().CAP_AWS_REGION || "us-east-1" }.amazonaws.com/${fileKey}`; } diff --git a/apps/web/actions/organizations/add-videos.ts b/apps/web/actions/organizations/add-videos.ts index 3571238088..080a4ea1e4 100644 --- a/apps/web/actions/organizations/add-videos.ts +++ b/apps/web/actions/organizations/add-videos.ts @@ -9,13 +9,12 @@ import { sharedVideos, videos, } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function addVideosToOrganization( organizationId: string, - videoIds: Video.VideoId[], + videoIds: string[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/organizations/remove-videos.ts b/apps/web/actions/organizations/remove-videos.ts index 307384fc39..bfebb3e9cc 100644 --- a/apps/web/actions/organizations/remove-videos.ts +++ b/apps/web/actions/organizations/remove-videos.ts @@ -9,13 +9,12 @@ import { sharedVideos, videos, } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function removeVideosFromOrganization( organizationId: string, - videoIds: Video.VideoId[], + videoIds: string[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/screenshots/get-screenshot.ts b/apps/web/actions/screenshots/get-screenshot.ts new file mode 100644 index 0000000000..b6bc3f3458 --- /dev/null +++ b/apps/web/actions/screenshots/get-screenshot.ts @@ -0,0 +1,67 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { s3Buckets, videos } from "@cap/database/schema"; +import { buildEnv, serverEnv } from "@cap/env"; +import { S3_BUCKET_URL } from "@cap/utils"; +import { eq } from "drizzle-orm"; +import { createBucketProvider } from "@/utils/s3"; + +export async function getScreenshot(userId: string, screenshotId: string) { + if (!userId || !screenshotId) { + throw new Error("userId or screenshotId not supplied"); + } + + const query = await db() + .select({ video: videos, bucket: s3Buckets }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, screenshotId)); + + if (query.length === 0) { + throw new Error("Video does not exist"); + } + + const result = query[0]; + if (!result?.video) { + throw new Error("Video not found"); + } + + const { video, bucket } = result; + + if (video.public === false) { + const user = await getCurrentUser(); + + if (!user || user.id !== video.ownerId) { + throw new Error("Video is not public"); + } + } + + const bucketProvider = await createBucketProvider(bucket); + const screenshotPrefix = `${userId}/${screenshotId}/`; + + try { + const objects = await bucketProvider.listObjects({ + prefix: screenshotPrefix, + }); + + const screenshot = objects.Contents?.find((object) => + object.Key?.endsWith(".png"), + ); + + if (!screenshot) { + throw new Error("Screenshot not found"); + } + + let screenshotUrl: string; + + if (video.awsBucket !== serverEnv().CAP_AWS_BUCKET) { + screenshotUrl = await bucketProvider.getSignedObjectUrl(screenshot.Key!); + } else { + screenshotUrl = `${S3_BUCKET_URL}/${screenshot.Key}`; + } + + return { url: screenshotUrl }; + } catch (error) { + throw new Error(`Error generating screenshot URL: ${error}`); + } +} diff --git a/apps/web/actions/spaces/add-videos.ts b/apps/web/actions/spaces/add-videos.ts index 0e5e77aec5..651d88cdb8 100644 --- a/apps/web/actions/spaces/add-videos.ts +++ b/apps/web/actions/spaces/add-videos.ts @@ -4,14 +4,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { spaces, spaceVideos, videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function addVideosToSpace( - spaceId: string, - videoIds: Video.VideoId[], -) { +export async function addVideosToSpace(spaceId: string, videoIds: string[]) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/spaces/remove-videos.ts b/apps/web/actions/spaces/remove-videos.ts index 0f252bfc9d..27454f3b84 100644 --- a/apps/web/actions/spaces/remove-videos.ts +++ b/apps/web/actions/spaces/remove-videos.ts @@ -3,13 +3,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders, spaceVideos, videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function removeVideosFromSpace( spaceId: string, - videoIds: Video.VideoId[], + videoIds: string[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 9bcd05a7c9..d6dc394a30 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -7,16 +7,13 @@ import { import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; +import { s3Buckets, videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; -import { type Folder, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; -import { runPromise } from "@/lib/server"; import { dub } from "@/utils/dub"; +import { createBucketProvider } from "@/utils/s3"; async function getVideoUploadPresignedUrl({ fileKey, @@ -88,6 +85,8 @@ async function getVideoUploadPresignedUrl({ } } + const bucket = await createBucketProvider(customBucket); + const contentType = fileKey.endsWith(".aac") ? "audio/aac" : fileKey.endsWith(".webm") @@ -109,27 +108,19 @@ async function getVideoUploadPresignedUrl({ "x-amz-meta-audiocodec": audioCodec ?? "", }; - const presignedPostData = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(customBucket?.id), - ); - - const presignedPostData = yield* bucket.getPresignedPostUrl(fileKey, { - Fields, - Expires: 1800, - }); + const presignedPostData = await bucket.getPresignedPostUrl(fileKey, { + Fields, + Expires: 1800, + }); - const customEndpoint = serverEnv().CAP_AWS_ENDPOINT; - if (customEndpoint && !customEndpoint.includes("amazonaws.com")) { - if (serverEnv().S3_PATH_STYLE) { - presignedPostData.url = `${customEndpoint}/${bucket.bucketName}`; - } else { - presignedPostData.url = customEndpoint; - } + const customEndpoint = serverEnv().CAP_AWS_ENDPOINT; + if (customEndpoint && !customEndpoint.includes("amazonaws.com")) { + if (serverEnv().S3_PATH_STYLE) { + presignedPostData.url = `${customEndpoint}/${bucket.name}`; + } else { + presignedPostData.url = customEndpoint; } - - return presignedPostData; - }).pipe(runPromise); + } const videoId = fileKey.split("/")[1]; if (videoId) { @@ -164,17 +155,15 @@ export async function createVideoAndGetUploadUrl({ isScreenshot = false, isUpload = false, folderId, - orgId, }: { - videoId?: Video.VideoId; + videoId?: string; duration?: number; resolution?: string; videoCodec?: string; audioCodec?: string; isScreenshot?: boolean; isUpload?: boolean; - folderId?: Folder.FolderId; - orgId: string; + folderId?: string; }) { const user = await getCurrentUser(); @@ -222,7 +211,9 @@ export async function createVideoAndGetUploadUrl({ } } - const idToUse = Video.VideoId.make(videoId || nanoId()); + const idToUse = videoId || nanoId(); + + const bucket = await createBucketProvider(customBucket); const videoData = { id: idToUse, @@ -230,7 +221,7 @@ export async function createVideoAndGetUploadUrl({ isScreenshot ? "Screenshot" : isUpload ? "Upload" : "Recording" } - ${formattedDate}`, ownerId: user.id, - orgId, + awsBucket: bucket.name, source: { type: "desktopMP4" as const }, isScreenshot, bucket: customBucket?.id, @@ -240,10 +231,6 @@ export async function createVideoAndGetUploadUrl({ await db().insert(videos).values(videoData); - await db().insert(videoUploads).values({ - videoId: idToUse, - }); - const fileKey = `${user.id}/${idToUse}/${ isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" }`; diff --git a/apps/web/actions/videos/download.ts b/apps/web/actions/videos/download.ts index 13acff3232..a32ae5afd3 100644 --- a/apps/web/actions/videos/download.ts +++ b/apps/web/actions/videos/download.ts @@ -3,13 +3,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; -export async function downloadVideo(videoId: Video.VideoId) { +export async function downloadVideo(videoId: string) { const user = await getCurrentUser(); if (!user || !videoId) { @@ -33,14 +30,10 @@ export async function downloadVideo(videoId: Video.VideoId) { } try { + const bucketProvider = await createBucketProvider(); const videoKey = `${video.ownerId}/${videoId}/result.mp4`; - const downloadUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(video.bucket), - ); - return yield* bucket.getSignedObjectUrl(videoKey); - }).pipe(runPromise); + const downloadUrl = await bucketProvider.getSignedObjectUrl(videoKey); return { success: true, diff --git a/apps/web/actions/videos/edit-date.ts b/apps/web/actions/videos/edit-date.ts index 1d3565cb69..d0ebbfae77 100644 --- a/apps/web/actions/videos/edit-date.ts +++ b/apps/web/actions/videos/edit-date.ts @@ -4,11 +4,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function editDate(videoId: Video.VideoId, date: string) { +export async function editDate(videoId: string, date: string) { const user = await getCurrentUser(); if (!user || !date || !videoId) { diff --git a/apps/web/actions/videos/edit-title.ts b/apps/web/actions/videos/edit-title.ts index d88a267d59..7760ebac34 100644 --- a/apps/web/actions/videos/edit-title.ts +++ b/apps/web/actions/videos/edit-title.ts @@ -3,11 +3,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function editTitle(videoId: Video.VideoId, title: string) { +export async function editTitle(videoId: string, title: string) { const user = await getCurrentUser(); if (!user || !title || !videoId) { diff --git a/apps/web/actions/videos/edit-transcript.ts b/apps/web/actions/videos/edit-transcript.ts index 4cd630a2b8..038363824a 100644 --- a/apps/web/actions/videos/edit-transcript.ts +++ b/apps/web/actions/videos/edit-transcript.ts @@ -1,17 +1,15 @@ "use server"; +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; export async function editTranscriptEntry( - videoId: Video.VideoId, + videoId: string, entryId: number, newText: string, ): Promise<{ success: boolean; message: string }> { @@ -52,28 +50,20 @@ export async function editTranscriptEntry( }; } - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(result.bucket?.id), - ).pipe(runPromise); + const bucket = await createBucketProvider(result.bucket); try { const transcriptKey = `${video.ownerId}/${videoId}/transcription.vtt`; - const vttContent = await bucket.getObject(transcriptKey).pipe(runPromise); - if (Option.isNone(vttContent)) + const vttContent = await bucket.getObject(transcriptKey); + if (!vttContent) return { success: false, message: "Transcript file not found" }; - const updatedVttContent = updateVttEntry( - vttContent.value, - entryId, - newText, - ); - - await bucket - .putObject(transcriptKey, updatedVttContent, { - contentType: "text/vtt", - }) - .pipe(runPromise); + const updatedVttContent = updateVttEntry(vttContent, entryId, newText); + + await bucket.putObject(transcriptKey, updatedVttContent, { + contentType: "text/vtt", + }); revalidatePath(`/s/${videoId}`); diff --git a/apps/web/actions/videos/generate-ai-metadata.ts b/apps/web/actions/videos/generate-ai-metadata.ts index 80d987fcb0..744d4d7cc9 100644 --- a/apps/web/actions/videos/generate-ai-metadata.ts +++ b/apps/web/actions/videos/generate-ai-metadata.ts @@ -1,20 +1,15 @@ "use server"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { db } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; -import { runPromise } from "@/lib/server"; - -export async function generateAiMetadata( - videoId: Video.VideoId, - userId: string, -) { +import { createBucketProvider } from "@/utils/s3"; +export async function generateAiMetadata(videoId: string, userId: string) { const groqClient = getGroqClient(); if (!groqClient && !serverEnv().OPENAI_API_KEY) { console.error( @@ -121,25 +116,27 @@ export async function generateAiMetadata( const { video } = row; - const vtt = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(row.bucket?.id), + const awsBucket = video.awsBucket; + if (!awsBucket) { + console.error( + `[generateAiMetadata] AWS bucket not found for video ${videoId}`, ); + throw new Error(`AWS bucket not found for video ${videoId}`); + } + + const bucket = await createBucketProvider(row.bucket); - return yield* bucket.getObject(`${userId}/${videoId}/transcription.vtt`); - }).pipe(runPromise); + const transcriptKey = `${userId}/${videoId}/transcription.vtt`; + const vtt = await bucket.getObject(transcriptKey); - if (Option.isNone(vtt)) { - console.error(`[generateAiMetadata] Transcript is empty`); - throw new Error("Transcript is empty"); - } else if (vtt.value.length < 10) { + if (!vtt || vtt.length < 10) { console.error( - `[generateAiMetadata] Transcript is too short (${vtt.value.length} chars)`, + `[generateAiMetadata] Transcript is empty or too short (${vtt?.length} chars)`, ); - throw new Error("Transcript is too short"); + throw new Error("Transcript is empty or too short"); } - const transcriptText = vtt.value + const transcriptText = vtt .split("\n") .filter( (l) => diff --git a/apps/web/actions/videos/get-og-image.tsx b/apps/web/actions/videos/get-og-image.tsx index cc10ffac5a..0eafb0aa8a 100644 --- a/apps/web/actions/videos/get-og-image.tsx +++ b/apps/web/actions/videos/get-og-image.tsx @@ -1,13 +1,10 @@ import { db } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; import { ImageResponse } from "next/og"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; -export async function generateVideoOgImage(videoId: Video.VideoId) { +export async function generateVideoOgImage(videoId: string) { const videoData = await getData(videoId); if (!videoData) { @@ -60,17 +57,13 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { ); } + const bucket = await createBucketProvider(videoData.bucket); + const screenshotKey = `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`; let screenshotUrl = null; try { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(videoData.bucket?.id), - ); - - screenshotUrl = yield* bucket.getSignedObjectUrl(screenshotKey); - }).pipe(runPromise); + screenshotUrl = await bucket.getSignedObjectUrl(screenshotKey); } catch (error) { console.error("Error generating URL for screenshot:", error); } @@ -152,7 +145,7 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { ); } -async function getData(videoId: Video.VideoId) { +async function getData(videoId: string) { const query = await db() .select({ video: videos, diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index eb4a0d8cd2..ac73e509ac 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -16,10 +16,11 @@ const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000; export interface VideoStatusResult { transcriptionStatus: "PROCESSING" | "COMPLETE" | "ERROR" | null; - aiTitle: string | null; aiProcessing: boolean; + aiTitle: string | null; summary: string | null; chapters: { title: string; start: number }[] | null; + // generationError: string | null; error?: string; } @@ -179,8 +180,7 @@ export async function getVideoStatus( ); } catch (error) { console.error( - "[Get Status] Error generating AI metadata for video %s", - videoId, + `[Get Status] Error generating AI metadata for video ${videoId}:`, error, ); @@ -198,14 +198,15 @@ export async function getVideoStatus( metadata: { ...currentMetadata, aiProcessing: false, + // generationError: + // error instanceof Error ? error.message : String(error), }, }) .where(eq(videos.id, videoId)); } } catch (resetError) { console.error( - `[Get Status] Failed to reset AI processing flag for video %s`, - videoId, + `[Get Status] Failed to reset AI processing flag for video ${videoId}:`, resetError, ); } diff --git a/apps/web/actions/videos/get-transcript.ts b/apps/web/actions/videos/get-transcript.ts index 90b910f7da..ca94aea1a2 100644 --- a/apps/web/actions/videos/get-transcript.ts +++ b/apps/web/actions/videos/get-transcript.ts @@ -3,14 +3,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { runPromise } from "@/lib/server"; +import { createBucketProvider } from "@/utils/s3"; export async function getTranscript( - videoId: Video.VideoId, + videoId: string, ): Promise<{ success: boolean; content?: string; message: string }> { const user = await getCurrentUser(); @@ -48,24 +45,20 @@ export async function getTranscript( }; } + const bucket = await createBucketProvider(result.bucket); + try { - const vttContent = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(result.bucket?.id), - ); + const transcriptKey = `${video.ownerId}/${videoId}/transcription.vtt`; - return yield* bucket.getObject( - `${video.ownerId}/${videoId}/transcription.vtt`, - ); - }).pipe(runPromise); + const vttContent = await bucket.getObject(transcriptKey); - if (Option.isNone(vttContent)) { + if (!vttContent) { return { success: false, message: "Transcript file not found" }; } return { success: true, - content: vttContent.value, + content: vttContent, message: "Transcript retrieved successfully", }; } catch (error) { diff --git a/apps/web/actions/videos/get-user-videos.ts b/apps/web/actions/videos/get-user-videos.ts index d12d4c0431..64bb3f5803 100644 --- a/apps/web/actions/videos/get-user-videos.ts +++ b/apps/web/actions/videos/get-user-videos.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { comments, users, videos, videoUploads } from "@cap/database/schema"; +import { comments, users, videos } from "@cap/database/schema"; import { desc, eq, sql } from "drizzle-orm"; export async function getUserVideos(limit?: number) { @@ -31,14 +31,10 @@ export async function getUserVideos(limit?: number) { ${videos.createdAt} ) `, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), }) .from(videos) .leftJoin(comments, eq(videos.id, comments.videoId)) .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) .where(eq(videos.ownerId, userId)) .groupBy( videos.id, diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 85a4466f7b..ea0e0b63a0 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -4,16 +4,14 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { comments } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { revalidatePath } from "next/cache"; import { createNotification } from "@/lib/Notification"; export async function newComment(data: { content: string; - videoId: Video.VideoId; + videoId: string; type: "text" | "emoji"; parentCommentId: string; - timestamp: number; }) { const user = await getCurrentUser(); @@ -25,7 +23,6 @@ export async function newComment(data: { const videoId = data.videoId; const type = data.type; const parentCommentId = data.parentCommentId; - const timestamp = data.timestamp; const conditionalType = parentCommentId ? "reply" : type === "emoji" @@ -43,7 +40,7 @@ export async function newComment(data: { type: type, content: content, videoId: videoId, - timestamp: timestamp ?? null, + timestamp: null, parentCommentId: parentCommentId, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/web/actions/videos/password.ts b/apps/web/actions/videos/password.ts index 5046e27e82..3396a242c5 100644 --- a/apps/web/actions/videos/password.ts +++ b/apps/web/actions/videos/password.ts @@ -4,15 +4,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { encrypt, hashPassword, verifyPassword } from "@cap/database/crypto"; import { videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; -export async function setVideoPassword( - videoId: Video.VideoId, - password: string, -) { +export async function setVideoPassword(videoId: string, password: string) { try { const user = await getCurrentUser(); @@ -46,7 +42,7 @@ export async function setVideoPassword( } } -export async function removeVideoPassword(videoId: Video.VideoId) { +export async function removeVideoPassword(videoId: string) { try { const user = await getCurrentUser(); @@ -79,10 +75,7 @@ export async function removeVideoPassword(videoId: Video.VideoId) { } } -export async function verifyVideoPassword( - videoId: Video.VideoId, - password: string, -) { +export async function verifyVideoPassword(videoId: string, password: string) { try { if (!videoId || typeof password !== "string") throw new Error("Missing data"); @@ -98,7 +91,7 @@ export async function verifyVideoPassword( if (!valid) throw new Error("Invalid password"); - (await cookies()).set("x-cap-password", await encrypt(video.password)); + cookies().set("x-cap-password", await encrypt(video.password)); return { success: true, value: "Password verified" }; } catch (error) { diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 3bd54d2b5d..7d5e9c1c9a 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -23,8 +23,6 @@ type SharedContext = { sidebarCollapsed: boolean; upgradeModalOpen: boolean; setUpgradeModalOpen: (open: boolean) => void; - referClickedState: boolean; - setReferClickedStateHandler: (referClicked: boolean) => void; }; type ITheme = "light" | "dark"; @@ -54,7 +52,6 @@ export function DashboardContexts({ anyNewNotifications, initialTheme, initialSidebarCollapsed, - referClicked, }: { children: React.ReactNode; organizationData: SharedContext["organizationData"]; @@ -66,14 +63,12 @@ export function DashboardContexts({ anyNewNotifications: boolean; initialTheme: ITheme; initialSidebarCollapsed: boolean; - referClicked: boolean; }) { const [theme, setTheme] = useState(initialTheme); const [sidebarCollapsed, setSidebarCollapsed] = useState( initialSidebarCollapsed, ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); - const [referClickedState, setReferClickedState] = useState(referClicked); const pathname = usePathname(); // Calculate user's spaces (both owned and member of) @@ -94,7 +89,7 @@ export function DashboardContexts({ (member) => member.userId === user.id && member.organizationId === space.organizationId && - member.role === "member", + member.role === "MEMBER", ), ) || null; @@ -130,7 +125,6 @@ export function DashboardContexts({ document.body.className = "light"; }; }, [theme]); - const toggleSidebarCollapsed = () => { setSidebarCollapsed(!sidebarCollapsed); Cookies.set("sidebarCollapsed", !sidebarCollapsed ? "true" : "false", { @@ -138,13 +132,6 @@ export function DashboardContexts({ }); }; - const setReferClickedStateHandler = (referClicked: boolean) => { - setReferClickedState(referClicked); - Cookies.set("referClicked", referClicked ? "true" : "false", { - expires: 365, - }); - }; - return ( {children} diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Refer.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Refer.tsx index aae32a2be9..a7572753c7 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Refer.tsx +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Refer.tsx @@ -53,7 +53,6 @@ const ReferIcon = forwardRef( onAnimationComplete={() => setIsAnimating(false)} {...props} > - Box = { + "/dashboard/caps": "Caps", + "/dashboard/shared-caps": "Shared Caps", + "/dashboard/settings/organization": "Organization Settings", + "/dashboard/settings/account": "Account Settings", + "/dashboard/spaces": "Spaces", + "/dashboard/spaces/browse": "Browse Spaces", + }; + + const title = activeSpace ? activeSpace.name : titles[pathname] || ""; + const { theme, setThemeHandler } = useTheme(); + const [toggleNotifications, setToggleNotifications] = useState(false); + const bellRef = useRef(null); + const notificationsRef: MutableRefObject = useClickAway( + (e) => { + if (bellRef.current && !bellRef.current.contains(e.target as Node)) { + setToggleNotifications(false); + } + }, + ); + const isSharedCapsPage = pathname === "/dashboard/shared-caps"; return ( -
- +
+
+
+ {activeSpace && Space} +
+ {activeSpace && + (activeSpace.iconUrl ? ( + {activeSpace?.name + ) : ( + + ))} +

+ {title} +

+
+
+
+ {buildEnv.NEXT_PUBLIC_IS_CAP && } +
{ + setToggleNotifications(!toggleNotifications); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setToggleNotifications(!toggleNotifications); + } + }} + tabIndex={0} + role="button" + aria-label={`Notifications${ + anyNewNotifications ? " (new notifications available)" : "" + }`} + aria-expanded={toggleNotifications} + className="hidden relative justify-center data-[state=open]:hover:bg-gray-5 items-center bg-gray-3 + rounded-full transition-colors cursor-pointer lg:flex + hover:bg-gray-5 data-[state=open]:bg-gray-5 + focus:outline-none + size-9" + > + {anyNewNotifications && ( +
+
+
+
+
+
+ )} + + + {toggleNotifications && } + +
+
{ + if (document.startViewTransition) { + document.startViewTransition(() => { + setThemeHandler(theme === "light" ? "dark" : "light"); + }); + } else { + setThemeHandler(theme === "light" ? "dark" : "light"); + } + }} + className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" + > + +
+ +
+
- {/* Top cap: renders rounded corner and top/side borders without affecting scroller */} -
- {/* Scrolling content area shares border/background; top border removed to meet cap */} -
-
{children}
-
+
{children}
{isSharedCapsPage && activeOrganization?.members && ( ); } + +const User = () => { + const [menuOpen, setMenuOpen] = useState(false); + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const { user, isSubscribed } = useDashboardContext(); + + const menuItems = useMemo( + () => [ + { + name: "Homepage", + icon: , + href: "/home", + onClick: () => setMenuOpen(false), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: true, + }, + { + name: "Upgrade to Pro", + icon: , + onClick: () => { + setMenuOpen(false); + setUpgradeModalOpen(true); + }, + iconClassName: "text-amber-400 group-hover:text-amber-500", + showCondition: !isSubscribed && buildEnv.NEXT_PUBLIC_IS_CAP, + }, + { + name: "Earn 40% Referral", + icon: , + href: "/dashboard/refer", + onClick: () => setMenuOpen(false), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: buildEnv.NEXT_PUBLIC_IS_CAP, + }, + { + name: "Settings", + icon: , + href: "/dashboard/settings/account", + onClick: () => setMenuOpen(false), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: true, + }, + { + name: "Chat Support", + icon: , + onClick: () => window.open("https://cap.link/discord", "_blank"), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: true, + }, + { + name: "Download App", + icon: , + onClick: () => window.open("https://cap.so/download", "_blank"), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: true, + }, + { + name: "Sign Out", + icon: , + onClick: () => signOut(), + iconClassName: "text-gray-11 group-hover:text-gray-12", + showCondition: true, + }, + ], + [], + ); + + return ( + <> + + + +
+
+ {user.image ? ( + {user.name + ) : ( + + )} + + {user.name ?? "User"} + +
+ +
+
+ + + + {menuItems + .filter((item) => item.showCondition) + .map((item, index) => ( + + ))} + + + +
+ + ); +}; + +interface Props { + icon: React.ReactElement; + name: string; + href?: string; + onClick: () => void; + iconClassName?: string; +} + +const MenuItem = memo(({ icon, name, href, onClick, iconClassName }: Props) => { + const iconRef = useRef(null); + return ( + { + iconRef.current?.startAnimation(); + }} + onMouseLeave={() => { + iconRef.current?.stopAnimation(); + }} + > + +
+ {cloneElement(icon, { + ref: iconRef, + className: iconClassName, + size: 14, + })} +
+

{name}

+ +
+ ); +}); + +const ReferButton = () => { + const iconRef = useRef(null); + + return ( + + {/* Red notification dot with pulse animation */} +
+
+
+
+
+
+ +
{ + iconRef.current?.startAnimation(); + }} + onMouseLeave={() => { + iconRef.current?.stopAnimation(); + }} + className="flex justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 hover:bg-gray-5 size-9" + > + {cloneElement(, { + ref: iconRef, + className: "text-gray-12 size-3.5", + })} +
+ + ); +}; diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx deleted file mode 100644 index b4cd329016..0000000000 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -import { Avatar } from "@cap/ui"; -import { useClickAway } from "@uidotdev/usehooks"; -import clsx from "clsx"; -import { Check, ChevronDown } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - type Dispatch, - type LegacyRef, - type MutableRefObject, - type SetStateAction, - useRef, - useState, -} from "react"; -import { useDashboardContext } from "../Contexts"; -import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; -import { updateActiveOrganization } from "./Navbar/server"; - -const Tabs = [ - { icon: , href: "/dashboard/spaces/browse" }, - { icon: , href: "/dashboard/caps" }, - { - icon: , - href: "/dashboard/settings/organization", - ownerOnly: true, - }, -]; - -const MobileTab = () => { - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - const { activeOrganization: activeOrg, user } = useDashboardContext(); - const isOwner = activeOrg?.organization.ownerId === user.id; - const menuRef = useClickAway((e) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - }); - return ( -
- - {open && } - - -
- {Tabs.filter((i) => !i.ownerOnly || isOwner).map((tab) => ( - - {tab.icon} - - ))} -
-
- ); -}; - -const Orgs = ({ - setOpen, - open, - containerRef, -}: { - setOpen: Dispatch>; - open: boolean; - containerRef: MutableRefObject; -}) => { - const { activeOrganization: activeOrg } = useDashboardContext(); - return ( -
setOpen((p) => !p)} - ref={containerRef} - className="flex gap-1.5 items-center p-2 rounded-full border bg-gray-3 border-gray-5" - > - {activeOrg?.organization.iconUrl ? ( -
- {activeOrg.organization.name -
- ) : ( - - )} -

- {activeOrg?.organization.name} -

- -
- ); -}; - -const OrgsMenu = ({ - setOpen, - menuRef, -}: { - setOpen: Dispatch>; - menuRef: MutableRefObject; -}) => { - const { activeOrganization: activeOrg, organizationData: orgData } = - useDashboardContext(); - const router = useRouter(); - return ( - } - className={ - "isolate absolute overscroll-contain bottom-14 p-2 space-y-1.5 w-full rounded-xl h-fit border bg-gray-3 max-h-[325px] custom-scroll max-w-[200px] border-gray-4" - } - > - {orgData?.map((organization) => { - const isSelected = - activeOrg?.organization.id === organization.organization.id; - return ( -
{ - await updateActiveOrganization(organization.organization.id); - setOpen(false); - router.push("/dashboard/caps"); - }} - > -
- {organization.organization.iconUrl ? ( -
- {organization.organization.name -
- ) : ( - - )} -

- {organization.organization.name} -

- {isSelected && ( - - )} -
-
- ); - })} -
- ); -}; - -export default MobileTab; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx index 7e490ad099..cffe0f3617 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx @@ -24,11 +24,11 @@ const CapAIBox = ({ }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} - className="hidden p-3 mb-6 w-[calc(100%-12px)] mx-auto rounded-xl border transition-colors cursor-pointer md:block hover:bg-gray-2 h-fit border-gray-3" + className="hidden p-3 mb-6 w-full rounded-xl border transition-colors cursor-pointer md:block hover:bg-gray-2 h-fit border-gray-3" >

Cap AI

-

Available now

+

Available now

void }) => {

Features include:

-
    +
      {[ "Auto-generated titles", "Recording summaries", @@ -65,7 +65,7 @@ const CapAIDialog = ({ setOpen }: { setOpen: (open: boolean) => void }) => { icon={faWandMagicSparkles} className="mr-2 mt-0.5 text-blue-11 size-3" /> - {feature} + {feature} ))}
    diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx index e683f4dd33..f4c3c0d58e 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx @@ -26,40 +26,45 @@ export const DesktopNav = () => { toggleSidebarCollapsed(); } }; + window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; }, [toggleSidebarCollapsed]); return ( - -
    +
    +
    + {
    - + ); }; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index eaa8988bb6..b0ddcad51d 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -19,11 +19,7 @@ import { PopoverTrigger, } from "@cap/ui"; import { classNames } from "@cap/utils"; -import { - faBuilding, - faCircleInfo, - faLink, -} from "@fortawesome/free-solid-svg-icons"; +import { faBuilding } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; @@ -31,7 +27,7 @@ import { Check, ChevronDown, Plus } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { cloneElement, type RefObject, useRef, useState } from "react"; +import { cloneElement, useRef, useState } from "react"; import { NewOrganization } from "@/components/forms/NewOrganization"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; @@ -46,6 +42,9 @@ interface Props { toggleMobileNav?: () => void; } +export const navItemClass = + "flex items-center justify-start rounded-xl outline-none tracking-tight overflow-hidden"; + const AdminNavItems = ({ toggleMobileNav }: Props) => { const pathname = usePathname(); const [open, setOpen] = useState(false); @@ -92,9 +91,6 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const router = useRouter(); const isPathActive = (path: string) => pathname.includes(path); - const isDomainSetupVerified = - activeOrg?.organization.customDomain && - activeOrg?.organization.domainVerified; return ( @@ -111,13 +107,13 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { duration: 0.2, }} className={clsx( - "mt-1.5 mx-auto rounded-xl cursor-pointer bg-gray-3", - sidebarCollapsed ? "w-fit px-2 py-0.5" : "w-full p-2.5", + "mt-1.5 mx-auto p-2.5 rounded-xl cursor-pointer bg-gray-3", + sidebarCollapsed ? "w-fit" : "w-full", )} >
    { "flex items-center", sidebarCollapsed ? "justify-center w-fit" - : "justify-between gap-2.5 w-full", + : "justify-between w-full", )} >
    {activeOrg?.organization.iconUrl ? ( -
    +
    { { ) : ( { } /> )} -
    -
    -
    - {!sidebarCollapsed && ( -

    - {activeOrg?.organization.name ?? - "No organization found"} -

    - )} - {!sidebarCollapsed && ( - - )} -
    {!sidebarCollapsed && ( - - -

    - {isDomainSetupVerified - ? activeOrg?.organization.customDomain - : "No custom domain set"} -

    - +

    + {activeOrg?.organization.name ?? + "No organization found"} +

    )}
    + {!sidebarCollapsed && ( + + )}
    { ? "pointer-events-none" : "text-gray-10 hover:text-gray-12 hover:bg-gray-6", )} - key={`${organization.organization.name}-organization`} + key={organization.organization.name + "-organization"} onSelect={async () => { await updateActiveOrganization( organization.organization.id, @@ -448,11 +409,7 @@ const NavItem = ({ }: { name: string; href: string; - icon: React.ReactElement<{ - ref: RefObject; - className: string; - size: number; - }>; + icon: React.ReactElement; sidebarCollapsed: boolean; toggleMobileNav?: () => void; isPathActive: (path: string) => boolean; @@ -479,7 +436,7 @@ const NavItem = ({ isPathActive(href) ? "bg-transparent pointer-events-none" : "hover:bg-gray-2", - "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", + navItemClass, )} > {cloneElement(icon, { diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx index a40afd15ed..398a897f95 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx @@ -5,7 +5,7 @@ import { faMoon, faSun } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useClickAway } from "@uidotdev/usehooks"; import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; +import { Menu, X } from "lucide-react"; import Link from "next/link"; import { type MutableRefObject, useState } from "react"; import { useTheme } from "../../Contexts"; @@ -54,7 +54,7 @@ export const AdminMobileNav = () => {
    -
    +
    { setThemeHandler(theme === "light" ? "dark" : "light"); @@ -66,6 +66,12 @@ export const AdminMobileNav = () => { icon={theme === "dark" ? faSun : faMoon} />
    +
    diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index 1f4779a40c..a02b1720ad 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -109,7 +109,7 @@ const SpaceDialog = ({ export interface NewSpaceFormProps { onSpaceCreated: () => void; - formRef?: React.RefObject; + formRef?: React.RefObject; setCreateLoading?: React.Dispatch>; onNameChange?: (name: string) => void; edit?: boolean; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx index ad77e09ce7..0b7588e365 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx @@ -23,6 +23,7 @@ import type { Spaces } from "../../dashboard-data"; import { LayersIcon } from "../AnimatedIcons"; import type { LayersIconHandle } from "../AnimatedIcons/Layers"; import { ConfirmationDialog } from "../ConfirmationDialog"; +import { navItemClass } from "./Items"; import SpaceDialog from "./SpaceDialog"; const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { @@ -180,7 +181,7 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { pathname.includes("/dashboard/spaces/browse") ? "bg-gray-3 pointer-events-none" : "hover:bg-gray-2", - "flex items-center justify-start rounded-xl outline-none tracking-tight overflow-hidden", + navItemClass, )} > { - const { activeSpace, anyNewNotifications, activeOrganization } = - useDashboardContext(); - const [toggleNotifications, setToggleNotifications] = useState(false); - const bellRef = useRef(null); - const { theme, setThemeHandler } = useTheme(); - const queryClient = useQueryClient(); - - const pathname = usePathname(); - - const titles: Record = { - "/dashboard/caps": "Caps", - "/dashboard/folder": "Caps", - "/dashboard/shared-caps": "Shared Caps", - "/dashboard/settings/organization": "Organization Settings", - "/dashboard/settings/account": "Account Settings", - "/dashboard/spaces": "Spaces", - "/dashboard/spaces/browse": "Browse Spaces", - }; - - const title = activeSpace - ? activeSpace.name - : pathname.includes("/dashboard/folder") - ? "Caps" - : titles[pathname] || ""; - - const notificationsRef: MutableRefObject = useClickAway( - (e) => { - if (bellRef.current && !bellRef.current.contains(e.target as Node)) { - setToggleNotifications(false); - } - }, - ); - - const markAllAsread = useMutation({ - mutationFn: () => markAsRead(), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["notifications"], - }); - }, - onError: (error) => { - console.error("Error marking notifications as read:", error); - }, - }); - - return ( -
    -
    - {activeSpace && Space} -
    - {activeSpace && - (activeSpace.iconUrl ? ( - {activeSpace?.name - ) : ( - - ))} -

    - {title} -

    -
    -
    -
    - {buildEnv.NEXT_PUBLIC_IS_CAP && } -
    { - if (anyNewNotifications) { - markAllAsread.mutate(); - } - setToggleNotifications(!toggleNotifications); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (anyNewNotifications) { - markAllAsread.mutate(); - } - setToggleNotifications(!toggleNotifications); - } - }} - tabIndex={0} - role="button" - aria-label={`Notifications${ - anyNewNotifications ? " (new notifications available)" : "" - }`} - aria-expanded={toggleNotifications} - className="hidden relative justify-center data-[state=open]:hover:bg-gray-5 items-center bg-gray-3 - rounded-full transition-colors cursor-pointer lg:flex - hover:bg-gray-5 data-[state=open]:bg-gray-5 - focus:outline-none - size-9" - > - {anyNewNotifications && ( -
    -
    -
    -
    -
    -
    - )} - - - {toggleNotifications && } - -
    -
    { - if (document.startViewTransition) { - document.startViewTransition(() => { - setThemeHandler(theme === "light" ? "dark" : "light"); - }); - } else { - setThemeHandler(theme === "light" ? "dark" : "light"); - } - }} - className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" - > - -
    - -
    -
    - ); -}; - -const User = () => { - const [menuOpen, setMenuOpen] = useState(false); - const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); - const { user, isSubscribed } = useDashboardContext(); - - const menuItems = useMemo( - () => [ - { - name: "Homepage", - icon: , - href: "/home", - onClick: () => setMenuOpen(false), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - { - name: "Upgrade to Pro", - icon: , - onClick: () => { - setMenuOpen(false); - setUpgradeModalOpen(true); - }, - iconClassName: "text-amber-400 group-hover:text-amber-500", - showCondition: !isSubscribed && buildEnv.NEXT_PUBLIC_IS_CAP, - }, - { - name: "Earn 40% Referral", - icon: , - href: "/dashboard/refer", - onClick: () => setMenuOpen(false), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: buildEnv.NEXT_PUBLIC_IS_CAP, - }, - { - name: "Settings", - icon: , - href: "/dashboard/settings/account", - onClick: () => setMenuOpen(false), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - { - name: "Chat Support", - icon: , - onClick: () => window.open("https://cap.link/discord", "_blank"), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - { - name: "Download App", - icon: , - onClick: () => window.open("https://cap.so/download", "_blank"), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - { - name: "Sign Out", - icon: , - onClick: () => signOut(), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - ], - [], - ); - - return ( - <> - - - -
    -
    - {user.image ? ( - {user.name - ) : ( - - )} - - {user.name ?? "User"} - -
    - -
    -
    - - - - {menuItems - .filter((item) => item.showCondition) - .map((item, index) => ( - - ))} - - - -
    - - ); -}; - -interface Props { - icon: React.ReactElement<{ - ref: RefObject; - className: string; - size: number; - }>; - name: string; - href?: string; - onClick: () => void; - iconClassName?: string; -} - -const MenuItem = memo(({ icon, name, href, onClick, iconClassName }: Props) => { - const iconRef = useRef(null); - return ( - { - iconRef.current?.startAnimation(); - }} - onMouseLeave={() => { - iconRef.current?.stopAnimation(); - }} - > - -
    - {cloneElement(icon, { - ref: iconRef, - className: iconClassName, - size: 14, - })} -
    -

    {name}

    - -
    - ); -}); - -const ReferButton = () => { - const iconRef = useRef(null); - const { setReferClickedStateHandler, referClickedState } = - useDashboardContext(); - - return ( - - {!referClickedState && ( -
    -
    -
    -
    -
    -
    - )} - -
    { - setReferClickedStateHandler(true); - }} - onMouseEnter={() => { - iconRef.current?.startAnimation(); - }} - onMouseLeave={() => { - iconRef.current?.stopAnimation(); - }} - className="flex justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 hover:bg-gray-5 size-9" - > - {cloneElement(, { - ref: iconRef, - className: "text-gray-12 size-3.5", - })} -
    - - ); -}; - -export default Top; diff --git a/apps/web/app/(org)/dashboard/_components/actions.ts b/apps/web/app/(org)/dashboard/_components/actions.ts index aefafd7739..a1506be070 100644 --- a/apps/web/app/(org)/dashboard/_components/actions.ts +++ b/apps/web/app/(org)/dashboard/_components/actions.ts @@ -2,6 +2,6 @@ import { cookies } from "next/headers"; export const setTheme = async (newTheme: "light" | "dark") => { - const cookieStore = await cookies(); + const cookieStore = cookies(); cookieStore.set("theme", newTheme); }; diff --git a/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx b/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx new file mode 100644 index 0000000000..1cbc882532 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getPaidUsersStatsInRange, getUsersCreatedInRange } from "./actions"; +import type { DateRange } from "./dateRangeUtils"; +import UserLookup from "./UserLookup"; + +type Stats = { + newUsers: number | null; + paidUsersStats: { + totalPaidUsers: number; + usersWhoCreatedVideoFirst: number; + percentage: number; + } | null; +}; + +export default function AdminDashboardClient() { + const [dateRange, setDateRange] = useState("today"); + const [stats, setStats] = useState({ + newUsers: null, + paidUsersStats: null, + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + const [newUsers, paidUsersStats] = await Promise.all([ + getUsersCreatedInRange(dateRange), + getPaidUsersStatsInRange(dateRange), + ]); + setStats({ newUsers, paidUsersStats }); + setLoading(false); + }; + + fetchStats(); + }, [dateRange]); + + return ( +
    +
    +

    Admin Dashboard

    + +
    + +
    +
    +

    New Users

    + {loading ? ( +
    + ) : ( +

    + {stats.newUsers || 0} +

    + )} +
    + +
    +

    + New Paid Users +

    + {loading ? ( +
    + ) : ( +

    + {stats.paidUsersStats?.totalPaidUsers || 0} +

    + )} +
    + +
    +

    + Created Video Before Paying +

    + {loading ? ( +
    +
    +
    +
    + ) : ( + <> +

    + {stats.paidUsersStats?.percentage || 0}% +

    +

    + {stats.paidUsersStats?.usersWhoCreatedVideoFirst || 0} of{" "} + {stats.paidUsersStats?.totalPaidUsers || 0} users +

    + + )} +
    +
    + +
    +

    User Lookup

    + +
    +
    + ); +} diff --git a/apps/web/app/(org)/dashboard/admin/UserLookup.tsx b/apps/web/app/(org)/dashboard/admin/UserLookup.tsx new file mode 100644 index 0000000000..2f92a0f2b9 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/UserLookup.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; +import { lookupUserById } from "./actions"; + +export default function UserLookup() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const formData = new FormData(e.currentTarget); + const result = await lookupUserById(formData); + setData(result); + } catch (error) { + console.error("Error looking up user:", error); + } finally { + setLoading(false); + } + }; + + return ( +
    +
    +
    + +
    + + +
    +
    +
    + + {data && ( +
    +

    User Data:

    +
    +						{JSON.stringify(data, null, 2)}
    +					
    +
    + )} +
    + ); +} diff --git a/apps/web/app/(org)/dashboard/admin/actions.ts b/apps/web/app/(org)/dashboard/admin/actions.ts new file mode 100644 index 0000000000..6fcdb0eba6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/actions.ts @@ -0,0 +1,301 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { users, videos } from "@cap/database/schema"; +import { stripe } from "@cap/utils"; +import { and, eq, gte, isNotNull, lte, or, sql } from "drizzle-orm"; +import { type DateRange, getDateRangeFilter } from "./dateRangeUtils"; + +export async function lookupUserById(data: FormData) { + const currentUser = await getCurrentUser(); + if (currentUser?.email !== "richie@mcilroy.co") return; + + const [user] = await db() + .select() + .from(users) + .where(eq(users.id, data.get("id") as string)); + + return user; +} + +export async function getUsersCreatedToday() { + const currentUser = await getCurrentUser(); + if (currentUser?.email !== "richie@mcilroy.co") return null; + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const result = await db() + .select({ count: sql`count(*)` }) + .from(users) + .where(gte(users.created_at, startOfToday)); + + return result[0]?.count || 0; +} + +export async function getUsersCreatedInRange(dateRange: DateRange) { + const currentUser = await getCurrentUser(); + if (currentUser?.email !== "richie@mcilroy.co") return null; + + const { start, end } = getDateRangeFilter(dateRange); + + const result = await db() + .select({ count: sql`count(*)` }) + .from(users) + .where(and(gte(users.created_at, start), lte(users.created_at, end))); + + return result[0]?.count || 0; +} + +export async function getPaidUsersStats() { + const currentUser = await getCurrentUser(); + if (currentUser?.email !== "richie@mcilroy.co") return null; + + // Get all users with active subscriptions (including third-party) + const paidUsers = await db() + .select({ + id: users.id, + email: users.email, + stripeCustomerId: users.stripeCustomerId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionId: users.stripeSubscriptionId, + thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId, + created_at: users.created_at, + }) + .from(users) + .where( + or( + and( + isNotNull(users.stripeSubscriptionId), + or( + eq(users.stripeSubscriptionStatus, "active"), + eq(users.stripeSubscriptionStatus, "trialing"), + ), + ), + isNotNull(users.thirdPartyStripeSubscriptionId), + ), + ); + + // For each paid user, check if they created videos before subscribing + const paidUsersWithVideoCheck = await Promise.all( + paidUsers.map(async (user) => { + // Get subscription start date from Stripe + let subscriptionStartDate: Date | null = null; + + if ( + user.stripeCustomerId && + user.stripeSubscriptionId && + !user.thirdPartyStripeSubscriptionId + ) { + try { + const subscription = await stripe().subscriptions.retrieve( + user.stripeSubscriptionId, + ); + subscriptionStartDate = new Date(subscription.created * 1000); + } catch (error) { + console.error( + `Failed to fetch subscription for user ${user.id}:`, + error, + ); + // For third-party subscriptions or errors, assume they subscribed when first video was created + const firstVideo = await db() + .select({ createdAt: videos.createdAt }) + .from(videos) + .where(eq(videos.ownerId, user.id)) + .orderBy(videos.createdAt) + .limit(1); + + if (firstVideo[0]) { + // Add 1 day to first video as a conservative estimate + subscriptionStartDate = new Date(firstVideo[0].createdAt); + subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); + } + } + } else if (user.thirdPartyStripeSubscriptionId) { + // For third-party subscriptions, we can't get the exact date from Stripe + // So we'll use a conservative approach: assume they subscribed after their first video + const firstVideo = await db() + .select({ createdAt: videos.createdAt }) + .from(videos) + .where(eq(videos.ownerId, user.id)) + .orderBy(videos.createdAt) + .limit(1); + + if (firstVideo[0]) { + // Add 1 day to first video as a conservative estimate + subscriptionStartDate = new Date(firstVideo[0].createdAt); + subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); + } + } + + // Check if user created videos before subscription + let createdVideoBeforeSubscription = false; + if (subscriptionStartDate) { + const videosBeforeSubscription = await db() + .select({ count: sql`count(*)` }) + .from(videos) + .where( + and( + eq(videos.ownerId, user.id), + sql`${videos.createdAt} < ${subscriptionStartDate}`, + ), + ); + + createdVideoBeforeSubscription = + (videosBeforeSubscription[0]?.count || 0) > 0; + } + + return { + ...user, + subscriptionStartDate, + createdVideoBeforeSubscription, + }; + }), + ); + + const totalPaidUsers = paidUsersWithVideoCheck.length; + const usersWhoCreatedVideoFirst = paidUsersWithVideoCheck.filter( + (user) => user.createdVideoBeforeSubscription, + ).length; + + const percentage = + totalPaidUsers > 0 + ? Math.round((usersWhoCreatedVideoFirst / totalPaidUsers) * 100) + : 0; + + return { + totalPaidUsers, + usersWhoCreatedVideoFirst, + percentage, + }; +} + +export async function getPaidUsersStatsInRange(dateRange: DateRange) { + const currentUser = await getCurrentUser(); + if (currentUser?.email !== "richie@mcilroy.co") return null; + + const { start, end } = getDateRangeFilter(dateRange); + + // Get paid users who joined within the date range + const paidUsers = await db() + .select({ + id: users.id, + email: users.email, + stripeCustomerId: users.stripeCustomerId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionId: users.stripeSubscriptionId, + thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId, + created_at: users.created_at, + }) + .from(users) + .where( + and( + or( + and( + isNotNull(users.stripeSubscriptionId), + or( + eq(users.stripeSubscriptionStatus, "active"), + eq(users.stripeSubscriptionStatus, "trialing"), + ), + ), + isNotNull(users.thirdPartyStripeSubscriptionId), + ), + gte(users.created_at, start), + lte(users.created_at, end), + ), + ); + + // For each paid user, check if they created videos before subscribing + const paidUsersWithVideoCheck = await Promise.all( + paidUsers.map(async (user) => { + // Get subscription start date from Stripe + let subscriptionStartDate: Date | null = null; + + if ( + user.stripeCustomerId && + user.stripeSubscriptionId && + !user.thirdPartyStripeSubscriptionId + ) { + try { + const subscription = await stripe().subscriptions.retrieve( + user.stripeSubscriptionId, + ); + subscriptionStartDate = new Date(subscription.created * 1000); + } catch (error) { + console.error( + `Failed to fetch subscription for user ${user.id}:`, + error, + ); + // For third-party subscriptions or errors, assume they subscribed when first video was created + const firstVideo = await db() + .select({ createdAt: videos.createdAt }) + .from(videos) + .where(eq(videos.ownerId, user.id)) + .orderBy(videos.createdAt) + .limit(1); + + if (firstVideo[0]) { + // Add 1 day to first video as a conservative estimate + subscriptionStartDate = new Date(firstVideo[0].createdAt); + subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); + } + } + } else if (user.thirdPartyStripeSubscriptionId) { + // For third-party subscriptions, we can't get the exact date from Stripe + // So we'll use a conservative approach: assume they subscribed after their first video + const firstVideo = await db() + .select({ createdAt: videos.createdAt }) + .from(videos) + .where(eq(videos.ownerId, user.id)) + .orderBy(videos.createdAt) + .limit(1); + + if (firstVideo[0]) { + // Add 1 day to first video as a conservative estimate + subscriptionStartDate = new Date(firstVideo[0].createdAt); + subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); + } + } + + // Check if user created videos before subscription + let createdVideoBeforeSubscription = false; + if (subscriptionStartDate) { + const videosBeforeSubscription = await db() + .select({ count: sql`count(*)` }) + .from(videos) + .where( + and( + eq(videos.ownerId, user.id), + sql`${videos.createdAt} < ${subscriptionStartDate}`, + ), + ); + + createdVideoBeforeSubscription = + (videosBeforeSubscription[0]?.count || 0) > 0; + } + + return { + ...user, + subscriptionStartDate, + createdVideoBeforeSubscription, + }; + }), + ); + + const totalPaidUsers = paidUsersWithVideoCheck.length; + const usersWhoCreatedVideoFirst = paidUsersWithVideoCheck.filter( + (user) => user.createdVideoBeforeSubscription, + ).length; + + const percentage = + totalPaidUsers > 0 + ? Math.round((usersWhoCreatedVideoFirst / totalPaidUsers) * 100) + : 0; + + return { + totalPaidUsers, + usersWhoCreatedVideoFirst, + percentage, + }; +} diff --git a/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts b/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts new file mode 100644 index 0000000000..47c897e2e4 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts @@ -0,0 +1,51 @@ +export type DateRange = + | "today" + | "yesterday" + | "last7days" + | "thisMonth" + | "allTime"; + +export function getDateRangeFilter(range: DateRange): { + start: Date; + end: Date; +} { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (range) { + case "today": + return { + start: today, + end: new Date(today.getTime() + 24 * 60 * 60 * 1000), + }; + case "yesterday": { + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + return { + start: yesterday, + end: today, + }; + } + case "last7days": { + const last7Days = new Date(today); + last7Days.setDate(last7Days.getDate() - 7); + return { + start: last7Days, + end: new Date(today.getTime() + 24 * 60 * 60 * 1000), + }; + } + case "thisMonth": { + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return { + start: monthStart, + end: monthEnd, + }; + } + case "allTime": + return { + start: new Date(0), + end: new Date(), + }; + } +} diff --git a/apps/web/app/(org)/dashboard/admin/loading.tsx b/apps/web/app/(org)/dashboard/admin/loading.tsx new file mode 100644 index 0000000000..55e7cd3149 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/loading.tsx @@ -0,0 +1,40 @@ +export default function AdminDashboardLoading() { + return ( +
    +

    Admin Dashboard

    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/app/(org)/dashboard/admin/page.tsx b/apps/web/app/(org)/dashboard/admin/page.tsx new file mode 100644 index 0000000000..1ba406285c --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/page.tsx @@ -0,0 +1,13 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { redirect } from "next/navigation"; +import AdminDashboardClient from "./AdminDashboardClient"; + +export default async function AdminDashboard() { + const currentUser = await getCurrentUser(); + + if (currentUser?.email !== "richie@mcilroy.co") { + redirect("/dashboard"); + } + + return ; +} diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index e83dd11aa3..cb26d506a6 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQuery } from "@tanstack/react-query"; import { Effect, Exit } from "effect"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useEffectMutation } from "@/lib/EffectRuntime"; import { Rpc, withRpc } from "@/lib/Rpcs"; @@ -24,7 +24,7 @@ import { CapPagination } from "./components/CapPagination"; import { EmptyCapState } from "./components/EmptyCapState"; import type { FolderDataType } from "./components/Folder"; import Folder from "./components/Folder"; -import { useUploadingContext, useUploadingStatus } from "./UploadingContext"; +import { useUploadingContext } from "./UploadingContext"; export type VideoData = { id: Video.VideoId; @@ -46,7 +46,6 @@ export type VideoData = { ownerName: string; metadata?: VideoMetadata; hasPassword: boolean; - hasActiveUpload: boolean; }[]; export const Caps = ({ @@ -74,6 +73,13 @@ export const Caps = ({ const previousCountRef = useRef(0); const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); + const { + isUploading, + setIsUploading, + setUploadingCapId, + setUploadProgress, + setUploadingThumbnailUrl, + } = useUploadingContext(); const anyCapSelected = selectedCaps.length > 0; @@ -254,18 +260,11 @@ export const Caps = ({ toast.success("Cap deleted successfully"); router.refresh(); }, - onError: () => toast.error("Failed to delete cap"), + onError: () => { + toast.error("Failed to delete cap"); + }, }); - const [isUploading, uploadingCapId] = useUploadingStatus(); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? data.filter((video) => video.id !== uploadingCapId) - : data, - [data, isUploading, uploadingCapId], - ); - if (count === 0) return ; return ( @@ -284,7 +283,21 @@ export const Caps = ({ New Folder - + { + setIsUploading(true); + setUploadingCapId(id); + setUploadingThumbnailUrl(thumbnailUrl); + setUploadProgress(0); + }} + size="sm" + onComplete={() => { + setIsUploading(false); + setUploadingCapId(null); + setUploadingThumbnailUrl(undefined); + setUploadProgress(0); + }} + />
    {folders.length > 0 && ( <> @@ -298,7 +311,7 @@ export const Caps = ({
    )} - {visibleVideos.length > 0 && ( + {data.length > 0 && ( <>

    Videos

    @@ -308,29 +321,27 @@ export const Caps = ({ {isUploading && ( )} - {visibleVideos.map((video) => { - return ( - { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} - userId={user?.id} - customDomain={customDomain} - isLoadingAnalytics={isLoadingAnalytics} - domainVerified={domainVerified} - isSelected={selectedCaps.includes(video.id)} - anyCapSelected={anyCapSelected} - onSelectToggle={() => handleCapSelection(video.id)} - /> - ); - })} + {data.map((cap) => ( + { + if (selectedCaps.length > 0) { + deleteCaps(selectedCaps); + } else { + deleteCap(cap.id); + } + }} + userId={user?.id} + customDomain={customDomain} + isLoadingAnalytics={isLoadingAnalytics} + domainVerified={domainVerified} + isSelected={selectedCaps.includes(cap.id)} + anyCapSelected={anyCapSelected} + onSelectToggle={() => handleCapSelection(cap.id)} + /> + ))}
    )} diff --git a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx index a24220c151..f52eb9405a 100644 --- a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx +++ b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx @@ -1,37 +1,17 @@ "use client"; -import { useStore } from "@tanstack/react-store"; -import { Store } from "@tanstack/store"; import type React from "react"; -import { createContext, useContext, useEffect, useState } from "react"; - -export type UploadStatus = - | { - status: "parsing"; - } - | { - status: "creating"; - } - | { - status: "converting"; - capId: string; - progress: number; - } - | { - status: "uploadingThumbnail"; - capId: string; - progress: number; - } - | { - status: "uploadingVideo"; - capId: string; - progress: number; - thumbnailUrl: string | undefined; - }; +import { createContext, useContext, useState } from "react"; interface UploadingContextType { - uploadingStore: Store<{ uploadStatus?: UploadStatus }>; - setUploadStatus: (state: UploadStatus | undefined) => void; + isUploading: boolean; + setIsUploading: (value: boolean) => void; + uploadingCapId: string | null; + setUploadingCapId: (id: string | null) => void; + uploadingThumbnailUrl: string | undefined; + setUploadingThumbnailUrl: (url: string | undefined) => void; + uploadProgress: number; + setUploadProgress: (progress: number) => void; } const UploadingContext = createContext( @@ -40,69 +20,36 @@ const UploadingContext = createContext( export function useUploadingContext() { const context = useContext(UploadingContext); - if (!context) + if (!context) { throw new Error( "useUploadingContext must be used within an UploadingProvider", ); + } return context; } -export function useUploadingStatus() { - const { uploadingStore } = useUploadingContext(); - return useStore( - uploadingStore, - (s) => - [ - s.uploadStatus !== undefined, - s.uploadStatus && "capId" in s.uploadStatus - ? s.uploadStatus.capId - : null, - ] as const, - ); -} - export function UploadingProvider({ children }: { children: React.ReactNode }) { - const [uploadingStore] = useState>( - () => new Store({}), - ); + const [isUploading, setIsUploading] = useState(false); + const [uploadingCapId, setUploadingCapId] = useState(null); + const [uploadingThumbnailUrl, setUploadingThumbnailUrl] = useState< + string | undefined + >(undefined); + const [uploadProgress, setUploadProgress] = useState(0); return ( { - uploadingStore.setState((state) => ({ - ...state, - uploadStatus: status, - })); - }, + isUploading, + setIsUploading, + uploadingCapId, + setUploadingCapId, + uploadingThumbnailUrl, + setUploadingThumbnailUrl, + uploadProgress, + setUploadProgress, }} > {children} - - ); } - -// Separated to prevent rerendering whole tree -function ForbidLeaveWhenUploading() { - const { uploadingStore } = useUploadingContext(); - const uploadStatus = useStore(uploadingStore, (state) => state.uploadStatus); - - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (uploadStatus?.status) { - e.preventDefault(); - // Chrome requires returnValue to be set - e.returnValue = ""; - return ""; - } - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [uploadStatus]); - - return null; -} diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 2e244a9bcf..98374c03cd 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -1,19 +1,16 @@ import type { VideoMetadata } from "@cap/database/types"; -import { buildEnv, NODE_ENV } from "@cap/env"; import { + Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@cap/ui"; import type { Video } from "@cap/web-domain"; -import { HttpClient } from "@effect/platform"; import { faCheck, faCopy, - faDownload, faEllipsis, - faLink, faLock, faTrash, faUnlock, @@ -22,23 +19,21 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; -import { Effect, Option } from "effect"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { type PropsWithChildren, useState } from "react"; import { toast } from "sonner"; +import { downloadVideo } from "@/actions/videos/download"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import ProgressCircle, { - useUploadProgress, -} from "@/app/s/[videoId]/_components/ProgressCircle"; +import { Tooltip } from "@/components/Tooltip"; import { VideoThumbnail } from "@/components/VideoThumbnail"; import { useEffectMutation } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { PasswordDialog } from "../PasswordDialog"; import { SharingDialog } from "../SharingDialog"; import { CapCardAnalytics } from "./CapCardAnalytics"; -import { CapCardButton } from "./CapCardButton"; +import { CapCardButtons } from "./CapCardButtons"; import { CapCardContent } from "./CapCardContent"; export interface CapCardProps extends PropsWithChildren { @@ -64,7 +59,6 @@ export interface CapCardProps extends PropsWithChildren { ownerName: string | null; metadata?: VideoMetadata; hasPassword?: boolean; - hasActiveUpload: boolean | undefined; duration?: number; }; analytics: number; @@ -113,29 +107,27 @@ export const CapCard = ({ const router = useRouter(); - const downloadMutation = useEffectMutation({ - mutationFn: () => - Effect.gen(function* () { - const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id)); - const httpClient = yield* HttpClient.HttpClient; - if (Option.isSome(result)) { - const fetchResponse = yield* httpClient.get(result.value.downloadUrl); - const blob = yield* fetchResponse.arrayBuffer; - - const blobUrl = window.URL.createObjectURL(new Blob([blob])); - const link = document.createElement("a"); - link.href = blobUrl; - link.download = result.value.fileName; - link.style.display = "none"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - window.URL.revokeObjectURL(blobUrl); - } else { - throw new Error("Failed to get download URL"); - } - }), + const downloadMutation = useMutation({ + mutationFn: async () => { + const response = await downloadVideo(cap.id); + if (response.success && response.downloadUrl) { + const fetchResponse = await fetch(response.downloadUrl); + const blob = await fetchResponse.blob(); + + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = response.filename; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.URL.revokeObjectURL(blobUrl); + } else { + throw new Error("Failed to get download URL"); + } + }, }); const deleteMutation = useMutation({ @@ -152,9 +144,6 @@ export const CapCard = ({ const duplicateMutation = useEffectMutation({ mutationFn: () => withRpc((r) => r.VideoDuplicate(cap.id)), - onSuccess: () => { - router.refresh(); - }, }); const handleSharingUpdated = () => { @@ -168,11 +157,6 @@ export const CapCard = ({ const isOwner = userId === cap.ownerId; - const uploadProgress = useUploadProgress( - cap.id, - cap.hasActiveUpload || false, - ); - // Helper function to create a drag preview element const createDragPreview = (text: string): HTMLElement => { // Create the element @@ -292,11 +276,11 @@ export const CapCard = ({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} className={clsx( - "flex relative overflow-hidden transition-colors duration-200 flex-col gap-4 w-full h-full rounded-xl cursor-default bg-gray-1 border border-gray-3 group", + "flex relative transition-colors duration-200 flex-col gap-4 w-full h-full rounded-xl cursor-default bg-gray-1 border border-gray-3 group border-px", isSelected - ? "!border-blue-10" + ? "!border-blue-10 border-px" : anyCapSelected - ? "border-blue-10 hover:border-blue-10" + ? "border-blue-10 border-px hover:border-blue-10" : "hover:border-blue-10", isDragging && "opacity-50", isOwner && !anyCapSelected && "cursor-grab active:cursor-grabbing", @@ -305,122 +289,60 @@ export const CapCard = ({ {anyCapSelected && !sharedCapCard && (
    )} + {!sharedCapCard && ( +
    + -
    - { - e.stopPropagation(); - handleCopy( - buildEnv.NEXT_PUBLIC_IS_CAP && - NODE_ENV === "production" && - customDomain && - domainVerified - ? `https://${customDomain}/s/${cap.id}` - : buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production" - ? `https://cap.link/${cap.id}` - : `${location.origin}/s/${cap.id}`, - ); - }} - className="delay-0" - icon={() => { - return !copyPressed ? ( - - ) : ( - - - - ); - }} - /> - { - e.stopPropagation(); - handleDownload(); - }} - disabled={downloadMutation.isPending} - className="delay-25" - icon={() => { - return downloadMutation.isPending ? ( -
    - -
    - ) : ( - - ); - }} - /> - - {isOwner && ( - -
    - ( - + + +
    -
    + variant="white" + size="sm" + aria-label="More options" + > + + + + + { + onClick={() => toast.promise(duplicateMutation.mutateAsync(), { loading: "Duplicating cap...", success: "Cap duplicated successfully", error: "Failed to duplicate cap", - }); - }} + }) + } disabled={duplicateMutation.isPending} className="flex gap-2 items-center rounded-lg" > @@ -429,8 +351,11 @@ export const CapCard = ({ { - if (!isSubscribed) setUpgradeModalOpen(true); - else setIsPasswordDialogOpen(true); + if (!isSubscribed) { + setUpgradeModalOpen(true); + } else { + setIsPasswordDialogOpen(true); + } }} className="flex gap-2 items-center rounded-lg" > @@ -454,25 +379,23 @@ export const CapCard = ({
    - )} - - } - title="Delete Cap" - description={`Are you sure you want to delete the cap "${cap.name}"? This action cannot be undone.`} - confirmLabel={deleteMutation.isPending ? "Deleting..." : "Delete"} - cancelLabel="Cancel" - loading={deleteMutation.isPending} - onConfirm={() => deleteMutation.mutate()} - onCancel={() => setConfirmOpen(false)} - /> -
    - + } + title="Delete Cap" + description={`Are you sure you want to delete the cap "${cap.name}"? This action cannot be undone.`} + confirmLabel={deleteMutation.isPending ? "Deleting..." : "Delete"} + cancelLabel="Cancel" + loading={deleteMutation.isPending} + onConfirm={() => deleteMutation.mutate()} + onCancel={() => setConfirmOpen(false)} + /> +
    + )} {!sharedCapCard && onSelectToggle && (
    )} -
    - { - if (isDeleting) { - e.preventDefault(); - } - }} - href={`/s/${cap.id}`} - > - - - {uploadProgress && ( -
    - {uploadProgress.status === "failed" ? ( -
    -
    - -
    -

    - Upload failed -

    -
    - ) : ( -
    - -
    - )} -
    + + onClick={(e) => { + if (isDeleting) { + e.preventDefault(); + } + }} + href={`/s/${cap.id}`} + > + +
    void; - disabled?: boolean; - className: string; - icon: () => ReactNode; - asChild?: boolean; -} - -export const CapCardButton = ({ - tooltipContent, - onClick = () => {}, - disabled, - className, - icon, - asChild, -}: CapCardButtonProps) => { - return ( - - - - ); -}; diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButtons.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButtons.tsx new file mode 100644 index 0000000000..3232c92b92 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButtons.tsx @@ -0,0 +1,173 @@ +import { buildEnv, NODE_ENV } from "@cap/env"; +import { Button } from "@cap/ui"; +import { faDownload, faLink } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { Tooltip } from "@/components/Tooltip"; +import { usePublicEnv } from "@/utils/public-env"; + +interface ButtonConfig { + tooltipContent: string; + onClick: (e: React.MouseEvent) => void; + className: string; + disabled: boolean; + icon: () => ReactNode; +} + +export interface CapCardButtonsProps { + capId: string; + copyPressed: boolean; + isDownloading: boolean; + handleCopy: (url: string) => void; + handleDownload: () => void; + customDomain?: string | null; + domainVerified?: boolean; +} + +export const CapCardButtons: React.FC = ({ + capId, + copyPressed, + isDownloading, + handleCopy, + handleDownload, + customDomain, + domainVerified, +}) => { + const { webUrl } = usePublicEnv(); + return ( + <> + {buttons( + capId, + copyPressed, + isDownloading, + handleCopy, + handleDownload, + webUrl, + customDomain, + domainVerified, + ).map((button, index) => ( + + + + ))} + + ); +}; + +const buttons = ( + capId: string, + copyPressed: boolean, + isDownloading: boolean, + handleCopy: (url: string) => void, + handleDownload: () => void, + webUrl: string, + customDomain?: string | null, + domainVerified?: boolean, +): ButtonConfig[] => [ + { + tooltipContent: "Copy link", + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + + const getVideoLink = () => { + if (NODE_ENV === "development" && customDomain && domainVerified) { + return `https://${customDomain}/s/${capId}`; + } else if ( + NODE_ENV === "development" && + !customDomain && + !domainVerified + ) { + return `${webUrl}/s/${capId}`; + } else if ( + buildEnv.NEXT_PUBLIC_IS_CAP && + customDomain && + domainVerified + ) { + return `https://${customDomain}/s/${capId}`; + } else if ( + buildEnv.NEXT_PUBLIC_IS_CAP && + !customDomain && + !domainVerified + ) { + return `https://cap.link/${capId}`; + } else { + return `${webUrl}/s/${capId}`; + } + }; + + handleCopy(getVideoLink()); + }, + className: "delay-0", + disabled: false, + icon: () => { + return !copyPressed ? ( + + ) : ( + + + + ); + }, + }, + { + tooltipContent: "Download Cap", + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + handleDownload(); + }, + className: "delay-25", + disabled: isDownloading, + icon: () => { + return isDownloading ? ( +
    + + + + +
    + ) : ( + + ); + }, + }, +]; diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index b70e62fcfb..2cdf34c3db 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -319,7 +319,7 @@ const FolderCard = ({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={clsx( - "flex justify-between items-center px-4 py-4 w-full h-auto rounded-lg border transition-all duration-200 cursor-pointer bg-gray-3 hover:bg-gray-4 hover:border-gray-6", + "flex justify-between items-center px-4 py-4 w-full h-auto rounded-lg border transition-colors duration-200 cursor-pointer bg-gray-3 hover:bg-gray-4 hover:border-gray-6", isDragOver ? "border-blue-10 bg-gray-4" : "border-gray-5", isMovingVideo && "opacity-70", )} @@ -331,7 +331,6 @@ const FolderCard = ({ />
    { - e.preventDefault(); e.stopPropagation(); }} className="flex flex-col justify-center h-10" @@ -348,11 +347,11 @@ const FolderCard = ({ await updateFolderNameHandler(); } }} - onKeyDown={async (e) => { + onKeyDown={(e) => { if (e.key === "Enter") { setIsRenaming(false); if (updateName.trim() !== name) { - await updateFolderNameHandler(); + updateFolderNameHandler(); } } }} @@ -360,17 +359,15 @@ const FolderCard = ({ focus:ring-0 focus:border-none text-gray-12 text-[15px] max-w-[116px] truncate p-0 m-0 h-[22px] leading-[22px] overflow-hidden font-normal tracking-normal" /> ) : ( -
    { - e.preventDefault(); e.stopPropagation(); setIsRenaming(true); }} + className="text-[15px] truncate text-gray-12 w-full max-w-[116px] m-0 p-0 h-[22px] leading-[22px] font-normal tracking-normal" > -

    - {updateName} -

    -
    + {updateName} +

    )}

    {`${videoCount} ${ videoCount === 1 ? "video" : "videos" diff --git a/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx b/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx index d224034b01..368c1b74a6 100644 --- a/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx @@ -16,7 +16,7 @@ interface FoldersDropdownProps { id: string; setIsRenaming: (isRenaming: boolean) => void; setConfirmDeleteFolderOpen: (open: boolean) => void; - nameRef: RefObject; + nameRef: RefObject; parentId?: string | null; } diff --git a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx index ef7dfe3c60..b1812247e7 100644 --- a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Button, Dialog, @@ -96,7 +94,7 @@ export const NewFolderDialog: React.FC = ({ }, {} as Record< (typeof FolderOptions)[number]["value"], - React.RefObject + React.RefObject >, ), ); diff --git a/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx index b8d24cc377..747717b7ad 100644 --- a/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx @@ -9,7 +9,6 @@ import { DialogTitle, Input, } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; @@ -23,7 +22,7 @@ import { interface PasswordDialogProps { isOpen: boolean; onClose: () => void; - videoId: Video.VideoId; + videoId: string; hasPassword: boolean; onPasswordUpdated: (protectedStatus: boolean) => void; } diff --git a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx index 8f82e8c6d7..79fdce4ed4 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx @@ -44,9 +44,9 @@ export const SelectedCapsBar = ({ transition: { duration: 0.2 }, }} transition={{ - opacity: { duration: 0.2, ease: "easeOut" }, - y: { type: "spring", damping: 15, stiffness: 300 }, - scale: { type: "spring", damping: 15, stiffness: 300 }, + opacity: { duration: 0.3, ease: "easeOut" }, + y: { type: "spring", damping: 15, stiffness: 200 }, + scale: { type: "spring", damping: 15, stiffness: 200 }, }} >

    diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index 4eb32644f4..1375f9dba1 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -9,7 +9,6 @@ import { Input, Switch, } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; import { faCopy, faShareNodes } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; @@ -27,7 +26,7 @@ import { Tooltip } from "@/components/Tooltip"; interface SharingDialogProps { isOpen: boolean; onClose: () => void; - capId: Video.VideoId; + capId: string; capName: string; sharedSpaces: { id: string; @@ -68,7 +67,7 @@ export const SharingDialog: React.FC = ({ spaceIds, public: isPublic, }: { - capId: Video.VideoId; + capId: string; spaceIds: string[]; public: boolean; }) => { diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 085d0dfb96..aae859c1af 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -2,38 +2,36 @@ import { Button } from "@cap/ui"; import { userIsPro } from "@cap/utils"; -import type { Folder } from "@cap/web-domain"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useStore } from "@tanstack/react-store"; import { useRouter } from "next/navigation"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import { - type UploadStatus, - useUploadingContext, -} from "@/app/(org)/dashboard/caps/UploadingContext"; +import { useUploadingContext } from "@/app/(org)/dashboard/caps/UploadingContext"; import { UpgradeModal } from "@/components/UpgradeModal"; -import { imageUrlQuery } from "@/components/VideoThumbnail"; export const UploadCapButton = ({ + onStart, + onProgress, + onComplete, size = "md", folderId, }: { + onStart?: (id: string, thumbnail?: string) => void; + onProgress?: (id: string, progress: number, uploadProgress?: number) => void; + onComplete?: (id: string) => void; size?: "sm" | "lg" | "md"; grey?: boolean; - folderId?: Folder.FolderId; + folderId?: string; }) => { - const { user, activeOrganization } = useDashboardContext(); + const { user } = useDashboardContext(); const inputRef = useRef(null); - const { uploadingStore, setUploadStatus } = useUploadingContext(); - const isUploading = useStore(uploadingStore, (s) => !!s.uploadStatus); + const { isUploading, setIsUploading, setUploadProgress } = + useUploadingContext(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const router = useRouter(); - const queryClient = useQueryClient(); const handleClick = () => { if (!user) return; @@ -52,367 +50,256 @@ export const UploadCapButton = ({ const file = e.target.files?.[0]; if (!file || !user) return; - // This should be unreachable. - if (activeOrganization === null) { - alert("No organization active!"); - return; - } - - const ok = await legacyUploadCap( - file, - folderId, - activeOrganization.organization.id, - setUploadStatus, - queryClient, - ); - if (ok) router.refresh(); - if (inputRef.current) inputRef.current.value = ""; - }; + setIsUploading(true); + setUploadProgress(0); + try { + const parser = await import("@remotion/media-parser"); + const webcodecs = await import("@remotion/webcodecs"); - return ( - <> - - - - - ); -}; + const metadata = await parser.parseMedia({ + src: file, + fields: { + durationInSeconds: true, + dimensions: true, + fps: true, + numberOfAudioChannels: true, + sampleRate: true, + }, + }); -async function legacyUploadCap( - file: File, - folderId: Folder.FolderId | undefined, - orgId: string, - setUploadStatus: (state: UploadStatus | undefined) => void, - queryClient: QueryClient, -) { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - try { - setUploadStatus({ status: "parsing" }); - const metadata = await parser.parseMedia({ - src: file, - fields: { - durationInSeconds: true, - dimensions: true, - fps: true, - numberOfAudioChannels: true, - sampleRate: true, - }, - }); - - const duration = metadata.durationInSeconds - ? Math.round(metadata.durationInSeconds) - : undefined; - - setUploadStatus({ status: "creating" }); - const videoData = await createVideoAndGetUploadUrl({ - duration, - resolution: metadata.dimensions - ? `${metadata.dimensions.width}x${metadata.dimensions.height}` - : undefined, - videoCodec: "h264", - audioCodec: "aac", - isScreenshot: false, - isUpload: true, - folderId, - orgId, - }); - - const uploadId = videoData.id; - - setUploadStatus({ status: "converting", capId: uploadId, progress: 0 }); - - let optimizedBlob: Blob; + const duration = metadata.durationInSeconds + ? Math.round(metadata.durationInSeconds) + : undefined; - try { - const calculateResizeOptions = () => { - if (!metadata.dimensions) return undefined; + const videoData = await createVideoAndGetUploadUrl({ + duration, + resolution: metadata.dimensions + ? `${metadata.dimensions.width}x${metadata.dimensions.height}` + : undefined, + videoCodec: "h264", + audioCodec: "aac", + isScreenshot: false, + isUpload: true, + folderId, + }); - const { width, height } = metadata.dimensions; - const maxWidth = 1920; - const maxHeight = 1080; + const uploadId = videoData.id; + // Initial start with thumbnail as undefined + onStart?.(uploadId); + onProgress?.(uploadId, 10); - if (width <= maxWidth && height <= maxHeight) { - return undefined; - } + const fileSizeMB = file.size / (1024 * 1024); + onProgress?.(uploadId, 15); - const widthScale = maxWidth / width; - const heightScale = maxHeight / height; - const scale = Math.min(widthScale, heightScale); + let optimizedBlob: Blob; - return { mode: "scale" as const, scale }; - }; + try { + const calculateResizeOptions = () => { + if (!metadata.dimensions) return undefined; - const resizeOptions = calculateResizeOptions(); + const { width, height } = metadata.dimensions; + const maxWidth = 1920; + const maxHeight = 1080; - const convertResult = await webcodecs.convertMedia({ - src: file, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - ...(resizeOptions && { resize: resizeOptions }), - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - const progressValue = overallProgress * 100; - setUploadStatus({ - status: "converting", - capId: uploadId, - progress: progressValue, - }); + if (width <= maxWidth && height <= maxHeight) { + return undefined; } - }, - }); - optimizedBlob = await convertResult.save(); - - if (optimizedBlob.size === 0) - throw new Error("Conversion produced empty file"); - const isValidVideo = await new Promise((resolve) => { - const testVideo = document.createElement("video"); - testVideo.muted = true; - testVideo.playsInline = true; - testVideo.preload = "metadata"; - - const timeout = setTimeout(() => { - console.warn("Video validation timed out"); - URL.revokeObjectURL(testVideo.src); - resolve(false); - }, 15000); - - let metadataLoaded = false; - - const validateVideo = () => { - if (metadataLoaded) return; - metadataLoaded = true; - - const hasValidDuration = - testVideo.duration > 0 && - !isNaN(testVideo.duration) && - isFinite(testVideo.duration); - - const hasValidDimensions = - (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || - (metadata.dimensions && - metadata.dimensions.width > 0 && - metadata.dimensions.height > 0); - - if (hasValidDuration && hasValidDimensions) { - clearTimeout(timeout); + + const widthScale = maxWidth / width; + const heightScale = maxHeight / height; + const scale = Math.min(widthScale, heightScale); + + return { mode: "scale" as const, scale }; + }; + + const resizeOptions = calculateResizeOptions(); + + const convertResult = await webcodecs.convertMedia({ + src: file, + container: "mp4", + videoCodec: "h264", + audioCodec: "aac", + ...(resizeOptions && { resize: resizeOptions }), + onProgress: ({ overallProgress }) => { + if (overallProgress !== null) { + const progressValue = overallProgress * 100; + onProgress?.(uploadId, progressValue); + } + }, + }); + optimizedBlob = await convertResult.save(); + + if (optimizedBlob.size === 0) { + throw new Error("Conversion produced empty file"); + } + const isValidVideo = await new Promise((resolve) => { + const testVideo = document.createElement("video"); + testVideo.muted = true; + testVideo.playsInline = true; + testVideo.preload = "metadata"; + + const timeout = setTimeout(() => { + console.warn("Video validation timed out"); URL.revokeObjectURL(testVideo.src); - resolve(true); - } else { - console.warn( - `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, - ); + resolve(false); + }, 15000); + + let metadataLoaded = false; + + const validateVideo = () => { + if (metadataLoaded) return; + metadataLoaded = true; + + const hasValidDuration = + testVideo.duration > 0 && + !isNaN(testVideo.duration) && + isFinite(testVideo.duration); + + const hasValidDimensions = + (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || + (metadata.dimensions && + metadata.dimensions.width > 0 && + metadata.dimensions.height > 0); + + if (hasValidDuration && hasValidDimensions) { + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(true); + } else { + console.warn( + `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, + ); + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(false); + } + }; + + testVideo.addEventListener("loadedmetadata", validateVideo); + testVideo.addEventListener("loadeddata", validateVideo); + testVideo.addEventListener("canplay", validateVideo); + + testVideo.addEventListener("error", (e) => { + console.error("Video validation error:", e); clearTimeout(timeout); URL.revokeObjectURL(testVideo.src); resolve(false); - } - }; + }); - testVideo.addEventListener("loadedmetadata", validateVideo); - testVideo.addEventListener("loadeddata", validateVideo); - testVideo.addEventListener("canplay", validateVideo); + testVideo.addEventListener("loadstart", () => {}); - testVideo.addEventListener("error", (e) => { - console.error("Video validation error:", e); - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(false); + testVideo.src = URL.createObjectURL(optimizedBlob); }); - testVideo.addEventListener("loadstart", () => {}); - - testVideo.src = URL.createObjectURL(optimizedBlob); - }); - - if (!isValidVideo) { - throw new Error("Converted video is not playable"); + if (!isValidVideo) { + throw new Error("Converted video is not playable"); + } + } catch (conversionError) { + console.error("Video conversion failed:", conversionError); + toast.error( + "Failed to process video file. This format may not be supported for upload.", + ); + return; } - } catch (conversionError) { - console.error("Video conversion failed:", conversionError); - toast.error( - "Failed to process video file. This format may not be supported for upload.", - ); - setUploadStatus(undefined); - return false; - } - const captureThumbnail = (): Promise => { - return new Promise((resolve) => { - const video = document.createElement("video"); - video.src = URL.createObjectURL(optimizedBlob); - video.muted = true; - video.playsInline = true; - video.crossOrigin = "anonymous"; + const captureThumbnail = (): Promise => { + return new Promise((resolve) => { + const video = document.createElement("video"); + video.src = URL.createObjectURL(optimizedBlob); + video.muted = true; + video.playsInline = true; + video.crossOrigin = "anonymous"; - const cleanup = () => { - URL.revokeObjectURL(video.src); - }; + const cleanup = () => { + URL.revokeObjectURL(video.src); + }; - const timeout = setTimeout(() => { - cleanup(); - console.warn( - "Thumbnail generation timed out, proceeding without thumbnail", - ); - resolve(null); - }, 10000); - - video.addEventListener("loadedmetadata", () => { - try { - const seekTime = Math.min(1, video.duration / 4); - video.currentTime = seekTime; - } catch (err) { - console.warn("Failed to seek video for thumbnail:", err); - clearTimeout(timeout); + const timeout = setTimeout(() => { cleanup(); + console.warn( + "Thumbnail generation timed out, proceeding without thumbnail", + ); resolve(null); - } - }); - - video.addEventListener("seeked", () => { - try { - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth || 640; - canvas.height = video.videoHeight || 480; - const ctx = canvas.getContext("2d"); - if (!ctx) { - console.warn("Failed to get canvas context"); + }, 10000); + + video.addEventListener("loadedmetadata", () => { + try { + const seekTime = Math.min(1, video.duration / 4); + video.currentTime = seekTime; + } catch (err) { + console.warn("Failed to seek video for thumbnail:", err); clearTimeout(timeout); cleanup(); resolve(null); - return; } - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - canvas.toBlob( - (blob) => { + }); + + video.addEventListener("seeked", () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || 640; + canvas.height = video.videoHeight || 480; + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.warn("Failed to get canvas context"); clearTimeout(timeout); cleanup(); - if (blob) { - resolve(blob); - } else { - console.warn("Failed to create thumbnail blob"); - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - } catch (err) { - console.warn("Error during thumbnail capture:", err); + resolve(null); + return; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob( + (blob) => { + clearTimeout(timeout); + cleanup(); + if (blob) { + resolve(blob); + } else { + console.warn("Failed to create thumbnail blob"); + resolve(null); + } + }, + "image/jpeg", + 0.8, + ); + } catch (err) { + console.warn("Error during thumbnail capture:", err); + clearTimeout(timeout); + cleanup(); + resolve(null); + } + }); + + video.addEventListener("error", (err) => { + console.warn("Video loading error for thumbnail:", err); clearTimeout(timeout); cleanup(); resolve(null); - } - }); + }); - video.addEventListener("error", (err) => { - console.warn("Video loading error for thumbnail:", err); - clearTimeout(timeout); - cleanup(); - resolve(null); + video.addEventListener("loadstart", () => {}); }); - - video.addEventListener("loadstart", () => {}); - }); - }; - - const thumbnailBlob = await captureThumbnail(); - const thumbnailUrl = thumbnailBlob - ? URL.createObjectURL(thumbnailBlob) - : undefined; - - const formData = new FormData(); - Object.entries(videoData.presignedPostData.fields).forEach( - ([key, value]) => { - formData.append(key, value as string); - }, - ); - formData.append("file", optimizedBlob); - - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: 0, - thumbnailUrl, - }); - - // Create progress tracking state outside React - const createProgressTracker = () => { - const uploadState = { - videoId: uploadId, - uploaded: 0, - total: 0, - pendingTask: undefined as ReturnType | undefined, - lastUpdateTime: Date.now(), - }; - - const scheduleProgressUpdate = (uploaded: number, total: number) => { - uploadState.uploaded = uploaded; - uploadState.total = total; - uploadState.lastUpdateTime = Date.now(); - - // Clear any existing pending task - if (uploadState.pendingTask) { - clearTimeout(uploadState.pendingTask); - uploadState.pendingTask = undefined; - } - - const shouldSendImmediately = uploaded >= total; - - if (shouldSendImmediately) { - // Don't send completion update immediately - let xhr.onload handle it - // to avoid double progress updates - return; - } else { - // Schedule delayed update (after 2 seconds) - uploadState.pendingTask = setTimeout(() => { - if (uploadState.videoId) { - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - } - uploadState.pendingTask = undefined; - }, 2000); - } }; - const cleanup = () => { - if (uploadState.pendingTask) { - clearTimeout(uploadState.pendingTask); - uploadState.pendingTask = undefined; - } - }; + const thumbnailBlob = await captureThumbnail(); + const thumbnailUrl = thumbnailBlob + ? URL.createObjectURL(thumbnailBlob) + : undefined; - const getTotal = () => uploadState.total; + // Pass the thumbnail URL to the parent component + onStart?.(uploadId, thumbnailUrl); + onProgress?.(uploadId, 100); - return { scheduleProgressUpdate, cleanup, getTotal }; - }; + const formData = new FormData(); + Object.entries(videoData.presignedPostData.fields).forEach( + ([key, value]) => { + formData.append(key, value as string); + }, + ); + formData.append("file", optimizedBlob); - const progressTracker = createProgressTracker(); + setUploadProgress(0); - try { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", videoData.presignedPostData.url); @@ -420,126 +307,102 @@ async function legacyUploadCap( xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: percent, - thumbnailUrl, - }); - - progressTracker.scheduleProgressUpdate(event.loaded, event.total); + setUploadProgress(percent); + onProgress?.(uploadId, 100, percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - progressTracker.cleanup(); - // Guarantee final 100% progress update - const total = progressTracker.getTotal() || 1; - sendProgressUpdate(uploadId, total, total); resolve(); } else { - progressTracker.cleanup(); reject(new Error(`Upload failed with status ${xhr.status}`)); } }; - xhr.onerror = () => { - progressTracker.cleanup(); - reject(new Error("Upload failed")); - }; + xhr.onerror = () => reject(new Error("Upload failed")); xhr.send(formData); }); - } catch (uploadError) { - progressTracker.cleanup(); - throw uploadError; - } - if (thumbnailBlob) { - const screenshotData = await createVideoAndGetUploadUrl({ - videoId: uploadId, - isScreenshot: true, - isUpload: true, - orgId, - }); - - const screenshotFormData = new FormData(); - Object.entries(screenshotData.presignedPostData.fields).forEach( - ([key, value]) => { - screenshotFormData.append(key, value as string); - }, - ); - screenshotFormData.append("file", thumbnailBlob); - - setUploadStatus({ - status: "uploadingThumbnail", - capId: uploadId, - progress: 0, - }); - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", screenshotData.presignedPostData.url); + if (thumbnailBlob) { + const screenshotData = await createVideoAndGetUploadUrl({ + videoId: uploadId, + isScreenshot: true, + isUpload: true, + }); - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - const thumbnailProgress = 90 + percent * 0.1; - setUploadStatus({ - status: "uploadingThumbnail", - capId: uploadId, - progress: thumbnailProgress, - }); - } - }; + const screenshotFormData = new FormData(); + Object.entries(screenshotData.presignedPostData.fields).forEach( + ([key, value]) => { + screenshotFormData.append(key, value as string); + }, + ); + screenshotFormData.append("file", thumbnailBlob); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", screenshotData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + const thumbnailProgress = 90 + percent * 0.1; + onProgress?.(uploadId, 100, thumbnailProgress); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject( + new Error(`Screenshot upload failed with status ${xhr.status}`), + ); + } + }; + xhr.onerror = () => reject(new Error("Screenshot upload failed")); - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - queryClient.refetchQueries(imageUrlQuery(uploadId)); - } else { - reject( - new Error(`Screenshot upload failed with status ${xhr.status}`), - ); - } - }; - xhr.onerror = () => reject(new Error("Screenshot upload failed")); + xhr.send(screenshotFormData); + }); + } else { + } - xhr.send(screenshotFormData); - }); + onProgress?.(uploadId, 100, 100); + onComplete?.(uploadId); + router.refresh(); + } catch (err) { + console.error("Video upload failed", err); + } finally { + setIsUploading(false); + setUploadProgress(0); + if (inputRef.current) inputRef.current.value = ""; } + }; - setUploadStatus(undefined); - return true; - } catch (err) { - console.error("Video upload failed", err); - } - - setUploadStatus(undefined); - return false; -} - -const sendProgressUpdate = async ( - videoId: string, - uploaded: number, - total: number, -) => { - try { - const response = await fetch("/api/desktop/video/progress", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - videoId, - uploaded, - total, - updatedAt: new Date().toISOString(), - }), - }); - - if (!response.ok) - console.error("Failed to send progress update:", response.status); - } catch (err) { - console.error("Error sending progress update:", err); - } + return ( + <> + + + + + ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx index 5f004dff5a..18806ea853 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx @@ -1,32 +1,28 @@ "use client"; import { LogoSpinner } from "@cap/ui"; -import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; -import { useStore } from "@tanstack/react-store"; -import { type UploadStatus, useUploadingContext } from "../UploadingContext"; - -const { circumference } = getProgressCircleConfig(); +import { + calculateStrokeDashoffset, + getProgressCircleConfig, + getUploadStatus, +} from "@cap/utils"; +import { useUploadingContext } from "../UploadingContext"; export const UploadPlaceholderCard = () => { - const { uploadingStore } = useUploadingContext(); - const uploadStatus = useStore(uploadingStore, (s) => s.uploadStatus); + const { uploadingThumbnailUrl, uploadProgress } = useUploadingContext(); + const { circumference } = getProgressCircleConfig(); + const status = getUploadStatus(uploadProgress); const strokeDashoffset = calculateStrokeDashoffset( - uploadStatus && - (uploadStatus.status === "converting" || - uploadStatus.status === "uploadingThumbnail" || - uploadStatus.status === "uploadingVideo") - ? uploadStatus.progress - : 0, + uploadProgress, circumference, ); - if (!uploadStatus) return null; return (
    - {uploadStatus.status === "uploadingVideo" ? ( + {uploadingThumbnailUrl ? ( Uploading thumbnail @@ -39,9 +35,7 @@ export const UploadPlaceholderCard = () => {
    - - {getFriendlyStatus(uploadStatus.status)} - + {status} {
    ); }; - -function getFriendlyStatus(status: UploadStatus["status"]) { - switch (status) { - case "parsing": - return "Parsing"; - case "creating": - return "Creating"; - case "converting": - return "Converting"; - case "uploadingThumbnail": - case "uploadingVideo": - return "Uploading"; - default: - return "Processing..."; - } -} diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 98f4753f4f..7a4fc36757 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -9,7 +9,6 @@ import { spaceVideos, users, videos, - videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { Video } from "@cap/web-domain"; @@ -23,7 +22,7 @@ export const metadata: Metadata = { }; // Helper function to fetch shared spaces data for videos -async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { +async function getSharedSpacesForVideos(videoIds: string[]) { if (videoIds.length === 0) return {}; // Fetch space-level sharing @@ -96,10 +95,11 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { return sharedSpacesMap; } -export default async function CapsPage(props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +export default async function CapsPage({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; }) { - const searchParams = await props.searchParams; const user = await getCurrentUser(); if (!user || !user.id) { @@ -172,17 +172,13 @@ export default async function CapsPage(props: { ${videos.createdAt} ) `, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), + hasPassword: sql`IF(${videos.password} IS NULL, 0, 1)`, }) .from(videos) .leftJoin(comments, eq(videos.id, comments.videoId)) .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) .leftJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) .where(and(eq(videos.ownerId, userId), isNull(videos.folderId))) .groupBy( videos.id, @@ -246,6 +242,7 @@ export default async function CapsPage(props: { [key: string]: any; } | undefined, + hasPassword: video.hasPassword === 1, }; }); diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index c581a783d6..7b8392c383 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -48,7 +48,6 @@ export async function getDashboardData(user: typeof userSelectProps) { email: users.email, inviteQuota: users.inviteQuota, image: users.image, - defaultOrgId: users.defaultOrgId, }, }) .from(organizations) diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx index 30f486272c..f8b8aa398a 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx @@ -1,18 +1,16 @@ "use client"; -import type { Folder } from "@cap/web-domain"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; - import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; import { useDashboardContext } from "../../../Contexts"; import { AllFolders } from "../../../caps/components/Folders"; interface BreadcrumbItemProps { - id: Folder.FolderId; + id: string; name: string; color: "normal" | "blue" | "red" | "yellow"; isLast: boolean; diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index 70ed6af15c..689fd053bb 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -1,7 +1,6 @@ "use client"; import { Avatar } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; @@ -49,7 +48,7 @@ export function ClientMyCapsLink() { }; const handleDrop = async ( - e: React.DragEvent | { id: Video.VideoId; name: string }, + e: React.DragEvent | { id: string; name: string }, ) => { if ("preventDefault" in e) { e.preventDefault(); @@ -75,7 +74,7 @@ export function ClientMyCapsLink() { }; // Common function to process the drop for both desktop and mobile - const processDrop = async (capData: { id: Video.VideoId; name: string }) => { + const processDrop = async (capData: { id: string; name: string }) => { setIsDragOver(false); try { diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx index ae33089287..171d24639a 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx @@ -2,36 +2,38 @@ import type { Video } from "@cap/web-domain"; import { useQuery } from "@tanstack/react-query"; -import { useStore } from "@tanstack/react-store"; import { Effect, Exit } from "effect"; import { useRouter } from "next/navigation"; -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { useEffectMutation } from "@/lib/EffectRuntime"; -import { Rpc, withRpc } from "@/lib/Rpcs"; +import { Rpc } from "@/lib/Rpcs"; import type { VideoData } from "../../../caps/Caps"; import { CapCard } from "../../../caps/components/CapCard/CapCard"; import { SelectedCapsBar } from "../../../caps/components/SelectedCapsBar"; import { UploadPlaceholderCard } from "../../../caps/components/UploadPlaceholderCard"; -import { useUploadingStatus } from "../../../caps/UploadingContext"; +import { useUploadingContext } from "../../../caps/UploadingContext"; interface FolderVideosSectionProps { initialVideos: VideoData; dubApiKeyEnabled: boolean; + cardType?: "shared" | "default"; } export default function FolderVideosSection({ initialVideos, dubApiKeyEnabled, + cardType = "default", }: FolderVideosSectionProps) { const router = useRouter(); - const { user } = useDashboardContext(); + const { isUploading } = useUploadingContext(); + const { activeOrganization, user } = useDashboardContext(); const [selectedCaps, setSelectedCaps] = useState([]); const previousCountRef = useRef(0); - const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ + const deleteCaps = useEffectMutation({ mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { if (ids.length === 0) return; @@ -61,7 +63,9 @@ export default function FolderVideosSection({ }).pipe(Effect.fork); toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), { - loading: `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, + loading: `Deleting ${selectedCaps.length} cap${ + selectedCaps.length === 1 ? "" : "s" + }...`, success: (data) => { if (data.error) { return `Successfully deleted ${data.success} cap${ @@ -86,17 +90,6 @@ export default function FolderVideosSection({ }, }); - const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ - mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)), - onSuccess: () => { - toast.success("Cap deleted successfully"); - router.refresh(); - }, - onError: () => { - toast.error("Failed to delete cap"); - }, - }); - const handleCapSelection = (capId: Video.VideoId) => { setSelectedCaps((prev) => { const newSelection = prev.includes(capId) @@ -153,15 +146,6 @@ export default function FolderVideosSection({ refetchOnMount: true, }); - const [isUploading, uploadingCapId] = useUploadingStatus(); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? initialVideos.filter((video) => video.id !== uploadingCapId) - : initialVideos, - [initialVideos, isUploading, uploadingCapId], - ); - const analytics = analyticsData || {}; return ( @@ -170,7 +154,7 @@ export default function FolderVideosSection({

    Videos

    - {visibleVideos.length === 0 && !isUploading ? ( + {initialVideos.length === 0 && !isUploading ? (

    No videos in this folder yet. Drag and drop into the folder or upload. @@ -180,7 +164,7 @@ export default function FolderVideosSection({ {isUploading && ( )} - {visibleVideos.map((video) => ( + {initialVideos.map((video) => ( 0} - isDeleting={isDeletingCaps || isDeletingCap} + isDeleting={deleteCaps.isPending} onSelectToggle={() => handleCapSelection(video.id)} - onDelete={() => { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} + onDelete={() => deleteCaps.mutateAsync(selectedCaps)} /> ))} @@ -206,8 +184,8 @@ export default function FolderVideosSection({ deleteCaps(selectedCaps)} - isDeleting={isDeletingCaps || isDeletingCap} + deleteSelectedCaps={() => deleteCaps.mutateAsync(selectedCaps)} + isDeleting={deleteCaps.isPending} /> ); diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx index 0c0561d560..d61fa3d336 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx @@ -102,7 +102,7 @@ export const SubfolderDialog: React.FC = ({ }, {} as Record< (typeof FolderOptions)[number]["value"], - React.RefObject + React.RefObject >, ), ); diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx new file mode 100644 index 0000000000..242793660f --- /dev/null +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { UploadCapButton } from "../../../caps/components/UploadCapButton"; +import { useUploadingContext } from "../../../caps/UploadingContext"; +export function UploadCapButtonWithFolder({ folderId }: { folderId: string }) { + const router = useRouter(); + const { + setIsUploading, + setUploadingCapId, + setUploadingThumbnailUrl, + setUploadProgress, + } = useUploadingContext(); + + return ( + { + setIsUploading(true); + setUploadingCapId(id); + setUploadingThumbnailUrl(thumbnail); + setUploadProgress(0); + }} + onComplete={(id) => { + // Reset all uploading state + setIsUploading(false); + setUploadingCapId(null); + setUploadingThumbnailUrl(undefined); + setUploadProgress(0); + router.refresh(); + }} + folderId={folderId} + size="sm" + /> + ); +} diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 302be68ece..0cd7302503 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -1,15 +1,10 @@ -import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; -import { CurrentUser, type Folder } from "@cap/web-domain"; -import { Effect } from "effect"; -import { notFound } from "next/navigation"; +import type { Folder } from "@cap/web-domain"; import { getChildFolders, getFolderBreadcrumb, getVideosByFolderId, } from "@/lib/folder"; -import { runPromise } from "@/lib/server"; -import { UploadCapButton } from "../../caps/components"; import FolderCard from "../../caps/components/Folder"; import { BreadcrumbItem, @@ -17,71 +12,65 @@ import { NewSubfolderButton, } from "./components"; import FolderVideosSection from "./components/FolderVideosSection"; +import { UploadCapButtonWithFolder } from "./components/UploadCapButtonWithFolder"; const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { - const user = await getCurrentUser(); - if (!user || !user.activeOrganizationId) return notFound(); + const [childFolders, breadcrumb, videosData] = await Promise.all([ + getChildFolders(params.id), + getFolderBreadcrumb(params.id), + getVideosByFolderId(params.id), + ]); - return Effect.gen(function* () { - const [childFolders, breadcrumb, videosData] = yield* Effect.all([ - getChildFolders(params.id, { variant: "user" }), - getFolderBreadcrumb(params.id), - getVideosByFolderId(params.id), - ]); + return ( +

    +
    + + +
    +
    +
    + - return ( -
    -
    - - + {breadcrumb.map((folder, index) => ( +
    +

    /

    + +
    + ))}
    -
    -
    - +
    - {breadcrumb.map((folder, index) => ( -
    -

    /

    - -
    + {/* Display Child Folders */} + {childFolders.length > 0 && ( + <> +

    Subfolders

    +
    + {childFolders.map((folder) => ( + ))}
    -
    + + )} - {/* Display Child Folders */} - {childFolders.length > 0 && ( - <> -

    - Subfolders -

    -
    - {childFolders.map((folder) => ( - - ))} -
    - - )} - - {/* Display Videos */} - -
    - ); - }).pipe(Effect.provideService(CurrentUser, user), runPromise); + {/* Display Videos */} + +
    + ); }; export default FolderPage; diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index 336837c4b5..90dc58aa4e 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -2,7 +2,6 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import DashboardInner from "./_components/DashboardInner"; -import MobileTab from "./_components/MobileTab"; import DesktopNav from "./_components/Navbar/Desktop"; import MobileNav from "./_components/Navbar/Mobile"; import { DashboardContexts } from "./Contexts"; @@ -63,9 +62,8 @@ export default async function DashboardLayout({ user.stripeSubscriptionStatus !== "cancelled") || !!user.thirdPartyStripeSubscriptionId; - const theme = (await cookies()).get("theme")?.value ?? "light"; - const sidebar = (await cookies()).get("sidebarCollapsed")?.value ?? "false"; - const referClicked = (await cookies()).get("referClicked")?.value ?? "false"; + const theme = cookies().get("theme")?.value ?? "light"; + const sidebar = cookies().get("sidebarCollapsed")?.value ?? "false"; return ( @@ -79,15 +77,17 @@ export default async function DashboardLayout({ initialSidebarCollapsed={sidebar === "true"} anyNewNotifications={anyNewNotifications} userPreferences={userPreferences} - referClicked={referClicked === "true"} > -
    - -
    +
    + +
    - {children} +
    + {children} +
    -
    diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 384c78438b..e97793dfe8 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -1,47 +1,34 @@ "use client"; import type { users } from "@cap/database/schema"; -import { - Button, - Card, - CardDescription, - CardTitle, - Input, - Select, -} from "@cap/ui"; +import { Button, Card, CardDescription, CardTitle, Input } from "@cap/ui"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; -import { useDashboardContext } from "../../Contexts"; -import { patchAccountSettings } from "./server"; export const Settings = ({ user, }: { user?: typeof users.$inferSelect | null; }) => { - const router = useRouter(); - const { organizationData } = useDashboardContext(); const [firstName, setFirstName] = useState(user?.name || ""); const [lastName, setLastName] = useState(user?.lastName || ""); - const [defaultOrgId, setDefaultOrgId] = useState( - user?.defaultOrgId || undefined, - ); - - // Track if form has unsaved changes - const hasChanges = - firstName !== (user?.name || "") || - lastName !== (user?.lastName || "") || - defaultOrgId !== user?.defaultOrgId; + const router = useRouter(); const { mutate: updateName, isPending: updateNamePending } = useMutation({ mutationFn: async () => { - await patchAccountSettings( - firstName.trim(), - lastName.trim() ? lastName.trim() : undefined, - defaultOrgId, - ); + const res = await fetch("/api/settings/user/name", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firstName: firstName.trim(), + lastName: lastName.trim() ? lastName.trim() : null, + }), + }); + if (!res.ok) { + throw new Error("Failed to update name"); + } }, onSuccess: () => { toast.success("Name updated successfully"); @@ -52,19 +39,6 @@ export const Settings = ({ }, }); - // Prevent navigation when there are unsaved changes - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasChanges) { - e.preventDefault(); - e.returnValue = ""; - } - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [hasChanges]); - return (
    { @@ -117,30 +91,9 @@ export const Settings = ({ disabled /> - -
    - Default organization - This is the default organization -
    - - {isVerified ? "Domain Verified" : "Verify your domain"} -

    +

    {isVerified ? "Your domain is verified!" : `Add the DNS records below to verify ownership of ${domain}: wait a minute after updating to verify.`} diff --git a/apps/web/app/(org)/dashboard/settings/organization/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/page.tsx index 48bed4f14e..ace8df116d 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/page.tsx @@ -1,7 +1,4 @@ -import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; -import { and, eq } from "drizzle-orm"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getDashboardData } from "../../dashboard-data"; @@ -18,24 +15,12 @@ export default async function OrganizationPage() { redirect("/auth/signin"); } - const [member] = await db() - .select({ - role: organizationMembers.role, - }) - .from(organizationMembers) - .limit(1) - .leftJoin( - organizations, - eq(organizationMembers.organizationId, organizations.id), - ) - .where( - and( - eq(organizationMembers.userId, user.id), - eq(organizations.id, user.activeOrganizationId), - ), - ); + const dashboardData = await getDashboardData(user); + const isOwner = dashboardData.organizationSelect.find( + (organization) => organization.organization.ownerId === user.id, + ); - if (member?.role !== "owner") { + if (!isOwner) { redirect("/dashboard/caps"); } diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index 2519774669..abf99a3e4c 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -32,7 +32,6 @@ type SharedVideoData = { totalReactions: number; ownerName: string | null; metadata?: VideoMetadata; - hasActiveUpload: boolean | undefined; }[]; type SpaceData = { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts index e641b35217..a8c9339d88 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts @@ -9,18 +9,16 @@ import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; -const spaceRole = z.union([z.literal("Admin"), z.literal("member")]); - const addSpaceMemberSchema = z.object({ spaceId: z.string(), userId: z.string(), - role: spaceRole, + role: z.string(), }); const addSpaceMembersSchema = z.object({ spaceId: z.string(), userIds: z.array(z.string()), - role: spaceRole, + role: z.string(), }); export async function addSpaceMember( @@ -151,7 +149,7 @@ export async function removeSpaceMember( const setSpaceMembersSchema = z.object({ spaceId: z.string(), userIds: z.array(z.string()), - role: spaceRole.default("member"), + role: z.string().default("member"), }); export async function setSpaceMembers( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx index cb3d7a0134..8ee0fd446f 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx @@ -9,7 +9,6 @@ import { Input, LoadingSpinner, } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; import { faVideo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -29,14 +28,14 @@ interface AddVideosDialogBaseProps { entityId: string; entityName: string; onVideosAdded?: () => void; - addVideos: (entityId: string, videoIds: Video.VideoId[]) => Promise; - removeVideos: (entityId: string, videoIds: Video.VideoId[]) => Promise; + addVideos: (entityId: string, videoIds: string[]) => Promise; + removeVideos: (entityId: string, videoIds: string[]) => Promise; getVideos: (limit?: number) => Promise; getEntityVideoIds: (entityId: string) => Promise; } -export interface VideoData { - id: Video.VideoId; +export interface Video { + id: string; ownerId: string; name: string; createdAt: Date; @@ -63,7 +62,7 @@ const AddVideosDialogBase: React.FC = ({ getVideos, getEntityVideoIds, }) => { - const [selectedVideos, setSelectedVideos] = useState([]); + const [selectedVideos, setSelectedVideos] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const filterTabs = ["all", "added", "notAdded"]; @@ -76,7 +75,7 @@ const AddVideosDialogBase: React.FC = ({ }, }); - const { data: videosData, isLoading } = useQuery({ + const { data: videosData, isLoading } = useQuery({ queryKey: ["user-videos"], queryFn: async () => { const result = await getVideos(); @@ -90,7 +89,7 @@ const AddVideosDialogBase: React.FC = ({ gcTime: 1000 * 60 * 5, }); - const { data: entityVideoIds } = useQuery({ + const { data: entityVideoIds } = useQuery({ queryKey: ["entity-video-ids", entityId], queryFn: async () => { const result = await getEntityVideoIds(entityId); @@ -109,8 +108,8 @@ const AddVideosDialogBase: React.FC = ({ toAdd, toRemove, }: { - toAdd: Video.VideoId[]; - toRemove: Video.VideoId[]; + toAdd: string[]; + toRemove: string[]; }) => { let addResult = { success: true, message: "", error: "" }; let removeResult = { success: true, message: "", error: "" }; @@ -154,25 +153,21 @@ const AddVideosDialogBase: React.FC = ({ const [videoTab, setVideoTab] = useState<(typeof filterTabs)[number]>("all"); // Memoize filtered videos for stable reference - const filteredVideos: VideoData[] = useMemo(() => { + const filteredVideos: Video[] = useMemo(() => { let vids = - videosData?.filter((video: VideoData) => + videosData?.filter((video: Video) => video.name.toLowerCase().includes(searchTerm.toLowerCase()), ) || []; if (videoTab === "added") { - vids = vids.filter((video: VideoData) => - entityVideoIds?.includes(video.id), - ); + vids = vids.filter((video: Video) => entityVideoIds?.includes(video.id)); } else if (videoTab === "notAdded") { - vids = vids.filter( - (video: VideoData) => !entityVideoIds?.includes(video.id), - ); + vids = vids.filter((video: Video) => !entityVideoIds?.includes(video.id)); } return vids; }, [videosData, searchTerm, videoTab, entityVideoIds]); // Memoize handleVideoToggle for stable reference - const handleVideoToggle = useCallback((videoId: Video.VideoId) => { + const handleVideoToggle = useCallback((videoId: string) => { setSelectedVideos((prev) => prev.includes(videoId) ? prev.filter((id) => id !== videoId) diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx index 943c3484a0..192e6a83f2 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx @@ -14,7 +14,6 @@ interface SharedCapCardProps { totalReactions: number; ownerName: string | null; metadata?: VideoMetadata; - hasActiveUpload: boolean | undefined; }; analytics: number; isLoadingAnalytics: boolean; @@ -50,6 +49,7 @@ export const SharedCapCard: React.FC = ({ isLoadingAnalytics={isLoadingAnalytics} cap={cap} analytics={displayCount} + sharedCapCard userId={userId} >

    diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx index 8adc2a307b..a3eaf28066 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx @@ -6,10 +6,10 @@ import type React from "react"; import { memo } from "react"; import { Tooltip } from "@/components/Tooltip"; import { VideoThumbnail } from "@/components/VideoThumbnail"; -import type { VideoData } from "./AddVideosDialogBase"; +import type { Video } from "./AddVideosDialogBase"; interface VideoCardProps { - video: VideoData; + video: Video; isSelected: boolean; onToggle: () => void; isAlreadyInEntity: boolean; @@ -100,6 +100,7 @@ const VideoCard: React.FC = memo( > void; - entityVideoIds: Video.VideoId[]; + handleVideoToggle: (id: string) => void; + entityVideoIds: string[]; height?: number; columnCount?: number; rowHeight?: number; @@ -50,7 +49,7 @@ const VirtualizedVideoGrid = ({ // Initialize the grid with responsive column count const grid = useGrid({ - scrollRef: scrollRef as RefObject, // React typing version mismatch + scrollRef, count: videos.length, columns: responsiveColumnCount, gap: { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 0bf4d07871..466e1f8323 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -1,98 +1,81 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; -import { CurrentUser, type Folder } from "@cap/web-domain"; -import { Effect } from "effect"; -import { notFound } from "next/navigation"; +import type { Folder } from "@cap/web-domain"; import FolderCard from "@/app/(org)/dashboard/caps/components/Folder"; import { getChildFolders, getFolderBreadcrumb, getVideosByFolderId, } from "@/lib/folder"; -import { runPromise } from "@/lib/server"; import { BreadcrumbItem, ClientMyCapsLink, NewSubfolderButton, } from "../../../../folder/[id]/components"; import FolderVideosSection from "../../../../folder/[id]/components/FolderVideosSection"; -import { getSpaceOrOrg } from "../../utils"; -const FolderPage = async (props: { - params: Promise<{ spaceId: string; folderId: Folder.FolderId }>; +const FolderPage = async ({ + params, +}: { + params: { spaceId: string; folderId: Folder.FolderId }; }) => { - const params = await props.params; const user = await getCurrentUser(); - if (!user) return notFound(); + if (!user) return; - return await Effect.gen(function* () { - const spaceOrOrg = yield* getSpaceOrOrg(params.spaceId); - if (!spaceOrOrg) notFound(); + const [childFolders, breadcrumb, videosData] = await Promise.all([ + getChildFolders(params.folderId), + getFolderBreadcrumb(params.folderId), + getVideosByFolderId(params.folderId), + ]); + const userId = user?.id as string; - const [childFolders, breadcrumb, videosData] = yield* Effect.all([ - getChildFolders( - params.folderId, - spaceOrOrg.variant === "space" - ? { variant: "space", spaceId: spaceOrOrg.space.id } - : { variant: "org", organizationId: spaceOrOrg.organization.id }, - ), - getFolderBreadcrumb(params.folderId), - getVideosByFolderId(params.folderId), - ]); - - return ( -
    -
    - + return ( +
    +
    + +
    +
    +
    + + {breadcrumb.map((folder, index) => ( +
    +

    /

    + +
    + ))}
    -
    -
    - - {breadcrumb.map((folder, index) => ( -
    -

    /

    - -
    +
    + {/* Display Child Folders */} + {childFolders.length > 0 && ( + <> +

    Subfolders

    +
    + {childFolders.map((folder) => ( + ))}
    -
    - {/* Display Child Folders */} - {childFolders.length > 0 && ( - <> -

    - Subfolders -

    -
    - {childFolders.map((folder) => ( - - ))} -
    - - )} - {/* Display Videos */} - -
    - ); - }).pipe( - Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), - Effect.provideService(CurrentUser, user), - runPromise, + + )} + {/* Display Videos */} + +
    ); }; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 507ba02580..467647214e 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -4,27 +4,38 @@ import { comments, folders, organizationMembers, + organizations, sharedVideos, spaceMembers, + spaces, spaceVideos, users, videos, - videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { CurrentUser, Video } from "@cap/web-domain"; +import { Video } from "@cap/web-domain"; import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; -import { Effect } from "effect"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { runPromise } from "@/lib/server"; import { SharedCaps } from "./SharedCaps"; -import { getSpaceOrOrg } from "./utils"; export const metadata: Metadata = { title: "Shared Caps — Cap", }; +type SpaceData = { + id: string; + name: string; + organizationId: string; + createdById: string; +}; + +type OrganizationData = { + id: string; + name: string; + ownerId: string; +}; + export type SpaceMemberData = { id: string; userId: string; @@ -35,6 +46,31 @@ export type SpaceMemberData = { }; // --- Helper functions --- +async function fetchSpaceData(id: string) { + return db() + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + createdById: spaces.createdById, + }) + .from(spaces) + .where(eq(spaces.id, id)) + .limit(1); +} + +async function fetchOrganizationData(id: string) { + return db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + }) + .from(organizations) + .where(eq(organizations.id, id)) + .limit(1); +} + async function fetchFolders(spaceId: string) { return db() .select({ @@ -81,34 +117,70 @@ async function fetchOrganizationMembers(orgId: string) { .where(eq(organizationMembers.organizationId, orgId)); } -export default async function SharedCapsPage(props: { - params: Promise<{ spaceId: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +export default async function SharedCapsPage({ + params, + searchParams, +}: { + params: { spaceId: string }; + searchParams: { [key: string]: string | string[] | undefined }; }) { - const searchParams = await props.searchParams; - const params = await props.params; const page = Number(searchParams.page) || 1; const limit = Number(searchParams.limit) || 15; const user = await getCurrentUser(); - if (!user) notFound(); + const userId = user?.id as string; + // this is just how it work atm + const spaceOrOrgId = params.spaceId; - const spaceOrOrg = await getSpaceOrOrg(params.spaceId).pipe( - Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), - Effect.provideService(CurrentUser, user), - runPromise, - ); + // Parallelize fetching space and org data + const [spaceData, organizationData] = await Promise.all([ + fetchSpaceData(spaceOrOrgId), + fetchOrganizationData(spaceOrOrgId), + ]); + + // organizationData assignment handled above + if (spaceData.length === 0 && organizationData.length === 0) { + notFound(); + } - if (!spaceOrOrg) notFound(); + const isSpace = spaceData.length > 0; - if (spaceOrOrg.variant === "space") { - const { space } = spaceOrOrg; + if (isSpace) { + const space = spaceData[0] as SpaceData; + const isSpaceCreator = space.createdById === userId; + let hasAccess = isSpaceCreator; + if (!isSpaceCreator) { + const [spaceMembership, orgMembership] = await Promise.all([ + db() + .select({ id: spaceMembers.id }) + .from(spaceMembers) + .where( + and( + eq(spaceMembers.userId, userId), + eq(spaceMembers.spaceId, spaceOrOrgId), + ), + ) + .limit(1), + db() + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.organizationId, space.organizationId), + ), + ) + .limit(1), + ]); + hasAccess = spaceMembership.length > 0 || orgMembership.length > 0; + } + if (!hasAccess) notFound(); // Fetch members in parallel const [spaceMembersData, organizationMembersData, foldersData] = await Promise.all([ - fetchSpaceMembers(space.id), + fetchSpaceMembers(spaceOrOrgId), fetchOrganizationMembers(space.organizationId), - fetchFolders(space.id), + fetchFolders(spaceOrOrgId), ]); async function fetchSpaceVideos( @@ -130,15 +202,11 @@ export default async function SharedCapsPage(props: { totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, effectiveDate: sql`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt})`, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), }) .from(spaceVideos) .innerJoin(videos, eq(spaceVideos.videoId, videos.id)) .leftJoin(comments, eq(videos.id, comments.videoId)) .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) .where( and(eq(spaceVideos.spaceId, spaceId), isNull(spaceVideos.folderId)), ) @@ -172,7 +240,7 @@ export default async function SharedCapsPage(props: { // Fetch videos and count in parallel const { videos: spaceVideoData, totalCount } = await fetchSpaceVideos( - space.id, + spaceOrOrgId, page, limit, ); @@ -196,14 +264,30 @@ export default async function SharedCapsPage(props: { dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} spaceMembers={spaceMembersData} organizationMembers={organizationMembersData} - currentUserId={user.id} + currentUserId={userId} folders={foldersData} /> ); - } + } else { + const organization = organizationData[0] as OrganizationData; + const isOrgOwner = organization.ownerId === userId; - if (spaceOrOrg.variant === "organization") { - const { organization } = spaceOrOrg; + if (!isOrgOwner) { + const orgMembership = await db() + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.organizationId, spaceOrOrgId), + ), + ) + .limit(1); + + if (orgMembership.length === 0) { + notFound(); + } + } async function fetchOrganizationVideos( orgId: string, @@ -224,15 +308,11 @@ export default async function SharedCapsPage(props: { totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, effectiveDate: sql`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt})`, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), }) .from(sharedVideos) .innerJoin(videos, eq(sharedVideos.videoId, videos.id)) .leftJoin(comments, eq(videos.id, comments.videoId)) .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) .where( and( eq(sharedVideos.organizationId, orgId), @@ -276,9 +356,9 @@ export default async function SharedCapsPage(props: { const [organizationVideos, organizationMembersData, foldersData] = await Promise.all([ - fetchOrganizationVideos(organization.id, page, limit), - fetchOrganizationMembers(organization.id), - fetchFolders(organization.id), + fetchOrganizationVideos(spaceOrOrgId, page, limit), + fetchOrganizationMembers(spaceOrOrgId), + fetchFolders(spaceOrOrgId), ]); const { videos: orgVideoData, totalCount } = organizationVideos; @@ -302,7 +382,7 @@ export default async function SharedCapsPage(props: { organizationData={organization} dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} organizationMembers={organizationMembersData} - currentUserId={user.id} + currentUserId={userId} folders={foldersData} /> ); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts deleted file mode 100644 index df6205f9af..0000000000 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { organizations, spaces } from "@cap/database/schema"; -import { Database, OrganisationsPolicy, SpacesPolicy } from "@cap/web-backend"; -import { Policy } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect } from "effect"; - -export const getSpaceOrOrg = Effect.fn(function* (spaceOrOrgId: string) { - const db = yield* Database; - const spacesPolicy = yield* SpacesPolicy; - const orgsPolicy = yield* OrganisationsPolicy; - - const [[space], [organization]] = yield* Effect.all([ - db.execute((db) => - db - .select({ - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - createdById: spaces.createdById, - }) - .from(spaces) - .where(eq(spaces.id, spaceOrOrgId)) - .limit(1), - ), - db.execute((db) => - db - .select({ - id: organizations.id, - name: organizations.name, - ownerId: organizations.ownerId, - }) - .from(organizations) - .where(eq(organizations.id, spaceOrOrgId)) - .limit(1), - ), - ]); - - if (space) - return yield* Effect.succeed({ variant: "space" as const, space }).pipe( - Policy.withPolicy(spacesPolicy.isMember(space.id)), - ); - - if (organization) - return yield* Effect.succeed({ - variant: "organization" as const, - organization, - }).pipe(Policy.withPolicy(orgsPolicy.isMember(organization.id))); -}); diff --git a/apps/web/app/(org)/invite/[inviteId]/page.tsx b/apps/web/app/(org)/invite/[inviteId]/page.tsx index 81d36d1e39..af09286b30 100644 --- a/apps/web/app/(org)/invite/[inviteId]/page.tsx +++ b/apps/web/app/(org)/invite/[inviteId]/page.tsx @@ -14,11 +14,10 @@ import { notFound } from "next/navigation"; import { InviteAccept } from ".//InviteAccept"; type Props = { - params: Promise<{ inviteId: string }>; + params: { inviteId: string }; }; -export async function generateMetadata(props: Props): Promise { - const params = await props.params; +export async function generateMetadata({ params }: Props): Promise { const inviteId = params.inviteId; const invite = await getInviteDetails(inviteId); @@ -50,8 +49,7 @@ async function getInviteDetails(inviteId: string) { return query[0]; } -export default async function InvitePage(props: Props) { - const params = await props.params; +export default async function InvitePage({ params }: Props) { const inviteId = params.inviteId; const user = await getCurrentUser(); const inviteDetails = await getInviteDetails(inviteId); diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index db2a2ab97b..70b375e4b8 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -157,7 +157,7 @@ export function LoginForm() { - + )} */} - - Don't have an account?{" "} - - Sign up here - - -
    +

    OR

    @@ -459,16 +447,18 @@ const NormalLogin = ({ className="flex flex-col gap-3 justify-center items-center" > {!oauthError && ( - - Google - Login with Google - + <> + + Google + Login with Google + + )} {oauthError && ( @@ -479,7 +469,8 @@ const NormalLogin = ({ />

    It looks like you've previously used this email to sign up via - email login. Please enter your email. + email login. Please enter your email below to receive a sign in + link.

    )} @@ -489,7 +480,7 @@ const NormalLogin = ({ className="w-full" layout onClick={() => setShowOrgInput(true)} - disabled={loading || emailSent} + disabled={loading} > Login with SAML SSO diff --git a/apps/web/app/(org)/signup/form.tsx b/apps/web/app/(org)/signup/form.tsx deleted file mode 100644 index d5817ac073..0000000000 --- a/apps/web/app/(org)/signup/form.tsx +++ /dev/null @@ -1,494 +0,0 @@ -"use client"; - -import { Button, Input, LogoBadge } from "@cap/ui"; -import { - faArrowLeft, - faEnvelope, - faExclamationCircle, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { AnimatePresence, motion } from "framer-motion"; -import Cookies from "js-cookie"; -import { LucideArrowUpRight } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { signIn } from "next-auth/react"; -import { Suspense, useEffect, useState } from "react"; -import { toast } from "sonner"; -import { getOrganizationSSOData } from "@/actions/organization/get-organization-sso-data"; -import { trackEvent } from "@/app/utils/analytics"; - -const MotionInput = motion(Input); -const MotionLogoBadge = motion(LogoBadge); -const MotionLink = motion(Link); -const MotionButton = motion(Button); - -export function SignupForm() { - const searchParams = useSearchParams(); - const router = useRouter(); - const next = searchParams?.get("next"); - const [email, setEmail] = useState(""); - const [loading, setLoading] = useState(false); - const [emailSent, setEmailSent] = useState(false); - const [oauthError, setOauthError] = useState(false); - const [showOrgInput, setShowOrgInput] = useState(false); - const [organizationId, setOrganizationId] = useState(""); - const [organizationName, setOrganizationName] = useState(null); - const [lastEmailSentTime, setLastEmailSentTime] = useState( - null, - ); - const theme = Cookies.get("theme") || "light"; - - useEffect(() => { - theme === "dark" - ? (document.body.className = "dark") - : (document.body.className = "light"); - //remove the dark mode when we leave the dashboard - return () => { - document.body.className = "light"; - }; - }, [theme]); - - useEffect(() => { - const error = searchParams?.get("error"); - const errorDesc = searchParams?.get("error_description"); - - const handleErrors = () => { - if (error === "OAuthAccountNotLinked" && !errorDesc) { - setOauthError(true); - return toast.error( - "This email is already associated with a different sign-in method", - ); - } else if ( - error === "profile_not_allowed_outside_organization" && - !errorDesc - ) { - return toast.error( - "Your email domain is not authorized for SSO access. Please use your work email or contact your administrator.", - ); - } else if (error && errorDesc) { - return toast.error(errorDesc); - } - }; - handleErrors(); - }, [searchParams]); - - useEffect(() => { - const pendingPriceId = localStorage.getItem("pendingPriceId"); - const pendingQuantity = localStorage.getItem("pendingQuantity") ?? "1"; - if (emailSent && pendingPriceId) { - localStorage.removeItem("pendingPriceId"); - localStorage.removeItem("pendingQuantity"); - - // Wait a bit to ensure the user is created - setTimeout(async () => { - const response = await fetch(`/api/settings/billing/subscribe`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - priceId: pendingPriceId, - quantity: parseInt(pendingQuantity), - }), - }); - const data = await response.json(); - - if (data.url) { - window.location.href = data.url; - } - }, 2000); - } - }, [emailSent]); - - const handleGoogleSignIn = () => { - trackEvent("auth_started", { method: "google", is_signup: true }); - signIn("google", { - ...(next && next.length > 0 ? { callbackUrl: next } : {}), - }); - }; - - const handleOrganizationLookup = async (e: React.FormEvent) => { - e.preventDefault(); - if (!organizationId) { - toast.error("Please enter an organization ID"); - return; - } - - try { - const data = await getOrganizationSSOData(organizationId); - setOrganizationName(data.name); - - signIn("workos", undefined, { - organization: data.organizationId, - connection: data.connectionId, - }); - } catch (error) { - console.error("Lookup Error:", error); - toast.error("Organization not found or SSO not configured"); - } - }; - - return ( - - setShowOrgInput(false)} - className="absolute overflow-hidden top-5 rounded-full left-5 z-20 hover:bg-gray-1 gap-2 items-center py-1.5 px-3 text-gray-12 bg-transparent border border-gray-4 transition-colors duration-300 cursor-pointer" - > - - - Back - - - - - - - - Sign up to Cap - - - Beautiful screen recordings, owned by you. - - - - - -
    - - ); -}; - -const NormalSignup = ({ - setShowOrgInput, - email, - emailSent, - setEmail, - loading, - oauthError, - handleGoogleSignIn, -}: { - setShowOrgInput: (show: boolean) => void; - email: string; - emailSent: boolean; - setEmail: (email: string) => void; - loading: boolean; - oauthError: boolean; - handleGoogleSignIn: () => void; -}) => { - return ( - - - { - setEmail(e.target.value); - }} - /> - } - > - Sign up with email - - -
    - -

    OR

    - -
    - - {!oauthError && ( - <> - - Google - Sign up with Google - - - )} - - {oauthError && ( -
    - -

    - It looks like you've previously used this email to sign up via - email. Please enter your email below to receive a sign up link. -

    -
    - )} - setShowOrgInput(true)} - disabled={loading} - > - - Sign up with SAML SSO - -
    -
    - ); -}; diff --git a/apps/web/app/(org)/signup/page.tsx b/apps/web/app/(org)/signup/page.tsx deleted file mode 100644 index de68b63d08..0000000000 --- a/apps/web/app/(org)/signup/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { getCurrentUser } from "@cap/database/auth/session"; -import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { SignupForm } from "./form"; - -export const dynamic = "force-dynamic"; - -export default async function SignupPage() { - const session = await getCurrentUser(); - if (session) { - redirect("/dashboard"); - } - return ( -
    -
    - - - Home - -
    - -
    - ); -} diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index 4c7ff058aa..f2effe76cf 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -156,19 +156,19 @@ export function VerifyOTPForm({ - +
    -

    +

    Enter verification code

    -

    +

    We sent a 6-digit code to {email}

    -
    +
    {code.map((digit, index) => ( ))} @@ -221,7 +221,7 @@ export function VerifyOTPForm({

    - By entering your email, you acknowledge that you have both read and + By verifying your email, you acknowledge that you have both read and agree to Cap's{" "} ; +export default async function VerifyOTPPage({ + searchParams, +}: { + searchParams: { email?: string; next?: string; lastSent?: string }; }) { - const searchParams = await props.searchParams; const session = await getSession(); if (session?.user) { diff --git a/apps/web/app/(site)/(seo)/free-screen-recorder/page.tsx b/apps/web/app/(site)/(seo)/free-screen-recorder/page.tsx deleted file mode 100644 index a57a6b42e9..0000000000 --- a/apps/web/app/(site)/(seo)/free-screen-recorder/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import { FreeScreenRecorderPage } from "@/components/pages/seo/FreeScreenRecorderPage"; - -export const metadata: Metadata = { - title: "Free Screen Recorder: High-Quality Recording at No Cost", - description: - "Cap offers a top-rated, free screen recorder with high-quality video capture, making it perfect for creating tutorials, educational content, and professional demos without any hidden fees.", - openGraph: { - title: "Free Screen Recorder: High-Quality Recording at No Cost", - description: - "Cap offers a top-rated, free screen recorder with high-quality video capture, making it perfect for creating tutorials, educational content, and professional demos without any hidden fees.", - url: "https://cap.so/free-screen-recorder", - siteName: "Cap", - images: [ - { - url: "https://cap.so/og.png", - width: 1200, - height: 630, - alt: "Cap: Free Screen Recorder", - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Free Screen Recorder: High-Quality Recording at No Cost", - description: - "Cap offers a top-rated, free screen recorder with high-quality video capture, making it perfect for creating tutorials, educational content, and professional demos without any hidden fees.", - images: ["https://cap.so/og.png"], - }, -}; - -export default function Page() { - return ; -} diff --git a/apps/web/app/(site)/(seo)/screen-recorder-mac/page.tsx b/apps/web/app/(site)/(seo)/screen-recorder-mac/page.tsx deleted file mode 100644 index c5de66d2bf..0000000000 --- a/apps/web/app/(site)/(seo)/screen-recorder-mac/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import { ScreenRecordMacPage } from "@/components/pages/seo/ScreenRecordMacPage"; - -export const metadata: Metadata = { - title: "Best Screen Recorder for Mac | High-Quality, Free & Easy (2025)", - description: - "Cap is the best free screen recorder for Mac, offering HD quality, unlimited recording, and easy export. Ideal for tutorials, presentations, and educational videos.", - openGraph: { - title: "Best Screen Recorder for Mac | High-Quality, Free & Easy (2025)", - description: - "Cap is the best free screen recorder for Mac, offering HD quality, unlimited recording, and easy export. Ideal for tutorials, presentations, and educational videos.", - url: "https://cap.so/screen-recorder-mac", - siteName: "Cap", - images: [ - { - url: "https://cap.so/og.png", - width: 1200, - height: 630, - alt: "Cap: Best Screen Recorder for Mac", - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Best Screen Recorder for Mac | Cap", - description: - "Cap is the best free screen recorder for Mac, offering HD quality, unlimited recording, and easy export. Ideal for tutorials, presentations, and educational videos.", - images: ["https://cap.so/og.png"], - }, -}; - -export default function Page() { - return ; -} diff --git a/apps/web/app/(site)/(seo)/screen-recorder-windows/page.tsx b/apps/web/app/(site)/(seo)/screen-recorder-windows/page.tsx deleted file mode 100644 index 78701d537c..0000000000 --- a/apps/web/app/(site)/(seo)/screen-recorder-windows/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import { ScreenRecordWindowsPage } from "@/components/pages/seo/ScreenRecordWindowsPage"; - -export const metadata: Metadata = { - title: "Best Screen Recorder for Windows: Easy, Powerful & Free (2025)", - description: - "Cap is the best screen recorder for Windows, offering HD quality recording, unlimited free usage, and seamless sharing. A perfect OBS alternative for tutorials, presentations, and more.", - openGraph: { - title: "Best Screen Recorder for Windows: Easy, Powerful & Free (2025)", - description: - "Cap is the best screen recorder for Windows, offering HD quality recording, unlimited free usage, and seamless sharing. A perfect OBS alternative for tutorials, presentations, and more.", - url: "https://cap.so/screen-recorder-windows", - siteName: "Cap", - images: [ - { - url: "https://cap.so/og.png", - width: 1200, - height: 630, - alt: "Cap: Best Screen Recorder for Windows", - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Best Screen Recorder for Windows | Cap", - description: - "Cap is the best screen recorder for Windows, offering HD quality recording, unlimited free usage, and seamless sharing. A perfect OBS alternative for tutorials, presentations, and more.", - images: ["https://cap.so/og.png"], - }, -}; - -export default function Page() { - return ; -} diff --git a/apps/web/app/(site)/(seo)/screen-recorder/page.tsx b/apps/web/app/(site)/(seo)/screen-recorder/page.tsx deleted file mode 100644 index a95a28655b..0000000000 --- a/apps/web/app/(site)/(seo)/screen-recorder/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ScreenRecorderPage } from "@/components/pages/seo/ScreenRecorderPage"; - -export default function Page() { - return ; -} diff --git a/apps/web/app/(site)/(seo)/screen-recording-software/page.tsx b/apps/web/app/(site)/(seo)/screen-recording-software/page.tsx deleted file mode 100644 index dbb777fa38..0000000000 --- a/apps/web/app/(site)/(seo)/screen-recording-software/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import { ScreenRecordingSoftwarePage } from "@/components/pages/seo/ScreenRecordingSoftwarePage"; - -export const metadata: Metadata = { - title: "Screen Recording Software: High-Quality, User-Friendly, and Free", - description: - "Cap is an all-in-one screen recording software offering high-quality video capture with an intuitive interface. Ideal for creating tutorials, presentations, and educational content, Cap provides everything you need at no cost.", - openGraph: { - title: "Screen Recording Software: High-Quality, User-Friendly, and Free", - description: - "Cap is an all-in-one screen recording software offering high-quality video capture with an intuitive interface. Ideal for creating tutorials, presentations, and educational content, Cap provides everything you need at no cost.", - url: "https://cap.so/screen-recording-software", - siteName: "Cap", - images: [ - { - url: "https://cap.so/og.png", - width: 1200, - height: 630, - alt: "Cap: Screen Recording Software", - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Screen Recording Software: High-Quality, User-Friendly, and Free", - description: - "Cap is an all-in-one screen recording software offering high-quality video capture with an intuitive interface. Ideal for creating tutorials, presentations, and educational content.", - images: ["https://cap.so/og.png"], - }, -}; - -export default function Page() { - return ; -} diff --git a/apps/web/app/(site)/(seo)/solutions/agencies/page.tsx b/apps/web/app/(site)/(seo)/solutions/agencies/page.tsx deleted file mode 100644 index e869a66a2c..0000000000 --- a/apps/web/app/(site)/(seo)/solutions/agencies/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { Metadata } from "next"; -import Script from "next/script"; -import { AgenciesPage } from "@/components/pages/seo/AgenciesPage"; - -// Create FAQ structured data for SEO -const createFaqStructuredData = () => { - const faqs = [ - { - question: "Does Cap work on both macOS and Windows?", - answer: - "Yes. Cap supports both macOS and Windows with desktop apps, so your entire team can use the same workflow regardless of their platform preference.", - }, - { - question: "Can clients view videos without installing anything?", - answer: - "Yes. Clients can watch videos directly in their browser through a simple link. No downloads, no account creation, no friction. They can also leave comments directly on the video.", - }, - { - question: "What's the difference between Instant Mode and Studio Mode?", - answer: - "Instant Mode generates a shareable link immediately after recording—perfect for quick updates. Studio Mode records locally for the highest quality and includes precision editing tools for professional client presentations.", - }, - { - question: "How long can we record on the free version?", - answer: - "The free version supports recordings up to 5 minutes. For longer client presentations and unlimited recording time, upgrade to Cap Pro at $8.16/month (billed annually).", - }, - { - question: "Is Cap secure enough for confidential client work?", - answer: - "Yes. Cap is open-source and privacy-first. You can connect your own S3 storage, use a custom domain for share links, and password-protect sensitive videos. This gives you complete control over client data.", - }, - { - question: "Can we use our own branding with Cap?", - answer: - "Yes. Cap Pro includes custom domain support (cap.yourdomain.com) so share links reflect your agency's brand. You can also use your own S3 storage for complete data ownership.", - }, - { - question: "How does Cap pricing work for agency teams?", - answer: - "Cap Pro is $8.16/month per user (billed annually) and includes unlimited cloud storage, custom domains, team workspaces, and all collaboration features. Volume discounts are available for teams over 10 users.", - }, - ]; - - const faqStructuredData = { - "@context": "https://schema.org", - "@type": "FAQPage", - mainEntity: faqs.map((faq) => ({ - "@type": "Question", - name: faq.question, - acceptedAnswer: { - "@type": "Answer", - text: faq.answer.replace(/<\/?[^>]+(>|$)/g, ""), - }, - })), - }; - - return JSON.stringify(faqStructuredData); -}; - -// Create SoftwareApplication structured data -const createSoftwareStructuredData = () => { - const softwareStructuredData = { - "@context": "https://schema.org", - "@type": "SoftwareApplication", - name: "Cap — Screen Recorder", - operatingSystem: ["macOS", "Windows"], - applicationCategory: "BusinessApplication", - description: - "Open-source, privacy-first screen recorder for agencies. Instant share links and studio-quality local recording with editing.", - publisher: { - "@type": "Organization", - name: "Cap", - }, - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - category: "FreeTrial", - }, - }; - - return JSON.stringify(softwareStructuredData); -}; - -export const metadata: Metadata = { - title: "Cap for Agencies — Faster Client Updates with Instant Video Links", - description: - "Send clearer client updates in minutes. Share instant links with comments, or craft polished walkthroughs. Cap for Agencies on macOS & Windows.", - openGraph: { - title: "Cap for Agencies — Faster Client Updates with Instant Video Links", - description: - "Send clearer client updates in minutes. Share instant links with comments, or craft polished walkthroughs. Cap for Agencies on macOS & Windows.", - url: "https://cap.so/solutions/agencies", - siteName: "Cap", - images: [ - { - url: "https://cap.so/og.png", - width: 1200, - height: 630, - alt: "Cap for Agencies", - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Cap for Agencies — Faster Client Updates with Instant Video Links", - description: - "Send clearer client updates in minutes. Share instant links with comments, or craft polished walkthroughs. Cap for Agencies on macOS & Windows.", - images: ["https://cap.so/og.png"], - }, -}; - -export default function Page() { - return ( - <> -