feat: add windows cursor preview diagnostics

This commit is contained in:
EtienneLescot
2026-05-05 10:16:01 +02:00
parent 28ff0fb7bf
commit bb0dec7344
13 changed files with 1713 additions and 29 deletions
+3
View File
@@ -171,6 +171,9 @@ See the documentation here:
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
Refresh if outdated.
Developer notes:
- [Windows native cursor test pipeline](docs/testing/windows-native-cursor.md)
## Contributing
Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
+85
View File
@@ -0,0 +1,85 @@
# Windows native cursor test pipeline
This branch includes two Windows-focused diagnostics for fast iteration on native cursor capture and rendering. They are intentionally local developer tools: they create short videos and JSON reports so cursor changes can be inspected without doing a full manual record/edit/export cycle.
## Native sampler diagnostic
```powershell
npm run test:cursor-native:win
```
This script does not launch OpenScreen. It:
- starts a Windows `GetCursorInfo` sampler
- moves the real OS pointer with `SetCursorPos`
- captures native cursor handles, hotspots, assets, and standard `IDC_*` cursor types
- writes normalized `CursorRecordingData`
- generates an abstract preview video
- generates a real-screen preview video using screenshots of the current desktop
The output directory is printed in the command result, for example:
```text
C:\Users\<user>\AppData\Local\Temp\openscreen-cursor-native-...
```
Useful files:
- `report.json`: sample counts, asset counts, cursor handles, and generated artifact paths
- `cursor-recording-data.json`: sidecar-compatible cursor data
- `preview.webm`: abstract path/asset/hotspot preview
- `real-capture-preview.webm`: real desktop screenshot background with reconstructed cursor overlay
- `assets/*.png`: raw cursor bitmaps captured from Windows
Environment overrides:
```powershell
$env:CURSOR_TEST_DURATION_MS = "3000"
$env:CURSOR_TEST_SAMPLE_INTERVAL_MS = "16"
$env:CURSOR_TEST_SCREEN_FRAME_INTERVAL_MS = "80"
$env:CURSOR_TEST_OUTPUT_DIR = "C:\temp\openscreen-cursor-test"
npm run test:cursor-native:win
```
## OpenScreen preview capture
```powershell
npm run capture:openscreen-preview
```
This script launches the real Electron app, injects a fixture video plus cursor sidecar data, opens the editor, captures frames from the actual OpenScreen preview UI, and encodes them into a WebM.
By default it uses the latest `cursor-recording-data.json` generated by `npm run test:cursor-native:win`. To force a specific sidecar:
```powershell
$env:CURSOR_RECORDING_DATA_PATH = "C:\path\to\cursor-recording-data.json"
npm run capture:openscreen-preview
```
Useful environment overrides:
```powershell
$env:OPENSCREEN_PREVIEW_SKIP_BUILD = "true"
$env:OPENSCREEN_PREVIEW_FRAME_COUNT = "120"
$env:OPENSCREEN_PREVIEW_FPS = "30"
$env:OPENSCREEN_PREVIEW_OUTPUT_DIR = "C:\temp\openscreen-preview"
npm run capture:openscreen-preview
```
Useful files:
- `openscreen-preview.webm`: video of the real OpenScreen editor preview
- `frames/*.png`: captured preview frames
- `report.json`: fixture paths, source sidecar, frame count, and output path
## What these tests validate
Together, the scripts make it quick to inspect:
- whether Windows cursor samples are visible and continuous
- whether native hotspots stay anchored when scaling to `3x`
- whether standard Windows cursors are recognized via `IDC_*`
- whether high-quality SVG cursor replacements follow the native hotspot
- whether the real OpenScreen preview renders the same cursor behavior as the diagnostic pipeline
They are not a full substitute for an end-to-end manual recording pass. Before shipping cursor changes, also test a real capture session and export from the packaged app.
+16
View File
@@ -267,6 +267,7 @@ function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
cy: typeof point.cy === "number" && Number.isFinite(point.cy) ? point.cy : 0.5,
assetId: typeof point.assetId === "string" ? point.assetId : null,
visible: typeof point.visible === "boolean" ? point.visible : true,
cursorType: typeof point.cursorType === "string" ? point.cursorType : null,
};
}
@@ -305,6 +306,7 @@ function normalizeCursorAsset(asset: unknown): NativeCursorAsset | null {
typeof candidate.scaleFactor === "number" && Number.isFinite(candidate.scaleFactor)
? Math.max(0.1, candidate.scaleFactor)
: undefined,
cursorType: typeof candidate.cursorType === "string" ? candidate.cursorType : null,
};
}
@@ -1079,6 +1081,20 @@ export function registerIpcHandlers(
return setCurrentVideoPath(path);
});
ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => {
const normalizedSession = normalizeRecordingSession(session);
setCurrentRecordingSessionState(normalizedSession);
currentVideoPath = normalizedSession?.screenVideoPath ?? null;
currentProjectPath = null;
return { success: true, session: currentRecordingSession };
});
ipcMain.handle("get-current-recording-session", () => {
return currentRecordingSession
? { success: true, session: currentRecordingSession }
: { success: false };
});
function setCurrentVideoPath(path: string): ProjectPathResult {
currentVideoPath = normalizeVideoSourcePath(path) ?? path;
currentProjectPath = null;
@@ -16,7 +16,7 @@ export function buildPowerShellCommand(sampleIntervalMs: number, windowHandle?:
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$targetWindowHandle = ${windowHandle ? `'${windowHandle}'` : '$null'}
$targetWindowHandle = ${windowHandle ? `'${windowHandle}'` : "$null"}
$source = @"
using System;
@@ -59,6 +59,9 @@ public static class OpenScreenCursorInterop {
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetCursorInfo(ref CURSORINFO pci);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
@@ -86,6 +89,37 @@ public static class OpenScreenCursorInterop {
Add-Type -TypeDefinition $source
$standardCursors = @{
arrow = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32512))
text = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32513))
wait = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32514))
crosshair = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32515))
'up-arrow' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32516))
'resize-nwse' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32642))
'resize-nesw' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32643))
'resize-ew' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32644))
'resize-ns' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32645))
move = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32646))
'not-allowed' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32648))
pointer = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32649))
'app-starting' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32650))
help = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32651))
}
function Get-StandardCursorType($cursorHandle) {
if ($cursorHandle -eq [IntPtr]::Zero) {
return $null
}
foreach ($entry in $standardCursors.GetEnumerator()) {
if ($entry.Value -eq $cursorHandle) {
return $entry.Key
}
}
return $null
}
function Write-JsonLine($payload) {
[Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6))
}
@@ -190,10 +224,14 @@ 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
$asset = $null
if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) {
$asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId
if ($asset -and $cursorType) {
$asset.cursorType = $cursorType
}
$lastCursorId = $cursorId
}
@@ -204,6 +242,7 @@ while ($true) {
y = $cursorInfo.ptScreenPos.Y
visible = $visible
handle = $cursorId
cursorType = $cursorType
bounds = Get-TargetBounds
asset = $asset
}
@@ -7,7 +7,10 @@ import type {
NativeCursorAsset,
} from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
import { buildPowerShellCommand, parseWindowHandleFromSourceId } from "./windowsNativeRecordingSession.script";
import {
buildPowerShellCommand,
parseWindowHandleFromSourceId,
} from "./windowsNativeRecordingSession.script";
import type {
WindowsCursorEvent,
WindowsNativeRecordingSessionOptions,
@@ -91,7 +94,9 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
assetCount: this.assets.size,
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
});
this.rejectReady(new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`));
this.rejectReady(
new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`),
);
});
child.once("error", (error) => {
this.logDiagnostic("process-error", { message: error.message });
@@ -168,6 +173,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
hotspotX: payload.asset.hotspotX,
hotspotY: payload.asset.hotspotY,
scaleFactor: assetDisplay.scaleFactor,
cursorType: payload.asset.cursorType ?? payload.cursorType ?? null,
});
this.logDiagnostic("asset", {
id: payload.asset.id,
@@ -192,13 +198,17 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
}
}
private normalizeSample(payload: Extract<WindowsCursorEvent, { type: "sample" }>): NormalizedSample {
const bounds = payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
private normalizeSample(
payload: Extract<WindowsCursorEvent, { type: "sample" }>,
): NormalizedSample {
const bounds =
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const normalizedX = (payload.x - bounds.x) / width;
const normalizedY = (payload.y - bounds.y) / height;
const withinBounds = normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
const withinBounds =
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) {
this.logDiagnostic("sample", {
@@ -221,6 +231,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
cy: normalizedY,
assetId: payload.handle,
visible: payload.visible && withinBounds,
cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null,
},
};
}
@@ -1,4 +1,5 @@
import type { Rectangle } from "electron";
import type { NativeCursorType } from "../../../../src/native/contracts";
export interface WindowsCursorSampleEvent {
type: "sample";
@@ -7,6 +8,7 @@ export interface WindowsCursorSampleEvent {
y: number;
visible: boolean;
handle: string | null;
cursorType?: NativeCursorType | null;
bounds?: {
x: number;
y: number;
@@ -34,6 +36,7 @@ export interface WindowsCursorAssetPayload {
height: number;
hotspotX: number;
hotspotY: number;
cursorType?: NativeCursorType | null;
}
export type WindowsCursorEvent =
+7 -5
View File
@@ -19,12 +19,14 @@
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
+262
View File
@@ -0,0 +1,262 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, _electron as electron } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const MAIN_JS = path.join(ROOT, "dist-electron", "main.js");
const TEST_VIDEO = path.join(ROOT, "tests", "fixtures", "sample.webm");
const OUTPUT_DIR =
process.env.OPENSCREEN_PREVIEW_OUTPUT_DIR ??
path.join(os.tmpdir(), `openscreen-real-preview-${Date.now()}`);
const FRAME_COUNT = Number(process.env.OPENSCREEN_PREVIEW_FRAME_COUNT ?? 90);
const FPS = Number(process.env.OPENSCREEN_PREVIEW_FPS ?? 30);
function findLatestCursorRecordingData() {
const explicit = process.env.CURSOR_RECORDING_DATA_PATH;
if (explicit) {
if (!fs.existsSync(explicit)) {
throw new Error(`CURSOR_RECORDING_DATA_PATH does not exist: ${explicit}`);
}
return explicit;
}
const tempDir = os.tmpdir();
const candidates = fs
.readdirSync(tempDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openscreen-cursor-native-"))
.map((entry) => path.join(tempDir, entry.name, "cursor-recording-data.json"))
.filter((candidate) => fs.existsSync(candidate))
.map((candidate) => ({ path: candidate, mtimeMs: fs.statSync(candidate).mtimeMs }))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (!candidates[0]) {
throw new Error(
"No cursor-recording-data.json found. Run npm run test:cursor-native:win first.",
);
}
return candidates[0].path;
}
function findPlaywrightChromiumExecutable(defaultPath) {
if (fs.existsSync(defaultPath)) {
return defaultPath;
}
const baseDir = path.join(process.env.LOCALAPPDATA ?? "", "ms-playwright");
if (!baseDir || !fs.existsSync(baseDir)) {
return defaultPath;
}
const candidates = fs
.readdirSync(baseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("chromium-"))
.map((entry) => path.join(baseDir, entry.name, "chrome-win64", "chrome.exe"))
.filter((candidate) => fs.existsSync(candidate))
.sort()
.reverse();
return candidates[0] ?? defaultPath;
}
function ensureBuildExists() {
if (!fs.existsSync(MAIN_JS)) {
throw new Error(`Missing ${MAIN_JS}. Run npm run build-vite first.`);
}
if (!fs.existsSync(path.join(ROOT, "dist", "index.html"))) {
throw new Error(`Missing renderer build. Run npm run build-vite first.`);
}
}
function runNpmBuildViteIfRequested() {
if (process.env.OPENSCREEN_PREVIEW_SKIP_BUILD === "true") {
ensureBuildExists();
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const child = spawn("cmd.exe", ["/d", "/s", "/c", "npm run build-vite"], {
cwd: ROOT,
stdio: "inherit",
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`npm run build-vite failed with code ${code}`));
});
});
}
async function encodeFramesToWebm(framePaths, outputPath) {
const frameData = framePaths.map((framePath) => ({
src: `data:image/png;base64,${fs.readFileSync(framePath).toString("base64")}`,
}));
const html = `<!doctype html>
<html>
<body>
<canvas id="canvas" width="1280" height="800"></canvas>
<script>
const frames = ${JSON.stringify(frameData)};
const fps = ${FPS};
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function load(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.src = src;
});
}
window.__encode = async function() {
const images = [];
for (const frame of frames) images.push(await load(frame.src));
canvas.width = images[0].naturalWidth;
canvas.height = images[0].naturalHeight;
const stream = canvas.captureStream(fps);
const recorder = new MediaRecorder(stream, { mimeType: "video/webm;codecs=vp9" });
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
const done = new Promise((resolve) => {
recorder.onstop = resolve;
});
recorder.start();
for (const image of images) {
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
await new Promise((resolve) => setTimeout(resolve, 1000 / fps));
}
recorder.stop();
await done;
const blob = new Blob(chunks, { type: "video/webm" });
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
};
</script>
</body>
</html>`;
const browser = await chromium.launch({
executablePath: findPlaywrightChromiumExecutable(chromium.executablePath()),
headless: true,
});
try {
const page = await browser.newPage();
await page.setContent(html);
const base64 = await page.evaluate(() => window.__encode());
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
} finally {
await browser.close();
}
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const cursorRecordingDataPath = findLatestCursorRecordingData();
const fixtureVideoPath = path.join(OUTPUT_DIR, "openscreen-preview-fixture.webm");
const outputVideoPath = path.join(OUTPUT_DIR, "openscreen-preview.webm");
fs.copyFileSync(TEST_VIDEO, fixtureVideoPath);
fs.copyFileSync(cursorRecordingDataPath, `${fixtureVideoPath}.cursor.json`);
await runNpmBuildViteIfRequested();
const app = await electron.launch({
args: [MAIN_JS, "--no-sandbox", "--enable-unsafe-swiftshader"],
env: {
...process.env,
HEADLESS: "false",
},
});
app.process().stdout?.on("data", (data) => process.stdout.write(`[electron] ${data}`));
app.process().stderr?.on("data", (data) => process.stderr.write(`[electron] ${data}`));
const framesDir = path.join(OUTPUT_DIR, "frames");
fs.mkdirSync(framesDir, { recursive: true });
try {
const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded");
await hudWindow.evaluate(async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
await window.electronAPI.getCurrentRecordingSession();
await window.electronAPI.getCurrentVideoPath();
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error("Timed out waiting for OpenScreen IPC handlers.");
});
try {
await hudWindow.evaluate(async (videoPath) => {
await window.electronAPI.setCurrentVideoPath(videoPath);
await window.electronAPI.switchToEditor();
}, fixtureVideoPath);
} catch {
// switchToEditor closes the HUD page before the evaluate promise can always resolve.
}
const editorWindow = await app.waitForEvent("window", {
predicate: (window) => window.url().includes("windowType=editor"),
timeout: 30_000,
});
await editorWindow.waitForLoadState("domcontentloaded");
await editorWindow.waitForSelector("video", { state: "attached", timeout: 30_000 });
await editorWindow.waitForSelector("canvas", { state: "attached", timeout: 30_000 });
await editorWindow.waitForSelector('img[aria-hidden="true"]', {
state: "attached",
timeout: 30_000,
});
await editorWindow.setViewportSize({ width: 1280, height: 800 });
await editorWindow.evaluate(async () => {
await document.fonts.ready;
for (const video of [...document.querySelectorAll("video")]) {
video.muted = true;
video.currentTime = 0;
video.dispatchEvent(new Event("timeupdate"));
}
});
await editorWindow.waitForTimeout(1000);
const framePaths = [];
for (let index = 0; index < FRAME_COUNT; index += 1) {
const timeSec = index / FPS;
await editorWindow.evaluate((time) => {
for (const video of [...document.querySelectorAll("video")]) {
video.currentTime = Math.min(time, Math.max(0, video.duration || time));
video.dispatchEvent(new Event("timeupdate"));
}
}, timeSec);
await editorWindow.waitForTimeout(40);
const framePath = path.join(framesDir, `frame-${String(index).padStart(4, "0")}.png`);
await editorWindow.screenshot({ path: framePath });
framePaths.push(framePath);
}
await encodeFramesToWebm(framePaths, outputVideoPath);
const report = {
outputDir: OUTPUT_DIR,
sourceCursorRecordingDataPath: cursorRecordingDataPath,
fixtureVideoPath,
outputVideoPath,
frameCount: framePaths.length,
fps: FPS,
};
fs.writeFileSync(path.join(OUTPUT_DIR, "report.json"), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
} finally {
await app.close();
}
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -26,10 +26,10 @@ import {
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import {
getNativeCursorDisplayMetrics,
hasNativeCursorRecordingData,
projectNativeCursorToStage,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
} from "@/lib/cursor/nativeCursor";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
@@ -1324,19 +1324,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
sample: frame.sample,
});
if (projectedPoint) {
const metrics = getNativeCursorDisplayMetrics(
const renderAsset = resolveNativeCursorRenderAsset(
frame.asset,
window.devicePixelRatio || 1,
frame.sample,
);
const scale = Math.max(0, cursorSizeRef.current);
if (nativeCursorImg.dataset.cursorId !== frame.asset.id) {
nativeCursorImg.src = frame.asset.imageDataUrl;
nativeCursorImg.dataset.cursorId = frame.asset.id;
if (nativeCursorImg.dataset.cursorId !== renderAsset.id) {
nativeCursorImg.src = renderAsset.imageDataUrl;
nativeCursorImg.dataset.cursorId = renderAsset.id;
}
nativeCursorImg.style.left = `${projectedPoint.x - metrics.hotspotX * scale}px`;
nativeCursorImg.style.top = `${projectedPoint.y - metrics.hotspotY * scale}px`;
nativeCursorImg.style.width = `${metrics.width * scale}px`;
nativeCursorImg.style.height = `${metrics.height * scale}px`;
nativeCursorImg.style.left = `${projectedPoint.x - renderAsset.hotspotX * scale}px`;
nativeCursorImg.style.top = `${projectedPoint.y - renderAsset.hotspotY * scale}px`;
nativeCursorImg.style.width = `${renderAsset.width * scale}px`;
nativeCursorImg.style.height = `${renderAsset.height * scale}px`;
nativeCursorImg.style.display = "block";
} else {
nativeCursorImg.style.display = "none";
+128
View File
@@ -1,9 +1,20 @@
import { type Container, Point } from "pixi.js";
import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg";
import arrowUrl from "@/assets/cursors/Cursor=Default.svg";
import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg";
import notAllowedUrl from "@/assets/cursors/Cursor=Menu.svg";
import moveUrl from "@/assets/cursors/Cursor=Move.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 type { CropRegion } from "@/components/video-editor/types";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
NativeCursorType,
} from "@/native/contracts";
export interface ActiveNativeCursorFrame {
@@ -23,6 +34,87 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
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: 5.8,
hotspotY: 3.2,
},
text: {
imageDataUrl: textUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
pointer: {
imageDataUrl: pointerUrl,
width: 32,
height: 32,
hotspotX: 11.8,
hotspotY: 2.6,
},
crosshair: {
imageDataUrl: crosshairUrl,
width: 32,
height: 32,
hotspotX: 16,
hotspotY: 16,
},
"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,
},
};
export function hasNativeCursorRecordingData(
recordingData: CursorRecordingData | null | undefined,
): recordingData is CursorRecordingData {
@@ -169,3 +261,39 @@ export function getNativeCursorDisplayMetrics(asset: NativeCursorAsset, deviceSc
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) : null;
}
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,
};
}
+13 -9
View File
@@ -57,13 +57,13 @@ import {
type StyledRenderRect,
} from "@/lib/compositeLayout";
import {
getNativeCursorDisplayMetrics,
projectNativeCursorToStage,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
} from "@/lib/cursor/nativeCursor";
import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import type { CursorRecordingData, NativeCursorAsset } from "@/native/contracts";
import type { CursorRecordingData } from "@/native/contracts";
import { renderAnnotations } from "./annotationRenderer";
import {
getLinearGradientPoints,
@@ -585,19 +585,23 @@ export class FrameRenderer {
return;
}
const image = await this.getCursorImage(activeNativeCursor.asset);
const metrics = getNativeCursorDisplayMetrics(activeNativeCursor.asset, 1);
const renderAsset = resolveNativeCursorRenderAsset(
activeNativeCursor.asset,
1,
activeNativeCursor.sample,
);
const image = await this.getCursorImage(renderAsset);
const scale = Math.max(0, this.config.cursorScale ?? 1);
this.compositeCtx.drawImage(
image,
projectedPoint.x - metrics.hotspotX * scale,
projectedPoint.y - metrics.hotspotY * scale,
metrics.width * scale,
metrics.height * scale,
projectedPoint.x - renderAsset.hotspotX * scale,
projectedPoint.y - renderAsset.hotspotY * scale,
renderAsset.width * scale,
renderAsset.height * scale,
);
}
private async getCursorImage(asset: NativeCursorAsset) {
private async getCursorImage(asset: { id: string; imageDataUrl: string }) {
const cachedImage = this.cursorImageCache.get(asset.id);
if (cachedImage) {
return cachedImage;
+17
View File
@@ -3,6 +3,21 @@ export const NATIVE_BRIDGE_VERSION = 1;
export type NativePlatform = "darwin" | "win32" | "linux";
export type CursorProviderKind = "native" | "none";
export type NativeCursorType =
| "arrow"
| "text"
| "pointer"
| "crosshair"
| "resize-ew"
| "resize-ns"
| "resize-nesw"
| "resize-nwse"
| "move"
| "not-allowed"
| "wait"
| "app-starting"
| "help"
| "up-arrow";
export interface CursorTelemetryPoint {
timeMs: number;
@@ -13,6 +28,7 @@ export interface CursorTelemetryPoint {
export interface CursorRecordingSample extends CursorTelemetryPoint {
assetId?: string | null;
visible?: boolean;
cursorType?: NativeCursorType | null;
}
export interface NativeCursorAsset {
@@ -24,6 +40,7 @@ export interface NativeCursorAsset {
hotspotX: number;
hotspotY: number;
scaleFactor?: number;
cursorType?: NativeCursorType | null;
}
export interface CursorRecordingData {