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/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/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 3cacb36c2d..d5a34b79da 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, @@ -52,10 +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 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, @@ -207,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", @@ -215,6 +223,62 @@ const BACKGROUND_THEMES = { orange: "Orange", }; +type CursorPresetValues = { + 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.", + }, +] 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 +306,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 +615,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 +675,7 @@ export function ConfigSidebar() { setProject("cursor", "friction", v[0])} + onChange={(v) => setCursorPhysics("friction", v[0])} minValue={0} maxValue={50} step={0.1} @@ -554,7 +684,7 @@ export function ConfigSidebar() { setProject("cursor", "mass", v[0])} + onChange={(v) => setCursorPhysics("mass", v[0])} minValue={0.1} maxValue={10} step={0.01} @@ -577,15 +707,6 @@ export function ConfigSidebar() { /> - {/* - setProject("cursor", "motionBlur", v[0])} - minValue={0} - maxValue={1} - step={0.001} - /> - */} {/* }> }> +
+ setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + setProject("background", "roundingType", value) + } + /> +
+
+ }> setProject("background", "rounding", v[0])} + value={[project.cursor.motionBlur ?? DEFAULT_CURSOR_MOTION_BLUR]} + onChange={(v) => setProject("cursor", "motionBlur" as any, v[0])} minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" + maxValue={1} + step={0.01} + formatTooltip={(value) => `${Math.round(value * 100)}%`} /> + } name="Camera">
@@ -2012,14 +2155,21 @@ function CameraConfig(props: { scrollRef: HTMLDivElement }) { /> }> - 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)} + /> +
}>
@@ -2090,6 +2240,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/PresetsDropdown.tsx b/apps/desktop/src/routes/editor/PresetsDropdown.tsx index 627493bb70..c50b34ab38 100644 --- a/apps/desktop/src/routes/editor/PresetsDropdown.tsx +++ b/apps/desktop/src/routes/editor/PresetsDropdown.tsx @@ -2,7 +2,7 @@ import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; import { cx } from "cva"; import { createSignal, For, Show, Suspense } from "solid-js"; import { reconcile } from "solid-js/store"; -import { useEditorContext } from "./context"; +import { normalizeProject, useEditorContext } from "./context"; import { DropdownItem, dropdownContainerClasses, @@ -47,12 +47,11 @@ export function PresetsDropdown() { function applyPreset() { setShowSettings(false); - setProject( - reconcile({ - ...preset.config, - timeline: project.timeline, - }), - ); + const normalizedConfig = normalizeProject({ + ...preset.config, + timeline: project.timeline, + }); + setProject(reconcile(normalizedConfig)); } return ( diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index bde98aac56..f9e3d1142a 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, + }; +} + +export 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 0a01999121..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,11 +381,12 @@ 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 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/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/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/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/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/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/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 9cf0fd6b97..3f7a77f996 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", @@ -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", @@ -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", @@ -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", @@ -388,13 +388,13 @@

Windows Cursors

name: "arrow", hash: "19502718917bb8a86b83ffb168021cf90517b5c5e510c33423060d230c9e2d20", svg: "assets/windows/arrow.svg", - hotspot: [0.055, 0.085], + hotspot: [0.288, 0.189], }, { name: "ibeam", hash: "77cc4cedcf68f3e1d41bfe16c567961b2306c6236b35b966cd3d5c9516565e33", svg: "assets/windows/ibeam.svg", - hotspot: [0.5, 0.5], + hotspot: [0.490, 0.471], }, { name: "wait", @@ -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..c0fa534035 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"), @@ -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"), @@ -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"), @@ -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 d2f2f4665f..7461c3f069 100644 --- a/crates/cursor-info/src/windows.rs +++ b/crates/cursor-info/src/windows.rs @@ -70,11 +70,11 @@ 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"), - hotspot: (0.5, 0.5), + hotspot: (0.490, 0.471), }, Self::Wait => ResolvedCursor { raw: include_str!("../assets/windows/wait.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"), diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index e787e1b5a3..c95f318a00 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)] @@ -308,7 +321,7 @@ impl Camera { } fn default_rounding() -> f32 { - 30.0 + 100.0 } } @@ -328,6 +341,7 @@ impl Default for Camera { blur: 10.5, }), shape: CameraShape::Square, + rounding_type: CornerStyle::default(), } } } @@ -384,13 +398,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(Type, Serialize, Deserialize, 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 +462,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/composite_frame.rs b/crates/rendering/src/composite_frame.rs index ca0061865c..92068f297c 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -15,12 +15,13 @@ pub struct CompositeVideoFrameUniforms { pub target_bounds: [f32; 4], pub output_size: [f32; 2], pub frame_size: [f32; 2], - pub velocity_uv: [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 rounding_type: 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, @@ -42,12 +43,13 @@ impl Default for CompositeVideoFrameUniforms { target_bounds: Default::default(), output_size: Default::default(), frame_size: Default::default(), - velocity_uv: 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(), + rounding_type: 0.0, 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/cursor_interpolation.rs b/crates/rendering/src/cursor_interpolation.rs index fff16b9f88..9482050d2d 100644 --- a/crates/rendering/src/cursor_interpolation.rs +++ b/crates/rendering/src/cursor_interpolation.rs @@ -1,10 +1,105 @@ -use cap_project::{CursorEvents, CursorMoveEvent, XY}; +use std::borrow::Cow; + +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, @@ -38,7 +133,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 { @@ -51,13 +146,22 @@ 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(cursor, prepared_moves.as_ref(), 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,13 +172,14 @@ pub fn interpolate_cursor( x: pos.x as f64, y: pos.y as f64, }), - velocity: XY::new(0.0, 0.0), + velocity, cursor_id, }) } } fn get_smoothed_cursor_events( + cursor: &CursorEvents, moves: &[CursorMoveEvent], smoothing_config: SpringMassDamperSimulationConfig, ) -> Vec { @@ -83,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)); @@ -104,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; @@ -161,6 +272,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, @@ -169,3 +361,82 @@ 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, + } + } + + 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)]; + + 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(_) + )); + } + + #[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 a5a493581b..9dabd3bec0 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -16,6 +16,12 @@ 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 CURSOR_VECTOR_CAP: f32 = 320.0; +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; /// The size to render the svg to. static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; @@ -205,14 +211,46 @@ 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 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, CURSOR_MAX_STRENGTH); + 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 combined_motion_px = if cursor_strength <= f32::EPSILON { + XY::new(0.0, 0.0) + } else { + combine_cursor_motion(parent_motion, child_motion) + }; - 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 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_NORMALIZED && cursor_strength > f32::EPSILON; + let scaled_motion = if has_motion { + clamp_cursor_vector(combined_motion_px * cursor_strength) + } else { + XY::new(0.0, 0.0) + }; let mut cursor_opacity = 1.0f32; if uniforms.project.cursor.hide_when_idle && !cursor.moves.is_empty() { @@ -356,6 +394,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, @@ -370,7 +410,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( @@ -394,13 +439,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_PX + && child.abs() > CURSOR_MIN_MOTION_PX + && 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 99cfe29fc4..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>, @@ -363,14 +370,18 @@ 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, 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)] @@ -385,10 +396,280 @@ 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 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, +} + +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 { + 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 { + fn none() -> Self { + Self::default() + } + + 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 { + mode: MotionBlurMode::Zoom, + strength, + movement_vector_uv: [0.0, 0.0], + zoom_center_uv: [center_uv.x, center_uv.y], + zoom_amount, + } + } +} + impl ProjectUniforms { fn get_crop(options: &RenderOptions, project: &ProjectConfiguration) -> Crop { project.background.crop.as_ref().cloned().unwrap_or(Crop { @@ -537,6 +818,79 @@ 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( + 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 mut analysis = analyze_motion(¤t, &previous); + if extra_zoom > 0.0 { + analysis.zoom_magnitude = (analysis.zoom_magnitude + extra_zoom).min(3.0); + } + + let descriptor = resolve_motion_descriptor( + &analysis, + base_amount, + DISPLAY_MOVE_MULTIPLIER, + DISPLAY_ZOOM_MULTIPLIER, + ); + let parent_vector = if analysis.movement_magnitude > MOTION_MIN_THRESHOLD { + analysis.movement_px + } else { + XY::new(0.0, 0.0) + }; + + MotionBlurComputation { + descriptor, + parent_movement_px: parent_vector, + } + } + + 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( + current: MotionBounds, + previous: MotionBounds, + has_previous: bool, + base_amount: f32, + ) -> MotionBlurDescriptor { + if !has_previous || base_amount <= f32::EPSILON { + return MotionBlurDescriptor::none(); + } + + let analysis = analyze_motion(¤t, &previous); + resolve_motion_descriptor(&analysis, base_amount, CAMERA_MULTIPLIER, CAMERA_MULTIPLIER) + } + fn auto_zoom_focus( cursor_events: &CursorEvents, time_secs: f32, @@ -646,11 +1000,18 @@ 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 current_recording_time = segment_frames.recording_time; + let prev_recording_time = (segment_frames.recording_time - 1.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); @@ -660,41 +1021,56 @@ 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 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 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 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 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]; @@ -710,78 +1086,94 @@ 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 = display_offset + zoom_start; - let end = end + zoom_end; + 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 target_size = end - start; + let target_size = (end - start).coord; let min_target_axis = target_size.x.min(target_size.y); + 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: velocity, - motion_blur_amount: (motion_blur_amount + scene.screen_blur as f32 * 0.8).min(1.0), - camera_motion_blur_amount: 0.0, - shadow: project.background.shadow, - shadow_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, + 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, + 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 @@ -799,54 +1191,87 @@ 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 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 camera_motion_blur = 0.0; + 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]], @@ -868,10 +1293,16 @@ 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 }, - velocity_uv: [0.0, 0.0], - motion_blur_amount, - camera_motion_blur_amount: camera_motion_blur, + 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 @@ -939,6 +1370,18 @@ impl ProjectUniforms { [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] }; + 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, frame_size, @@ -949,10 +1392,16 @@ 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 }, - velocity_uv: [0.0, 0.0], - motion_blur_amount: 0.0, - camera_motion_blur_amount: scene.camera_only_blur as f32 * 0.5, + 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, @@ -979,6 +1428,10 @@ impl ProjectUniforms { zoom, 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 ecfb17d7c6..04c2206083 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -3,12 +3,13 @@ struct Uniforms { target_bounds: vec4, output_size: vec2, frame_size: vec2, - velocity_uv: vec2, + motion_blur_vector: vec2, + motion_blur_zoom_center: vec2, + motion_blur_params: vec4, target_size: vec2, rounding_px: f32, + rounding_type: f32, mirror_x: f32, - motion_blur_amount: f32, - camera_motion_blur_amount: f32, shadow: f32, shadow_size: f32, shadow_opacity: 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); @@ -120,50 +140,77 @@ 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 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 { + 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 dir = normalize(target_uv - center_uv); - - let base_samples = 16.0; - let num_samples = i32(base_samples * smoothstep(0.0, 1.0, blur_amount)); - - 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 random_offset = (rand(target_uv + vec2(t)) - 0.5) * 0.1 * smoothstep(0.0, 0.2, blur_amount); + let base_weight = max(base_color.a, 0.001); + var accum = base_color * base_weight; + var weight_sum = base_weight; - 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 offset = rotated_dir * scale * t; + 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 - offset; - 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; + let sample_weight = weight * sample_color.a; + if sample_weight > 1e-6 { + accum += sample_color * sample_weight; + weight_sum += sample_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) * (1.0 + random_offset * 0.2); - 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; + let sample_weight = weight * sample_color.a; + if sample_weight > 1e-6 { + accum += sample_color * sample_weight; + weight_sum += sample_weight; + } + } } } @@ -223,20 +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; - - let distance = abs(length(target_rounding_coord)) - uniforms.rounding_px; + // 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; - - if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 && distance >= -distance_blur/2.0 { - return vec4(0.0); - // return mix(current_color, vec4(0.0), min(distance / distance_blur + 0.5, 1.0)); - } + let 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); - return current_color; + return vec4(current_color.rgb, current_color.a * coverage); } fn rand(co: vec2) -> f32 { diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index 4c3e3e2e04..2b058c5378 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; - - // Enhanced blur trail - let max_blur_offset = 3.0 * adaptive_blur; + let motion_len = length(motion_vec); + if (motion_len < 1e-4 || blur_strength < 0.001) { + return textureSample(t_cursor, s_cursor, input.uv) * opacity; + } - for (var i = 0; i < num_samples; i++) { - // Non-linear sampling for better blur distribution - let t = i / num_samples; + let direction = motion_vec / motion_len; + let max_offset = motion_len; - // Calculate sample offset with velocity-based scaling - let offset = blur_dir * max_blur_offset * (f32(i) / f32(num_samples)); + 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 * blur_strength) * 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 - ); - } - - final_color *= vec4(1.0, 1.0, 1.0, 1.0 - motion_blur_amount * 0.2); + var final_color = color_sum / weight_sum; 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..c4c1e2ee47 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 { @@ -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; } @@ -40,14 +46,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 +65,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