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> = { 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, }; }