@@ -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) => (
+
+ )}
+ >
+
+ 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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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