diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts index ac4a211..e1484a8 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts @@ -50,6 +50,9 @@ public static class OpenScreenCursorInterop { [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetCursorInfo(ref CURSORINFO pci); + [DllImport("user32.dll")] + public static extern short GetAsyncKeyState(int vKey); + [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName); @@ -276,6 +279,7 @@ while ($true) { $visible = ($cursorInfo.flags -band 1) -ne 0 $cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) } $cursorType = Get-StandardCursorType $cursorInfo.hCursor + $leftButtonDown = ([OpenScreenCursorInterop]::GetAsyncKeyState(0x01) -band 0x8000) -ne 0 $asset = $null if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) { @@ -296,6 +300,7 @@ while ($true) { visible = $visible handle = $cursorId cursorType = $cursorType + leftButtonDown = $leftButtonDown bounds = Get-TargetBounds asset = $asset } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index 6dc0253..1b6355a 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -32,6 +32,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private readyTimer: NodeJS.Timeout | null = null; private sampleCount = 0; private outOfBoundsSampleCount = 0; + private previousLeftButtonDown = false; constructor(private readonly options: WindowsNativeRecordingSessionOptions) {} @@ -42,6 +43,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.startTimeMs = this.options.startTimeMs ?? Date.now(); this.sampleCount = 0; this.outOfBoundsSampleCount = 0; + this.previousLeftButtonDown = false; const encodedCommand = buildPowerShellCommand( this.options.sampleIntervalMs, @@ -208,6 +210,14 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { const normalizedY = (payload.y - bounds.y) / height; const withinBounds = normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1; + const leftButtonDown = payload.leftButtonDown === true; + const interactionType = + leftButtonDown && !this.previousLeftButtonDown + ? "click" + : !leftButtonDown && this.previousLeftButtonDown + ? "mouseup" + : "move"; + this.previousLeftButtonDown = leftButtonDown; if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) { this.logDiagnostic("sample", { @@ -231,6 +241,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { assetId: payload.handle, visible: payload.visible && withinBounds, cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null, + interactionType, }, }; } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts index 5afc012..ce9185a 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts @@ -9,6 +9,7 @@ export interface WindowsCursorSampleEvent { visible: boolean; handle: string | null; cursorType?: NativeCursorType | null; + leftButtonDown?: boolean; bounds?: { x: number; y: number; diff --git a/scripts/test-windows-native-cursor.mjs b/scripts/test-windows-native-cursor.mjs index 7d7ea45..bb08182 100644 --- a/scripts/test-windows-native-cursor.mjs +++ b/scripts/test-windows-native-cursor.mjs @@ -138,6 +138,9 @@ public static class OpenScreenCursorDiagnosticInterop { [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetCursorInfo(ref CURSORINFO pci); + [DllImport("user32.dll")] + public static extern short GetAsyncKeyState(int vKey); + [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName); @@ -322,6 +325,7 @@ while ($true) { $visible = ($cursorInfo.flags -band 1) -ne 0 $cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) } $cursorType = Get-StandardCursorType $cursorInfo.hCursor + $leftButtonDown = ([OpenScreenCursorDiagnosticInterop]::GetAsyncKeyState(0x01) -band 0x8000) -ne 0 $asset = $null if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) { @@ -342,6 +346,7 @@ while ($true) { visible = $visible handle = $cursorId cursorType = $cursorType + leftButtonDown = $leftButtonDown bounds = @{ x = $screenBounds.Left y = $screenBounds.Top @@ -366,11 +371,15 @@ Add-Type -AssemblyName System.Windows.Forms $source = @" using System.Runtime.InteropServices; +using System; public static class OpenScreenMouseDiagnosticInterop { [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetCursorPos(int X, int Y); + + [DllImport("user32.dll")] + public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); } "@ @@ -386,8 +395,14 @@ for ($i = 0; $i -lt ${steps}; $i++) { $points += @{ x = $x; y = $y } } -foreach ($point in $points) { +for ($i = 0; $i -lt $points.Count; $i++) { + $point = $points[$i] [OpenScreenMouseDiagnosticInterop]::SetCursorPos($point.x, $point.y) | Out-Null + if ($i -eq [int]([Math]::Floor($points.Count / 2))) { + [OpenScreenMouseDiagnosticInterop]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 90 + [OpenScreenMouseDiagnosticInterop]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) + } Start-Sleep -Milliseconds ${stepMs} } `; @@ -489,12 +504,22 @@ function writeAssets(assets, outputDir) { function toRecordingData(samples, assets) { const firstTimestampMs = samples[0]?.timestampMs ?? Date.now(); + let previousLeftButtonDown = false; const normalizedSamples = samples.flatMap((sample) => { const bounds = sample.bounds; if (!bounds || bounds.width <= 0 || bounds.height <= 0) { return []; } + const leftButtonDown = sample.leftButtonDown === true; + const interactionType = + leftButtonDown && !previousLeftButtonDown + ? "click" + : !leftButtonDown && previousLeftButtonDown + ? "mouseup" + : "move"; + previousLeftButtonDown = leftButtonDown; + return [ { timeMs: Math.max(0, sample.timestampMs - firstTimestampMs), @@ -503,6 +528,7 @@ function toRecordingData(samples, assets) { assetId: sample.handle, visible: Boolean(sample.visible), cursorType: sample.cursorType ?? null, + interactionType, }, ]; }); @@ -1045,6 +1071,9 @@ function assertReport(report) { if (report.errorCount > 0) { failures.push(`Sampler reported ${report.errorCount} error event(s).`); } + if (report.leftButtonDownSampleCount === 0 || report.clickSampleCount === 0) { + failures.push("Left button click interaction was not observed."); + } if (failures.length > 0) { throw new Error(failures.join(" ")); @@ -1111,6 +1140,15 @@ const samples = events.filter((event) => event.type === "sample"); const errors = events.filter((event) => event.type === "error"); const recordingStartTimestampMs = samples[0]?.timestampMs ?? Date.now(); const uniquePositions = new Set(samples.map((sample) => `${sample.x},${sample.y}`)); +let previousLeftButtonDown = false; +let clickSampleCount = 0; +for (const sample of samples) { + const leftButtonDown = sample.leftButtonDown === true; + if (leftButtonDown && !previousLeftButtonDown) { + clickSampleCount += 1; + } + previousLeftButtonDown = leftButtonDown; +} const report = { outputDir: OUTPUT_DIR, sampleIntervalMs: SAMPLE_INTERVAL_MS, @@ -1121,6 +1159,8 @@ const report = { assetCount: assets.size, uniqueCursorHandleCount: new Set(samples.map((sample) => sample.handle).filter(Boolean)).size, uniquePositionCount: uniquePositions.size, + leftButtonDownSampleCount: samples.filter((sample) => sample.leftButtonDown === true).length, + clickSampleCount, errorCount: errors.length, firstSample: samples[0] ?? null, lastSample: samples.at(-1) ?? null, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 90390d2..1122200 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1507,6 +1507,9 @@ export default function VideoEditor() { cropRegion, cursorRecordingData, cursorScale: showCursor ? cursorSize : 0, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, annotationRegions, webcamLayoutPreset, webcamMaskShape, @@ -1650,6 +1653,9 @@ export default function VideoEditor() { cropRegion, cursorRecordingData, cursorScale: showCursor ? cursorSize : 0, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, annotationRegions, webcamLayoutPreset, webcamMaskShape, @@ -1744,6 +1750,9 @@ export default function VideoEditor() { effectiveCursorHighlight, showCursor, cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, t, ], ); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 3e87660..bf93e8b 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -26,11 +26,19 @@ import { type WebcamSizePreset, } from "@/lib/compositeLayout"; import { + createNativeCursorMotionBlurState, + createNativeCursorSmoothingState, + getNativeCursorClickBounceProgress, + getNativeCursorClickBounceScale, + getNativeCursorMotionBlurPx, hasNativeCursorRecordingData, projectNativeCursorToLocal, projectNativeCursorToStage, + resetNativeCursorMotionBlurState, + resetNativeCursorSmoothingState, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, + smoothNativeCursorSample, } from "@/lib/cursor/nativeCursor"; import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; @@ -642,6 +650,8 @@ const VideoPlayback = forwardRef( useEffect(() => { cursorRecordingDataRef.current = cursorRecordingData; + resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); + resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }, [cursorRecordingData]); useEffect(() => { @@ -1311,7 +1321,10 @@ const VideoPlayback = forwardRef( } if (nativeCursorImage) { nativeCursorImage.style.display = "none"; + nativeCursorImage.style.filter = "none"; } + resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); + resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }; if (nativeCursorImage) { if (hasNativeCursorRecordingRef.current && showCursorRef.current) { @@ -1321,13 +1334,20 @@ const VideoPlayback = forwardRef( timeMs, ); if (frame) { + const displaySample = smoothNativeCursorSample({ + forceSnap: !isPlayingRef.current || isSeekingRef.current, + sample: frame.sample, + smoothing: cursorSmoothingRef.current, + state: nativeCursorSmoothingStateRef.current, + timeMs, + }); const cameraContainer = cameraContainerRef.current; const videoContainer = videoContainerRef.current; const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; const projectedLocalPoint = projectNativeCursorToLocal({ cropRegion: cropRegionValue, maskRect: baseMaskRef.current, - sample: frame.sample, + sample: displaySample, }); const projectedStagePoint = cameraContainer && videoContainer @@ -1339,17 +1359,32 @@ const VideoPlayback = forwardRef( x: videoContainer.x, y: videoContainer.y, }, - sample: frame.sample, + sample: displaySample, }) : null; if (projectedLocalPoint && projectedStagePoint) { const renderAsset = resolveNativeCursorRenderAsset( frame.asset, window.devicePixelRatio || 1, - frame.sample, + displaySample, ); - const scale = Math.max(0, cursorSizeRef.current); + const bounceProgress = getNativeCursorClickBounceProgress( + cursorRecordingDataRef.current, + timeMs, + ); + const scale = + Math.max(0, cursorSizeRef.current) * + getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress); const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1); + const blurPx = + !isPlayingRef.current || isSeekingRef.current + ? 0 + : getNativeCursorMotionBlurPx({ + motionBlur: cursorMotionBlurRef.current, + point: projectedStagePoint, + state: nativeCursorMotionBlurStateRef.current, + timeMs, + }); if (nativeCursorImageIdRef.current !== renderAsset.id) { nativeCursorImage.src = renderAsset.imageDataUrl; nativeCursorImageIdRef.current = renderAsset.id; @@ -1357,6 +1392,8 @@ const VideoPlayback = forwardRef( nativeCursorImage.style.display = "block"; nativeCursorImage.style.width = `${renderAsset.width * transformedScale}px`; nativeCursorImage.style.height = `${renderAsset.height * transformedScale}px`; + nativeCursorImage.style.filter = + blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none"; nativeCursorImage.style.transform = `translate3d(${ projectedStagePoint.x - renderAsset.hotspotX * transformedScale }px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`; diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index 6f82e0b..0291b15 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -28,6 +28,20 @@ export interface ActiveNativeCursorFrame { sample: CursorRecordingSample; } +export interface NativeCursorSmoothingState { + cx: number; + cy: number; + lastTimeMs: number | null; + initialized: boolean; +} + +export interface NativeCursorMotionBlurState { + x: number; + y: number; + lastTimeMs: number | null; + initialized: boolean; +} + interface ProjectNativeCursorOptions { cropRegion: CropRegion; maskRect: { x: number; y: number; width: number; height: number }; @@ -43,6 +57,9 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +const NATIVE_CURSOR_CLICK_ANIMATION_MS = 140; +const NATIVE_CURSOR_MOTION_BLUR_MAX_PX = 6; + interface PrettyNativeCursorAsset { imageDataUrl: string; width: number; @@ -167,17 +184,20 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial 64 || asset.height < 24 || asset.height > 64) { + if ( + asset.cursorType || + asset.width < 24 || + asset.width > 64 || + asset.height < 24 || + asset.height > 64 + ) { return null; } const hotspotXNorm = asset.hotspotX / asset.width; const hotspotYNorm = asset.hotspotY / asset.height; const looksLikeChromiumGrabCursor = - hotspotXNorm >= 0.22 && - hotspotXNorm <= 0.55 && - hotspotYNorm >= 0.2 && - hotspotYNorm <= 0.45; + hotspotXNorm >= 0.22 && hotspotXNorm <= 0.55 && hotspotYNorm >= 0.2 && hotspotYNorm <= 0.45; return looksLikeChromiumGrabCursor ? (PRETTY_NATIVE_CURSOR_ASSETS["open-hand"] ?? null) : null; } @@ -193,6 +213,155 @@ export function hasNativeCursorRecordingData( ); } +export function createNativeCursorSmoothingState(): NativeCursorSmoothingState { + return { + cx: 0, + cy: 0, + lastTimeMs: null, + initialized: false, + }; +} + +export function resetNativeCursorSmoothingState(state: NativeCursorSmoothingState) { + state.cx = 0; + state.cy = 0; + state.lastTimeMs = null; + state.initialized = false; +} + +export function createNativeCursorMotionBlurState(): NativeCursorMotionBlurState { + return { + x: 0, + y: 0, + lastTimeMs: null, + initialized: false, + }; +} + +export function resetNativeCursorMotionBlurState(state: NativeCursorMotionBlurState) { + state.x = 0; + state.y = 0; + state.lastTimeMs = null; + state.initialized = false; +} + +export function smoothNativeCursorSample({ + forceSnap = false, + sample, + smoothing, + state, + timeMs, +}: { + forceSnap?: boolean; + sample: CursorRecordingSample; + smoothing: number; + state: NativeCursorSmoothingState; + timeMs: number; +}): CursorRecordingSample { + const clampedSmoothing = clamp(Number.isFinite(smoothing) ? smoothing : 0, 0, 0.98); + const previousTimeMs = state.lastTimeMs; + const shouldSnap = + forceSnap || + clampedSmoothing <= 0 || + !state.initialized || + previousTimeMs === null || + timeMs <= previousTimeMs; + + if (shouldSnap) { + state.cx = sample.cx; + state.cy = sample.cy; + state.lastTimeMs = timeMs; + state.initialized = true; + return sample; + } + + const frameCount = Math.max(1, (timeMs - previousTimeMs) / (1000 / 60)); + const alpha = 1 - Math.pow(clampedSmoothing, frameCount); + state.cx += (sample.cx - state.cx) * alpha; + state.cy += (sample.cy - state.cy) * alpha; + state.lastTimeMs = timeMs; + + return { + ...sample, + cx: state.cx, + cy: state.cy, + }; +} + +export function getNativeCursorClickBounceProgress( + recordingData: CursorRecordingData | null | undefined, + timeMs: number, +) { + if (!hasNativeCursorRecordingData(recordingData)) { + return 0; + } + + for (let index = recordingData.samples.length - 1; index >= 0; index -= 1) { + const sample = recordingData.samples[index]; + if (sample.timeMs > timeMs) { + continue; + } + + const ageMs = timeMs - sample.timeMs; + if (ageMs > NATIVE_CURSOR_CLICK_ANIMATION_MS) { + return 0; + } + + if (sample.interactionType === "click") { + return 1 - ageMs / NATIVE_CURSOR_CLICK_ANIMATION_MS; + } + } + + return 0; +} + +export function getNativeCursorClickBounceScale(clickBounce: number, progress: number) { + if (progress <= 0 || clickBounce <= 0) { + return 1; + } + + const bounceAmount = Math.sin(progress * Math.PI); + const amplitude = clamp(clickBounce, 0, 4) * 0.08; + return Math.max(0.72, 1 - bounceAmount * amplitude); +} + +export function getNativeCursorMotionBlurPx({ + motionBlur, + point, + state, + timeMs, +}: { + motionBlur: number; + point: { x: number; y: number }; + state: NativeCursorMotionBlurState; + timeMs: number; +}) { + const clampedMotionBlur = clamp(Number.isFinite(motionBlur) ? motionBlur : 0, 0, 1); + const previousTimeMs = state.lastTimeMs; + const shouldSnap = + clampedMotionBlur <= 0 || + !state.initialized || + previousTimeMs === null || + timeMs <= previousTimeMs; + + if (shouldSnap) { + state.x = point.x; + state.y = point.y; + state.lastTimeMs = timeMs; + state.initialized = true; + return 0; + } + + const deltaMs = Math.max(1, timeMs - previousTimeMs); + const distance = Math.hypot(point.x - state.x, point.y - state.y); + const speedPxPerSecond = (distance / deltaMs) * 1000; + state.x = point.x; + state.y = point.y; + state.lastTimeMs = timeMs; + + return clamp(speedPxPerSecond * clampedMotionBlur * 0.004, 0, NATIVE_CURSOR_MOTION_BLUR_MAX_PX); +} + function getCroppedCursorPosition(sample: CursorRecordingSample, cropRegion: CropRegion) { if (cropRegion.width <= 0 || cropRegion.height <= 0) { return null; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index b525b62..7b17632 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -57,9 +57,17 @@ import { type StyledRenderRect, } from "@/lib/compositeLayout"; import { + createNativeCursorMotionBlurState, + createNativeCursorSmoothingState, + getNativeCursorClickBounceProgress, + getNativeCursorClickBounceScale, + getNativeCursorMotionBlurPx, projectNativeCursorToLocal, + resetNativeCursorMotionBlurState, + resetNativeCursorSmoothingState, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, + smoothNativeCursorSample, } from "@/lib/cursor/nativeCursor"; import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { drawCanvasClipPath } from "@/lib/webcamMaskShapes"; @@ -87,6 +95,9 @@ interface FrameRenderConfig { cropRegion: CropRegion; cursorRecordingData?: CursorRecordingData | null; cursorScale?: number; + cursorSmoothing?: number; + cursorMotionBlur?: number; + cursorClickBounce?: number; videoWidth: number; videoHeight: number; webcamSize?: Size | null; @@ -151,6 +162,8 @@ export class FrameRenderer { private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; private motionBlurState: MotionBlurState = createMotionBlurState(); + private nativeCursorSmoothingState = createNativeCursorSmoothingState(); + private nativeCursorMotionBlurState = createNativeCursorMotionBlurState(); private smoothedAutoFocus: { cx: number; cy: number } | null = null; private prevAnimationTimeMs: number | null = null; private prevTargetProgress = 0; @@ -561,6 +574,8 @@ export class FrameRenderer { } if ((this.config.cursorScale ?? 1) <= 0) { + resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); + resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } @@ -569,23 +584,28 @@ export class FrameRenderer { timeMs, ); if (!activeNativeCursor) { + resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); + resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } + const displaySample = smoothNativeCursorSample({ + sample: activeNativeCursor.sample, + smoothing: this.config.cursorSmoothing ?? 0, + state: this.nativeCursorSmoothingState, + timeMs, + }); const projectedPoint = projectNativeCursorToLocal({ cropRegion: this.config.cropRegion, maskRect: this.layoutCache.maskRect, - sample: activeNativeCursor.sample, + sample: displaySample, }); if (!projectedPoint) { + resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } - const renderAsset = resolveNativeCursorRenderAsset( - activeNativeCursor.asset, - 1, - activeNativeCursor.sample, - ); + const renderAsset = resolveNativeCursorRenderAsset(activeNativeCursor.asset, 1, displaySample); let image: HTMLImageElement; try { image = await this.getCursorImage(renderAsset); @@ -593,10 +613,25 @@ export class FrameRenderer { this.warnOnce("native-cursor-image-load", "Failed to load native cursor asset", error); return; } - const scale = Math.max(0, this.config.cursorScale ?? 1); + const scale = + Math.max(0, this.config.cursorScale ?? 1) * + getNativeCursorClickBounceScale( + this.config.cursorClickBounce ?? 0, + getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs), + ); const appliedScale = this.animationState.appliedScale; const canvasX = projectedPoint.x * appliedScale + this.animationState.x; const canvasY = projectedPoint.y * appliedScale + this.animationState.y; + const blurPx = getNativeCursorMotionBlurPx({ + motionBlur: this.config.cursorMotionBlur ?? 0, + point: { x: canvasX, y: canvasY }, + state: this.nativeCursorMotionBlurState, + timeMs, + }); + const previousFilter = this.foregroundCtx.filter; + if (blurPx > 0) { + this.foregroundCtx.filter = `blur(${blurPx.toFixed(2)}px)`; + } this.foregroundCtx.drawImage( image, canvasX - renderAsset.hotspotX * scale * appliedScale, @@ -604,6 +639,7 @@ export class FrameRenderer { renderAsset.width * scale * appliedScale, renderAsset.height * scale * appliedScale, ); + this.foregroundCtx.filter = previousFilter; } private async getCursorImage(asset: { id: string; imageDataUrl: string }) { diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index c1120af..6ff3f87 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -9,9 +9,9 @@ import type { ZoomRegion, } from "@/components/video-editor/types"; import { BackgroundLoadError } from "@/lib/wallpaper"; +import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; -import type { CursorRecordingData } from "@/native/contracts"; import { FrameRenderer } from "./frameRenderer"; import { StreamingVideoDecoder } from "./streamingDecoder"; import type { @@ -50,6 +50,9 @@ interface GifExporterConfig { webcamPosition?: { cx: number; cy: number } | null; cursorRecordingData?: CursorRecordingData | null; cursorScale?: number; + cursorSmoothing?: number; + cursorMotionBlur?: number; + cursorClickBounce?: number; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -156,6 +159,9 @@ export class GifExporter { cropRegion: this.config.cropRegion, cursorRecordingData: this.config.cursorRecordingData, cursorScale: this.config.cursorScale, + cursorSmoothing: this.config.cursorSmoothing, + cursorMotionBlur: this.config.cursorMotionBlur, + cursorClickBounce: this.config.cursorClickBounce, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index fb38611..19cd5a0 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -8,9 +8,9 @@ import type { ZoomRegion, } from "@/components/video-editor/types"; import { BackgroundLoadError } from "@/lib/wallpaper"; +import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; -import type { CursorRecordingData } from "@/native/contracts"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; import { VideoMuxer } from "./muxer"; @@ -41,6 +41,9 @@ interface VideoExporterConfig extends ExportConfig { webcamPosition?: { cx: number; cy: number } | null; cursorRecordingData?: CursorRecordingData | null; cursorScale?: number; + cursorSmoothing?: number; + cursorMotionBlur?: number; + cursorClickBounce?: number; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -151,6 +154,9 @@ export class VideoExporter { cropRegion: this.config.cropRegion, cursorRecordingData: this.config.cursorRecordingData, cursorScale: this.config.cursorScale, + cursorSmoothing: this.config.cursorSmoothing, + cursorMotionBlur: this.config.cursorMotionBlur, + cursorClickBounce: this.config.cursorClickBounce, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, diff --git a/src/native/contracts.ts b/src/native/contracts.ts index ef45336..6836095 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -31,6 +31,7 @@ export interface CursorRecordingSample extends CursorTelemetryPoint { assetId?: string | null; visible?: boolean; cursorType?: NativeCursorType | null; + interactionType?: "move" | "click" | "mouseup"; } export interface NativeCursorAsset {