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:
committed by
Etienne Lescot
parent
31e394fe1c
commit
65bb5bc8dd
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user