feat: apply native cursor visual effects
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface WindowsCursorSampleEvent {
|
||||
visible: boolean;
|
||||
handle: string | null;
|
||||
cursorType?: NativeCursorType | null;
|
||||
leftButtonDown?: boolean;
|
||||
bounds?: {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
cursorRecordingDataRef.current = cursorRecordingData;
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
}, [cursorRecordingData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1311,7 +1321,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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)`;
|
||||
|
||||
@@ -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<Record<NativeCursorType, PrettyNative
|
||||
};
|
||||
|
||||
function resolveUntypedPrettyNativeCursorAsset(asset: NativeCursorAsset) {
|
||||
if (asset.cursorType || asset.width < 24 || asset.width > 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;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface CursorRecordingSample extends CursorTelemetryPoint {
|
||||
assetId?: string | null;
|
||||
visible?: boolean;
|
||||
cursorType?: NativeCursorType | null;
|
||||
interactionType?: "move" | "click" | "mouseup";
|
||||
}
|
||||
|
||||
export interface NativeCursorAsset {
|
||||
|
||||
Reference in New Issue
Block a user