From d0795aa3b64556d19c4e7b1299d4af88d7dd58ab Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 8 Nov 2025 23:03:09 +0000 Subject: [PATCH 01/17] feat: Add cursor movement style presets and motion blur --- .../src/routes/editor/ConfigSidebar.tsx | 140 +++++++++++++++++- apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/configuration.rs | 51 ++++++- crates/rendering/src/cursor_interpolation.rs | 16 +- crates/rendering/src/layers/cursor.rs | 30 +++- crates/rendering/src/lib.rs | 2 + crates/rendering/src/shaders/cursor.wgsl | 2 +- crates/rendering/src/spring_mass_damper.rs | 20 +-- 8 files changed, 228 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 3cacb36c2d..5781558078 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -45,6 +45,7 @@ import { type BackgroundSource, type CameraShape, type ClipOffsets, + type CursorAnimationStyle, commands, type SceneSegment, type StereoMode, @@ -54,6 +55,8 @@ import { import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import IconLucideTimer from "~icons/lucide/timer"; +import IconLucideRabbit from "~icons/lucide/rabbit"; +import IconLucideWind from "~icons/lucide/wind"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; import { @@ -215,6 +218,59 @@ const BACKGROUND_THEMES = { orange: "Orange", }; +type CursorPresetValues = { + tension: number; + mass: number; + friction: number; +}; + +const CURSOR_ANIMATION_STYLE_OPTIONS = [ + { + value: "slow", + label: "Slow", + description: "Relaxed easing with a gentle follow and higher inertia.", + preset: { tension: 65, mass: 1.8, friction: 16 }, + }, + { + value: "mellow", + label: "Mellow", + description: "Balanced smoothing for everyday tutorials and walkthroughs.", + preset: { tension: 120, mass: 1.1, friction: 18 }, + }, + { + value: "custom", + label: "Custom", + description: "Tune tension, friction, and mass manually for full control.", + }, +] satisfies Array<{ + value: CursorAnimationStyle; + label: string; + description: string; + preset?: CursorPresetValues; +}>; + +const CURSOR_PRESET_TOLERANCE = { + tension: 1, + mass: 0.05, + friction: 0.2, +} as const; + +const findCursorPreset = ( + values: CursorPresetValues, +): CursorAnimationStyle | null => { + const preset = CURSOR_ANIMATION_STYLE_OPTIONS.find( + (option) => + option.preset && + Math.abs(option.preset.tension - values.tension) <= + CURSOR_PRESET_TOLERANCE.tension && + Math.abs(option.preset.mass - values.mass) <= CURSOR_PRESET_TOLERANCE.mass && + Math.abs(option.preset.friction - values.friction) <= + CURSOR_PRESET_TOLERANCE.friction, + ); + + return preset?.value ?? null; +}; + const TAB_IDS = { background: "background", camera: "camera", @@ -242,6 +298,40 @@ export function ConfigSidebar() { const clampIdleDelay = (value: number) => Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10; + type CursorPhysicsKey = "tension" | "mass" | "friction"; + + const setCursorPhysics = (key: CursorPhysicsKey, value: number) => { + const nextValues: CursorPresetValues = { + tension: key === "tension" ? value : project.cursor.tension, + mass: key === "mass" ? value : project.cursor.mass, + friction: key === "friction" ? value : project.cursor.friction, + }; + const matched = findCursorPreset(nextValues); + const nextStyle = (matched ?? "custom") as CursorAnimationStyle; + + batch(() => { + setProject("cursor", key, value); + if (project.cursor.animationStyle !== nextStyle) { + setProject("cursor", "animationStyle", nextStyle); + } + }); + }; + + const applyCursorStylePreset = (style: CursorAnimationStyle) => { + const option = CURSOR_ANIMATION_STYLE_OPTIONS.find( + (item) => item.value === style, + ); + + batch(() => { + setProject("cursor", "animationStyle", style); + if (option?.preset) { + setProject("cursor", "tension", option.preset.tension); + setProject("cursor", "mass", option.preset.mass); + setProject("cursor", "friction", option.preset.friction); + } + }); + }; + const [state, setState] = createStore({ selectedTab: "background" as | "background" @@ -517,6 +607,38 @@ export function ConfigSidebar() { + } + > + + applyCursorStylePreset(value as CursorAnimationStyle) + } + > + {CURSOR_ANIMATION_STYLE_OPTIONS.map((option) => ( + + + + +
+ + {option.label} + + + {option.description} + +
+
+
+ ))} +
+
setProject("cursor", "tension", v[0])} + onChange={(v) => setCursorPhysics("tension", v[0])} minValue={1} maxValue={500} step={1} @@ -545,7 +667,7 @@ export function ConfigSidebar() { setProject("cursor", "friction", v[0])} + onChange={(v) => setCursorPhysics("friction", v[0])} minValue={0} maxValue={50} step={0.1} @@ -554,7 +676,7 @@ export function ConfigSidebar() { setProject("cursor", "mass", v[0])} + onChange={(v) => setCursorPhysics("mass", v[0])} minValue={0.1} maxValue={10} step={0.01} @@ -575,6 +697,18 @@ export function ConfigSidebar() { /> } /> + }> + + setProject("cursor", "motionBlur" as any, v[0]) + } + minValue={0} + maxValue={1} + step={0.01} + formatTooltip={(value) => `${Math.round(value * 100)}%`} + /> + {/* diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 0a01999121..af8507f864 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -385,7 +385,7 @@ export type Crop = { position: XY; size: XY } export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode } export type CurrentRecordingChanged = null export type CurrentRecordingTarget = { window: { id: WindowId; bounds: LogicalBounds } } | { screen: { id: DisplayId } } | { area: { screen: DisplayId; bounds: LogicalBounds } } -export type CursorAnimationStyle = "regular" | "slow" | "fast" +export type CursorAnimationStyle = "slow" | "mellow" | "custom" export type CursorConfiguration = { hide?: boolean; hideWhenIdle?: boolean; hideWhenIdleDelay?: number; size: number; type: CursorType; animationStyle: CursorAnimationStyle; tension: number; mass: number; friction: number; raw?: boolean; motionBlur?: number; useSvg?: boolean } export type CursorMeta = { imagePath: string; hotspot: XY; shape?: string | null } export type CursorType = "pointer" | "circle" diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index e787e1b5a3..16646bbcee 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -384,13 +384,39 @@ pub enum CursorType { Circle, } -#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum CursorAnimationStyle { #[default] - Regular, Slow, - Fast, + #[serde(alias = "regular", alias = "quick", alias = "rapid", alias = "fast")] + Mellow, + Custom, +} + +#[derive(Clone, Copy, Debug)] +pub struct CursorSmoothingPreset { + pub tension: f32, + pub mass: f32, + pub friction: f32, +} + +impl CursorAnimationStyle { + pub fn preset(self) -> Option { + match self { + Self::Slow => Some(CursorSmoothingPreset { + tension: 65.0, + mass: 1.8, + friction: 16.0, + }), + Self::Mellow => Some(CursorSmoothingPreset { + tension: 120.0, + mass: 1.1, + friction: 18.0, + }), + Self::Custom => None, + } + } } #[derive(Type, Serialize, Deserialize, Clone, Debug)] @@ -422,20 +448,29 @@ fn yes() -> bool { impl Default for CursorConfiguration { fn default() -> Self { - Self { + let animation_style = CursorAnimationStyle::default(); + let mut config = Self { hide: false, hide_when_idle: false, hide_when_idle_delay: Self::default_hide_when_idle_delay(), size: 100, r#type: CursorType::default(), - animation_style: CursorAnimationStyle::Regular, - tension: 100.0, - mass: 1.0, - friction: 20.0, + animation_style, + tension: 65.0, + mass: 1.8, + friction: 16.0, raw: false, motion_blur: 0.5, use_svg: true, + }; + + if let Some(preset) = animation_style.preset() { + config.tension = preset.tension; + config.mass = preset.mass; + config.friction = preset.friction; } + + config } } impl CursorConfiguration { diff --git a/crates/rendering/src/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index fff16b9f88..05dd032963 100644 --- a/crates/rendering/src/cursor_interpolation.rs +++ b/crates/rendering/src/cursor_interpolation.rs @@ -38,7 +38,7 @@ pub fn interpolate_cursor( } if let Some(event) = cursor.moves.last() - && event.time_ms < time_ms + && event.time_ms <= time_ms { return Some(InterpolatedCursorPosition { position: Coord::new(XY { @@ -54,10 +54,18 @@ pub fn interpolate_cursor( let events = get_smoothed_cursor_events(&cursor.moves, smoothing_config); interpolate_smoothed_position(&events, time_secs as f64, smoothing_config) } else { - let (pos, cursor_id) = cursor.moves.windows(2).find_map(|chunk| { + let (pos, cursor_id, velocity) = cursor.moves.windows(2).find_map(|chunk| { if time_ms >= chunk[0].time_ms && time_ms < chunk[1].time_ms { let c = &chunk[0]; - Some((XY::new(c.x as f32, c.y as f32), c.cursor_id.clone())) + let next = &chunk[1]; + let delta_ms = (next.time_ms - c.time_ms) as f32; + let dt = (delta_ms / 1000.0).max(0.000_1); + let velocity = XY::new(((next.x - c.x) as f32) / dt, ((next.y - c.y) as f32) / dt); + Some(( + XY::new(c.x as f32, c.y as f32), + c.cursor_id.clone(), + velocity, + )) } else { None } @@ -68,7 +76,7 @@ pub fn interpolate_cursor( x: pos.x as f64, y: pos.y as f64, }), - velocity: XY::new(0.0, 0.0), + velocity, cursor_id, }) } diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index a5a493581b..fd46368480 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -16,6 +16,8 @@ const CURSOR_CLICK_DURATION_MS: f64 = CURSOR_CLICK_DURATION * 1000.0; const CLICK_SHRINK_SIZE: f32 = 0.7; const CURSOR_IDLE_MIN_DELAY_MS: f64 = 500.0; const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0; +const MAX_CURSOR_VELOCITY_PX_PER_FRAME: f32 = 220.0; +const MOTION_BLUR_SPEED_BASE: f32 = 22.0; /// The size to render the svg to. static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; @@ -205,14 +207,30 @@ impl CursorLayer { return; }; - let velocity: [f32; 2] = [0.0, 0.0]; - // let velocity: [f32; 2] = [ - // interpolated_cursor.velocity.x * 75.0, - // interpolated_cursor.velocity.y * 75.0, - // ]; + let fps = uniforms.frame_rate.max(1) as f32; + let screen_size = constants.options.screen_size; + let mut velocity = [ + (interpolated_cursor.velocity.x * screen_size.x as f32) / fps, + (interpolated_cursor.velocity.y * screen_size.y as f32) / fps, + ]; + velocity[0] = velocity[0].clamp( + -MAX_CURSOR_VELOCITY_PX_PER_FRAME, + MAX_CURSOR_VELOCITY_PX_PER_FRAME, + ); + velocity[1] = velocity[1].clamp( + -MAX_CURSOR_VELOCITY_PX_PER_FRAME, + MAX_CURSOR_VELOCITY_PX_PER_FRAME, + ); let speed = (velocity[0] * velocity[0] + velocity[1] * velocity[1]).sqrt(); - let motion_blur_amount = (speed * 0.3).min(1.0) * 0.0; // uniforms.project.cursor.motion_blur; + let user_motion_blur = uniforms.project.cursor.motion_blur.clamp(0.0, 1.0); + let intensity_multiplier = (user_motion_blur / 0.35).max(0.0); + let motion_blur_amount = if user_motion_blur <= f32::EPSILON { + 0.0 + } else { + let dynamic = (speed / MOTION_BLUR_SPEED_BASE).clamp(0.0, 1.0); + (dynamic.powf(0.75) * intensity_multiplier).min(2.5) + }; let mut cursor_opacity = 1.0f32; if uniforms.project.cursor.hide_when_idle && !cursor.moves.is_empty() { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 99cfe29fc4..0650853a0d 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -363,6 +363,7 @@ impl RenderVideoConstants { pub struct ProjectUniforms { pub output_size: (u32, u32), pub cursor_size: f32, + pub frame_rate: u32, display: CompositeVideoFrameUniforms, camera: Option, camera_only: Option, @@ -979,6 +980,7 @@ impl ProjectUniforms { zoom, scene, interpolated_cursor, + frame_rate: fps, } } } diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index 4c3e3e2e04..63213bff73 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -102,7 +102,7 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { ); } - final_color *= vec4(1.0, 1.0, 1.0, 1.0 - motion_blur_amount * 0.2); + // Preserve opacity regardless of blur intensity so the cursor stays fully visible. final_color *= opacity; return final_color; diff --git a/crates/rendering/src/spring_mass_damper.rs b/crates/rendering/src/spring_mass_damper.rs index a5be0567ac..91e175f07a 100644 --- a/crates/rendering/src/spring_mass_damper.rs +++ b/crates/rendering/src/spring_mass_damper.rs @@ -16,7 +16,7 @@ pub struct SpringMassDamperSimulation { pub target_position: XY, } -const SIMULATION_TICK: f32 = 1000.0 / 60.0; +const SIMULATION_TICK_MS: f32 = 1000.0 / 60.0; impl SpringMassDamperSimulation { pub fn new(config: SpringMassDamperSimulationConfig) -> Self { @@ -40,14 +40,16 @@ impl SpringMassDamperSimulation { self.target_position = target_position; } - pub fn run(&mut self, dt: f32) -> XY { - if dt == 0.0 { + pub fn run(&mut self, dt_ms: f32) -> XY { + if dt_ms <= 0.0 { return self.position; } - let mut t = 0.0; + let mut remaining = dt_ms; - loop { + while remaining > 0.0 { + let step_ms = remaining.min(SIMULATION_TICK_MS); + let tick = step_ms / 1000.0; let d = self.target_position - self.position; let spring_force = d * self.tension; @@ -57,16 +59,10 @@ impl SpringMassDamperSimulation { let accel = total_force / self.mass.max(0.001); - let tick = (SIMULATION_TICK / 1000.0).min(dt - t); - self.velocity = self.velocity + accel * tick; self.position = self.position + self.velocity * tick; - if t >= dt { - break; - } - - t = (t + SIMULATION_TICK).min(dt); + remaining -= step_ms; } self.position From f3f29cc70d43a49f4f70534fafa952db2d3c9a6c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 11:37:56 +0000 Subject: [PATCH 02/17] wip: Cursor + display motion blur --- crates/rendering/src/composite_frame.rs | 2 + crates/rendering/src/lib.rs | 398 +++++++++++++++--- .../src/shaders/composite-video-frame.wgsl | 57 ++- 3 files changed, 387 insertions(+), 70 deletions(-) diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index ca0061865c..7003c36dc2 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -16,6 +16,7 @@ pub struct CompositeVideoFrameUniforms { pub output_size: [f32; 2], pub frame_size: [f32; 2], pub velocity_uv: [f32; 2], + pub blur_components: [f32; 2], pub target_size: [f32; 2], pub rounding_px: f32, pub mirror_x: f32, @@ -43,6 +44,7 @@ impl Default for CompositeVideoFrameUniforms { output_size: Default::default(), frame_size: Default::default(), velocity_uv: Default::default(), + blur_components: Default::default(), target_size: Default::default(), rounding_px: Default::default(), mirror_x: Default::default(), diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 0650853a0d..b04dfe57eb 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -390,6 +390,67 @@ const CAMERA_PADDING: f32 = 50.0; const SCREEN_MAX_PADDING: f64 = 0.4; +const MOTION_BLUR_BASELINE_FPS: f32 = 60.0; +const DISPLAY_TRANSLATION_BASE: f32 = 0.02; +const DISPLAY_ZOOM_BASE: f32 = 0.03; +const CAMERA_ZOOM_BASE: f32 = 0.015; +const DISPLAY_BLUR_INTENSITY_SCALE: f32 = 0.75; +const CAMERA_BLUR_INTENSITY_SCALE: f32 = 0.65; +const MOTION_BLUR_MIN_METRIC: f32 = 0.005; +const MOTION_BLUR_SMOOTHING: f32 = 0.7; +const DISPLAY_BLUR_VISUAL_SCALE: f32 = 0.22; + +#[derive(Clone, Copy, Debug, Default)] +struct MotionBlurDescriptor { + amount: f32, + translation_dir: [f32; 2], + translation_strength: f32, + zoom_strength: f32, +} + +impl MotionBlurDescriptor { + fn none() -> Self { + Self::default() + } + + fn smooth_with(self, previous: MotionBlurDescriptor, retention: f32) -> Self { + if retention <= f32::EPSILON { + return self; + } + + let retention = retention.clamp(0.0, 0.95); + let current_weight = 1.0 - retention; + + let blended_amount = previous.amount * retention + self.amount * current_weight; + let blended_translation_strength = + previous.translation_strength * retention + self.translation_strength * current_weight; + let blended_zoom_strength = + previous.zoom_strength * retention + self.zoom_strength * current_weight; + + let mut blended_dir = [ + previous.translation_dir[0] * retention + self.translation_dir[0] * current_weight, + previous.translation_dir[1] * retention + self.translation_dir[1] * current_weight, + ]; + + let dir_len = (blended_dir[0] * blended_dir[0] + blended_dir[1] * blended_dir[1]).sqrt(); + if dir_len > 1e-4 { + blended_dir[0] /= dir_len; + blended_dir[1] /= dir_len; + } else if self.translation_strength > previous.translation_strength { + blended_dir = self.translation_dir; + } else { + blended_dir = previous.translation_dir; + } + + Self { + amount: blended_amount.min(1.0), + translation_dir: blended_dir, + translation_strength: blended_translation_strength, + zoom_strength: blended_zoom_strength, + } + } +} + impl ProjectUniforms { fn get_crop(options: &RenderOptions, project: &ProjectConfiguration) -> Crop { project.background.crop.as_ref().cloned().unwrap_or(Crop { @@ -538,6 +599,143 @@ impl ProjectUniforms { end - display_offset } + fn display_bounds( + zoom: &InterpolatedZoom, + display_offset: Coord, + display_size: Coord, + output_size: XY, + ) -> (Coord, Coord) { + let base_end = Coord::new(output_size) - display_offset; + let zoom_start = Coord::new(zoom.bounds.top_left * display_size.coord); + let zoom_end = Coord::new((zoom.bounds.bottom_right - 1.0) * display_size.coord); + let start = display_offset + zoom_start; + let end = base_end + zoom_end; + (start, end) + } + + fn compute_display_motion_blur( + start: Coord, + end: Coord, + prev_start: Coord, + prev_end: Coord, + fps: f32, + user_motion_blur: f32, + ) -> MotionBlurDescriptor { + if user_motion_blur <= f32::EPSILON { + return MotionBlurDescriptor::none(); + } + + let fps_scale = (fps / MOTION_BLUR_BASELINE_FPS).max(0.1); + + let center = ((start + end) * 0.5).coord; + let prev_center = ((prev_start + prev_end) * 0.5).coord; + let translation = center - prev_center; + + let size = (end - start).coord; + let prev_size = (prev_end - prev_start).coord; + let base_width = size.x.abs().max(prev_size.x.abs()).max(1.0); + let base_height = size.y.abs().max(prev_size.y.abs()).max(1.0); + + let translation_uv = XY::new(translation.x / base_width, translation.y / base_height); + let translation_uv_mag = ((translation_uv.x * translation_uv.x + + translation_uv.y * translation_uv.y) + .sqrt() as f32) + .min(10.0); + let translation_metric = + (translation_uv_mag * fps_scale / DISPLAY_TRANSLATION_BASE).clamp(0.0, 1.2); + + let zoom_ratio_x = ((size.x - prev_size.x).abs() / base_width).min(1.0); + let zoom_ratio_y = ((size.y - prev_size.y).abs() / base_height).min(1.0); + let zoom_ratio = ((zoom_ratio_x + zoom_ratio_y) * 0.5) as f32; + let zoom_metric = (zoom_ratio * fps_scale / DISPLAY_ZOOM_BASE).clamp(0.0, 1.2); + + let translation_strength = Self::motion_blur_curve( + translation_metric, + user_motion_blur, + DISPLAY_BLUR_INTENSITY_SCALE, + 1.0, + ); + + let zoom_strength = Self::motion_blur_curve( + zoom_metric, + user_motion_blur, + DISPLAY_BLUR_INTENSITY_SCALE, + 1.0, + ); + + let translation_dir = if translation_metric <= MOTION_BLUR_MIN_METRIC { + [0.0, 0.0] + } else { + let magnitude = + (translation_uv.x * translation_uv.x + translation_uv.y * translation_uv.y).sqrt(); + if magnitude <= f64::EPSILON { + [0.0, 0.0] + } else { + [ + (translation_uv.x / magnitude) as f32, + (translation_uv.y / magnitude) as f32, + ] + } + }; + + MotionBlurDescriptor { + amount: translation_strength.max(zoom_strength), + translation_dir, + translation_strength, + zoom_strength, + } + } + + fn camera_zoom_factor( + zoom: &InterpolatedZoom, + scene: &InterpolatedScene, + base_size: f32, + zoom_size: f32, + ) -> f32 { + let t = zoom.t as f32; + let lerp = t * zoom_size * base_size + (1.0 - t) * base_size; + lerp * scene.camera_scale as f32 + } + + fn compute_camera_motion_blur( + zoomed_size: f32, + prev_zoomed_size: f32, + fps: f32, + user_motion_blur: f32, + ) -> MotionBlurDescriptor { + if user_motion_blur <= f32::EPSILON { + return MotionBlurDescriptor::none(); + } + + let fps_scale = (fps / MOTION_BLUR_BASELINE_FPS).max(0.1); + let delta = (zoomed_size - prev_zoomed_size).abs(); + let metric = (delta * fps_scale / CAMERA_ZOOM_BASE).clamp(0.0, 1.0); + + let zoom_strength = + Self::motion_blur_curve(metric, user_motion_blur, CAMERA_BLUR_INTENSITY_SCALE, 1.0); + + MotionBlurDescriptor { + amount: zoom_strength, + translation_dir: [0.0, 0.0], + translation_strength: 0.0, + zoom_strength, + } + } + + fn motion_blur_curve( + metric: f32, + user_motion_blur: f32, + intensity_scale: f32, + max_amount: f32, + ) -> f32 { + if user_motion_blur <= f32::EPSILON || metric <= MOTION_BLUR_MIN_METRIC { + return 0.0; + } + + let intensity = (user_motion_blur / intensity_scale).clamp(0.0, 2.5); + (metric.powf(0.85) * intensity).min(max_amount) + } + fn auto_zoom_focus( cursor_events: &CursorEvents, time_secs: f32, @@ -647,11 +845,22 @@ impl ProjectUniforms { ) -> Self { let options = &constants.options; let output_size = Self::get_output_size(options, project, resolution_base); - let frame_time = frame_number as f32 / fps as f32; - - let velocity = [0.0, 0.0]; - - let motion_blur_amount = 0.0; + let fps_f32 = fps as f32; + let frame_time = frame_number as f32 / fps_f32; + let prev_frame_time = if frame_number == 0 { + 0.0 + } else { + (frame_number - 1) as f32 / fps_f32 + }; + let prev_prev_frame_time = if frame_number >= 2 { + (frame_number - 2) as f32 / fps_f32 + } else { + 0.0 + }; + let current_recording_time = segment_frames.recording_time; + let prev_recording_time = (segment_frames.recording_time - 1.0 / fps_f32).max(0.0); + let prev_prev_recording_time = (segment_frames.recording_time - 2.0 / fps_f32).max(0.0); + let user_motion_blur = project.cursor.motion_blur.clamp(0.0, 1.0); let crop = Self::get_crop(options, project); @@ -661,38 +870,75 @@ impl ProjectUniforms { friction: project.cursor.friction, }); - let interpolated_cursor = interpolate_cursor( + let interpolated_cursor = + interpolate_cursor(cursor_events, current_recording_time, cursor_smoothing); + + let prev_interpolated_cursor = + interpolate_cursor(cursor_events, prev_recording_time, cursor_smoothing); + + let prev_prev_interpolated_cursor = if frame_number >= 2 { + interpolate_cursor(cursor_events, prev_prev_recording_time, cursor_smoothing) + } else { + prev_interpolated_cursor.clone() + }; + + let zoom_segments = project + .timeline + .as_ref() + .map(|t| t.zoom_segments.as_slice()) + .unwrap_or(&[]); + + let scene_segments = project + .timeline + .as_ref() + .map(|t| t.scene_segments.as_slice()) + .unwrap_or(&[]); + + let zoom_focus = Self::auto_zoom_focus( cursor_events, - segment_frames.recording_time, + current_recording_time, cursor_smoothing, + interpolated_cursor.clone(), ); - let zoom_focus = Self::auto_zoom_focus( + let prev_zoom_focus = Self::auto_zoom_focus( cursor_events, - segment_frames.recording_time, + prev_recording_time, cursor_smoothing, - interpolated_cursor.clone(), + prev_interpolated_cursor.clone(), + ); + + let prev_prev_zoom_focus = Self::auto_zoom_focus( + cursor_events, + prev_prev_recording_time, + cursor_smoothing, + prev_prev_interpolated_cursor.clone(), ); let zoom = InterpolatedZoom::new( - SegmentsCursor::new( - frame_time as f64, - project - .timeline - .as_ref() - .map(|t| t.zoom_segments.as_slice()) - .unwrap_or(&[]), - ), + SegmentsCursor::new(frame_time as f64, zoom_segments), zoom_focus, ); - let scene = InterpolatedScene::new(SceneSegmentsCursor::new( - frame_time as f64, - project - .timeline - .as_ref() - .map(|t| t.scene_segments.as_slice()) - .unwrap_or(&[]), + let prev_zoom = InterpolatedZoom::new( + SegmentsCursor::new(prev_frame_time as f64, zoom_segments), + prev_zoom_focus, + ); + + let prev_prev_zoom = InterpolatedZoom::new( + SegmentsCursor::new(prev_prev_frame_time as f64, zoom_segments), + prev_prev_zoom_focus, + ); + + let scene = + InterpolatedScene::new(SceneSegmentsCursor::new(frame_time as f64, scene_segments)); + let prev_scene = InterpolatedScene::new(SceneSegmentsCursor::new( + prev_frame_time as f64, + scene_segments, + )); + let prev_prev_scene = InterpolatedScene::new(SceneSegmentsCursor::new( + prev_prev_frame_time as f64, + scene_segments, )); let display = { @@ -711,19 +957,48 @@ impl ProjectUniforms { let display_offset = Self::display_offset(options, project, resolution_base); let display_size = Self::display_size(options, project, resolution_base); - let end = Coord::new(output_size) - display_offset; - - let (zoom_start, zoom_end) = ( - Coord::new(zoom.bounds.top_left * display_size.coord), - Coord::new((zoom.bounds.bottom_right - 1.0) * display_size.coord), - ); + let (start, end) = + Self::display_bounds(&zoom, display_offset, display_size, output_size); + let (prev_start, prev_end) = + Self::display_bounds(&prev_zoom, display_offset, display_size, output_size); + let (prev_prev_start, prev_prev_end) = + Self::display_bounds(&prev_prev_zoom, display_offset, display_size, output_size); - let start = display_offset + zoom_start; - let end = end + zoom_end; - - let target_size = end - start; + let target_size = (end - start).coord; let min_target_axis = target_size.x.min(target_size.y); + let mut display_blur = Self::compute_display_motion_blur( + start, + end, + prev_start, + prev_end, + fps_f32, + user_motion_blur, + ); + if frame_number >= 2 { + let prev_display_blur = Self::compute_display_motion_blur( + prev_start, + prev_end, + prev_prev_start, + prev_prev_end, + fps_f32, + user_motion_blur, + ); + display_blur = display_blur.smooth_with(prev_display_blur, MOTION_BLUR_SMOOTHING); + } + let scene_blur_strength = (scene.screen_blur as f32 * 0.8).min(1.0); + display_blur.zoom_strength = + (display_blur.zoom_strength + scene_blur_strength).clamp(0.0, 1.2); + display_blur.amount = display_blur.amount.max(display_blur.zoom_strength).min(1.0); + + display_blur.translation_strength *= DISPLAY_BLUR_VISUAL_SCALE; + display_blur.zoom_strength *= DISPLAY_BLUR_VISUAL_SCALE; + display_blur.amount = (display_blur.amount * DISPLAY_BLUR_VISUAL_SCALE).max( + display_blur + .translation_strength + .max(display_blur.zoom_strength), + ); + CompositeVideoFrameUniforms { output_size: [output_size.x as f32, output_size.y as f32], frame_size: size, @@ -737,8 +1012,12 @@ impl ProjectUniforms { target_size: [target_size.x as f32, target_size.y as f32], rounding_px: (project.background.rounding / 100.0 * 0.5 * min_target_axis) as f32, mirror_x: 0.0, - velocity_uv: velocity, - motion_blur_amount: (motion_blur_amount + scene.screen_blur as f32 * 0.8).min(1.0), + velocity_uv: display_blur.translation_dir, + blur_components: [ + display_blur.translation_strength, + display_blur.zoom_strength, + ], + motion_blur_amount: display_blur.amount, camera_motion_blur_amount: 0.0, shadow: project.background.shadow, shadow_size: project @@ -800,10 +1079,33 @@ impl ProjectUniforms { .unwrap_or(cap_project::Camera::default_zoom_size()) / 100.0; - let zoomed_size = - (zoom.t as f32) * zoom_size * base_size + (1.0 - zoom.t as f32) * base_size; - - let zoomed_size = zoomed_size * scene.camera_scale as f32; + let zoomed_size = Self::camera_zoom_factor(&zoom, &scene, base_size, zoom_size); + let prev_zoomed_size = + Self::camera_zoom_factor(&prev_zoom, &prev_scene, base_size, zoom_size); + let prev_prev_zoomed_size = Self::camera_zoom_factor( + &prev_prev_zoom, + &prev_prev_scene, + base_size, + zoom_size, + ); + + let camera_blur = Self::compute_camera_motion_blur( + zoomed_size, + prev_zoomed_size, + fps_f32, + user_motion_blur, + ); + let camera_blur = if frame_number >= 2 { + let prev_camera_blur = Self::compute_camera_motion_blur( + prev_zoomed_size, + prev_prev_zoomed_size, + fps_f32, + user_motion_blur, + ); + camera_blur.smooth_with(prev_camera_blur, MOTION_BLUR_SMOOTHING) + } else { + camera_blur + }; let aspect = frame_size[0] / frame_size[1]; let size = match project.camera.shape { @@ -847,8 +1149,6 @@ impl ProjectUniforms { position[1] + size[1], ]; - let camera_motion_blur = 0.0; - let crop_bounds = match project.camera.shape { CameraShape::Source => [0.0, 0.0, frame_size[0], frame_size[1]], CameraShape::Square => [ @@ -870,9 +1170,10 @@ impl ProjectUniforms { ], rounding_px: project.camera.rounding / 100.0 * 0.5 * size[0].min(size[1]), mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, - velocity_uv: [0.0, 0.0], - motion_blur_amount, - camera_motion_blur_amount: camera_motion_blur, + velocity_uv: camera_blur.translation_dir, + blur_components: [camera_blur.translation_strength, camera_blur.zoom_strength], + motion_blur_amount: 0.0, + camera_motion_blur_amount: camera_blur.amount, shadow: project.camera.shadow, shadow_size: project .camera @@ -940,6 +1241,8 @@ impl ProjectUniforms { [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] }; + let camera_only_blur = (scene.camera_only_blur as f32 * 0.5).clamp(0.0, 1.0); + CompositeVideoFrameUniforms { output_size, frame_size, @@ -952,8 +1255,9 @@ impl ProjectUniforms { rounding_px: 0.0, mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, velocity_uv: [0.0, 0.0], + blur_components: [0.0, camera_only_blur], motion_blur_amount: 0.0, - camera_motion_blur_amount: scene.camera_only_blur as f32 * 0.5, + camera_motion_blur_amount: camera_only_blur, shadow: 0.0, shadow_size: 0.0, shadow_opacity: 0.0, diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index ecfb17d7c6..13c6b02828 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -4,6 +4,7 @@ struct Uniforms { output_size: vec2, frame_size: vec2, velocity_uv: vec2, + blur_components: vec2, target_size: vec2, rounding_px: f32, mirror_x: f32, @@ -121,47 +122,57 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { base_color.a = base_color.a * uniforms.opacity; let blur_amount = select(uniforms.motion_blur_amount, uniforms.camera_motion_blur_amount, uniforms.camera_motion_blur_amount > 0.0); + let translation_strength = uniforms.blur_components.x; + let zoom_strength = uniforms.blur_components.y; - if blur_amount < 0.01 { + if blur_amount < 0.01 || (translation_strength < 0.01 && zoom_strength < 0.01) { return mix(shadow_color, base_color, base_color.a); } let center_uv = vec2(0.5, 0.5); - let dir = normalize(target_uv - center_uv); + let to_center = target_uv - center_uv; + let dist_from_center = length(to_center); + var radial_dir = vec2(0.0, -1.0); + if (dist_from_center > 1e-4) { + radial_dir = to_center / dist_from_center; + } + + var translation_dir = uniforms.velocity_uv; + let translation_len = length(translation_dir); + if (translation_len > 1e-4) { + translation_dir = translation_dir / translation_len; + } else { + translation_dir = radial_dir; + } + + let translation_scale = select(0.08, 0.22, uniforms.camera_motion_blur_amount > 0.0); + let zoom_scale = select(0.08, 0.24, uniforms.camera_motion_blur_amount > 0.0); + let jitter_scale = select(0.0008, 0.0015 + 0.0015 * blur_amount, uniforms.camera_motion_blur_amount > 0.0); - let base_samples = 16.0; - let num_samples = i32(base_samples * smoothstep(0.0, 1.0, blur_amount)); + let base_samples = clamp(8.0 + 14.0 * blur_amount, 6.0, 32.0); + let num_samples = i32(base_samples); var accum = base_color; var weight_sum = 1.0; for (var i = 1; i < num_samples; i = i + 1) { - let t = f32(i) / f32(num_samples); - let dist_from_center = length(target_uv - center_uv); + let t = f32(i) / f32(num_samples - 1); + let eased = smoothstep(0.0, 1.0, t); - let random_offset = (rand(target_uv + vec2(t)) - 0.5) * 0.1 * smoothstep(0.0, 0.2, blur_amount); - - let base_scale = select( - 0.08, // Regular content scale - 0.16, // Camera scale - uniforms.camera_motion_blur_amount > 0.0 - ); - let scale = dist_from_center * blur_amount * (base_scale + random_offset) * smoothstep(0.0, 0.1, blur_amount); - - let angle_variation = (rand(target_uv + vec2(t * 2.0)) - 0.5) * 0.1 * smoothstep(0.0, 0.2, blur_amount); - let rotated_dir = vec2( - dir.x * cos(angle_variation) - dir.y * sin(angle_variation), - dir.x * sin(angle_variation) + dir.y * cos(angle_variation) - ); + let translation_offset = translation_dir * translation_strength * translation_scale * eased; + let zoom_offset = radial_dir * (dist_from_center + 0.12) * zoom_strength * zoom_scale * eased; - let offset = rotated_dir * scale * t; + let jitter_seed = target_uv + vec2(t, f32(i) * 0.37); + let jitter_angle = rand(jitter_seed) * 6.2831853; + let jitter_radius = (rand(jitter_seed.yx) - 0.5) * jitter_scale * blur_amount; + let jitter = vec2(cos(jitter_angle), sin(jitter_angle)) * jitter_radius; - let sample_uv = target_uv - offset; + let sample_uv = target_uv - translation_offset - zoom_offset + jitter; if sample_uv.x >= 0.0 && sample_uv.x <= 1.0 && sample_uv.y >= 0.0 && sample_uv.y <= 1.0 { var sample_color = sample_texture(sample_uv, crop_bounds_uv); sample_color = apply_rounded_corners(sample_color, sample_uv); - let weight = (1.0 - t) * (1.0 + random_offset * 0.2); + let weight = 1.0 - t * 0.85; accum += sample_color * weight; weight_sum += weight; } From 5c4bce2f7da9a86c46c2f209cfc81551b2852301 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 12:47:21 +0000 Subject: [PATCH 03/17] feat: Improved motion system + physics --- crates/rendering/src/composite_frame.rs | 14 +- crates/rendering/src/layers/cursor.rs | 97 ++- crates/rendering/src/lib.rs | 749 +++++++++++------- .../src/shaders/composite-video-frame.wgsl | 107 +-- crates/rendering/src/shaders/cursor.wgsl | 55 +- 5 files changed, 601 insertions(+), 421 deletions(-) diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 7003c36dc2..28a8f0deda 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -15,13 +15,12 @@ pub struct CompositeVideoFrameUniforms { pub target_bounds: [f32; 4], pub output_size: [f32; 2], pub frame_size: [f32; 2], - pub velocity_uv: [f32; 2], - pub blur_components: [f32; 2], + pub motion_blur_vector: [f32; 2], + pub motion_blur_zoom_center: [f32; 2], + pub motion_blur_params: [f32; 4], pub target_size: [f32; 2], pub rounding_px: f32, pub mirror_x: f32, - pub motion_blur_amount: f32, - pub camera_motion_blur_amount: f32, pub shadow: f32, pub shadow_size: f32, pub shadow_opacity: f32, @@ -43,13 +42,12 @@ impl Default for CompositeVideoFrameUniforms { target_bounds: Default::default(), output_size: Default::default(), frame_size: Default::default(), - velocity_uv: Default::default(), - blur_components: Default::default(), + motion_blur_vector: Default::default(), + motion_blur_zoom_center: [0.5, 0.5], + motion_blur_params: Default::default(), target_size: Default::default(), rounding_px: Default::default(), mirror_x: Default::default(), - motion_blur_amount: Default::default(), - camera_motion_blur_amount: Default::default(), shadow: Default::default(), shadow_size: Default::default(), shadow_opacity: Default::default(), diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index fd46368480..6778c40247 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -16,8 +16,10 @@ const CURSOR_CLICK_DURATION_MS: f64 = CURSOR_CLICK_DURATION * 1000.0; const CLICK_SHRINK_SIZE: f32 = 0.7; const CURSOR_IDLE_MIN_DELAY_MS: f64 = 500.0; const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0; -const MAX_CURSOR_VELOCITY_PX_PER_FRAME: f32 = 220.0; -const MOTION_BLUR_SPEED_BASE: f32 = 22.0; +const CURSOR_VECTOR_CAP: f32 = 320.0; +const CURSOR_MIN_MOTION: f32 = 0.01; +const CURSOR_BASELINE_FPS: f32 = 60.0; +const CURSOR_MULTIPLIER: f32 = 1.0; /// The size to render the svg to. static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; @@ -209,27 +211,42 @@ impl CursorLayer { let fps = uniforms.frame_rate.max(1) as f32; let screen_size = constants.options.screen_size; - let mut velocity = [ - (interpolated_cursor.velocity.x * screen_size.x as f32) / fps, - (interpolated_cursor.velocity.y * screen_size.y as f32) / fps, - ]; - velocity[0] = velocity[0].clamp( - -MAX_CURSOR_VELOCITY_PX_PER_FRAME, - MAX_CURSOR_VELOCITY_PX_PER_FRAME, - ); - velocity[1] = velocity[1].clamp( - -MAX_CURSOR_VELOCITY_PX_PER_FRAME, - MAX_CURSOR_VELOCITY_PX_PER_FRAME, - ); + let screen_diag = + (((screen_size.x as f32).powi(2) + (screen_size.y as f32).powi(2)).sqrt()).max(1.0); + let fps_scale = fps / CURSOR_BASELINE_FPS; + let cursor_strength = + (uniforms.motion_blur_amount * CURSOR_MULTIPLIER * fps_scale).clamp(0.0, 3.0); + let parent_motion = uniforms.display_parent_motion_px; + let child_motion = uniforms + .prev_cursor + .as_ref() + .filter(|prev| prev.cursor_id == interpolated_cursor.cursor_id) + .map(|prev| { + let delta_uv = XY::new( + (interpolated_cursor.position.coord.x - prev.position.coord.x) as f32, + (interpolated_cursor.position.coord.y - prev.position.coord.y) as f32, + ); + XY::new( + delta_uv.x * screen_size.x as f32, + delta_uv.y * screen_size.y as f32, + ) + }) + .unwrap_or_else(|| XY::new(0.0, 0.0)); - let speed = (velocity[0] * velocity[0] + velocity[1] * velocity[1]).sqrt(); - let user_motion_blur = uniforms.project.cursor.motion_blur.clamp(0.0, 1.0); - let intensity_multiplier = (user_motion_blur / 0.35).max(0.0); - let motion_blur_amount = if user_motion_blur <= f32::EPSILON { - 0.0 + let combined_motion_px = if cursor_strength <= f32::EPSILON { + XY::new(0.0, 0.0) + } else { + combine_cursor_motion(parent_motion, child_motion) + }; + + let normalized_motion = ((combined_motion_px.x / screen_diag).powi(2) + + (combined_motion_px.y / screen_diag).powi(2)) + .sqrt(); + let has_motion = normalized_motion > CURSOR_MIN_MOTION && cursor_strength > f32::EPSILON; + let scaled_motion = if has_motion { + clamp_cursor_vector(combined_motion_px * cursor_strength) } else { - let dynamic = (speed / MOTION_BLUR_SPEED_BASE).clamp(0.0, 1.0); - (dynamic.powf(0.75) * intensity_multiplier).min(2.5) + XY::new(0.0, 0.0) }; let mut cursor_opacity = 1.0f32; @@ -374,6 +391,8 @@ impl CursorLayer { zoom, ) - zoomed_position; + let effective_strength = if has_motion { cursor_strength } else { 0.0 }; + let cursor_uniforms = CursorUniforms { position_size: [ zoomed_position.x as f32, @@ -388,7 +407,12 @@ impl CursorLayer { 0.0, ], screen_bounds: uniforms.display.target_bounds, - velocity_blur_opacity: [velocity[0], velocity[1], motion_blur_amount, cursor_opacity], + motion_vector_strength: [ + scaled_motion.x, + scaled_motion.y, + effective_strength, + cursor_opacity, + ], }; constants.queue.write_buffer( @@ -412,13 +436,40 @@ impl CursorLayer { } } +fn combine_cursor_motion(parent: XY, child: XY) -> XY { + fn combine_axis(parent: f32, child: f32) -> f32 { + if parent.abs() > CURSOR_MIN_MOTION + && child.abs() > CURSOR_MIN_MOTION + && parent.signum() != child.signum() + { + 0.0 + } else { + parent + child + } + } + + XY::new( + combine_axis(parent.x, child.x), + combine_axis(parent.y, child.y), + ) +} + +fn clamp_cursor_vector(vec: XY) -> XY { + let len = (vec.x * vec.x + vec.y * vec.y).sqrt(); + if len <= CURSOR_VECTOR_CAP || len <= f32::EPSILON { + vec + } else { + vec * (CURSOR_VECTOR_CAP / len) + } +} + #[repr(C)] #[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] pub struct CursorUniforms { position_size: [f32; 4], output_size: [f32; 4], screen_bounds: [f32; 4], - velocity_blur_opacity: [f32; 4], + motion_vector_strength: [f32; 4], } fn compute_cursor_idle_opacity( diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index b04dfe57eb..308069f00f 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -368,10 +368,13 @@ pub struct ProjectUniforms { camera: Option, camera_only: Option, interpolated_cursor: Option, + pub prev_cursor: Option, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, pub scene: InterpolatedScene, pub resolution_base: XY, + pub display_parent_motion_px: XY, + pub motion_blur_amount: f32, } #[derive(Debug, Clone)] @@ -386,26 +389,252 @@ impl Zoom { } } +#[derive(Clone, Copy, Debug)] +struct MotionBounds { + start: Coord, + end: Coord, +} + +impl MotionBounds { + fn new(start: Coord, end: Coord) -> Self { + Self { start, end } + } + + fn size(&self) -> XY { + (self.end - self.start).coord + } + + fn center(&self) -> XY { + (self.start.coord + self.end.coord) * 0.5 + } + + fn diagonal(&self) -> f64 { + let size = self.size(); + (size.x * size.x + size.y * size.y).sqrt().max(f64::EPSILON) + } + + fn contains(&self, other: &Self) -> bool { + self.start.coord.x <= other.start.coord.x + && self.start.coord.y <= other.start.coord.y + && self.end.coord.x >= other.end.coord.x + && self.end.coord.y >= other.end.coord.y + } + + fn to_uv(&self, point: XY) -> XY { + let size = self.size(); + XY::new( + ((point.x - self.start.coord.x) / size.x.max(f64::EPSILON)) as f32, + ((point.y - self.start.coord.y) / size.y.max(f64::EPSILON)) as f32, + ) + } + + fn top_left(&self) -> XY { + self.start.coord + } + + fn top_right(&self) -> XY { + XY::new(self.end.coord.x, self.start.coord.y) + } + + fn bottom_left(&self) -> XY { + XY::new(self.start.coord.x, self.end.coord.y) + } + + fn bottom_right(&self) -> XY { + self.end.coord + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct MotionAnalysis { + movement_px: XY, + movement_uv: XY, + movement_magnitude: f32, + zoom_center_uv: XY, + zoom_magnitude: f32, +} + +#[derive(Clone, Copy, Debug)] +struct MotionBlurComputation { + descriptor: MotionBlurDescriptor, + parent_movement_px: XY, +} + +impl MotionBlurComputation { + fn none() -> Self { + Self { + descriptor: MotionBlurDescriptor::none(), + parent_movement_px: XY::new(0.0, 0.0), + } + } +} + +fn analyze_motion(current: &MotionBounds, previous: &MotionBounds) -> MotionAnalysis { + let mut analysis = MotionAnalysis::default(); + + let current_center = current.center(); + let prev_center = previous.center(); + let movement_px = XY::new( + (current_center.x - prev_center.x) as f32, + (current_center.y - prev_center.y) as f32, + ); + + let current_size = current.size(); + let previous_size = previous.size(); + let min_current = current_size.x.min(current_size.y); + let min_previous = previous_size.x.min(previous_size.y); + let base_span = min_current.max(min_previous).max(1.0) as f32; + + let movement_uv = XY::new(movement_px.x / base_span, movement_px.y / base_span); + let movement_magnitude = (movement_uv.x * movement_uv.x + movement_uv.y * movement_uv.y).sqrt(); + + let prev_diag = previous.diagonal(); + let curr_diag = current.diagonal(); + let zoom_magnitude = if prev_diag <= f64::EPSILON { + 0.0 + } else { + ((curr_diag - prev_diag).abs() / prev_diag) as f32 + }; + + let zoom_center_point = if previous.contains(current) { + previous.center() + } else { + zoom_vanishing_point(current, previous).unwrap_or(previous.center()) + }; + + analysis.movement_px = movement_px; + analysis.movement_uv = movement_uv; + analysis.movement_magnitude = movement_magnitude; + analysis.zoom_magnitude = zoom_magnitude; + analysis.zoom_center_uv = current.to_uv(zoom_center_point); + analysis +} + +fn zoom_vanishing_point(current: &MotionBounds, previous: &MotionBounds) -> Option> { + line_intersection( + previous.top_left(), + current.top_left(), + previous.bottom_right(), + current.bottom_right(), + ) + .or_else(|| { + line_intersection( + previous.top_right(), + current.top_right(), + previous.bottom_left(), + current.bottom_left(), + ) + }) +} + +fn line_intersection(a1: XY, a2: XY, b1: XY, b2: XY) -> Option> { + let denom = (a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x); + if denom.abs() <= f64::EPSILON { + return None; + } + + let a_det = a1.x * a2.y - a1.y * a2.x; + let b_det = b1.x * b2.y - b1.y * b2.x; + let x = (a_det * (b1.x - b2.x) - (a1.x - a2.x) * b_det) / denom; + let y = (a_det * (b1.y - b2.y) - (a1.y - a2.y) * b_det) / denom; + Some(XY::new(x, y)) +} + +fn clamp_vector(vec: XY, max_len: f32) -> XY { + let len = (vec.x * vec.x + vec.y * vec.y).sqrt(); + if len <= max_len || len <= f32::EPSILON { + vec + } else { + vec * (max_len / len) + } +} + +fn resolve_motion_descriptor( + analysis: &MotionAnalysis, + base_amount: f32, + move_multiplier: f32, + zoom_multiplier: f32, +) -> MotionBlurDescriptor { + if base_amount <= f32::EPSILON { + return MotionBlurDescriptor::none(); + } + + let zoom_metric = analysis.zoom_magnitude; + let move_metric = analysis.movement_magnitude; + let zoom_strength = (base_amount * zoom_multiplier).min(2.0); + let move_strength = (base_amount * move_multiplier).min(2.0); + + if zoom_metric > move_metric && zoom_metric > MOTION_MIN_THRESHOLD && zoom_strength > 0.0 { + let zoom_amount = (zoom_metric * zoom_strength).min(MAX_ZOOM_AMOUNT); + MotionBlurDescriptor::zoom(analysis.zoom_center_uv, zoom_amount, zoom_strength) + } else if move_metric > MOTION_MIN_THRESHOLD && move_strength > 0.0 { + let vector = XY::new( + analysis.movement_uv.x * move_strength, + analysis.movement_uv.y * move_strength, + ); + MotionBlurDescriptor::movement(clamp_vector(vector, MOTION_VECTOR_CAP), move_strength) + } else { + MotionBlurDescriptor::none() + } +} + +fn normalized_motion_amount(user_motion_blur: f32, fps: f32) -> f32 { + if user_motion_blur <= f32::EPSILON { + 0.0 + } else { + (user_motion_blur * (fps / MOTION_BLUR_BASELINE_FPS)).max(0.0) + } +} + const CAMERA_PADDING: f32 = 50.0; const SCREEN_MAX_PADDING: f64 = 0.4; const MOTION_BLUR_BASELINE_FPS: f32 = 60.0; -const DISPLAY_TRANSLATION_BASE: f32 = 0.02; -const DISPLAY_ZOOM_BASE: f32 = 0.03; -const CAMERA_ZOOM_BASE: f32 = 0.015; -const DISPLAY_BLUR_INTENSITY_SCALE: f32 = 0.75; -const CAMERA_BLUR_INTENSITY_SCALE: f32 = 0.65; -const MOTION_BLUR_MIN_METRIC: f32 = 0.005; -const MOTION_BLUR_SMOOTHING: f32 = 0.7; -const DISPLAY_BLUR_VISUAL_SCALE: f32 = 0.22; +const MOTION_MIN_THRESHOLD: f32 = 0.003; +const MOTION_VECTOR_CAP: f32 = 0.85; +const MAX_ZOOM_AMOUNT: f32 = 0.9; +const DISPLAY_MOVE_MULTIPLIER: f32 = 0.6; +const DISPLAY_ZOOM_MULTIPLIER: f32 = 0.45; +const CAMERA_MULTIPLIER: f32 = 1.0; +const CAMERA_ONLY_MULTIPLIER: f32 = 0.45; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MotionBlurMode { + None, + Movement, + Zoom, +} -#[derive(Clone, Copy, Debug, Default)] +impl MotionBlurMode { + fn as_f32(self) -> f32 { + match self { + MotionBlurMode::None => 0.0, + MotionBlurMode::Movement => 1.0, + MotionBlurMode::Zoom => 2.0, + } + } +} + +#[derive(Clone, Copy, Debug)] struct MotionBlurDescriptor { - amount: f32, - translation_dir: [f32; 2], - translation_strength: f32, - zoom_strength: f32, + mode: MotionBlurMode, + strength: f32, + movement_vector_uv: [f32; 2], + zoom_center_uv: [f32; 2], + zoom_amount: f32, +} + +impl Default for MotionBlurDescriptor { + fn default() -> Self { + Self { + mode: MotionBlurMode::None, + strength: 0.0, + movement_vector_uv: [0.0, 0.0], + zoom_center_uv: [0.5, 0.5], + zoom_amount: 0.0, + } + } } impl MotionBlurDescriptor { @@ -413,40 +642,23 @@ impl MotionBlurDescriptor { Self::default() } - fn smooth_with(self, previous: MotionBlurDescriptor, retention: f32) -> Self { - if retention <= f32::EPSILON { - return self; - } - - let retention = retention.clamp(0.0, 0.95); - let current_weight = 1.0 - retention; - - let blended_amount = previous.amount * retention + self.amount * current_weight; - let blended_translation_strength = - previous.translation_strength * retention + self.translation_strength * current_weight; - let blended_zoom_strength = - previous.zoom_strength * retention + self.zoom_strength * current_weight; - - let mut blended_dir = [ - previous.translation_dir[0] * retention + self.translation_dir[0] * current_weight, - previous.translation_dir[1] * retention + self.translation_dir[1] * current_weight, - ]; - - let dir_len = (blended_dir[0] * blended_dir[0] + blended_dir[1] * blended_dir[1]).sqrt(); - if dir_len > 1e-4 { - blended_dir[0] /= dir_len; - blended_dir[1] /= dir_len; - } else if self.translation_strength > previous.translation_strength { - blended_dir = self.translation_dir; - } else { - blended_dir = previous.translation_dir; + fn movement(vector_uv: XY, strength: f32) -> Self { + Self { + mode: MotionBlurMode::Movement, + strength, + movement_vector_uv: [vector_uv.x, vector_uv.y], + zoom_center_uv: [0.5, 0.5], + zoom_amount: 0.0, } + } + fn zoom(center_uv: XY, zoom_amount: f32, strength: f32) -> Self { Self { - amount: blended_amount.min(1.0), - translation_dir: blended_dir, - translation_strength: blended_translation_strength, - zoom_strength: blended_zoom_strength, + mode: MotionBlurMode::Zoom, + strength, + movement_vector_uv: [0.0, 0.0], + zoom_center_uv: [center_uv.x, center_uv.y], + zoom_amount, } } } @@ -614,75 +826,36 @@ impl ProjectUniforms { } fn compute_display_motion_blur( - start: Coord, - end: Coord, - prev_start: Coord, - prev_end: Coord, - fps: f32, - user_motion_blur: f32, - ) -> MotionBlurDescriptor { - if user_motion_blur <= f32::EPSILON { - return MotionBlurDescriptor::none(); + current: MotionBounds, + previous: MotionBounds, + has_previous: bool, + base_amount: f32, + extra_zoom: f32, + ) -> MotionBlurComputation { + if !has_previous || base_amount <= f32::EPSILON { + return MotionBlurComputation::none(); } - let fps_scale = (fps / MOTION_BLUR_BASELINE_FPS).max(0.1); - - let center = ((start + end) * 0.5).coord; - let prev_center = ((prev_start + prev_end) * 0.5).coord; - let translation = center - prev_center; - - let size = (end - start).coord; - let prev_size = (prev_end - prev_start).coord; - let base_width = size.x.abs().max(prev_size.x.abs()).max(1.0); - let base_height = size.y.abs().max(prev_size.y.abs()).max(1.0); - - let translation_uv = XY::new(translation.x / base_width, translation.y / base_height); - let translation_uv_mag = ((translation_uv.x * translation_uv.x - + translation_uv.y * translation_uv.y) - .sqrt() as f32) - .min(10.0); - let translation_metric = - (translation_uv_mag * fps_scale / DISPLAY_TRANSLATION_BASE).clamp(0.0, 1.2); - - let zoom_ratio_x = ((size.x - prev_size.x).abs() / base_width).min(1.0); - let zoom_ratio_y = ((size.y - prev_size.y).abs() / base_height).min(1.0); - let zoom_ratio = ((zoom_ratio_x + zoom_ratio_y) * 0.5) as f32; - let zoom_metric = (zoom_ratio * fps_scale / DISPLAY_ZOOM_BASE).clamp(0.0, 1.2); - - let translation_strength = Self::motion_blur_curve( - translation_metric, - user_motion_blur, - DISPLAY_BLUR_INTENSITY_SCALE, - 1.0, - ); + let mut analysis = analyze_motion(¤t, &previous); + if extra_zoom > 0.0 { + analysis.zoom_magnitude = (analysis.zoom_magnitude + extra_zoom).min(3.0); + } - let zoom_strength = Self::motion_blur_curve( - zoom_metric, - user_motion_blur, - DISPLAY_BLUR_INTENSITY_SCALE, - 1.0, + let descriptor = resolve_motion_descriptor( + &analysis, + base_amount, + DISPLAY_MOVE_MULTIPLIER, + DISPLAY_ZOOM_MULTIPLIER, ); - - let translation_dir = if translation_metric <= MOTION_BLUR_MIN_METRIC { - [0.0, 0.0] + let parent_vector = if analysis.movement_magnitude > MOTION_MIN_THRESHOLD { + analysis.movement_px } else { - let magnitude = - (translation_uv.x * translation_uv.x + translation_uv.y * translation_uv.y).sqrt(); - if magnitude <= f64::EPSILON { - [0.0, 0.0] - } else { - [ - (translation_uv.x / magnitude) as f32, - (translation_uv.y / magnitude) as f32, - ] - } + XY::new(0.0, 0.0) }; - MotionBlurDescriptor { - amount: translation_strength.max(zoom_strength), - translation_dir, - translation_strength, - zoom_strength, + MotionBlurComputation { + descriptor, + parent_movement_px: parent_vector, } } @@ -698,42 +871,17 @@ impl ProjectUniforms { } fn compute_camera_motion_blur( - zoomed_size: f32, - prev_zoomed_size: f32, - fps: f32, - user_motion_blur: f32, + current: MotionBounds, + previous: MotionBounds, + has_previous: bool, + base_amount: f32, ) -> MotionBlurDescriptor { - if user_motion_blur <= f32::EPSILON { + if !has_previous || base_amount <= f32::EPSILON { return MotionBlurDescriptor::none(); } - let fps_scale = (fps / MOTION_BLUR_BASELINE_FPS).max(0.1); - let delta = (zoomed_size - prev_zoomed_size).abs(); - let metric = (delta * fps_scale / CAMERA_ZOOM_BASE).clamp(0.0, 1.0); - - let zoom_strength = - Self::motion_blur_curve(metric, user_motion_blur, CAMERA_BLUR_INTENSITY_SCALE, 1.0); - - MotionBlurDescriptor { - amount: zoom_strength, - translation_dir: [0.0, 0.0], - translation_strength: 0.0, - zoom_strength, - } - } - - fn motion_blur_curve( - metric: f32, - user_motion_blur: f32, - intensity_scale: f32, - max_amount: f32, - ) -> f32 { - if user_motion_blur <= f32::EPSILON || metric <= MOTION_BLUR_MIN_METRIC { - return 0.0; - } - - let intensity = (user_motion_blur / intensity_scale).clamp(0.0, 2.5); - (metric.powf(0.85) * intensity).min(max_amount) + let analysis = analyze_motion(¤t, &previous); + resolve_motion_descriptor(&analysis, base_amount, CAMERA_MULTIPLIER, CAMERA_MULTIPLIER) } fn auto_zoom_focus( @@ -852,15 +1000,11 @@ impl ProjectUniforms { } else { (frame_number - 1) as f32 / fps_f32 }; - let prev_prev_frame_time = if frame_number >= 2 { - (frame_number - 2) as f32 / fps_f32 - } else { - 0.0 - }; let current_recording_time = segment_frames.recording_time; let prev_recording_time = (segment_frames.recording_time - 1.0 / fps_f32).max(0.0); - let prev_prev_recording_time = (segment_frames.recording_time - 2.0 / fps_f32).max(0.0); let user_motion_blur = project.cursor.motion_blur.clamp(0.0, 1.0); + let has_previous = frame_number > 0; + let normalized_motion = normalized_motion_amount(user_motion_blur, fps_f32); let crop = Self::get_crop(options, project); @@ -876,12 +1020,6 @@ impl ProjectUniforms { let prev_interpolated_cursor = interpolate_cursor(cursor_events, prev_recording_time, cursor_smoothing); - let prev_prev_interpolated_cursor = if frame_number >= 2 { - interpolate_cursor(cursor_events, prev_prev_recording_time, cursor_smoothing) - } else { - prev_interpolated_cursor.clone() - }; - let zoom_segments = project .timeline .as_ref() @@ -908,13 +1046,6 @@ impl ProjectUniforms { prev_interpolated_cursor.clone(), ); - let prev_prev_zoom_focus = Self::auto_zoom_focus( - cursor_events, - prev_prev_recording_time, - cursor_smoothing, - prev_prev_interpolated_cursor.clone(), - ); - let zoom = InterpolatedZoom::new( SegmentsCursor::new(frame_time as f64, zoom_segments), zoom_focus, @@ -925,23 +1056,14 @@ impl ProjectUniforms { prev_zoom_focus, ); - let prev_prev_zoom = InterpolatedZoom::new( - SegmentsCursor::new(prev_prev_frame_time as f64, zoom_segments), - prev_prev_zoom_focus, - ); - let scene = InterpolatedScene::new(SceneSegmentsCursor::new(frame_time as f64, scene_segments)); let prev_scene = InterpolatedScene::new(SceneSegmentsCursor::new( prev_frame_time as f64, scene_segments, )); - let prev_prev_scene = InterpolatedScene::new(SceneSegmentsCursor::new( - prev_prev_frame_time as f64, - scene_segments, - )); - let display = { + let (display, display_motion_parent) = { let output_size = XY::new(output_size.0 as f64, output_size.1 as f64); let size = [options.screen_size.x as f32, options.screen_size.y as f32]; @@ -961,107 +1083,89 @@ impl ProjectUniforms { Self::display_bounds(&zoom, display_offset, display_size, output_size); let (prev_start, prev_end) = Self::display_bounds(&prev_zoom, display_offset, display_size, output_size); - let (prev_prev_start, prev_prev_end) = - Self::display_bounds(&prev_prev_zoom, display_offset, display_size, output_size); let target_size = (end - start).coord; let min_target_axis = target_size.x.min(target_size.y); - - let mut display_blur = Self::compute_display_motion_blur( - start, - end, - prev_start, - prev_end, - fps_f32, - user_motion_blur, - ); - if frame_number >= 2 { - let prev_display_blur = Self::compute_display_motion_blur( - prev_start, - prev_end, - prev_prev_start, - prev_prev_end, - fps_f32, - user_motion_blur, - ); - display_blur = display_blur.smooth_with(prev_display_blur, MOTION_BLUR_SMOOTHING); - } - let scene_blur_strength = (scene.screen_blur as f32 * 0.8).min(1.0); - display_blur.zoom_strength = - (display_blur.zoom_strength + scene_blur_strength).clamp(0.0, 1.2); - display_blur.amount = display_blur.amount.max(display_blur.zoom_strength).min(1.0); - - display_blur.translation_strength *= DISPLAY_BLUR_VISUAL_SCALE; - display_blur.zoom_strength *= DISPLAY_BLUR_VISUAL_SCALE; - display_blur.amount = (display_blur.amount * DISPLAY_BLUR_VISUAL_SCALE).max( - display_blur - .translation_strength - .max(display_blur.zoom_strength), + let scene_blur_strength = (scene.screen_blur as f32 * 0.8).min(1.2); + + let display_motion = Self::compute_display_motion_blur( + MotionBounds::new(start, end), + MotionBounds::new(prev_start, prev_end), + has_previous, + normalized_motion, + scene_blur_strength, ); + let descriptor = display_motion.descriptor; + let display_parent_motion_px = display_motion.parent_movement_px; - CompositeVideoFrameUniforms { - output_size: [output_size.x as f32, output_size.y as f32], - frame_size: size, - crop_bounds: [ - crop_start.x as f32, - crop_start.y as f32, - crop_end.x as f32, - crop_end.y as f32, - ], - target_bounds: [start.x as f32, start.y as f32, end.x as f32, end.y as f32], - target_size: [target_size.x as f32, target_size.y as f32], - rounding_px: (project.background.rounding / 100.0 * 0.5 * min_target_axis) as f32, - mirror_x: 0.0, - velocity_uv: display_blur.translation_dir, - blur_components: [ - display_blur.translation_strength, - display_blur.zoom_strength, - ], - motion_blur_amount: display_blur.amount, - camera_motion_blur_amount: 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.screen_opacity as f32, - border_enabled: if project - .background - .border - .as_ref() - .is_some_and(|b| b.enabled) - { - 1.0 - } else { - 0.0 - }, - border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), - _padding0: 0.0, - _padding1: [0.0; 2], - _padding1b: [0.0; 2], - border_color: if let Some(b) = project.background.border.as_ref() { - [ - b.color[0] as f32 / 255.0, - b.color[1] as f32 / 255.0, - b.color[2] as f32 / 255.0, - (b.opacity / 100.0).clamp(0.0, 1.0), - ] - } else { - [1.0, 1.0, 1.0, 0.8] + ( + CompositeVideoFrameUniforms { + output_size: [output_size.x as f32, output_size.y as f32], + frame_size: size, + crop_bounds: [ + crop_start.x as f32, + crop_start.y as f32, + crop_end.x as f32, + crop_end.y as f32, + ], + target_bounds: [start.x as f32, start.y as f32, end.x as f32, end.y as f32], + target_size: [target_size.x as f32, target_size.y as f32], + rounding_px: (project.background.rounding / 100.0 * 0.5 * min_target_axis) + as f32, + mirror_x: 0.0, + motion_blur_vector: descriptor.movement_vector_uv, + motion_blur_zoom_center: descriptor.zoom_center_uv, + motion_blur_params: [ + descriptor.mode.as_f32(), + descriptor.strength, + descriptor.zoom_amount, + 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.screen_opacity as f32, + border_enabled: if project + .background + .border + .as_ref() + .is_some_and(|b| b.enabled) + { + 1.0 + } else { + 0.0 + }, + border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), + _padding0: 0.0, + _padding1: [0.0; 2], + _padding1b: [0.0; 2], + border_color: if let Some(b) = project.background.border.as_ref() { + [ + b.color[0] as f32 / 255.0, + b.color[1] as f32 / 255.0, + b.color[2] as f32 / 255.0, + (b.opacity / 100.0).clamp(0.0, 1.0), + ] + } else { + [1.0, 1.0, 1.0, 0.8] + }, + _padding2: [0.0; 4], }, - _padding2: [0.0; 4], - } + display_parent_motion_px, + ) }; let camera = options @@ -1082,72 +1186,84 @@ impl ProjectUniforms { let zoomed_size = Self::camera_zoom_factor(&zoom, &scene, base_size, zoom_size); let prev_zoomed_size = Self::camera_zoom_factor(&prev_zoom, &prev_scene, base_size, zoom_size); - let prev_prev_zoomed_size = Self::camera_zoom_factor( - &prev_prev_zoom, - &prev_prev_scene, - base_size, - zoom_size, - ); - - let camera_blur = Self::compute_camera_motion_blur( - zoomed_size, - prev_zoomed_size, - fps_f32, - user_motion_blur, - ); - let camera_blur = if frame_number >= 2 { - let prev_camera_blur = Self::compute_camera_motion_blur( - prev_zoomed_size, - prev_prev_zoomed_size, - fps_f32, - user_motion_blur, - ); - camera_blur.smooth_with(prev_camera_blur, MOTION_BLUR_SMOOTHING) - } else { - camera_blur - }; let aspect = frame_size[0] / frame_size[1]; - let size = match project.camera.shape { + let camera_size_for = |scale: f32| match project.camera.shape { CameraShape::Source => { if aspect >= 1.0 { [ - (min_axis * zoomed_size + CAMERA_PADDING) * aspect, - min_axis * zoomed_size + CAMERA_PADDING, + (min_axis * scale + CAMERA_PADDING) * aspect, + min_axis * scale + CAMERA_PADDING, ] } else { [ - min_axis * zoomed_size + CAMERA_PADDING, - (min_axis * zoomed_size + CAMERA_PADDING) / aspect, + min_axis * scale + CAMERA_PADDING, + (min_axis * scale + CAMERA_PADDING) / aspect, ] } } CameraShape::Square => [ - min_axis * zoomed_size + CAMERA_PADDING, - min_axis * zoomed_size + CAMERA_PADDING, + min_axis * scale + CAMERA_PADDING, + min_axis * scale + CAMERA_PADDING, ], }; - let position = { + let size = camera_size_for(zoomed_size); + let prev_size = camera_size_for(prev_zoomed_size); + + let position_for = |subject_size: [f32; 2]| { let x = match &project.camera.position.x { CameraXPosition::Left => CAMERA_PADDING, - CameraXPosition::Center => output_size[0] / 2.0 - (size[0]) / 2.0, - CameraXPosition::Right => output_size[0] - CAMERA_PADDING - size[0], + CameraXPosition::Center => output_size[0] / 2.0 - subject_size[0] / 2.0, + CameraXPosition::Right => output_size[0] - CAMERA_PADDING - subject_size[0], }; let y = match &project.camera.position.y { CameraYPosition::Top => CAMERA_PADDING, - CameraYPosition::Bottom => output_size[1] - size[1] - CAMERA_PADDING, + CameraYPosition::Bottom => { + output_size[1] - subject_size[1] - CAMERA_PADDING + } }; [x, y] }; + let position = position_for(size); + let prev_position = position_for(prev_size); + let target_bounds = [ position[0], position[1], position[0] + size[0], position[1] + size[1], ]; + let prev_target_bounds = [ + prev_position[0], + prev_position[1], + prev_position[0] + prev_size[0], + prev_position[1] + prev_size[1], + ]; + + let current_bounds = MotionBounds::new( + Coord::new(XY::new(target_bounds[0] as f64, target_bounds[1] as f64)), + Coord::new(XY::new(target_bounds[2] as f64, target_bounds[3] as f64)), + ); + let prev_bounds = MotionBounds::new( + Coord::new(XY::new( + prev_target_bounds[0] as f64, + prev_target_bounds[1] as f64, + )), + Coord::new(XY::new( + prev_target_bounds[2] as f64, + prev_target_bounds[3] as f64, + )), + ); + + let camera_descriptor = Self::compute_camera_motion_blur( + current_bounds, + prev_bounds, + has_previous, + normalized_motion, + ); let crop_bounds = match project.camera.shape { CameraShape::Source => [0.0, 0.0, frame_size[0], frame_size[1]], @@ -1170,10 +1286,14 @@ impl ProjectUniforms { ], rounding_px: project.camera.rounding / 100.0 * 0.5 * size[0].min(size[1]), mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, - velocity_uv: camera_blur.translation_dir, - blur_components: [camera_blur.translation_strength, camera_blur.zoom_strength], - motion_blur_amount: 0.0, - camera_motion_blur_amount: camera_blur.amount, + motion_blur_vector: camera_descriptor.movement_vector_uv, + motion_blur_zoom_center: camera_descriptor.zoom_center_uv, + motion_blur_params: [ + camera_descriptor.mode.as_f32(), + camera_descriptor.strength, + camera_descriptor.zoom_amount, + 0.0, + ], shadow: project.camera.shadow, shadow_size: project .camera @@ -1241,7 +1361,17 @@ impl ProjectUniforms { [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] }; - let camera_only_blur = (scene.camera_only_blur as f32 * 0.5).clamp(0.0, 1.0); + let camera_only_blur = + (scene.camera_only_blur as f32 * CAMERA_ONLY_MULTIPLIER).clamp(0.0, 1.0); + let camera_only_descriptor = if camera_only_blur <= f32::EPSILON { + MotionBlurDescriptor::none() + } else { + MotionBlurDescriptor::zoom( + XY::new(0.5, 0.5), + (camera_only_blur * 0.75).min(MAX_ZOOM_AMOUNT), + camera_only_blur, + ) + }; CompositeVideoFrameUniforms { output_size, @@ -1254,10 +1384,14 @@ impl ProjectUniforms { ], rounding_px: 0.0, mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, - velocity_uv: [0.0, 0.0], - blur_components: [0.0, camera_only_blur], - motion_blur_amount: 0.0, - camera_motion_blur_amount: camera_only_blur, + motion_blur_vector: camera_only_descriptor.movement_vector_uv, + motion_blur_zoom_center: camera_only_descriptor.zoom_center_uv, + motion_blur_params: [ + camera_only_descriptor.mode.as_f32(), + camera_only_descriptor.strength, + camera_only_descriptor.zoom_amount, + 0.0, + ], shadow: 0.0, shadow_size: 0.0, shadow_opacity: 0.0, @@ -1285,6 +1419,9 @@ impl ProjectUniforms { scene, interpolated_cursor, frame_rate: fps, + prev_cursor: prev_interpolated_cursor, + display_parent_motion_px: display_motion_parent, + motion_blur_amount: user_motion_blur, } } } diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index 13c6b02828..80a89a6afd 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -3,13 +3,12 @@ struct Uniforms { target_bounds: vec4, output_size: vec2, frame_size: vec2, - velocity_uv: vec2, - blur_components: vec2, + motion_blur_vector: vec2, + motion_blur_zoom_center: vec2, + motion_blur_params: vec4, target_size: vec2, rounding_px: f32, mirror_x: f32, - motion_blur_amount: f32, - camera_motion_blur_amount: f32, shadow: f32, shadow_size: f32, shadow_opacity: f32, @@ -121,60 +120,70 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { base_color = apply_rounded_corners(base_color, target_uv); base_color.a = base_color.a * uniforms.opacity; - let blur_amount = select(uniforms.motion_blur_amount, uniforms.camera_motion_blur_amount, uniforms.camera_motion_blur_amount > 0.0); - let translation_strength = uniforms.blur_components.x; - let zoom_strength = uniforms.blur_components.y; + let blur_mode = uniforms.motion_blur_params.x; + let blur_strength = uniforms.motion_blur_params.y; + let zoom_amount = uniforms.motion_blur_params.z; - if blur_amount < 0.01 || (translation_strength < 0.01 && zoom_strength < 0.01) { + if blur_mode < 0.5 || blur_strength < 0.001 { return mix(shadow_color, base_color, base_color.a); } - let center_uv = vec2(0.5, 0.5); - let to_center = target_uv - center_uv; - let dist_from_center = length(to_center); - var radial_dir = vec2(0.0, -1.0); - if (dist_from_center > 1e-4) { - radial_dir = to_center / dist_from_center; - } - - var translation_dir = uniforms.velocity_uv; - let translation_len = length(translation_dir); - if (translation_len > 1e-4) { - translation_dir = translation_dir / translation_len; - } else { - translation_dir = radial_dir; - } - - let translation_scale = select(0.08, 0.22, uniforms.camera_motion_blur_amount > 0.0); - let zoom_scale = select(0.08, 0.24, uniforms.camera_motion_blur_amount > 0.0); - let jitter_scale = select(0.0008, 0.0015 + 0.0015 * blur_amount, uniforms.camera_motion_blur_amount > 0.0); - - let base_samples = clamp(8.0 + 14.0 * blur_amount, 6.0, 32.0); - let num_samples = i32(base_samples); - var accum = base_color; var weight_sum = 1.0; - for (var i = 1; i < num_samples; i = i + 1) { - let t = f32(i) / f32(num_samples - 1); - let eased = smoothstep(0.0, 1.0, t); - - let translation_offset = translation_dir * translation_strength * translation_scale * eased; - let zoom_offset = radial_dir * (dist_from_center + 0.12) * zoom_strength * zoom_scale * eased; - - let jitter_seed = target_uv + vec2(t, f32(i) * 0.37); - let jitter_angle = rand(jitter_seed) * 6.2831853; - let jitter_radius = (rand(jitter_seed.yx) - 0.5) * jitter_scale * blur_amount; - let jitter = vec2(cos(jitter_angle), sin(jitter_angle)) * jitter_radius; + if blur_mode < 1.5 { + let motion_vec = uniforms.motion_blur_vector; + let motion_len = length(motion_vec); + if motion_len < 1e-4 { + return mix(shadow_color, base_color, base_color.a); + } - let sample_uv = target_uv - translation_offset - zoom_offset + jitter; - if sample_uv.x >= 0.0 && sample_uv.x <= 1.0 && sample_uv.y >= 0.0 && sample_uv.y <= 1.0 { - var sample_color = sample_texture(sample_uv, crop_bounds_uv); - sample_color = apply_rounded_corners(sample_color, sample_uv); + let direction = motion_vec / motion_len; + let stroke = min(motion_len, 0.35); + let num_samples = i32(clamp(6.0 + 24.0 * blur_strength, 6.0, 36.0)); + + for (var i = 1; i < num_samples; i = i + 1) { + let t = f32(i) / f32(num_samples); + let eased = smoothstep(0.0, 1.0, t); + let offset = direction * stroke * eased; + let jitter_seed = target_uv + vec2(t, f32(i) * 0.37); + let jitter = (rand(jitter_seed) - 0.5) * stroke * 0.15; + let sample_uv = target_uv - offset + direction * jitter; + + if sample_uv.x >= 0.0 && sample_uv.x <= 1.0 && sample_uv.y >= 0.0 && sample_uv.y <= 1.0 { + var sample_color = sample_texture(sample_uv, crop_bounds_uv); + sample_color = apply_rounded_corners(sample_color, sample_uv); + let weight = 1.0 - t * 0.8; + accum += sample_color * weight; + weight_sum += weight; + } + } + } else { + let center = uniforms.motion_blur_zoom_center; + let to_center = target_uv - center; + let dist = length(to_center); + if dist < 1e-4 || zoom_amount < 1e-4 { + return mix(shadow_color, base_color, base_color.a); + } - let weight = 1.0 - t * 0.85; - accum += sample_color * weight; - weight_sum += weight; + let radial_dir = to_center / dist; + let sample_span = zoom_amount * 1.2; + let num_samples = i32(clamp(8.0 + 26.0 * blur_strength, 8.0, 40.0)); + + for (var i = 1; i < num_samples; i = i + 1) { + let t = f32(i) / f32(num_samples); + let offset = radial_dir * sample_span * t; + let jitter_seed = vec2(t, target_uv.x + target_uv.y); + let jitter = (rand(jitter_seed) - 0.5) * zoom_amount * 0.1; + let sample_uv = target_uv - offset + radial_dir * jitter; + + if sample_uv.x >= 0.0 && sample_uv.x <= 1.0 && sample_uv.y >= 0.0 && sample_uv.y <= 1.0 { + var sample_color = sample_texture(sample_uv, crop_bounds_uv); + sample_color = apply_rounded_corners(sample_color, sample_uv); + let weight = 1.0 - t * 0.9; + accum += sample_color * weight; + weight_sum += weight; + } } } diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index 63213bff73..412bc3f3f1 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -7,7 +7,7 @@ struct Uniforms { position_size: vec4, output_size: vec4, screen_bounds: vec4, - velocity_blur_opacity: vec4, + motion_vector_strength: vec4, }; @group(0) @binding(0) @@ -56,54 +56,39 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn fs_main(input: VertexOutput) -> @location(0) vec4 { // Increase samples for higher quality blur let num_samples = 20; - var color_sum = vec4(0.0); - var weight_sum = 0.0; + let base_sample = textureSample(t_cursor, s_cursor, input.uv); + var color_sum = base_sample; + var weight_sum = 1.0; // Calculate velocity magnitude for adaptive blur strength - let velocity = uniforms.velocity_blur_opacity.xy; - let motion_blur_amount = uniforms.velocity_blur_opacity.z; - let opacity = uniforms.velocity_blur_opacity.w; + let motion_vec = uniforms.motion_vector_strength.xy; + let blur_strength = uniforms.motion_vector_strength.z; + let opacity = uniforms.motion_vector_strength.w; - let velocity_mag = length(velocity); - let adaptive_blur = motion_blur_amount * smoothstep(0.0, 50.0, velocity_mag); - - // Calculate blur direction from velocity - var blur_dir = velocity; + let motion_len = length(motion_vec); + if (motion_len < 1e-4 || blur_strength < 0.001) { + return textureSample(t_cursor, s_cursor, input.uv) * opacity; + } - // Enhanced blur trail - let max_blur_offset = 3.0 * adaptive_blur; + let direction = motion_vec / motion_len; + let max_offset = motion_len; for (var i = 0; i < num_samples; i++) { - // Non-linear sampling for better blur distribution - let t = i / num_samples; - - // Calculate sample offset with velocity-based scaling - let offset = blur_dir * max_blur_offset * (f32(i) / f32(num_samples)); + let t = f32(i) / f32(num_samples - 1); + let eased = smoothstep(0.0, 1.0, t); + let offset = direction * max_offset * eased; let sample_uv = input.uv + offset / uniforms.output_size.xy; // Sample with bilinear filtering let sample = textureSample(t_cursor, s_cursor, sample_uv); // Accumulate weighted sample - color_sum += sample; + let weight = 1.0 - t * 0.75; + color_sum += sample * weight; + weight_sum += weight; } - // Normalize the result - var final_color = color_sum / f32(num_samples); - - // Enhance contrast slightly for fast movements - if (velocity_mag > 30.0) { - // Create new color with enhanced contrast instead of modifying components - final_color = vec4( - pow(final_color.r, 0.95), - pow(final_color.g, 0.95), - pow(final_color.b, 0.95), - final_color.a - ); - } - - // Preserve opacity regardless of blur intensity so the cursor stays fully visible. + var final_color = color_sum / weight_sum; final_color *= opacity; - return final_color; } From 62773b94fd51c43e1ae028ceaf4a42c11e5db752 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 13:02:58 +0000 Subject: [PATCH 04/17] feat: Add cursor move densification for smoothing --- crates/rendering/src/cursor_interpolation.rs | 131 ++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/rendering/src/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index 05dd032963..9b46ba71df 100644 --- a/crates/rendering/src/cursor_interpolation.rs +++ b/crates/rendering/src/cursor_interpolation.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use cap_project::{CursorEvents, CursorMoveEvent, XY}; use crate::{ @@ -51,7 +53,8 @@ pub fn interpolate_cursor( } if let Some(smoothing_config) = smoothing { - let events = get_smoothed_cursor_events(&cursor.moves, smoothing_config); + let prepared_moves = densify_cursor_moves(&cursor.moves); + let events = get_smoothed_cursor_events(prepared_moves.as_ref(), smoothing_config); interpolate_smoothed_position(&events, time_secs as f64, smoothing_config) } else { let (pos, cursor_id, velocity) = cursor.moves.windows(2).find_map(|chunk| { @@ -169,6 +172,87 @@ fn interpolate_smoothed_position( }) } +const CURSOR_FRAME_DURATION_MS: f64 = 1000.0 / 60.0; +const GAP_INTERPOLATION_THRESHOLD_MS: f64 = CURSOR_FRAME_DURATION_MS * 4.0; +const MIN_CURSOR_TRAVEL_FOR_INTERPOLATION: f64 = 0.02; +const MAX_INTERPOLATED_STEPS: usize = 120; + +fn densify_cursor_moves<'a>(moves: &'a [CursorMoveEvent]) -> Cow<'a, [CursorMoveEvent]> { + if moves.len() < 2 { + return Cow::Borrowed(moves); + } + + let requires_interpolation = moves.windows(2).any(|window| { + let current = &window[0]; + let next = &window[1]; + should_fill_gap(current, next) + }); + + if !requires_interpolation { + return Cow::Borrowed(moves); + } + + let mut dense_moves = Vec::with_capacity(moves.len()); + dense_moves.push(moves[0].clone()); + + for i in 0..moves.len() - 1 { + let current = &moves[i]; + let next = &moves[i + 1]; + if should_fill_gap(current, next) { + push_interpolated_samples(current, next, &mut dense_moves); + } else { + dense_moves.push(next.clone()); + } + } + + Cow::Owned(dense_moves) +} + +fn should_fill_gap(from: &CursorMoveEvent, to: &CursorMoveEvent) -> bool { + if from.cursor_id != to.cursor_id { + return false; + } + + let dt_ms = (to.time_ms - from.time_ms).max(0.0); + if dt_ms < GAP_INTERPOLATION_THRESHOLD_MS { + return false; + } + + let dx = to.x - from.x; + let dy = to.y - from.y; + let distance = (dx * dx + dy * dy).sqrt(); + + distance >= MIN_CURSOR_TRAVEL_FOR_INTERPOLATION +} + +fn push_interpolated_samples( + from: &CursorMoveEvent, + to: &CursorMoveEvent, + output: &mut Vec, +) { + let dt_ms = (to.time_ms - from.time_ms).max(0.0); + if dt_ms <= 0.0 { + output.push(to.clone()); + return; + } + + let segments = + ((dt_ms / CURSOR_FRAME_DURATION_MS).ceil() as usize).clamp(2, MAX_INTERPOLATED_STEPS); + + for step in 1..segments { + let t = step as f64 / segments as f64; + output.push(CursorMoveEvent { + active_modifiers: to.active_modifiers.clone(), + cursor_id: to.cursor_id.clone(), + time_ms: from.time_ms + dt_ms * t, + x: from.x + (to.x - from.x) * t, + y: from.y + (to.y - from.y) * t, + }); + } + + output.push(to.clone()); +} + #[derive(Debug)] struct SmoothedCursorEvent { time: f32, @@ -177,3 +261,48 @@ struct SmoothedCursorEvent { velocity: XY, cursor_id: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn cursor_move(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent { + CursorMoveEvent { + active_modifiers: vec![], + cursor_id: "primary".into(), + time_ms, + x, + y, + } + } + + #[test] + fn densify_inserts_samples_for_large_gaps() { + let moves = vec![cursor_move(0.0, 0.1, 0.1), cursor_move(140.0, 0.9, 0.9)]; + + match densify_cursor_moves(&moves) { + Cow::Owned(dense) => { + assert!(dense.len() > moves.len(), "expected interpolated samples"); + assert_eq!( + dense.first().unwrap().time_ms, + moves.first().unwrap().time_ms + ); + assert_eq!(dense.last().unwrap().time_ms, moves.last().unwrap().time_ms); + } + Cow::Borrowed(_) => panic!("expected densified output"), + } + } + + #[test] + fn densify_skips_small_gaps_or_cursor_switches() { + let small_gap = vec![cursor_move(0.0, 0.1, 0.1), cursor_move(30.0, 0.2, 0.2)]; + assert!(matches!(densify_cursor_moves(&small_gap), Cow::Borrowed(_))); + + let mut cursor_switch = vec![cursor_move(0.0, 0.1, 0.1), cursor_move(100.0, 0.8, 0.8)]; + cursor_switch[1].cursor_id = "text".into(); + assert!(matches!( + densify_cursor_moves(&cursor_switch), + Cow::Borrowed(_) + )); + } +} From 04c3444e50d233370a44e748410bde07ad587b39 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:54:22 +0000 Subject: [PATCH 05/17] feat: improved cursors (with shadows!) --- apps/desktop/src-tauri/src/recording.rs | 3 +- crates/cursor-info/assets/mac/arrow.svg | 19 +++++++++++- .../cursor-info/assets/mac/pointing_hand.svg | 19 +++++++++++- crates/cursor-info/assets/windows/arrow.svg | 21 +++++++++++-- crates/cursor-info/assets/windows/hand.svg | 30 ++++++++++++++----- crates/cursor-info/cursors.html | 8 ++--- crates/cursor-info/src/macos.rs | 4 +-- crates/cursor-info/src/windows.rs | 4 +-- 8 files changed, 86 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 9529e3210e..c5e4316b5c 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1090,6 +1090,7 @@ fn generate_zoom_segments_from_clicks_impl( const MOVEMENT_WINDOW_SECONDS: f64 = 1.2; const MOVEMENT_EVENT_DISTANCE_THRESHOLD: f64 = 0.025; const MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.1; + const AUTO_ZOOM_AMOUNT: f64 = 1.5; if max_duration <= 0.0 { return Vec::new(); @@ -1227,7 +1228,7 @@ fn generate_zoom_segments_from_clicks_impl( Some(ZoomSegment { start, end, - amount: 2.0, + amount: AUTO_ZOOM_AMOUNT, mode: ZoomMode::Auto, }) }) diff --git a/crates/cursor-info/assets/mac/arrow.svg b/crates/cursor-info/assets/mac/arrow.svg index 42ae068a3a..8d65f5c35c 100644 --- a/crates/cursor-info/assets/mac/arrow.svg +++ b/crates/cursor-info/assets/mac/arrow.svg @@ -1 +1,18 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/mac/pointing_hand.svg b/crates/cursor-info/assets/mac/pointing_hand.svg index ef77a7d706..d14e9e59df 100644 --- a/crates/cursor-info/assets/mac/pointing_hand.svg +++ b/crates/cursor-info/assets/mac/pointing_hand.svg @@ -1 +1,18 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/windows/arrow.svg b/crates/cursor-info/assets/windows/arrow.svg index 8fc222050e..7c0aa3987f 100644 --- a/crates/cursor-info/assets/windows/arrow.svg +++ b/crates/cursor-info/assets/windows/arrow.svg @@ -1,3 +1,18 @@ - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/windows/hand.svg b/crates/cursor-info/assets/windows/hand.svg index 9705811906..ae8b6af0e0 100644 --- a/crates/cursor-info/assets/windows/hand.svg +++ b/crates/cursor-info/assets/windows/hand.svg @@ -1,8 +1,22 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/cursors.html b/crates/cursor-info/cursors.html index 9cf0fd6b97..f175744fd9 100644 --- a/crates/cursor-info/cursors.html +++ b/crates/cursor-info/cursors.html @@ -163,7 +163,7 @@

Windows Cursors

name: "arrow", hash: "de2d1f4a81e520b65fd1317b845b00a1c51a4d1f71cca3cd4ccdab52b98d1ac9", svg: "assets/mac/arrow.svg", - hotspot: [0.347, 0.33], + hotspot: [0.302, 0.226], }, { name: "contextual_menu", @@ -217,7 +217,7 @@

Windows Cursors

name: "pointing_hand", hash: "b0443e9f72e724cb6d94b879bf29c6cb18376d0357c6233e5a7561cf8a9943c6", svg: "assets/mac/pointing_hand.svg", - hotspot: [0.516, 0.461], + hotspot: [0.342, 0.172], }, { name: "resize_down", @@ -388,7 +388,7 @@

Windows Cursors

name: "arrow", hash: "19502718917bb8a86b83ffb168021cf90517b5c5e510c33423060d230c9e2d20", svg: "assets/windows/arrow.svg", - hotspot: [0.055, 0.085], + hotspot: [0.288, 0.189], }, { name: "ibeam", @@ -430,7 +430,7 @@

Windows Cursors

name: "hand", hash: "44a554b439a681410d337d239bf08afe7c66486538563ebb93dc1c309f0a9209", svg: "assets/windows/hand.svg", - hotspot: [0.42, 0], + hotspot: [0.441, 0.143], }, { name: "uparrow", diff --git a/crates/cursor-info/src/macos.rs b/crates/cursor-info/src/macos.rs index bb7e5120e7..44aa981acc 100644 --- a/crates/cursor-info/src/macos.rs +++ b/crates/cursor-info/src/macos.rs @@ -71,7 +71,7 @@ impl CursorShapeMacOS { Some(match self { Self::Arrow => ResolvedCursor { raw: include_str!("../assets/mac/arrow.svg"), - hotspot: (0.347, 0.33), + hotspot: (0.302, 0.226), }, Self::ContextualMenu => ResolvedCursor { raw: include_str!("../assets/mac/contextual_menu.svg"), @@ -108,7 +108,7 @@ impl CursorShapeMacOS { }, Self::PointingHand => ResolvedCursor { raw: include_str!("../assets/mac/pointing_hand.svg"), - hotspot: (0.516, 0.461), + hotspot: (0.342, 0.172), }, Self::ResizeDown => ResolvedCursor { raw: include_str!("../assets/mac/resize_down.svg"), diff --git a/crates/cursor-info/src/windows.rs b/crates/cursor-info/src/windows.rs index d2f2f4665f..81c4810970 100644 --- a/crates/cursor-info/src/windows.rs +++ b/crates/cursor-info/src/windows.rs @@ -70,7 +70,7 @@ impl CursorShapeWindows { Some(match self { Self::Arrow => ResolvedCursor { raw: include_str!("../assets/windows/arrow.svg"), - hotspot: (0.055, 0.085), + hotspot: (0.288, 0.189), }, Self::IBeam => ResolvedCursor { raw: include_str!("../assets/windows/ibeam.svg"), @@ -114,7 +114,7 @@ impl CursorShapeWindows { }, Self::Hand => ResolvedCursor { raw: include_str!("../assets/windows/hand.svg"), - hotspot: (0.42, 0.0), + hotspot: (0.441, 0.143), }, Self::AppStarting => ResolvedCursor { raw: include_str!("../assets/windows/appstarting.svg"), From 647f9729bf1deac662c7a2ebe09d2a21c65fec76 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:58:27 +0000 Subject: [PATCH 06/17] feat: Add dynamic spring profiles for cursor interpolation --- crates/rendering/src/cursor_interpolation.rs | 138 ++++++++++++++++++- crates/rendering/src/layers/cursor.rs | 7 +- crates/rendering/src/spring_mass_damper.rs | 6 + 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/crates/rendering/src/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index 9b46ba71df..9482050d2d 100644 --- a/crates/rendering/src/cursor_interpolation.rs +++ b/crates/rendering/src/cursor_interpolation.rs @@ -1,12 +1,105 @@ use std::borrow::Cow; -use cap_project::{CursorEvents, CursorMoveEvent, XY}; +use cap_project::{CursorClickEvent, CursorEvents, CursorMoveEvent, XY}; use crate::{ Coord, RawDisplayUVSpace, spring_mass_damper::{SpringMassDamperSimulation, SpringMassDamperSimulationConfig}, }; +const CLICK_REACTION_WINDOW_MS: f64 = 160.0; +const MIN_MASS: f32 = 0.1; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum SpringProfile { + Default, + Snappy, + Drag, +} + +struct CursorSpringPresets { + default: SpringMassDamperSimulationConfig, + snappy: SpringMassDamperSimulationConfig, + drag: SpringMassDamperSimulationConfig, +} + +impl CursorSpringPresets { + fn new(base: SpringMassDamperSimulationConfig) -> Self { + Self { + default: base, + snappy: scale_config(base, 1.65, 0.65, 1.25), + drag: scale_config(base, 1.25, 0.85, 1.1), + } + } + + fn config(&self, profile: SpringProfile) -> SpringMassDamperSimulationConfig { + match profile { + SpringProfile::Default => self.default, + SpringProfile::Snappy => self.snappy, + SpringProfile::Drag => self.drag, + } + } +} + +fn scale_config( + base: SpringMassDamperSimulationConfig, + tension_scale: f32, + mass_scale: f32, + friction_scale: f32, +) -> SpringMassDamperSimulationConfig { + SpringMassDamperSimulationConfig { + tension: base.tension * tension_scale, + mass: (base.mass * mass_scale).max(MIN_MASS), + friction: base.friction * friction_scale, + } +} + +struct CursorSpringContext<'a> { + clicks: &'a [CursorClickEvent], + next_click_index: usize, + last_click_time: Option, + primary_button_down: bool, +} + +impl<'a> CursorSpringContext<'a> { + fn new(clicks: &'a [cap_project::CursorClickEvent]) -> Self { + Self { + clicks, + next_click_index: 0, + last_click_time: None, + primary_button_down: false, + } + } + + fn advance_to(&mut self, time_ms: f64) { + while let Some(click) = self.clicks.get(self.next_click_index) + && click.time_ms <= time_ms + { + self.last_click_time = Some(click.time_ms); + if click.cursor_num == 0 { + self.primary_button_down = click.down; + } + self.next_click_index += 1; + } + } + + fn profile(&self, time_ms: f64) -> SpringProfile { + if self.was_recent_click(time_ms) { + SpringProfile::Snappy + } else if self.primary_button_down { + SpringProfile::Drag + } else { + SpringProfile::Default + } + } + + fn was_recent_click(&self, time_ms: f64) -> bool { + self.last_click_time + .map(|t| (time_ms - t).abs() <= CLICK_REACTION_WINDOW_MS) + .unwrap_or(false) + } +} + #[derive(Debug, Clone)] pub struct InterpolatedCursorPosition { pub position: Coord, @@ -54,7 +147,7 @@ pub fn interpolate_cursor( if let Some(smoothing_config) = smoothing { let prepared_moves = densify_cursor_moves(&cursor.moves); - let events = get_smoothed_cursor_events(prepared_moves.as_ref(), smoothing_config); + let events = get_smoothed_cursor_events(cursor, prepared_moves.as_ref(), smoothing_config); interpolate_smoothed_position(&events, time_secs as f64, smoothing_config) } else { let (pos, cursor_id, velocity) = cursor.moves.windows(2).find_map(|chunk| { @@ -86,6 +179,7 @@ pub fn interpolate_cursor( } fn get_smoothed_cursor_events( + cursor: &CursorEvents, moves: &[CursorMoveEvent], smoothing_config: SpringMassDamperSimulationConfig, ) -> Vec { @@ -94,6 +188,8 @@ fn get_smoothed_cursor_events( let mut events = vec![]; let mut sim = SpringMassDamperSimulation::new(smoothing_config); + let presets = CursorSpringPresets::new(smoothing_config); + let mut context = CursorSpringContext::new(&cursor.clicks); sim.set_position(XY::new(moves[0].x, moves[0].y).map(|v| v as f32)); sim.set_velocity(XY::new(0.0, 0.0)); @@ -115,6 +211,10 @@ fn get_smoothed_cursor_events( .unwrap_or(sim.target_position); sim.set_target_position(target_position); + context.advance_to(m.time_ms); + let profile = context.profile(m.time_ms); + sim.set_config(presets.config(profile)); + sim.run(m.time_ms as f32 - last_time); last_time = m.time_ms as f32; @@ -276,6 +376,16 @@ mod tests { } } + fn click_event(time_ms: f64, down: bool) -> CursorClickEvent { + CursorClickEvent { + active_modifiers: vec![], + cursor_id: "primary".into(), + cursor_num: 0, + time_ms, + down, + } + } + #[test] fn densify_inserts_samples_for_large_gaps() { let moves = vec![cursor_move(0.0, 0.1, 0.1), cursor_move(140.0, 0.9, 0.9)]; @@ -305,4 +415,28 @@ mod tests { Cow::Borrowed(_) )); } + + #[test] + fn spring_context_detects_dragging_between_clicks() { + let clicks = vec![click_event(100.0, true), click_event(360.0, false)]; + let mut context = CursorSpringContext::new(&clicks); + + context.advance_to(280.0); + assert_eq!(context.profile(280.0), SpringProfile::Drag); + + context.advance_to(620.0); + assert_eq!(context.profile(620.0), SpringProfile::Default); + } + + #[test] + fn spring_context_switches_to_snappy_near_click_events() { + let clicks = vec![click_event(80.0, true), click_event(140.0, false)]; + let mut context = CursorSpringContext::new(&clicks); + + context.advance_to(80.0); + assert_eq!(context.profile(80.0), SpringProfile::Snappy); + + context.advance_to(340.0); + assert_eq!(context.profile(340.0), SpringProfile::Default); + } } diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 6778c40247..941e8e914d 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -19,7 +19,8 @@ const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0; const CURSOR_VECTOR_CAP: f32 = 320.0; const CURSOR_MIN_MOTION: f32 = 0.01; const CURSOR_BASELINE_FPS: f32 = 60.0; -const CURSOR_MULTIPLIER: f32 = 1.0; +const CURSOR_MULTIPLIER: f32 = 3.0; +const CURSOR_MAX_STRENGTH: f32 = 5.0; /// The size to render the svg to. static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; @@ -214,8 +215,8 @@ impl CursorLayer { let screen_diag = (((screen_size.x as f32).powi(2) + (screen_size.y as f32).powi(2)).sqrt()).max(1.0); let fps_scale = fps / CURSOR_BASELINE_FPS; - let cursor_strength = - (uniforms.motion_blur_amount * CURSOR_MULTIPLIER * fps_scale).clamp(0.0, 3.0); + let cursor_strength = (uniforms.motion_blur_amount * CURSOR_MULTIPLIER * fps_scale) + .clamp(0.0, CURSOR_MAX_STRENGTH); let parent_motion = uniforms.display_parent_motion_px; let child_motion = uniforms .prev_cursor diff --git a/crates/rendering/src/spring_mass_damper.rs b/crates/rendering/src/spring_mass_damper.rs index 91e175f07a..c4c1e2ee47 100644 --- a/crates/rendering/src/spring_mass_damper.rs +++ b/crates/rendering/src/spring_mass_damper.rs @@ -30,6 +30,12 @@ impl SpringMassDamperSimulation { } } + pub fn set_config(&mut self, config: SpringMassDamperSimulationConfig) { + self.tension = config.tension; + self.mass = config.mass; + self.friction = config.friction; + } + pub fn set_position(&mut self, position: XY) { self.position = position; } From 999d3189d56957a2b1af989f16b9056a4992fdad Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 15:14:05 +0000 Subject: [PATCH 07/17] feat: More cursor updates --- crates/cursor-info/assets/mac/ibeam.svg | 19 +++++++++++++++- .../cursor-info/assets/mac/tahoe/default.svg | 22 +++++++++++++++---- .../cursor-info/assets/mac/tahoe/pointer.svg | 22 +++++++++++++++---- crates/cursor-info/assets/mac/tahoe/tahoe.svg | 18 +++++++++++++++ crates/cursor-info/assets/mac/tahoe/text.svg | 22 +++++++++++++++---- crates/cursor-info/assets/windows/ibeam.svg | 21 +++++++++++++++--- crates/cursor-info/cursors.html | 10 ++++----- crates/cursor-info/src/macos.rs | 8 +++---- crates/cursor-info/src/windows.rs | 2 +- 9 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 crates/cursor-info/assets/mac/tahoe/tahoe.svg diff --git a/crates/cursor-info/assets/mac/ibeam.svg b/crates/cursor-info/assets/mac/ibeam.svg index b7de00fe1a..002c72d78c 100644 --- a/crates/cursor-info/assets/mac/ibeam.svg +++ b/crates/cursor-info/assets/mac/ibeam.svg @@ -1 +1,18 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/mac/tahoe/default.svg b/crates/cursor-info/assets/mac/tahoe/default.svg index 18b29be70b..0acd949174 100644 --- a/crates/cursor-info/assets/mac/tahoe/default.svg +++ b/crates/cursor-info/assets/mac/tahoe/default.svg @@ -1,4 +1,18 @@ - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/mac/tahoe/pointer.svg b/crates/cursor-info/assets/mac/tahoe/pointer.svg index a8000ee15f..092faf32ed 100644 --- a/crates/cursor-info/assets/mac/tahoe/pointer.svg +++ b/crates/cursor-info/assets/mac/tahoe/pointer.svg @@ -1,4 +1,18 @@ - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/mac/tahoe/tahoe.svg b/crates/cursor-info/assets/mac/tahoe/tahoe.svg new file mode 100644 index 0000000000..d96b5a0f0b --- /dev/null +++ b/crates/cursor-info/assets/mac/tahoe/tahoe.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/mac/tahoe/text.svg b/crates/cursor-info/assets/mac/tahoe/text.svg index ade24f4cc0..d96b5a0f0b 100644 --- a/crates/cursor-info/assets/mac/tahoe/text.svg +++ b/crates/cursor-info/assets/mac/tahoe/text.svg @@ -1,4 +1,18 @@ - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/assets/windows/ibeam.svg b/crates/cursor-info/assets/windows/ibeam.svg index bd12ae6b23..0fc644c319 100644 --- a/crates/cursor-info/assets/windows/ibeam.svg +++ b/crates/cursor-info/assets/windows/ibeam.svg @@ -1,3 +1,18 @@ - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/cursor-info/cursors.html b/crates/cursor-info/cursors.html index f175744fd9..3f7a77f996 100644 --- a/crates/cursor-info/cursors.html +++ b/crates/cursor-info/cursors.html @@ -199,7 +199,7 @@

Windows Cursors

name: "ibeam", hash: "492dca0bb6751a30607ac728803af992ba69365052b7df2dff1c0dfe463e653c", svg: "assets/mac/ibeam.svg", - hotspot: [0.525, 0.52], + hotspot: [0.484, 0.520], }, { name: "open_hand", @@ -265,7 +265,7 @@

Windows Cursors

name: "tahoe_arrow", hash: "57a1d610df3e421ebef670ba58c97319d2ab6990d64dca34d28140e4527fd54d", svg: "assets/mac/tahoe/default.svg", - hotspot: [0.495, 0.463], + hotspot: [0.320, 0.192], }, { name: "tahoe_contextual_menu", @@ -308,7 +308,7 @@

Windows Cursors

name: "tahoe_ibeam", hash: "3de4a52b22f76f28db5206dc4c2219dff28a6ee5abfb9c5656a469f2140f7eaa", svg: "assets/mac/tahoe/text.svg", - hotspot: [0.525, 0.52], + hotspot: [0.493, 0.464], }, { name: "tahoe_open_hand", @@ -326,7 +326,7 @@

Windows Cursors

name: "tahoe_pointing_hand", hash: "cb0277925fa3ecca8bc54bc98b3ef1d5c08cfd4c6086733f4d849c675f68bf6f", svg: "assets/mac/tahoe/pointer.svg", - hotspot: [0.516, 0.459], + hotspot: [0.425, 0.167], }, { name: "tahoe_resize_down", @@ -394,7 +394,7 @@

Windows Cursors

name: "ibeam", hash: "77cc4cedcf68f3e1d41bfe16c567961b2306c6236b35b966cd3d5c9516565e33", svg: "assets/windows/ibeam.svg", - hotspot: [0.5, 0.5], + hotspot: [0.490, 0.471], }, { name: "wait", diff --git a/crates/cursor-info/src/macos.rs b/crates/cursor-info/src/macos.rs index 44aa981acc..c0fa534035 100644 --- a/crates/cursor-info/src/macos.rs +++ b/crates/cursor-info/src/macos.rs @@ -96,7 +96,7 @@ impl CursorShapeMacOS { }, Self::IBeam => ResolvedCursor { raw: include_str!("../assets/mac/ibeam.svg"), - hotspot: (0.525, 0.52), + hotspot: (0.484, 0.520), }, Self::OpenHand => ResolvedCursor { raw: include_str!("../assets/mac/open_hand.svg"), @@ -141,7 +141,7 @@ impl CursorShapeMacOS { // Tahoe cursor variants Self::TahoeArrow => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/default.svg"), - hotspot: (0.495, 0.463), + hotspot: (0.320, 0.192), }, Self::TahoeContextualMenu => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/context-menu.svg"), @@ -167,7 +167,7 @@ impl CursorShapeMacOS { Self::TahoeIBeam => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/text.svg"), - hotspot: (0.525, 0.52), + hotspot: (0.493, 0.464), }, Self::TahoeOpenHand => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/grab.svg"), @@ -179,7 +179,7 @@ impl CursorShapeMacOS { }, Self::TahoePointingHand => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/pointer.svg"), - hotspot: (0.516, 0.459), + hotspot: (0.425, 0.167), }, Self::TahoeResizeDown => ResolvedCursor { raw: include_str!("../assets/mac/tahoe/resize-s.svg"), diff --git a/crates/cursor-info/src/windows.rs b/crates/cursor-info/src/windows.rs index 81c4810970..7461c3f069 100644 --- a/crates/cursor-info/src/windows.rs +++ b/crates/cursor-info/src/windows.rs @@ -74,7 +74,7 @@ impl CursorShapeWindows { }, Self::IBeam => ResolvedCursor { raw: include_str!("../assets/windows/ibeam.svg"), - hotspot: (0.5, 0.5), + hotspot: (0.490, 0.471), }, Self::Wait => ResolvedCursor { raw: include_str!("../assets/windows/wait.svg"), From cbc793737e7d80e3e234459a6dc2e35c2e418f7d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:23:17 +0000 Subject: [PATCH 08/17] feat: Add squircle corner style option for camera and background --- Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/camera.wgsl | 10 +- apps/desktop/src/routes/camera.tsx | 15 +- .../src/routes/editor/ConfigSidebar.tsx | 168 +++++++++++++----- apps/desktop/src/routes/editor/Player.tsx | 11 +- apps/desktop/src/routes/editor/context.ts | 62 ++++++- apps/desktop/src/utils/tauri.ts | 5 +- crates/project/src/configuration.rs | 14 ++ crates/rendering/src/composite_frame.rs | 2 + crates/rendering/src/lib.rs | 14 +- .../src/shaders/composite-video-frame.wgsl | 45 +++-- 12 files changed, 285 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1141acf3cd..7074489506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.82" +version = "0.3.83" dependencies = [ "anyhow", "async-stream", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 02b6b27389..a6cf8cfe17 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.82" +version = "0.3.83" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/camera.wgsl b/apps/desktop/src-tauri/src/camera.wgsl index a55893f3fa..7ae3e31f01 100644 --- a/apps/desktop/src-tauri/src/camera.wgsl +++ b/apps/desktop/src-tauri/src/camera.wgsl @@ -69,6 +69,12 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput { return out; } +fn squircle_norm(p: vec2, power: f32) -> f32 { + let x = pow(abs(p.x), power); + let y = pow(abs(p.y), power); + return pow(x + y, 1.0 / power); +} + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Calculate the effective rendering dimensions (accounting for toolbar) @@ -127,13 +133,13 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let corner_radius = select(0.1, 0.12, size == 1.0); let abs_uv = abs(center_uv); let corner_pos = abs_uv - (1.0 - corner_radius); - let corner_dist = length(max(corner_pos, vec2(0.0, 0.0))); + let corner_norm = squircle_norm(max(corner_pos, vec2(0.0, 0.0)), 4.0); // Enhanced anti-aliasing for corners let pixel_size = length(fwidth(center_uv)); let aa_width = max(pixel_size, 0.002); - let edge_distance = corner_dist - corner_radius; + let edge_distance = corner_norm - corner_radius; mask = 1.0 - smoothstep(-aa_width, aa_width, edge_distance); } else if (shape == 2.0) { // Full shape with aspect ratio-corrected rounded corners diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index c1d7e9f4b5..bfe56bc45e 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -38,6 +38,9 @@ namespace CameraWindow { }; } +const SQUIRCLE_CLIP_PATH = + "path('M100.0000% 50.0000% L99.9699% 38.9244% L99.8795% 34.3462% L99.7287% 30.8473% L99.5173% 27.9155% L99.2451% 25.3535% L98.9117% 23.0610% L98.5166% 20.9789% L98.0593% 19.0693% L97.5392% 17.3062% L96.9553% 15.6708% L96.3068% 14.1495% L95.5925% 12.7317% L94.8109% 11.4092% L93.9605% 10.1756% L93.0393% 9.0256% L92.0448% 7.9552% L90.9744% 6.9607% L89.8244% 6.0395% L88.5908% 5.1891% L87.2683% 4.4075% L85.8505% 3.6932% L84.3292% 3.0447% L82.6938% 2.4608% L80.9307% 1.9407% L79.0211% 1.4834% L76.9390% 1.0883% L74.6465% 0.7549% L72.0845% 0.4827% L69.1527% 0.2713% L65.6538% 0.1205% L61.0756% 0.0301% L50.0000% 0.0000% L38.9244% 0.0301% L34.3462% 0.1205% L30.8473% 0.2713% L27.9155% 0.4827% L25.3535% 0.7549% L23.0610% 1.0883% L20.9789% 1.4834% L19.0693% 1.9407% L17.3062% 2.4608% L15.6708% 3.0447% L14.1495% 3.6932% L12.7317% 4.4075% L11.4092% 5.1891% L10.1756% 6.0395% L9.0256% 6.9607% L7.9552% 7.9552% L6.9607% 9.0256% L6.0395% 10.1756% L5.1891% 11.4092% L4.4075% 12.7317% L3.6932% 14.1495% L3.0447% 15.6708% L2.4608% 17.3062% L1.9407% 19.0693% L1.4834% 20.9789% L1.0883% 23.0610% L0.7549% 25.3535% L0.4827% 27.9155% L0.2713% 30.8473% L0.1205% 34.3462% L0.0301% 38.9244% L0.0000% 50.0000% L0.0301% 61.0756% L0.1205% 65.6538% L0.2713% 69.1527% L0.4827% 72.0845% L0.7549% 74.6465% L1.0883% 76.9390% L1.4834% 79.0211% L1.9407% 80.9307% L2.4608% 82.6938% L3.0447% 84.3292% L3.6932% 85.8505% L4.4075% 87.2683% L5.1891% 88.5908% L6.0395% 89.8244% L6.9607% 90.9744% L7.9552% 92.0448% L9.0256% 93.0393% L10.1756% 93.9605% L11.4092% 94.8109% L12.7317% 95.5925% L14.1495% 96.3068% L15.6708% 96.9553% L17.3062% 97.5392% L19.0693% 98.0593% L20.9789% 98.5166% L23.0610% 98.9117% L25.3535% 99.2451% L27.9155% 99.5173% L30.8473% 99.7287% L34.3462% 99.8795% L38.9244% 99.9699% L50.0000% 100.0000% L61.0756% 99.9699% L65.6538% 99.8795% L69.1527% 99.7287% L72.0845% 99.5173% L74.6465% 99.2451% L76.9390% 98.9117% L79.0211% 98.5166% L80.9307% 98.0593% L82.6938% 97.5392% L84.3292% 96.9553% L85.8505% 96.3068% L87.2683% 95.5925% L88.5908% 94.8109% L89.8244% 93.9605% L90.9744% 93.0393% L92.0448% 92.0448% L93.0393% 90.9744% L93.9605% 89.8244% L94.8109% 88.5908% L95.5925% 87.2683% L96.3068% 85.8505% L96.9553% 84.3292% L97.5392% 82.6938% L98.0593% 80.9307% L98.5166% 79.0211% L98.9117% 76.9390% L99.2451% 74.6465% L99.5173% 72.0845% L99.7287% 69.1527% L99.8795% 65.6538% L99.9699% 61.0756% L100.0000% 50.0000% Z')"; + export default function () { document.documentElement.classList.toggle("dark", true); @@ -310,8 +313,18 @@ function LegacyCameraPreviewPage() {
}> diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 5781558078..5e136eef50 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -53,12 +53,12 @@ import { type ZoomSegment, } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; +import IconLucideRabbit from "~icons/lucide/rabbit"; import IconLucideSparkles from "~icons/lucide/sparkles"; import IconLucideTimer from "~icons/lucide/timer"; -import IconLucideRabbit from "~icons/lucide/rabbit"; import IconLucideWind from "~icons/lucide/wind"; import { CaptionsTab } from "./CaptionsTab"; -import { useEditorContext } from "./context"; +import { type CornerRoundingType, useEditorContext } from "./context"; import { DEFAULT_GRADIENT_FROM, DEFAULT_GRADIENT_TO, @@ -210,6 +210,11 @@ const CAMERA_SHAPES = [ }, ] satisfies Array<{ name: string; value: CameraShape }>; +const CORNER_STYLE_OPTIONS = [ + { name: "Squircle", value: "squircle" }, + { name: "Rounded", value: "rounded" }, +] satisfies Array<{ name: string; value: CornerRoundingType }>; + const BACKGROUND_THEMES = { macOS: "macOS", dark: "Dark", @@ -263,7 +268,8 @@ const findCursorPreset = ( option.preset && Math.abs(option.preset.tension - values.tension) <= CURSOR_PRESET_TOLERANCE.tension && - Math.abs(option.preset.mass - values.mass) <= CURSOR_PRESET_TOLERANCE.mass && + Math.abs(option.preset.mass - values.mass) <= + CURSOR_PRESET_TOLERANCE.mass && Math.abs(option.preset.friction - values.friction) <= CURSOR_PRESET_TOLERANCE.friction, ); @@ -618,27 +624,27 @@ export function ConfigSidebar() { applyCursorStylePreset(value as CursorAnimationStyle) } > - {CURSOR_ANIMATION_STYLE_OPTIONS.map((option) => ( - - - - -
- - {option.label} - - - {option.description} - -
-
-
- ))} - - + {CURSOR_ANIMATION_STYLE_OPTIONS.map((option) => ( + + + + +
+ + {option.label} + + + {option.description} + +
+
+
+ ))} + + }> - setProject("background", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> +
+ setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + setProject("background", "roundingType", value) + } + /> +
}> - setProject("camera", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> +
+ setProject("camera", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + setProject("camera", "roundingType", value)} + /> +
}>
@@ -2224,6 +2246,72 @@ function CameraConfig(props: { scrollRef: HTMLDivElement }) { ); } +function CornerStyleSelect(props: { + label?: string; + value: CornerRoundingType; + onChange: (value: CornerRoundingType) => void; +}) { + return ( +
+ + {(label) => ( + + {label()} + + )} + + + options={CORNER_STYLE_OPTIONS} + optionValue="value" + optionTextValue="name" + value={CORNER_STYLE_OPTIONS.find( + (option) => option.value === props.value, + )} + onChange={(option) => option && props.onChange(option.value)} + disallowEmptySelection + itemComponent={(itemProps) => ( + + as={KSelect.Item} + item={itemProps.item} + > + + {itemProps.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(iconProps) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + +
+ ); +} + function ZoomSegmentPreview(props: { segmentIndex: number; segment: ZoomSegment; diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 36a593c725..ad986e4b64 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -7,7 +7,12 @@ import Tooltip from "~/components/Tooltip"; import { captionsStore } from "~/store/captions"; import { commands } from "~/utils/tauri"; import AspectRatioSelect from "./AspectRatioSelect"; -import { FPS, OUTPUT_SIZE, useEditorContext } from "./context"; +import { + FPS, + OUTPUT_SIZE, + serializeProjectConfiguration, + useEditorContext, +} from "./context"; import { EditorButton, Slider } from "./ui"; import { useEditorShortcuts } from "./useEditorShortcuts"; import { formatTime } from "./utils"; @@ -68,7 +73,9 @@ export function Player() { setProject(updatedProject); // Save the updated project configuration - await commands.setProjectConfig(updatedProject); + await commands.setProjectConfig( + serializeProjectConfiguration(updatedProject), + ); } } } diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index bde98aac56..1298c4ef93 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -61,6 +61,62 @@ export type CustomDomainResponse = { domain_verified: boolean | null; }; +export type CornerRoundingType = "rounded" | "squircle"; + +type WithCornerStyle = T & { roundingType: CornerRoundingType }; + +export type EditorProjectConfiguration = Omit< + ProjectConfiguration, + "background" | "camera" +> & { + background: WithCornerStyle; + camera: WithCornerStyle; +}; + +function withCornerDefaults< + T extends { + roundingType?: CornerRoundingType; + rounding_type?: CornerRoundingType; + }, +>(value: T): T & { roundingType: CornerRoundingType } { + const roundingType = value.roundingType ?? value.rounding_type ?? "squircle"; + return { + ...value, + roundingType, + }; +} + +function normalizeProject( + config: ProjectConfiguration, +): EditorProjectConfiguration { + return { + ...config, + background: withCornerDefaults(config.background), + camera: withCornerDefaults(config.camera), + }; +} + +export function serializeProjectConfiguration( + project: EditorProjectConfiguration, +): ProjectConfiguration { + const { background, camera, ...rest } = project; + const { roundingType: backgroundRoundingType, ...backgroundRest } = + background; + const { roundingType: cameraRoundingType, ...cameraRest } = camera; + + return { + ...rest, + background: { + ...backgroundRest, + roundingType: backgroundRoundingType, + }, + camera: { + ...cameraRest, + rounding_type: cameraRoundingType, + }, + }; +} + export const [EditorContextProvider, useEditorContext] = createContextProvider( (props: { meta: () => TransformedMeta; @@ -68,8 +124,8 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( refetchMeta(): Promise; }) => { const editorInstanceContext = useEditorInstanceContext(); - const [project, setProject] = createStore( - props.editorInstance.savedProjectConfig, + const [project, setProject] = createStore( + normalizeProject(props.editorInstance.savedProjectConfig), ); const projectActions = { @@ -245,7 +301,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( trackStore(project); }, debounce(() => { - commands.setProjectConfig(project); + commands.setProjectConfig(serializeProjectConfiguration(project)); }), { defer: true }, ), diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index af8507f864..9f2c25ff4c 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -358,10 +358,10 @@ export type AudioMeta = { path: string; 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; organizations?: Organization[] } -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; roundingType?: CornerStyle; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: 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 Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoom_size: number | null; rounding?: number; shadow?: number; advanced_shadow?: ShadowConfiguration | null; shape?: CameraShape; rounding_type?: CornerStyle } export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } export type CameraPosition = { x: CameraXPosition; y: CameraYPosition } export type CameraPreviewShape = "round" | "square" | "full" @@ -381,6 +381,7 @@ export type CaptureWindowWithThumbnail = { id: WindowId; owner_name: string; nam 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 CornerStyle = "squircle" | "rounded" export type Crop = { position: XY; size: XY } export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode } export type CurrentRecordingChanged = null diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 16646bbcee..9f5a968778 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -180,6 +180,14 @@ impl From<(T, T)> for XY { } } +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum CornerStyle { + #[default] + Squircle, + Rounded, +} + #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "camelCase")] pub struct Crop { @@ -216,6 +224,8 @@ pub struct BackgroundConfiguration { pub blur: f64, pub padding: f64, pub rounding: f64, + #[serde(default)] + pub rounding_type: CornerStyle, pub inset: u32, pub crop: Option, #[serde(default)] @@ -244,6 +254,7 @@ impl Default for BackgroundConfiguration { blur: 0.0, padding: 0.0, rounding: 0.0, + rounding_type: CornerStyle::default(), inset: 0, crop: None, shadow: 73.6, @@ -292,6 +303,8 @@ pub struct Camera { pub advanced_shadow: Option, #[serde(default)] pub shape: CameraShape, + #[serde(default)] + pub rounding_type: CornerStyle, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, Default)] @@ -328,6 +341,7 @@ impl Default for Camera { blur: 10.5, }), shape: CameraShape::Square, + rounding_type: CornerStyle::default(), } } } diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 28a8f0deda..92068f297c 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -20,6 +20,7 @@ pub struct CompositeVideoFrameUniforms { pub motion_blur_params: [f32; 4], pub target_size: [f32; 2], pub rounding_px: f32, + pub rounding_type: f32, pub mirror_x: f32, pub shadow: f32, pub shadow_size: f32, @@ -47,6 +48,7 @@ impl Default for CompositeVideoFrameUniforms { motion_blur_params: Default::default(), target_size: Default::default(), rounding_px: Default::default(), + rounding_type: 0.0, mirror_x: Default::default(), shadow: Default::default(), shadow_size: Default::default(), diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 308069f00f..7ae4b6fbab 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, ClipOffsets, Crop, CursorEvents, - ProjectConfiguration, RecordingMeta, StudioRecordingMeta, XY, + AspectRatio, CameraShape, CameraXPosition, CameraYPosition, ClipOffsets, CornerStyle, Crop, + CursorEvents, ProjectConfiguration, RecordingMeta, StudioRecordingMeta, XY, }; use composite_frame::CompositeVideoFrameUniforms; use core::f64; @@ -41,6 +41,13 @@ use zoom::*; const STANDARD_CURSOR_HEIGHT: f32 = 75.0; +fn rounding_type_value(style: CornerStyle) -> f32 { + match style { + CornerStyle::Rounded => 0.0, + CornerStyle::Squircle => 1.0, + } +} + #[derive(Debug, Clone, Copy, Type)] pub struct RenderOptions { pub camera_size: Option>, @@ -1112,6 +1119,7 @@ impl ProjectUniforms { target_size: [target_size.x as f32, target_size.y as f32], rounding_px: (project.background.rounding / 100.0 * 0.5 * min_target_axis) as f32, + rounding_type: rounding_type_value(project.background.rounding_type), mirror_x: 0.0, motion_blur_vector: descriptor.movement_vector_uv, motion_blur_zoom_center: descriptor.zoom_center_uv, @@ -1285,6 +1293,7 @@ impl ProjectUniforms { target_bounds[3] - target_bounds[1], ], rounding_px: project.camera.rounding / 100.0 * 0.5 * size[0].min(size[1]), + rounding_type: rounding_type_value(project.camera.rounding_type), mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, motion_blur_vector: camera_descriptor.movement_vector_uv, motion_blur_zoom_center: camera_descriptor.zoom_center_uv, @@ -1383,6 +1392,7 @@ impl ProjectUniforms { target_bounds[3] - target_bounds[1], ], rounding_px: 0.0, + rounding_type: rounding_type_value(project.camera.rounding_type), mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, motion_blur_vector: camera_only_descriptor.movement_vector_uv, motion_blur_zoom_center: camera_only_descriptor.zoom_center_uv, diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index 80a89a6afd..fa99cfae59 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -8,6 +8,7 @@ struct Uniforms { motion_blur_params: vec4, target_size: vec2, rounding_px: f32, + rounding_type: f32, mirror_x: f32, shadow: f32, shadow_size: f32, @@ -44,9 +45,26 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } -fn sdf_rounded_rect(p: vec2, b: vec2, r: f32) -> f32 { +fn superellipse_norm(p: vec2, power: f32) -> f32 { + let x = pow(abs(p.x), power); + let y = pow(abs(p.y), power); + return pow(x + y, 1.0 / power); +} + +fn rounded_corner_norm(p: vec2, rounding_type: f32) -> f32 { + if rounding_type < 0.5 { + return length(p); + } + + let power = 4.0; + return superellipse_norm(p, power); +} + +fn sdf_rounded_rect(p: vec2, b: vec2, r: f32, rounding_type: f32) -> f32 { let q = abs(p) - b + vec2(r); - return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; + let outside = max(q, vec2(0.0)); + let outside_norm = rounded_corner_norm(outside, rounding_type); + return outside_norm + min(max(q.x, q.y), 0.0) - r; } @fragment @@ -55,7 +73,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { let center = (uniforms.target_bounds.xy + uniforms.target_bounds.zw) * 0.5; let size = (uniforms.target_bounds.zw - uniforms.target_bounds.xy) * 0.5; - let dist = sdf_rounded_rect(p - center, size, uniforms.rounding_px); + let dist = sdf_rounded_rect(p - center, size, uniforms.rounding_px, uniforms.rounding_type); let min_frame_size = min(size.x, size.y); let shadow_enabled = uniforms.shadow > 0.0; @@ -82,7 +100,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { shadow_enabled ); - let shadow_dist = sdf_rounded_rect(p - center, size, uniforms.rounding_px); + let shadow_dist = sdf_rounded_rect(p - center, size, uniforms.rounding_px, uniforms.rounding_type); // Apply blur and size to shadow let shadow_strength_final = smoothstep(shadow_size + shadow_blur, -shadow_blur, abs(shadow_dist)); @@ -98,9 +116,11 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { let border_outer_dist = sdf_rounded_rect( p - center, size + vec2(uniforms.border_width), - uniforms.rounding_px + uniforms.border_width + uniforms.rounding_px + uniforms.border_width, + uniforms.rounding_type ); - let border_inner_dist = sdf_rounded_rect(p - center, size, uniforms.rounding_px); + let border_inner_dist = + sdf_rounded_rect(p - center, size, uniforms.rounding_px, uniforms.rounding_type); if (border_outer_dist <= 0.0 && border_inner_dist > 0.0) { let inner_alpha = smoothstep(-0.5, 0.5, border_inner_dist); @@ -247,13 +267,16 @@ fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 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)); + if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 { + let local_coord = max(target_rounding_coord, vec2(0.0)); + let corner_norm = rounded_corner_norm(local_coord, uniforms.rounding_type); + let distance = corner_norm - uniforms.rounding_px; + + if distance >= -distance_blur / 2.0 { + return vec4(0.0); + } } return current_color; From 5ccd19627a504b0071f31727a0f6aecdf44effe9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:08:06 +0000 Subject: [PATCH 09/17] coderabbit bits --- apps/desktop/src/routes/editor/context.ts | 2 +- crates/project/src/configuration.rs | 2 +- crates/rendering/src/shaders/composite-video-frame.wgsl | 2 +- crates/rendering/src/shaders/cursor.wgsl | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 1298c4ef93..dbec248bbe 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -108,7 +108,7 @@ export function serializeProjectConfiguration( ...rest, background: { ...backgroundRest, - roundingType: backgroundRoundingType, + rounding_type: backgroundRoundingType, }, camera: { ...cameraRest, diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 9f5a968778..f6947b223e 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -408,7 +408,7 @@ pub enum CursorAnimationStyle { Custom, } -#[derive(Clone, Copy, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug)] pub struct CursorSmoothingPreset { pub tension: f32, pub mass: f32, diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index fa99cfae59..2e66d3ab2b 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -275,7 +275,7 @@ fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 let distance = corner_norm - uniforms.rounding_px; if distance >= -distance_blur / 2.0 { - return vec4(0.0); + return vec4(0.0); } } diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index 412bc3f3f1..2b058c5378 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -73,10 +73,10 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { let direction = motion_vec / motion_len; let max_offset = motion_len; - for (var i = 0; i < num_samples; i++) { + for (var i = 1; i < num_samples; i++) { let t = f32(i) / f32(num_samples - 1); let eased = smoothstep(0.0, 1.0, t); - let offset = direction * max_offset * eased; + let offset = direction * (max_offset * blur_strength) * eased; let sample_uv = input.uv + offset / uniforms.output_size.xy; // Sample with bilinear filtering From b583b339e3fe8039aecf621037b5076641d3b2aa Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:13:06 +0000 Subject: [PATCH 10/17] Refactor cursor motion threshold constants --- crates/rendering/src/layers/cursor.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 941e8e914d..9dabd3bec0 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -17,7 +17,8 @@ const CLICK_SHRINK_SIZE: f32 = 0.7; const CURSOR_IDLE_MIN_DELAY_MS: f64 = 500.0; const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0; const CURSOR_VECTOR_CAP: f32 = 320.0; -const CURSOR_MIN_MOTION: f32 = 0.01; +const CURSOR_MIN_MOTION_NORMALIZED: f32 = 0.01; +const CURSOR_MIN_MOTION_PX: f32 = 1.0; const CURSOR_BASELINE_FPS: f32 = 60.0; const CURSOR_MULTIPLIER: f32 = 3.0; const CURSOR_MAX_STRENGTH: f32 = 5.0; @@ -243,7 +244,8 @@ impl CursorLayer { let normalized_motion = ((combined_motion_px.x / screen_diag).powi(2) + (combined_motion_px.y / screen_diag).powi(2)) .sqrt(); - let has_motion = normalized_motion > CURSOR_MIN_MOTION && cursor_strength > f32::EPSILON; + let has_motion = + normalized_motion > CURSOR_MIN_MOTION_NORMALIZED && cursor_strength > f32::EPSILON; let scaled_motion = if has_motion { clamp_cursor_vector(combined_motion_px * cursor_strength) } else { @@ -439,8 +441,8 @@ impl CursorLayer { fn combine_cursor_motion(parent: XY, child: XY) -> XY { fn combine_axis(parent: f32, child: f32) -> f32 { - if parent.abs() > CURSOR_MIN_MOTION - && child.abs() > CURSOR_MIN_MOTION + if parent.abs() > CURSOR_MIN_MOTION_PX + && child.abs() > CURSOR_MIN_MOTION_PX && parent.signum() != child.signum() { 0.0 From bc22cd3d62b6f43288dc8fa041d26c34a245175c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:47:45 +0000 Subject: [PATCH 11/17] Improve blending and anti-aliasing in video frame shader --- .../src/shaders/composite-video-frame.wgsl | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index 2e66d3ab2b..04c2206083 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -148,8 +148,9 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { return mix(shadow_color, base_color, base_color.a); } - var accum = base_color; - var weight_sum = 1.0; + let base_weight = max(base_color.a, 0.001); + var accum = base_color * base_weight; + var weight_sum = base_weight; if blur_mode < 1.5 { let motion_vec = uniforms.motion_blur_vector; @@ -174,8 +175,11 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { var sample_color = sample_texture(sample_uv, crop_bounds_uv); sample_color = apply_rounded_corners(sample_color, sample_uv); let weight = 1.0 - t * 0.8; - accum += sample_color * weight; - weight_sum += weight; + let sample_weight = weight * sample_color.a; + if sample_weight > 1e-6 { + accum += sample_color * sample_weight; + weight_sum += sample_weight; + } } } } else { @@ -201,8 +205,11 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { var sample_color = sample_texture(sample_uv, crop_bounds_uv); sample_color = apply_rounded_corners(sample_color, sample_uv); let weight = 1.0 - t * 0.9; - accum += sample_color * weight; - weight_sum += weight; + let sample_weight = weight * sample_color.a; + if sample_weight > 1e-6 { + accum += sample_color * sample_weight; + weight_sum += sample_weight; + } } } } @@ -263,23 +270,16 @@ 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 rounding_point = uniforms.target_size / 2.0 - uniforms.rounding_px; - let target_rounding_coord = target_coord - rounding_point; + // Compute the signed distance to the rounded rect in pixel space so we can + // blend edges smoothly instead of hard-clipping them (which produced jaggies). + let centered_uv = (target_uv - vec2(0.5)) * uniforms.target_size; + let half_size = uniforms.target_size * 0.5; + let distance = sdf_rounded_rect(centered_uv, half_size, uniforms.rounding_px, uniforms.rounding_type); - let distance_blur = 1.0; + let anti_alias_width = max(fwidth(distance), 0.001); + let coverage = clamp(1.0 - smoothstep(-anti_alias_width, anti_alias_width, distance), 0.0, 1.0); - if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 { - let local_coord = max(target_rounding_coord, vec2(0.0)); - let corner_norm = rounded_corner_norm(local_coord, uniforms.rounding_type); - let distance = corner_norm - uniforms.rounding_px; - - if distance >= -distance_blur / 2.0 { - return vec4(0.0); - } - } - - return current_color; + return vec4(current_color.rgb, current_color.a * coverage); } fn rand(co: vec2) -> f32 { From 3d90fcc99fb292e20ae42b7bbc2ca0dfcdadc653 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:47:52 +0000 Subject: [PATCH 12/17] Increase default camera rounding value --- crates/project/src/configuration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f6947b223e..c95f318a00 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -321,7 +321,7 @@ impl Camera { } fn default_rounding() -> f32 { - 30.0 + 100.0 } } From 827617059c92ec519c6fdff5ac92cee827bc0896 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:48:11 +0000 Subject: [PATCH 13/17] Refactor ConfigSidebar.tsx --- .../src/routes/editor/ConfigSidebar.tsx | 5792 ++++++++--------- 1 file changed, 2893 insertions(+), 2899 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 5e136eef50..ba438bf702 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -1,11 +1,11 @@ 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,19 +18,19 @@ 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 { Dynamic } from "solid-js/web"; @@ -42,15 +42,15 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { - type BackgroundSource, - type CameraShape, - type ClipOffsets, - type CursorAnimationStyle, - commands, - type SceneSegment, - type StereoMode, - type TimelineSegment, - type ZoomSegment, + type BackgroundSource, + type CameraShape, + type ClipOffsets, + type CursorAnimationStyle, + commands, + type SceneSegment, + type StereoMode, + type TimelineSegment, + type ZoomSegment, } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideRabbit from "~icons/lucide/rabbit"; @@ -60,673 +60,654 @@ import IconLucideWind from "~icons/lucide/wind"; import { CaptionsTab } from "./CaptionsTab"; import { type CornerRoundingType, useEditorContext } from "./context"; import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, - type RGBColor, + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, } from "./projectConfig"; 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", // 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 - "#00000000", // Transparent + "#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 + "#00000000", // Transparent ]; const BACKGROUND_GRADIENTS = [ - { 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 + { 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 wallpapers - "macOS/tahoe-dusk-min", - "macOS/tahoe-dawn-min", - "macOS/tahoe-day-min", - "macOS/tahoe-night-min", - "macOS/tahoe-dark", - "macOS/tahoe-light", - "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", + // macOS wallpapers + "macOS/tahoe-dusk-min", + "macOS/tahoe-dawn-min", + "macOS/tahoe-day-min", + "macOS/tahoe-night-min", + "macOS/tahoe-dark", + "macOS/tahoe-light", + "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 CORNER_STYLE_OPTIONS = [ - { name: "Squircle", value: "squircle" }, - { name: "Rounded", value: "rounded" }, + { name: "Squircle", value: "squircle" }, + { name: "Rounded", value: "rounded" }, ] satisfies Array<{ name: string; value: CornerRoundingType }>; const BACKGROUND_THEMES = { - macOS: "macOS", - dark: "Dark", - blue: "Blue", - purple: "Purple", - orange: "Orange", + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", }; type CursorPresetValues = { - tension: number; - mass: number; - friction: number; + tension: number; + mass: number; + friction: number; }; +const DEFAULT_CURSOR_MOTION_BLUR = 0.5; + const CURSOR_ANIMATION_STYLE_OPTIONS = [ - { - value: "slow", - label: "Slow", - description: "Relaxed easing with a gentle follow and higher inertia.", - preset: { tension: 65, mass: 1.8, friction: 16 }, - }, - { - value: "mellow", - label: "Mellow", - description: "Balanced smoothing for everyday tutorials and walkthroughs.", - preset: { tension: 120, mass: 1.1, friction: 18 }, - }, - { - value: "custom", - label: "Custom", - description: "Tune tension, friction, and mass manually for full control.", - }, + { + value: "slow", + label: "Slow", + description: "Relaxed easing with a gentle follow and higher inertia.", + preset: { tension: 65, mass: 1.8, friction: 16 }, + }, + { + value: "mellow", + label: "Mellow", + description: "Balanced smoothing for everyday tutorials and walkthroughs.", + preset: { tension: 120, mass: 1.1, friction: 18 }, + }, + { + value: "custom", + label: "Custom", + description: "Tune tension, friction, and mass manually for full control.", + }, ] satisfies Array<{ - value: CursorAnimationStyle; - label: string; - description: string; - preset?: CursorPresetValues; + value: CursorAnimationStyle; + label: string; + description: string; + preset?: CursorPresetValues; }>; const CURSOR_PRESET_TOLERANCE = { - tension: 1, - mass: 0.05, - friction: 0.2, + tension: 1, + mass: 0.05, + friction: 0.2, } as const; const findCursorPreset = ( - values: CursorPresetValues, + values: CursorPresetValues ): CursorAnimationStyle | null => { - const preset = CURSOR_ANIMATION_STYLE_OPTIONS.find( - (option) => - option.preset && - Math.abs(option.preset.tension - values.tension) <= - CURSOR_PRESET_TOLERANCE.tension && - Math.abs(option.preset.mass - values.mass) <= - CURSOR_PRESET_TOLERANCE.mass && - Math.abs(option.preset.friction - values.friction) <= - CURSOR_PRESET_TOLERANCE.friction, - ); - - return preset?.value ?? null; + const preset = CURSOR_ANIMATION_STYLE_OPTIONS.find( + (option) => + option.preset && + Math.abs(option.preset.tension - values.tension) <= + CURSOR_PRESET_TOLERANCE.tension && + Math.abs(option.preset.mass - values.mass) <= + CURSOR_PRESET_TOLERANCE.mass && + Math.abs(option.preset.friction - values.friction) <= + CURSOR_PRESET_TOLERANCE.friction + ); + + return preset?.value ?? null; }; 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 cursorIdleDelay = () => - ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? - 2) as number; - - const clampIdleDelay = (value: number) => - Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10; - - type CursorPhysicsKey = "tension" | "mass" | "friction"; - - const setCursorPhysics = (key: CursorPhysicsKey, value: number) => { - const nextValues: CursorPresetValues = { - tension: key === "tension" ? value : project.cursor.tension, - mass: key === "mass" ? value : project.cursor.mass, - friction: key === "friction" ? value : project.cursor.friction, - }; - const matched = findCursorPreset(nextValues); - const nextStyle = (matched ?? "custom") as CursorAnimationStyle; - - batch(() => { - setProject("cursor", key, value); - if (project.cursor.animationStyle !== nextStyle) { - setProject("cursor", "animationStyle", nextStyle); - } - }); - }; - - const applyCursorStylePreset = (style: CursorAnimationStyle) => { - const option = CURSOR_ANIMATION_STYLE_OPTIONS.find( - (item) => item.value === style, - ); - - batch(() => { - setProject("cursor", "animationStyle", style); - if (option?.preset) { - setProject("cursor", "tension", option.preset.tension); - setProject("cursor", "mass", option.preset.mass); - setProject("cursor", "friction", option.preset.friction); - } - }); - }; - - 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) => ( - { - // Clear any active selection first - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - } - 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} - /> - - - - - )} - - {/* + const { + project, + setProject, + setEditorState, + projectActions, + editorInstance, + editorState, + meta, + } = useEditorContext(); + + const cursorIdleDelay = () => + ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? + 2) as number; + + const clampIdleDelay = (value: number) => + Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10; + + type CursorPhysicsKey = "tension" | "mass" | "friction"; + + const setCursorPhysics = (key: CursorPhysicsKey, value: number) => { + const nextValues: CursorPresetValues = { + tension: key === "tension" ? value : project.cursor.tension, + mass: key === "mass" ? value : project.cursor.mass, + friction: key === "friction" ? value : project.cursor.friction, + }; + const matched = findCursorPreset(nextValues); + const nextStyle = (matched ?? "custom") as CursorAnimationStyle; + + batch(() => { + setProject("cursor", key, value); + if (project.cursor.animationStyle !== nextStyle) { + setProject("cursor", "animationStyle", nextStyle); + } + }); + }; + + const applyCursorStylePreset = (style: CursorAnimationStyle) => { + const option = CURSOR_ANIMATION_STYLE_OPTIONS.find( + (item) => item.value === style + ); + + batch(() => { + setProject("cursor", "animationStyle", style); + if (option?.preset) { + setProject("cursor", "tension", option.preset.tension); + setProject("cursor", "mass", option.preset.mass); + setProject("cursor", "friction", option.preset.friction); + } + }); + }; + + 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) => ( + { + // Clear any active selection first + if (editorState.timeline.selection) { + setEditorState("timeline", "selection", null); + } + 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("audio", "mute", v)} /> */} - {/* + {/* */} - - {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", "hideWhenIdle", value) - } - /> - } - /> - - -
- { - const rounded = clampIdleDelay(v[0]); - setProject("cursor", "hideWhenIdleDelay" as any, rounded); - }} - minValue={0.5} - maxValue={5} - step={0.1} - formatTooltip={(value) => `${value.toFixed(1)}s`} - /> - - {cursorIdleDelay().toFixed(1)}s - -
-
-
- } - > - - applyCursorStylePreset(value as CursorAnimationStyle) - } - > - {CURSOR_ANIMATION_STYLE_OPTIONS.map((option) => ( - - - - -
- - {option.label} - - - {option.description} - -
-
-
- ))} -
-
- - } - value={ - { - setProject("cursor", "raw", !value); - }} - /> - } - /> - - {/* if Content has padding or margin the animation doesn't look as good */} -
- - setCursorPhysics("tension", v[0])} - minValue={1} - maxValue={500} - step={1} - /> - - - setCursorPhysics("friction", v[0])} - minValue={0} - maxValue={50} - step={0.1} - /> - - - setCursorPhysics("mass", v[0])} - minValue={0.1} - maxValue={10} - step={0.01} - /> - -
-
-
- } - value={ - { - setProject("cursor", "useSvg" as any, value); - }} - /> - } - /> - }> - - setProject("cursor", "motionBlur" as any, v[0]) - } - minValue={0} - maxValue={1} - step={0.01} - formatTooltip={(value) => `${Math.round(value * 100)}%`} - /> - -
- - {/* - setProject("cursor", "motionBlur", v[0])} - minValue={0} - maxValue={1} - step={0.001} + + {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", "hideWhenIdle", value) + } + /> + } + /> + + +
+ { + const rounded = clampIdleDelay(v[0]); + setProject("cursor", "hideWhenIdleDelay" as any, rounded); + }} + minValue={0.5} + maxValue={5} + step={0.1} + formatTooltip={(value) => `${value.toFixed(1)}s`} + /> + + {cursorIdleDelay().toFixed(1)}s + +
+
+
+ } + > + + applyCursorStylePreset(value as CursorAnimationStyle) + } + > + {CURSOR_ANIMATION_STYLE_OPTIONS.map((option) => ( + + + + +
+ + {option.label} + + + {option.description} + +
+
+
+ ))} +
+
+ + } + value={ + { + setProject("cursor", "raw", !value); + }} + /> + } + /> + + {/* if Content has padding or margin the animation doesn't look as good */} +
+ + setCursorPhysics("tension", v[0])} + minValue={1} + maxValue={500} + step={1} + /> + + + setCursorPhysics("friction", v[0])} + minValue={0} + maxValue={50} + step={0.1} + /> + + + setCursorPhysics("mass", v[0])} + minValue={0.1} + maxValue={10} + step={0.01} + /> + +
+
+
+ } + value={ + { + setProject("cursor", "useSvg" as any, value); + }} /> - */} - {/* }> + } + /> +
+ + {/* }> */} -
- - }> - - - - - - - - - - -
-
- - {(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 segments = sceneSelection.indices - .map((idx) => ({ - segment: project.timeline?.sceneSegments?.[idx], - index: idx, - })) - .filter((s) => s.segment !== undefined); - - if (segments.length === 0) return; - return { selection: sceneSelection, segments }; - })()} - > - {(value) => ( - 1} - fallback={ - - } - > -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} scene{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - const indices = value().selection.indices; - - // Delete segments in reverse order to maintain indices - [...indices] - .sort((a, b) => b - a) - .forEach((idx) => { - projectActions.deleteSceneSegment(idx); - }); - }} - leftIcon={} - > - Delete - -
-
-
- )} -
- { - const clipSelection = selection(); - if (clipSelection.type !== "clip") return; - - const segments = clipSelection.indices - .map((idx) => ({ - segment: project.timeline?.segments?.[idx], - index: idx, - })) - .filter((s) => s.segment !== undefined); - - if (segments.length === 0) return; - return { selection: clipSelection, segments }; - })()} - > - {(value) => ( - 1} - fallback={ - - } - > -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} clip{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - const indices = value().selection.indices; - - // Delete segments in reverse order to maintain indices - [...indices] - .sort((a, b) => b - a) - .forEach((idx) => { - projectActions.deleteClipSegment(idx); - }); - }} - leftIcon={} - > - Delete - -
-
-
- )} -
- - )} - -
-
- ); +
+ + }> + + + + + + + + + + +
+
+ + {(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 segments = sceneSelection.indices + .map((idx) => ({ + segment: project.timeline?.sceneSegments?.[idx], + index: idx, + })) + .filter((s) => s.segment !== undefined); + + if (segments.length === 0) return; + return { selection: sceneSelection, segments }; + })()} + > + {(value) => ( + 1} + fallback={ + + } + > +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {value().segments.length} scene{" "} + {value().segments.length === 1 + ? "segment" + : "segments"}{" "} + selected + +
+ { + const indices = value().selection.indices; + + // Delete segments in reverse order to maintain indices + [...indices] + .sort((a, b) => b - a) + .forEach((idx) => { + projectActions.deleteSceneSegment(idx); + }); + }} + leftIcon={} + > + Delete + +
+
+
+ )} +
+ { + const clipSelection = selection(); + if (clipSelection.type !== "clip") return; + + const segments = clipSelection.indices + .map((idx) => ({ + segment: project.timeline?.segments?.[idx], + index: idx, + })) + .filter((s) => s.segment !== undefined); + + if (segments.length === 0) return; + return { selection: clipSelection, segments }; + })()} + > + {(value) => ( + 1} + fallback={ + + } + > +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {value().segments.length} clip{" "} + {value().segments.length === 1 + ? "segment" + : "segments"}{" "} + selected + +
+ { + const indices = value().selection.indices; + + // Delete segments in reverse order to maintain indices + [...indices] + .sort((a, b) => b - a) + .forEach((idx) => { + projectActions.deleteClipSegment(idx); + }); + }} + leftIcon={} + > + Delete + +
+
+
+ )} +
+ + )} + +
+
+ ); } function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - 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 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; - - /* + 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 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) => ( -