feat: add windows cursor preview diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user