Files
openscreen/src/lib/cursor/nativeCursor.ts
T
2026-05-10 15:11:27 +02:00

558 lines
13 KiB
TypeScript

import { type Container, Point } from "pixi.js";
import appStartingUrl from "@/assets/cursors/Cursor=App-Starting.svg";
import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg";
import arrowUrl from "@/assets/cursors/Cursor=Default.svg";
import closedHandUrl from "@/assets/cursors/Cursor=Hand-(Grabbing).svg";
import openHandUrl from "@/assets/cursors/Cursor=Hand-(Open).svg";
import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg";
import helpUrl from "@/assets/cursors/Cursor=Help.svg";
import moveUrl from "@/assets/cursors/Cursor=Move.svg";
import notAllowedUrl from "@/assets/cursors/Cursor=Not-Allowed.svg";
import resizeNeswUrl from "@/assets/cursors/Cursor=Resize-North-East-South-West.svg";
import resizeNsUrl from "@/assets/cursors/Cursor=Resize-North-South.svg";
import resizeNwseUrl from "@/assets/cursors/Cursor=Resize-North-West-South-East.svg";
import resizeEwUrl from "@/assets/cursors/Cursor=Resize-West-East.svg";
import textUrl from "@/assets/cursors/Cursor=Text-Cursor.svg";
import upArrowUrl from "@/assets/cursors/Cursor=Up-Arrow.svg";
import waitUrl from "@/assets/cursors/Cursor=Wait.svg";
import type { CropRegion } from "@/components/video-editor/types";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
NativeCursorType,
} from "@/native/contracts";
export interface ActiveNativeCursorFrame {
asset: NativeCursorAsset;
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 };
sample: CursorRecordingSample;
}
interface ProjectNativeCursorToStageOptions extends ProjectNativeCursorOptions {
cameraContainer: Container;
videoContainerPosition: { x: number; y: number };
}
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;
height: number;
hotspotX: number;
hotspotY: number;
}
const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNativeCursorAsset>> = {
arrow: {
imageDataUrl: arrowUrl,
width: 32,
height: 32,
hotspotX: 16.25,
hotspotY: 15.03,
},
text: {
imageDataUrl: textUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
pointer: {
imageDataUrl: pointerUrl,
width: 32,
height: 33,
hotspotX: 16.65,
hotspotY: 14.24,
},
crosshair: {
imageDataUrl: crosshairUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"open-hand": {
imageDataUrl: openHandUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 9,
},
"closed-hand": {
imageDataUrl: closedHandUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 9,
},
"resize-ew": {
imageDataUrl: resizeEwUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"resize-ns": {
imageDataUrl: resizeNsUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"resize-nesw": {
imageDataUrl: resizeNeswUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"resize-nwse": {
imageDataUrl: resizeNwseUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
move: {
imageDataUrl: moveUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"not-allowed": {
imageDataUrl: notAllowedUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
wait: {
imageDataUrl: waitUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"app-starting": {
imageDataUrl: appStartingUrl,
width: 32,
height: 32,
hotspotX: 7.25,
hotspotY: 4.03,
},
help: {
imageDataUrl: helpUrl,
width: 32,
height: 32,
hotspotX: 7.25,
hotspotY: 4.03,
},
"up-arrow": {
imageDataUrl: upArrowUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 3,
},
};
function resolveUntypedPrettyNativeCursorAsset(asset: NativeCursorAsset) {
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;
return looksLikeChromiumGrabCursor ? (PRETTY_NATIVE_CURSOR_ASSETS["open-hand"] ?? null) : null;
}
export function hasNativeCursorRecordingData(
recordingData: CursorRecordingData | null | undefined,
): recordingData is CursorRecordingData {
return Boolean(
recordingData &&
recordingData.provider === "native" &&
recordingData.samples.length > 0 &&
recordingData.assets.length > 0,
);
}
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;
}
const croppedCx = (sample.cx - cropRegion.x) / cropRegion.width;
const croppedCy = (sample.cy - cropRegion.y) / cropRegion.height;
if (croppedCx < 0 || croppedCx > 1 || croppedCy < 0 || croppedCy > 1) {
return null;
}
return {
cx: clamp(croppedCx, 0, 1),
cy: clamp(croppedCy, 0, 1),
};
}
function getNativeCursorMaskPoint(sample: CursorRecordingSample, cropRegion: CropRegion) {
const croppedPosition = getCroppedCursorPosition(sample, cropRegion);
if (!croppedPosition) {
return null;
}
return new Point(croppedPosition.cx, croppedPosition.cy);
}
export function resolveActiveNativeCursorFrame(
recordingData: CursorRecordingData | null | undefined,
timeMs: number,
): ActiveNativeCursorFrame | null {
if (!hasNativeCursorRecordingData(recordingData)) {
return null;
}
for (let index = recordingData.samples.length - 1; index >= 0; index -= 1) {
const sample = recordingData.samples[index];
if (sample.timeMs > timeMs) {
continue;
}
if (sample.visible === false || !sample.assetId) {
return null;
}
const asset = recordingData.assets.find((candidate) => candidate.id === sample.assetId);
if (!asset) {
return null;
}
return { sample, asset };
}
return null;
}
export function resolveInterpolatedNativeCursorFrame(
recordingData: CursorRecordingData | null | undefined,
timeMs: number,
): ActiveNativeCursorFrame | null {
if (!hasNativeCursorRecordingData(recordingData)) {
return null;
}
const samples = recordingData.samples;
let activeIndex = -1;
for (let index = samples.length - 1; index >= 0; index -= 1) {
if (samples[index].timeMs <= timeMs) {
activeIndex = index;
break;
}
}
if (activeIndex < 0) {
return null;
}
const activeSample = samples[activeIndex];
if (activeSample.visible === false || !activeSample.assetId) {
return null;
}
const asset = recordingData.assets.find((candidate) => candidate.id === activeSample.assetId);
if (!asset) {
return null;
}
const nextSample = samples[activeIndex + 1];
if (
!nextSample ||
nextSample.timeMs <= activeSample.timeMs ||
nextSample.visible === false ||
nextSample.assetId !== activeSample.assetId ||
timeMs <= activeSample.timeMs
) {
return { asset, sample: activeSample };
}
const interpolation = clamp(
(timeMs - activeSample.timeMs) / (nextSample.timeMs - activeSample.timeMs),
0,
1,
);
return {
asset,
sample: {
...activeSample,
cx: activeSample.cx + (nextSample.cx - activeSample.cx) * interpolation,
cy: activeSample.cy + (nextSample.cy - activeSample.cy) * interpolation,
},
};
}
export function projectNativeCursorToLocal({
cropRegion,
maskRect,
sample,
}: ProjectNativeCursorOptions) {
const maskPoint = getNativeCursorMaskPoint(sample, cropRegion);
if (!maskPoint) {
return null;
}
return new Point(
maskRect.x + maskPoint.x * maskRect.width,
maskRect.y + maskPoint.y * maskRect.height,
);
}
export function projectNativeCursorToStage({
cameraContainer,
videoContainerPosition,
...options
}: ProjectNativeCursorToStageOptions) {
const localPoint = projectNativeCursorToLocal(options);
if (!localPoint) {
return null;
}
return cameraContainer.toGlobal(
new Point(localPoint.x + videoContainerPosition.x, localPoint.y + videoContainerPosition.y),
);
}
export function getNativeCursorDisplayMetrics(asset: NativeCursorAsset, deviceScaleFactor: number) {
const scaleFactor = asset.scaleFactor ?? deviceScaleFactor ?? 1;
return {
width: asset.width / scaleFactor,
height: asset.height / scaleFactor,
hotspotX: asset.hotspotX / scaleFactor,
hotspotY: asset.hotspotY / scaleFactor,
};
}
export function resolvePrettyNativeCursorAsset(
asset: NativeCursorAsset,
sample?: CursorRecordingSample,
) {
const cursorType = sample?.cursorType ?? asset.cursorType ?? null;
return cursorType
? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null)
: resolveUntypedPrettyNativeCursorAsset(asset);
}
export function resolveNativeCursorRenderAsset(
asset: NativeCursorAsset,
deviceScaleFactor: number,
sample?: CursorRecordingSample,
) {
const prettyAsset = resolvePrettyNativeCursorAsset(asset, sample);
if (prettyAsset) {
return {
id: `pretty:${sample?.cursorType ?? asset.cursorType}`,
imageDataUrl: prettyAsset.imageDataUrl,
width: prettyAsset.width,
height: prettyAsset.height,
hotspotX: prettyAsset.hotspotX,
hotspotY: prettyAsset.hotspotY,
};
}
const metrics = getNativeCursorDisplayMetrics(asset, deviceScaleFactor);
return {
id: asset.id,
imageDataUrl: asset.imageDataUrl,
width: metrics.width,
height: metrics.height,
hotspotX: metrics.hotspotX,
hotspotY: metrics.hotspotY,
};
}