feat(cursor): clip native cursor to camera-aware video bounds in preview and export

- Add nativeCursorClipRef div (outside preserve-3d) with CSS inset() clip-path that
  tracks the camera-transformed video boundary, including border-radius
- Add cameraAwareMaskRect() in FrameRenderer that computes the same boundary for
  Canvas 2D clip in the export path; remove stage-clamping so rounded corners match
  the preview's inset() behavior when zoom/pan pushes the mask off-stage
- Cache maskBorderRadius in LayoutCache so both shadow and direct composite paths
  can apply camera-aware rounded clipping
- Fix double mask.x offset introduced by nativeCursorMaskRef; replace mask div with
  clip-path on the outer wrapper
- Normalize cursor size relative to maskRect.width so preview and export scale match
- Clip cursor to canvas boundary and hide on non-recorded display
- Wire cursorClipToBounds flag through FrameRenderConfig and VideoExporter
This commit is contained in:
auberginewly
2026-05-18 17:34:11 +08:00
committed by Etienne Lescot
parent 31e394fe1c
commit 65bb5bc8dd
9 changed files with 278 additions and 87 deletions
@@ -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 (<THRESHOLD samples) are handled by
// clip-path — the cursor clips to the canvas edge instead of snapping invisible.
// Sustained exits (≥THRESHOLD samples, ≈100ms) mark visible=false to prevent
// ghost cursors and motion trails from multi-display movement.
if (isOutsideDisplay) {
this.consecutiveOutsideSamples++;
} else {
this.consecutiveOutsideSamples = 0;
}
const visible =
this.consecutiveOutsideSamples < MacNativeCursorRecordingSession.OUTSIDE_HIDE_THRESHOLD;
const interactionType =
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
? "click"
@@ -337,7 +355,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
timeMs: Math.max(0, timestampMs - this.startTimeMs),
cx: clamp(normalizedX, 0, 1),
cy: clamp(normalizedY, 0, 1),
visible: true,
visible,
interactionType,
...(cursorType ? { cursorType } : {}),
});
+83 -63
View File
@@ -315,6 +315,8 @@ interface SettingsPanelProps {
onCursorMotionBlurChange?: (blur: number) => 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 && (
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">Show Cursor</div>
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.show")}
</div>
<Switch
checked={showCursor}
onCheckedChange={onShowCursorChange}
@@ -1411,78 +1417,92 @@ export function SettingsPanel({
/>
</div>
{showCursor && (
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Size</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorSize.toFixed(1)}
</span>
<>
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.clipToBounds")}
</div>
<Slider
value={[cursorSize]}
onValueChange={(values) => 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"
<Switch
checked={cursorClipToBounds}
onCheckedChange={onCursorClipToBoundsChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
Smoothing
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.size")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorSize.toFixed(1)}
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorSmoothing * 100)}%
</span>
<Slider
value={[cursorSize]}
onValueChange={(values) => 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"
/>
</div>
<Slider
value={[cursorSmoothing]}
onValueChange={(values) => 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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
Motion Blur
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.smoothing")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorSmoothing * 100)}%
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorMotionBlur * 100)}%
</span>
<Slider
value={[cursorSmoothing]}
onValueChange={(values) => 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"
/>
</div>
<Slider
value={[cursorMotionBlur]}
onValueChange={(values) => 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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
Click Bounce
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.motionBlur")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorMotionBlur * 100)}%
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorClickBounce.toFixed(1)}
</span>
<Slider
value={[cursorMotionBlur]}
onValueChange={(values) => 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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.clickBounce")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorClickBounce.toFixed(1)}
</span>
</div>
<Slider
value={[cursorClickBounce]}
onValueChange={(values) => 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"
/>
</div>
<Slider
value={[cursorClickBounce]}
onValueChange={(values) => 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"
/>
</div>
</div>
</>
)}
</div>
)}
@@ -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<NativePlatform | null>(null);
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
useState<CursorCaptureMode | null>(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}
/>
</div>
</div>
@@ -2266,6 +2272,8 @@ export default function VideoEditor() {
onCursorMotionBlurChange={setCursorMotionBlur}
cursorClickBounce={cursorClickBounce}
onCursorClickBounceChange={setCursorClickBounce}
cursorClipToBounds={cursorClipToBounds}
onCursorClipToBoundsChange={setCursorClipToBounds}
hasCursorData={
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
}
+66 -18
View File
@@ -148,6 +148,7 @@ interface VideoPlaybackProps {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClipToBounds?: boolean;
}
export interface VideoPlaybackRef {
@@ -268,6 +269,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
cursorClipToBounds = true,
},
ref,
) => {
@@ -338,6 +340,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cursorSmoothingRef = useRef(cursorSmoothing);
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClipToBoundsRef = useRef(cursorClipToBounds);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
@@ -356,6 +359,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const nativeCursorImageIdRef = useRef<string | null>(null);
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
const nativeCursorClipRef = useRef<HTMLDivElement | null>(null);
const borderRadiusRef = useRef<number>(0);
const hasNativeCursorRecording = useMemo(
() => hasNativeCursorRecordingData(cursorRecordingData),
@@ -553,6 +558,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
})
: 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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
<img
ref={nativeCursorImageRef}
alt=""
aria-hidden="true"
className="absolute left-0 top-0 select-none"
style={{
display: "none",
pointerEvents: "none",
transformOrigin: "0 0",
zIndex: 18,
}}
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
@@ -2006,6 +2033,27 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
</div>
)}
</div>
{/* 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. */}
<div
ref={nativeCursorClipRef}
className="absolute inset-0"
style={{ zIndex: 18, pointerEvents: "none" }}
>
<img
ref={nativeCursorImageRef}
alt=""
aria-hidden="true"
className="absolute left-0 top-0 select-none"
style={{
display: "none",
pointerEvents: "none",
transformOrigin: "0 0",
}}
/>
</div>
<video
ref={videoRef}
src={videoPath}
+3
View File
@@ -194,6 +194,9 @@ export const DEFAULT_CURSOR_SIZE = 3.0;
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
// true = clip the native cursor to the video canvas bounds (current behavior);
// false = allow the cursor to overflow into the background.
export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = true;
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
export interface TrimRegion {
@@ -32,6 +32,7 @@ interface LayoutResult {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: RenderRect;
maskBorderRadius: number;
webcamRect: StyledRenderRect | null;
cropBounds: { startX: number; endX: number; startY: number; endY: number };
}
@@ -150,6 +151,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: compositeLayout.screenRect,
maskBorderRadius:
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
webcamRect: compositeLayout.webcamRect,
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
+92 -5
View File
@@ -92,6 +92,7 @@ interface FrameRenderConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClipToBounds?: boolean;
videoWidth: number;
videoHeight: number;
webcamSize?: Size | null;
@@ -124,6 +125,7 @@ interface LayoutCache {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
maskBorderRadius: number;
webcamRect: StyledRenderRect | null;
}
@@ -520,6 +522,28 @@ export class FrameRenderer {
}
}
// The video's actual on-screen boundary, accounting for the zoom camera
// transform. The PIXI mask lives inside cameraContainer, so during zoom the
// visible video extends beyond the static maskRect — a static clip would crop
// it. Mirrors the preview, which clips via the same camera-scaled bounds.
private cameraAwareMaskRect() {
if (!this.layoutCache) return null;
const { x: maskX, y: maskY, width: maskW, height: maskH } = this.layoutCache.maskRect;
const camS = this.animationState.appliedScale;
const camX = this.animationState.x;
const camY = this.animationState.y;
// No stage clamping: canvas naturally clips to its bounds, matching CSS inset() behavior.
// Clamping x/y would shift rounded corners to the stage edge rather than the true mask
// boundary, causing preview/export mismatch when zoom/pan pushes the mask off-stage.
return {
x: camX + camS * maskX,
y: camY + camS * maskY,
width: camS * maskW,
height: camS * maskH,
br: this.layoutCache.maskBorderRadius * camS,
};
}
private async drawNativeCursor(timeMs: number) {
if (!this.foregroundCtx || !this.layoutCache) {
return;
@@ -573,6 +597,12 @@ export class FrameRenderer {
getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs),
);
const appliedScale = this.animationState.appliedScale;
// Normalize cursor size so it appears at the same fraction of the video width
// as in the preview — both paths now use maskRect.width / croppedVideoWidth.
const sizeNorm =
this.layoutCache.videoSize.width > 0
? this.layoutCache.maskRect.width / this.layoutCache.videoSize.width
: 1;
const canvasX = projectedPoint.x * appliedScale + this.animationState.x;
const canvasY = projectedPoint.y * appliedScale + this.animationState.y;
const blurPx = getNativeCursorMotionBlurPx({
@@ -581,18 +611,34 @@ export class FrameRenderer {
state: this.nativeCursorMotionBlurState,
timeMs,
});
// Clip cursor to the actual visible video boundary, accounting for zoom.
// Skip when cursorClipToBounds is off so the cursor can overflow the canvas.
const cursorClip = this.config.cursorClipToBounds === false ? null : this.cameraAwareMaskRect();
this.foregroundCtx.save();
this.foregroundCtx.beginPath();
if (cursorClip) {
this.foregroundCtx.roundRect(
cursorClip.x,
cursorClip.y,
cursorClip.width,
cursorClip.height,
cursorClip.br,
);
this.foregroundCtx.clip();
}
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,
canvasY - renderAsset.hotspotY * scale * appliedScale,
renderAsset.width * scale * appliedScale,
renderAsset.height * scale * appliedScale,
canvasX - renderAsset.hotspotX * scale * appliedScale * sizeNorm,
canvasY - renderAsset.hotspotY * scale * appliedScale * sizeNorm,
renderAsset.width * scale * appliedScale * sizeNorm,
renderAsset.height * scale * appliedScale * sizeNorm,
);
this.foregroundCtx.filter = previousFilter;
this.foregroundCtx.restore();
}
private async getCursorImage(asset: { id: string; imageDataUrl: string }) {
@@ -717,6 +763,7 @@ export class FrameRenderer {
y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY,
},
maskRect: compositeLayout.screenRect,
maskBorderRadius: scaledBorderRadius,
webcamRect: compositeLayout.webcamRect,
};
}
@@ -980,8 +1027,48 @@ export class FrameRenderer {
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h);
// Erase square corners left by PIXI WebGL alpha, then redraw video with explicit
// 2D clip so shadow extends beyond the rounded area but video is precisely clipped.
// The clip is camera-aware so zoom doesn't crop the magnified video.
const shadowClip =
(this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null;
if (shadowClip) {
const { x: smx, y: smy, width: smw, height: smh, br: sbr } = shadowClip;
fgCtx.save();
fgCtx.globalCompositeOperation = "destination-out";
fgCtx.beginPath();
fgCtx.rect(smx, smy, smw, smh);
fgCtx.roundRect(smx, smy, smw, smh, sbr);
fgCtx.fill("evenodd");
fgCtx.restore();
fgCtx.save();
fgCtx.beginPath();
fgCtx.roundRect(smx, smy, smw, smh, sbr);
fgCtx.clip();
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
fgCtx.restore();
}
} else {
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
// Direct path: explicit 2D clip guarantees rounded corners regardless of PIXI
// WebGL alpha. Camera-aware so zoom doesn't crop the magnified video.
const directClip =
(this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null;
if (directClip) {
fgCtx.save();
fgCtx.beginPath();
fgCtx.roundRect(
directClip.x,
directClip.y,
directClip.width,
directClip.height,
directClip.br,
);
fgCtx.clip();
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
fgCtx.restore();
} else {
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
}
}
const webcamRect = this.layoutCache?.webcamRect ?? null;
+2
View File
@@ -53,6 +53,7 @@ interface GifExporterConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClipToBounds?: boolean;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -161,6 +162,7 @@ export class GifExporter {
cursorSmoothing: this.config.cursorSmoothing,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClipToBounds: this.config.cursorClipToBounds,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
+2
View File
@@ -44,6 +44,7 @@ export interface VideoExporterConfig extends ExportConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClipToBounds?: boolean;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -241,6 +242,7 @@ export class VideoExporter {
cursorSmoothing: this.config.cursorSmoothing,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClipToBounds: this.config.cursorClipToBounds,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,