diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 4164cb0..95ed10c 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -178,6 +178,10 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { private readyReject: ((error: Error) => void) | null = null; private readyTimer: NodeJS.Timeout | null = null; private previousLeftButtonDown = false; + private consecutiveOutsideSamples = 0; + // Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval). + // Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing. + private static readonly OUTSIDE_HIDE_THRESHOLD = 3; constructor(private readonly options: MacNativeCursorRecordingSessionOptions) {} @@ -186,6 +190,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { this.lineBuffer = ""; this.startTimeMs = this.options.startTimeMs ?? Date.now(); this.previousLeftButtonDown = false; + this.consecutiveOutsideSamples = 0; try { systemPreferences.isTrustedAccessibilityClient(true); @@ -325,6 +330,19 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { const height = Math.max(1, bounds.height); const normalizedX = (cursor.x - bounds.x) / width; const normalizedY = (cursor.y - bounds.y) / height; + const isOutsideDisplay = + normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1; + // Fast swipes that briefly exit the display ( void; cursorClickBounce?: number; onCursorClickBounceChange?: (bounce: number) => void; + cursorClipToBounds?: boolean; + onCursorClipToBoundsChange?: (clip: boolean) => void; hasCursorData?: boolean; showCursorSettings?: boolean; } @@ -437,6 +439,8 @@ export function SettingsPanel({ onCursorMotionBlurChange, cursorClickBounce = 2.5, onCursorClickBounceChange, + cursorClipToBounds = true, + onCursorClipToBoundsChange, hasCursorData = false, showCursorSettings = true, }: SettingsPanelProps) { @@ -1403,7 +1407,9 @@ export function SettingsPanel({ {activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
-
Show Cursor
+
+ {t("cursor.show")} +
{showCursor && ( -
-
-
-
Size
- - {cursorSize.toFixed(1)} - + <> +
+
+ {t("cursor.clipToBounds")}
- onCursorSizeChange?.(values[0])} - min={0.5} - max={10} - step={0.1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" +
-
-
-
- Smoothing +
+
+
+
+ {t("cursor.size")} +
+ + {cursorSize.toFixed(1)} +
- - {Math.round(cursorSmoothing * 100)}% - + onCursorSizeChange?.(values[0])} + min={0.5} + max={10} + step={0.1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- onCursorSmoothingChange?.(values[0])} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- Motion Blur +
+
+
+ {t("cursor.smoothing")} +
+ + {Math.round(cursorSmoothing * 100)}% +
- - {Math.round(cursorMotionBlur * 100)}% - + onCursorSmoothingChange?.(values[0])} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- onCursorMotionBlurChange?.(values[0])} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- Click Bounce +
+
+
+ {t("cursor.motionBlur")} +
+ + {Math.round(cursorMotionBlur * 100)}% +
- - {cursorClickBounce.toFixed(1)} - + onCursorMotionBlurChange?.(values[0])} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("cursor.clickBounce")} +
+ + {cursorClickBounce.toFixed(1)} + +
+ onCursorClickBounceChange?.(values[0])} + min={0} + max={5} + step={0.1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- onCursorClickBounceChange?.(values[0])} - min={0} - max={5} - step={0.1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - />
-
+ )}
)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 17b340d..15d582d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -72,6 +72,7 @@ import { DEFAULT_ANNOTATION_STYLE, DEFAULT_BLUR_DATA, DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLIP_TO_BOUNDS, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, @@ -242,6 +243,7 @@ export default function VideoEditor() { const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); + const [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS); const [nativePlatform, setNativePlatform] = useState(null); const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = useState(null); @@ -1619,6 +1621,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorClipToBounds, annotationRegions, webcamLayoutPreset, webcamMaskShape, @@ -1709,6 +1712,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorClipToBounds, annotationRegions, webcamLayoutPreset, webcamMaskShape, @@ -1824,6 +1828,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorClipToBounds, t, ], ); @@ -2106,6 +2111,7 @@ export default function VideoEditor() { cursorSmoothing={cursorSmoothing} cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} + cursorClipToBounds={cursorClipToBounds} />
@@ -2266,6 +2272,8 @@ export default function VideoEditor() { onCursorMotionBlurChange={setCursorMotionBlur} cursorClickBounce={cursorClickBounce} onCursorClickBounceChange={setCursorClickBounce} + cursorClipToBounds={cursorClipToBounds} + onCursorClipToBoundsChange={setCursorClipToBounds} hasCursorData={ cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) } diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 391d015..6a4f2f3 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -148,6 +148,7 @@ interface VideoPlaybackProps { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorClipToBounds?: boolean; } export interface VideoPlaybackRef { @@ -268,6 +269,7 @@ const VideoPlayback = forwardRef( cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, + cursorClipToBounds = true, }, ref, ) => { @@ -338,6 +340,7 @@ const VideoPlayback = forwardRef( const cursorSmoothingRef = useRef(cursorSmoothing); const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); + const cursorClipToBoundsRef = useRef(cursorClipToBounds); const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); @@ -356,6 +359,8 @@ const VideoPlayback = forwardRef( const nativeCursorImageIdRef = useRef(null); const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState()); const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState()); + const nativeCursorClipRef = useRef(null); + const borderRadiusRef = useRef(0); const hasNativeCursorRecording = useMemo( () => hasNativeCursorRecordingData(cursorRecordingData), @@ -553,6 +558,7 @@ const VideoPlayback = forwardRef( baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; + borderRadiusRef.current = result.maskBorderRadius; cropBoundsRef.current = result.cropBounds; setWebcamLayout(result.webcamRect); @@ -822,6 +828,10 @@ const VideoPlayback = forwardRef( cursorClickBounceRef.current = cursorClickBounce; }, [cursorClickBounce]); + useEffect(() => { + cursorClipToBoundsRef.current = cursorClipToBounds; + }, [cursorClipToBounds]); + // Sync cursor overlay config when settings change useEffect(() => { const overlay = cursorOverlayRef.current; @@ -1481,6 +1491,9 @@ const VideoPlayback = forwardRef( nativeCursorImage.style.display = "none"; nativeCursorImage.style.filter = "none"; } + if (nativeCursorClipRef.current) { + nativeCursorClipRef.current.style.clipPath = ""; + } resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }; @@ -1521,11 +1534,9 @@ const VideoPlayback = forwardRef( }) : null; if (projectedLocalPoint && projectedStagePoint) { - const renderAsset = resolveNativeCursorRenderAsset( - frame.asset, - window.devicePixelRatio || 1, - displaySample, - ); + // Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR. + // Size is normalized below so preview matches export proportionally. + const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample); const bounceProgress = getNativeCursorClickBounceProgress( cursorRecordingDataRef.current, timeMs, @@ -1533,7 +1544,13 @@ const VideoPlayback = forwardRef( const scale = Math.max(0, cursorSizeRef.current) * getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress); - const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1); + // Normalize cursor size to the displayed video width so the cursor + // appears at the same fraction of the video in both preview and export. + const crop = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; + const croppedVideoWidth = (videoRef.current?.videoWidth ?? 0) * crop.width; + const sizeNorm = + croppedVideoWidth > 0 ? baseMaskRef.current.width / croppedVideoWidth : 1; + const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1) * sizeNorm; const blurPx = !isPlayingRef.current || isSeekingRef.current ? 0 @@ -1548,10 +1565,32 @@ const VideoPlayback = forwardRef( nativeCursorImageIdRef.current = renderAsset.id; } nativeCursorImage.style.display = "block"; + // Update clip-path on nativeCursorClipRef to the camera-aware video boundary. + // clip-path works correctly here because nativeCursorClipRef is outside preserve-3d. + // When cursorClipToBounds is off, allow the cursor to overflow the canvas. + if (nativeCursorClipRef.current) { + if (!cursorClipToBoundsRef.current) { + nativeCursorClipRef.current.style.clipPath = "none"; + } else { + const mask = baseMaskRef.current; + const stage = stageSizeRef.current; + const br = borderRadiusRef.current; + const s = cameraContainer ? Math.abs(cameraContainer.scale.x) : 1; + const camX = cameraContainer ? cameraContainer.position.x : 0; + const camY = cameraContainer ? cameraContainer.position.y : 0; + const clipLeft = camX + s * mask.x; + const clipTop = camY + s * mask.y; + const clipRight = camX + s * (mask.x + mask.width); + const clipBottom = camY + s * (mask.y + mask.height); + nativeCursorClipRef.current.style.clipPath = `inset(${clipTop}px ${stage.width - clipRight}px ${stage.height - clipBottom}px ${clipLeft}px round ${br * s}px)`; + } + } 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"; + // translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin). + // projectedStagePoint.x is the stage-space cursor position — no offset needed. nativeCursorImage.style.transform = `translate3d(${ projectedStagePoint.x - renderAsset.hotspotX * transformedScale }px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`; @@ -1813,18 +1852,6 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && (() => { const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); @@ -2006,6 +2033,27 @@ const VideoPlayback = forwardRef(
)}
+ {/* Clip the native cursor overlay to the exact video canvas boundary. + Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works + correctly even during 3D zoom rotation regions. + clip-path is set dynamically to the camera-aware video bounds. */} +
+ +