Merge pull request #217 from EtienneLescot/feat/cursor-pipeline
feat: add Windows native capture and cursor pipeline
@@ -1,45 +1,52 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Native helper build outputs
|
||||
/electron/native/wgc-capture/build/
|
||||
/electron/native/bin/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
@@ -0,0 +1,39 @@
|
||||
# Native Bridge Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified.
|
||||
|
||||
## Layers
|
||||
|
||||
1. Native adapters
|
||||
Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery.
|
||||
|
||||
2. Main-process services
|
||||
Services orchestrate adapters, own runtime state, and expose domain-level operations.
|
||||
|
||||
3. Unified IPC transport
|
||||
Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts.
|
||||
|
||||
4. Renderer client
|
||||
React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs.
|
||||
|
||||
## Principles
|
||||
|
||||
- Single source of truth: runtime-native state lives in the Electron main process.
|
||||
- Capability-first: renderer can query support before attempting native behavior.
|
||||
- Versioned contracts: requests and responses are explicit and evolve predictably.
|
||||
- Resilience: every response uses a consistent result envelope with stable error codes.
|
||||
|
||||
## Current rollout
|
||||
|
||||
This repository now contains the initial scaffold:
|
||||
|
||||
- shared contracts in `src/native/contracts.ts`
|
||||
- renderer SDK in `src/native/client.ts`
|
||||
- main-process state store in `electron/native-bridge/store.ts`
|
||||
- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts`
|
||||
- domain services in `electron/native-bridge/services/*`
|
||||
- unified handler registration in `electron/ipc/nativeBridge.ts`
|
||||
|
||||
The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Windows Native Recorder Roadmap
|
||||
|
||||
OpenScreen's Windows recorder should be owned by one native backend. Electron capture can remain available for non-Windows platforms and temporary developer diagnostics, but Windows production recording should not silently fall back to `getDisplayMedia` / `MediaRecorder`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Capture displays and windows through Windows Graphics Capture (WGC).
|
||||
- Render the native Windows cursor as OpenScreen's high-quality scalable cursor overlay.
|
||||
- Capture system audio through WASAPI loopback.
|
||||
- Capture microphone audio through WASAPI.
|
||||
- Mix system audio and microphone audio into the primary screen recording.
|
||||
- Capture webcam video natively and compose it into the Windows helper MP4 during the native-recording migration.
|
||||
- Keep preview/export aligned because screen video, audio, webcam, and cursor share one native timing origin.
|
||||
- Keep exported MP4s Windows-friendly: H.264 video plus AAC audio. Opus-in-MP4 is not an acceptable Windows export target.
|
||||
- Package the native helper with the Windows app.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the editor/export pipeline.
|
||||
- Replacing the editor/export pipeline. A later pass can reintroduce a separate editable native `webcamVideoPath`; the current Windows-native milestone prioritizes a helper-owned multi-flux MP4 with deterministic screen/audio/mic/webcam sync.
|
||||
- Adding a native fallback for macOS or Linux in this branch.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The renderer keeps the existing recording controls. On Windows, `useScreenRecorder` sends a complete recording request to Electron and does not assemble Windows `MediaStream` tracks with `MediaRecorder`.
|
||||
|
||||
Electron owns the native recording session:
|
||||
|
||||
- resolves the selected source;
|
||||
- resolves output paths;
|
||||
- starts cursor sampling;
|
||||
- starts the helper process;
|
||||
- sends pause/resume/stop/cancel commands;
|
||||
- writes `RecordingSession` manifests;
|
||||
- reports explicit errors when a Windows-native capability is unavailable.
|
||||
|
||||
The helper owns Windows media capture:
|
||||
|
||||
- WGC screen/window frames;
|
||||
- WASAPI system loopback;
|
||||
- WASAPI microphone input;
|
||||
- Media Foundation webcam capture;
|
||||
- DirectShow webcam fallback for virtual cameras not visible to Media Foundation;
|
||||
- Media Foundation encoding/muxing;
|
||||
- stream timestamp normalization.
|
||||
|
||||
## Helper Contract V2
|
||||
|
||||
The helper receives a single JSON argument:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"recordingId": 1234567890,
|
||||
"source": {
|
||||
"type": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 123,
|
||||
"windowHandle": null,
|
||||
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
|
||||
},
|
||||
"video": {
|
||||
"fps": 60,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"bitrate": 18000000
|
||||
},
|
||||
"audio": {
|
||||
"system": { "enabled": true },
|
||||
"microphone": { "enabled": true, "deviceId": "default", "gain": 1.4 }
|
||||
},
|
||||
"webcam": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "Camera (NVIDIA Broadcast)",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30,
|
||||
"bitrate": 18000000
|
||||
},
|
||||
"outputs": {
|
||||
"screenPath": "C:\\Users\\me\\recording-123.mp4",
|
||||
"manifestPath": "C:\\Users\\me\\recording-123.session.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The helper emits newline-delimited JSON events to stdout:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "schemaVersion": 2 }
|
||||
{ "event": "recording-started", "timestampMs": 1234567890 }
|
||||
{ "event": "warning", "code": "audio-device-unavailable", "message": "..." }
|
||||
{ "event": "recording-stopped", "screenPath": "..." }
|
||||
{ "event": "error", "code": "unsupported-window-source", "message": "..." }
|
||||
```
|
||||
|
||||
During migration, Electron also accepts the current textual helper messages so existing display-only smoke tests keep working.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Native Session Boundary
|
||||
|
||||
- Add a structured Windows native recording request type.
|
||||
- Pass source kind, audio flags, microphone device, webcam flags, and output paths into the helper.
|
||||
- On Windows, do not silently fall back to Electron capture. If the helper is unavailable or a native feature is missing, show a clear error.
|
||||
- Keep Electron fallback only for non-Windows and optional developer diagnostics.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Display-only recording still works.
|
||||
- Enabling an unsupported native feature returns an explicit native error instead of recording through Electron.
|
||||
|
||||
### 2. WASAPI System Audio
|
||||
|
||||
Status: initial implementation landed. The helper captures the default render endpoint with WASAPI loopback, passes the runtime mix format into `MFEncoder`, and muxes AAC audio into the primary MP4. Long-run drift correction and explicit silence insertion remain follow-up hardening work.
|
||||
|
||||
- Add `WasapiLoopbackCapture`.
|
||||
- Capture the default render endpoint in shared loopback mode.
|
||||
- Keep `WasapiLoopbackCapture` responsible only for device activation, packet capture, and packet timestamps.
|
||||
- Keep `MFEncoder` responsible for all Media Foundation stream definitions and muxing.
|
||||
- Feed the endpoint mix format into `MFEncoder` as the single source of truth for audio stream shape: sample rate, channel count, bits per sample, block alignment, average bytes/sec, and subtype (`PCM` or `Float`).
|
||||
- Encode the primary screen MP4 with H.264 video and AAC audio through one `IMFSinkWriter`.
|
||||
- Timestamp audio from the captured frame count in 100ns units. The first implementation uses the WASAPI packet timeline; later drift correction will add explicit silence or resampling if long recordings show measurable clock skew.
|
||||
- Treat microphone mixing as a later phase. System loopback must land first without introducing renderer-side audio code.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Screen MP4 has an AAC audio track when system audio is enabled.
|
||||
- A 5-minute recording has audio/video duration drift below one frame.
|
||||
|
||||
SSOT rules for this phase:
|
||||
|
||||
- `src/lib/nativeWindowsRecording.ts` is the renderer/main TypeScript request contract.
|
||||
- `docs/engineering/windows-native-recorder-roadmap.md` is the feature-level contract and phase checklist.
|
||||
- `WgcSession::captureWidth()/captureHeight()` is the encoded screen frame size until a dedicated native scaling stage exists.
|
||||
- `WasapiLoopbackCapture::inputFormat()` is the runtime audio format source used by `MFEncoder`.
|
||||
- The renderer passes both the browser webcam `deviceId` and selected display label as `deviceName`; `electron/native/wgc-capture/src/webcam_capture.*` is the only place that maps those values to Media Foundation devices.
|
||||
- Electron resolves the selected label to a DirectShow filter CLSID once and passes it as `webcamDirectShowClsid`; the helper must not independently guess among DirectShow filters.
|
||||
- No duplicated hard-coded audio format assumptions in `main.cpp`.
|
||||
|
||||
### 3. WASAPI Microphone
|
||||
|
||||
Status: initial implementation in progress. The helper can open the default WASAPI capture endpoint, apply the OpenScreen microphone gain, encode mic-only audio, and mix system loopback plus microphone through a single queued `AudioMixer` timeline when both endpoints expose the same runtime format. Audio endpoints are warmed before WGC starts, the mixer drops pre-roll and begins its paced timeline on the first encoded video frame, then cuts queued tail audio on stop so the MP4 does not drift past the video. Browser `deviceId` to MMDevice id mapping, resampling between mismatched endpoint formats, and drift correction remain follow-up hardening work.
|
||||
|
||||
- Add microphone device enumeration and stable device-id mapping.
|
||||
- Capture selected/default microphone through WASAPI.
|
||||
- Apply OpenScreen's current mic gain policy.
|
||||
- Mix microphone and system audio before AAC encoding.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Mic-only, system-only, and mixed audio recordings produce a valid AAC track.
|
||||
- Device unplug/permission failure produces an explicit error or warning.
|
||||
|
||||
### 4. Webcam Capture
|
||||
|
||||
- Add Media Foundation webcam source reader.
|
||||
- Select requested dimensions/fps or the nearest format accepted by Media Foundation.
|
||||
- Convert webcam samples to BGRA and compose them into the primary helper MP4 as an initial bottom-right picture-in-picture overlay.
|
||||
- Ignore black webcam warmup frames and keep the overlay hidden until the first visible frame is available, so virtual cameras do not flash a black picture-in-picture rectangle at recording start.
|
||||
- Keep the helper process as the SSOT for screen/window, WASAPI system audio, microphone, webcam, and mux timing.
|
||||
- Match the requested webcam through Media Foundation friendly names first, then browser device ids/symbolic links, so UI selection remains stable across Chromium and Windows native device namespaces.
|
||||
- Use the Electron-resolved DirectShow CLSID when the selected virtual camera, for example NVIDIA Broadcast, is registered for DirectShow but absent from Media Foundation enumeration.
|
||||
- Later: promote the same webcam capture source to a separate editable native `webcamVideoPath` if product requirements need post-recording layout edits.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Native display/window recordings can include webcam without returning to Electron capture.
|
||||
- `npm run test:wgc-webcam:win` validates the helper path when a webcam is available and skips explicitly when no webcam device exists.
|
||||
- Combined webcam + system audio + microphone produces one MP4 with H.264 video and AAC audio.
|
||||
|
||||
### 5. Native Window Capture
|
||||
|
||||
Status: initial implementation in progress. Electron parses the `window:<HWND>:...` desktop source id through the shared native Windows recording contract and passes `windowHandle` to the helper. The helper resolves the `HWND`, validates it with `IsWindow`, and creates the WGC item with `CreateForWindow(HWND)`. Resize/minimize/move hardening and protected-window diagnostics remain follow-up work.
|
||||
|
||||
- Resolve Electron `window:*` selections to an `HWND`.
|
||||
- Use WGC `CreateForWindow(HWND)`.
|
||||
- Handle window close, minimize, resize, DPI scaling, and monitor moves.
|
||||
- Return clear errors for unsupported protected windows.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Capturing a normal app window works with cursor/audio/mic/webcam.
|
||||
- Window resize and movement do not corrupt the recording.
|
||||
|
||||
### 6. Runtime Controls
|
||||
|
||||
- Add pause/resume commands to the helper.
|
||||
- Add cancel command that removes partial screen/webcam outputs.
|
||||
- Keep restart as stop-discard-start from Electron until the helper supports a native restart event.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Pause/resume keeps preview duration coherent.
|
||||
- Cancel leaves no stale media/session/cursor files.
|
||||
|
||||
### 7. Test Pipeline
|
||||
|
||||
- `npm run test:wgc-helper:win`: display-only helper smoke test.
|
||||
- `npm run test:wgc-audio:win`: validates AAC track presence and duration.
|
||||
- `npm run test:wgc-window:win`: captures a fixture window by HWND.
|
||||
- `npm run test:wgc-webcam:win`: validates webcam output when a webcam is available, otherwise skips explicitly.
|
||||
- Packaging check: confirms the helper is in `app.asar.unpacked`.
|
||||
- Export check: exported MP4s generated from native recordings keep an AAC audio track when the source has audio.
|
||||
- `npm run test:wgc-mic:win`: validates default-microphone capture writes an AAC track when an input endpoint is available.
|
||||
- `npm run test:wgc-mixed-audio:win`: validates system loopback plus microphone writes one mixed AAC track when endpoint formats are compatible.
|
||||
|
||||
## Backlog
|
||||
|
||||
### Native Cursor Click Bounce Is Not Visibly Applied
|
||||
|
||||
Status: open. Do not treat Windows native cursor `Click Bounce` as shipped.
|
||||
|
||||
Problem:
|
||||
|
||||
- The cursor settings UI exposes `Size`, `Smoothing`, `Motion Blur`, and `Click Bounce`.
|
||||
- On Windows native cursor recordings, `Size`, `Smoothing`, and `Motion Blur` are visibly applied in preview/export.
|
||||
- `Click Bounce` still has no visible effect in manual packaged-app testing, even after adding click-related sample metadata.
|
||||
|
||||
What has already been tried:
|
||||
|
||||
- Added `interactionType: "click" | "mouseup" | "move"` to native cursor samples.
|
||||
- Added polling-based left-button state through `GetAsyncKeyState`.
|
||||
- Added the `GetAsyncKeyState` low-bit path to catch quick clicks between samples.
|
||||
- Added a PowerShell/C# `WH_MOUSE_LL` mouse hook experiment and launched the sampler through a temporary `.ps1` file to avoid Windows command-line length limits.
|
||||
- Updated `npm run test:cursor-native:win` so the diagnostic can observe a synthetic short click and emit `clickSampleCount`.
|
||||
|
||||
Current diagnosis:
|
||||
|
||||
- The diagnostic can observe synthetic click events, but this has not translated into a visible `Click Bounce` effect in the real packaged app.
|
||||
- The test currently proves that some click metadata can be recorded, not that the full OpenScreen record -> preview -> export path displays a bounce at the expected time.
|
||||
- The current native implementation may be animating from metadata that is not present in the real recording session, may be using the wrong timestamp origin, or may be applying a scale change too subtle to notice on the DOM/native cursor path.
|
||||
|
||||
Next investigation when resumed:
|
||||
|
||||
- Inspect the actual `.cursor.json`/session sidecar generated by a packaged-app manual recording and confirm whether real clicks produce `interactionType: "click"` at the right `timeMs`.
|
||||
- Add a targeted end-to-end fixture that records a known click, loads the generated project, and asserts the preview/export cursor scale changes across adjacent frames.
|
||||
- Compare the native DOM cursor path against the older `PixiCursorOverlay` click visual state and decide whether native cursor bounce should be a scale-only animation, an additional click ring, or a short explicit keyframe animation independent of sample cadence.
|
||||
- If event capture remains unreliable in the PowerShell sampler, move click events into a small native cursor helper instead of PowerShell/C# script injection.
|
||||
|
||||
## Ship Criteria
|
||||
|
||||
- Windows display capture works with cursor, system audio, microphone, and webcam.
|
||||
- Windows window capture works with cursor, system audio, microphone, and webcam.
|
||||
- Preview and export show no cursor position drift.
|
||||
- Preview and export show no measurable audio/video/webcam drift.
|
||||
- Windows production builds do not depend on Electron capture fallback.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
|
||||
## Known Gap
|
||||
|
||||
Windows native cursor `Click Bounce` is currently backlogged. `Size`, `Smoothing`, and `Motion Blur` can be validated through preview/export, but `Click Bounce` has not shown a visible effect in packaged-app manual testing. The current diagnostic can observe synthetic click metadata, but that is not enough to validate the real OpenScreen record -> preview -> export path.
|
||||
|
||||
Track the open item in `docs/engineering/windows-native-recorder-roadmap.md` under `Native Cursor Click Bounce Is Not Visibly Applied`.
|
||||
|
||||
## Native Windows capture backend
|
||||
|
||||
The app now routes Windows recordings through an external WGC helper instead of Electron `getDisplayMedia`. This is meant to remove the coordinate and clock split that made the reconstructed cursor drift in the preview/export path.
|
||||
|
||||
Current native availability rules:
|
||||
|
||||
- Windows 10 build 19041 or newer
|
||||
- a helper executable is available
|
||||
|
||||
The helper currently implements display/window video capture, system audio loopback, default microphone capture, Media Foundation webcam capture, and DirectShow fallback for selected virtual cameras such as NVIDIA Broadcast. Webcam frames are composed into the primary MP4 as a bottom-right picture-in-picture overlay, and black webcam warmup frames are ignored until the first visible frame is available.
|
||||
|
||||
Build OpenScreen's helper locally:
|
||||
|
||||
```powershell
|
||||
npm run build:native:win
|
||||
```
|
||||
|
||||
Smoke-test the helper directly:
|
||||
|
||||
```powershell
|
||||
npm run test:wgc-helper:win
|
||||
npm run test:wgc-window:win
|
||||
npm run test:wgc-audio:win
|
||||
npm run test:wgc-mic:win
|
||||
npm run test:wgc-mixed-audio:win
|
||||
npm run test:wgc-webcam:win
|
||||
```
|
||||
|
||||
For local diagnostics with another compatible helper, point OpenScreen at that executable:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_CAPTURE_EXE = "C:\path\to\wgc-capture.exe"
|
||||
npm run build-vite
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The helper receives one JSON config argument, emits JSON lifecycle events, prints the legacy `Recording started` marker, accepts `stop` on stdin, and prints `Recording stopped. Output path: <path>`. See `electron/native/README.md` for the exact contract and build output paths.
|
||||
@@ -5,7 +5,6 @@
|
||||
"asar": true,
|
||||
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
|
||||
"asarUnpack": [
|
||||
"node_modules/uiohook-napi/**/*",
|
||||
"**/*.node"
|
||||
],
|
||||
"productName": "Openscreen",
|
||||
@@ -69,7 +68,14 @@
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
"icon": "icons/icons/win/icon.ico",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/native/bin",
|
||||
"to": "electron/native/bin",
|
||||
"filter": ["**/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -24,6 +24,9 @@ declare namespace NodeJS {
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
invokeNativeBridge: <TData = unknown>(
|
||||
request: import("../src/native/contracts").NativeBridgeRequest,
|
||||
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
@@ -37,11 +40,6 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
@@ -68,7 +66,29 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
|
||||
setRecordingState: (
|
||||
recording: boolean,
|
||||
recordingId?: number,
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
||||
) => Promise<void>;
|
||||
isNativeWindowsCaptureAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
available: boolean;
|
||||
helperPath?: string;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeWindowsRecording: (
|
||||
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
||||
stopNativeWindowsRecording: (discard?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ipcMain } from "electron";
|
||||
import {
|
||||
NATIVE_BRIDGE_CHANNEL,
|
||||
NATIVE_BRIDGE_VERSION,
|
||||
type NativeBridgeErrorCode,
|
||||
type NativeBridgeRequest,
|
||||
type NativeBridgeResponse,
|
||||
type NativePlatform,
|
||||
type ProjectFileResult,
|
||||
type ProjectPathResult,
|
||||
} from "../../src/native/contracts";
|
||||
import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter";
|
||||
import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter";
|
||||
import { CursorService } from "../native-bridge/services/cursorService";
|
||||
import { ProjectService } from "../native-bridge/services/projectService";
|
||||
import { SystemService } from "../native-bridge/services/systemService";
|
||||
import { NativeBridgeStateStore } from "../native-bridge/store";
|
||||
|
||||
export interface NativeBridgeContext {
|
||||
getPlatform: () => NodeJS.Platform;
|
||||
getCurrentProjectPath: () => string | null;
|
||||
getCurrentVideoPath: () => string | null;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
resolveAssetBasePath: () => string | null;
|
||||
resolveVideoPath: (videoPath?: string | null) => string | null;
|
||||
loadCursorRecordingData: (
|
||||
videoPath: string,
|
||||
) => Promise<import("../../src/native/contracts").CursorRecordingData>;
|
||||
loadCursorTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
|
||||
function normalizePlatform(platform: NodeJS.Platform): NativePlatform {
|
||||
if (platform === "darwin" || platform === "win32") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
function createMeta(requestId?: string) {
|
||||
return {
|
||||
version: NATIVE_BRIDGE_VERSION,
|
||||
requestId: requestId || `native-${Date.now()}`,
|
||||
timestampMs: Date.now(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createSuccessResponse<TData>(requestId: string | undefined, data: TData) {
|
||||
return {
|
||||
ok: true,
|
||||
data,
|
||||
meta: createMeta(requestId),
|
||||
} satisfies NativeBridgeResponse<TData>;
|
||||
}
|
||||
|
||||
function createErrorResponse(
|
||||
requestId: string | undefined,
|
||||
code: NativeBridgeErrorCode,
|
||||
message: string,
|
||||
retryable = false,
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
},
|
||||
meta: createMeta(requestId),
|
||||
} satisfies NativeBridgeResponse;
|
||||
}
|
||||
|
||||
function isBridgeRequest(value: unknown): value is NativeBridgeRequest {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<NativeBridgeRequest>;
|
||||
return typeof candidate.domain === "string" && typeof candidate.action === "string";
|
||||
}
|
||||
|
||||
export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL);
|
||||
|
||||
const platform = normalizePlatform(context.getPlatform());
|
||||
const store = new NativeBridgeStateStore(platform);
|
||||
const projectService = new ProjectService({
|
||||
store,
|
||||
getCurrentProjectPath: context.getCurrentProjectPath,
|
||||
getCurrentVideoPath: context.getCurrentVideoPath,
|
||||
saveProjectFile: context.saveProjectFile,
|
||||
loadProjectFile: context.loadProjectFile,
|
||||
loadCurrentProjectFile: context.loadCurrentProjectFile,
|
||||
setCurrentVideoPath: context.setCurrentVideoPath,
|
||||
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath: context.clearCurrentVideoPath,
|
||||
});
|
||||
const cursorService = new CursorService({
|
||||
store,
|
||||
adapter: new TelemetryCursorAdapter({
|
||||
loadRecordingData: context.loadCursorRecordingData,
|
||||
resolveVideoPath: context.resolveVideoPath,
|
||||
loadTelemetry: context.loadCursorTelemetry,
|
||||
}),
|
||||
});
|
||||
const systemService = new SystemService({
|
||||
store,
|
||||
getPlatform: () => platform,
|
||||
getAssetBasePath: context.resolveAssetBasePath,
|
||||
getCursorCapabilities: () => cursorService.getCapabilities(),
|
||||
});
|
||||
|
||||
ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (_, request: unknown) => {
|
||||
if (!isBridgeRequest(request)) {
|
||||
return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request.");
|
||||
}
|
||||
|
||||
const requestId = request.requestId;
|
||||
const domain = request.domain as string;
|
||||
|
||||
try {
|
||||
switch (request.domain) {
|
||||
case "system": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getPlatform":
|
||||
return createSuccessResponse(requestId, systemService.getPlatform());
|
||||
case "getAssetBasePath":
|
||||
return createSuccessResponse(requestId, systemService.getAssetBasePath());
|
||||
case "getCapabilities":
|
||||
return createSuccessResponse(requestId, await systemService.getCapabilities());
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported system action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case "project": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getCurrentContext":
|
||||
return createSuccessResponse(requestId, projectService.getCurrentContext());
|
||||
case "saveProjectFile":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.saveProjectFile(
|
||||
request.payload.projectData,
|
||||
request.payload.suggestedName,
|
||||
request.payload.existingProjectPath,
|
||||
),
|
||||
);
|
||||
case "loadProjectFile":
|
||||
return createSuccessResponse(requestId, await projectService.loadProjectFile());
|
||||
case "loadCurrentProjectFile":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadCurrentProjectFile(),
|
||||
);
|
||||
case "setCurrentVideoPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.setCurrentVideoPath(request.payload.path),
|
||||
);
|
||||
case "getCurrentVideoPath":
|
||||
return createSuccessResponse(requestId, projectService.getCurrentVideoPath());
|
||||
case "clearCurrentVideoPath":
|
||||
return createSuccessResponse(requestId, projectService.clearCurrentVideoPath());
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported project action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case "cursor": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getCapabilities":
|
||||
return createSuccessResponse(requestId, await cursorService.getCapabilities());
|
||||
case "getTelemetry":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await cursorService.getTelemetry(request.payload?.videoPath),
|
||||
);
|
||||
case "getRecordingData":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await cursorService.getRecordingData(request.payload?.videoPath),
|
||||
);
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported cursor action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported bridge domain: ${domain}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"INTERNAL_ERROR",
|
||||
error instanceof Error ? error.message : "Unknown native bridge error.",
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -490,7 +490,9 @@ app.whenReady().then(async () => {
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {
|
||||
// This only triggers the system prompt; permission state is read separately.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
CursorProviderKind,
|
||||
CursorRecordingData,
|
||||
CursorTelemetryPoint,
|
||||
} from "../../../src/native/contracts";
|
||||
|
||||
export interface CursorTelemetryLoadResult {
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CursorNativeAdapter {
|
||||
readonly kind: CursorProviderKind;
|
||||
getCapabilities(): Promise<CursorCapabilities>;
|
||||
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
|
||||
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import { TelemetryRecordingSession } from "./telemetryRecordingSession";
|
||||
import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession";
|
||||
|
||||
interface CreateCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
platform: NodeJS.Platform;
|
||||
sampleIntervalMs: number;
|
||||
sourceId?: string | null;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
export function createCursorRecordingSession(
|
||||
options: CreateCursorRecordingSessionOptions,
|
||||
): CursorRecordingSession {
|
||||
if (options.platform === "win32") {
|
||||
return new WindowsNativeRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
sourceId: options.sourceId,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
// macOS / Linux: capture cursor positions via Electron's `screen` API on an
|
||||
// interval. No cursor sprites/assets and no clicks — just position telemetry,
|
||||
// which is what auto-zoom and other features consume.
|
||||
return new TelemetryRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CursorRecordingData } from "../../../../src/native/contracts";
|
||||
|
||||
export interface CursorRecordingSession {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<CursorRecordingData>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type Rectangle, screen } from "electron";
|
||||
import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface TelemetryRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export class TelemetryRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
private startTimeMs = 0;
|
||||
|
||||
constructor(private readonly options: TelemetryRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.captureSample();
|
||||
this.interval = setInterval(() => {
|
||||
this.captureSample();
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: "none",
|
||||
samples: this.samples,
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
private captureSample() {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const display = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
|
||||
const width = Math.max(1, display.width);
|
||||
const height = Math.max(1, display.height);
|
||||
|
||||
this.samples.push({
|
||||
timeMs: Math.max(0, Date.now() - this.startTimeMs),
|
||||
cx: clamp((cursor.x - display.x) / width, 0, 1),
|
||||
cy: clamp((cursor.y - display.y) / height, 0, 1),
|
||||
visible: true,
|
||||
});
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
export function buildPowerShellScript(sampleIntervalMs: number, windowHandle?: string | null) {
|
||||
const targetWindowHandle =
|
||||
typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle)
|
||||
? `'${windowHandle}'`
|
||||
: "$null";
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$targetWindowHandle = ${targetWindowHandle}
|
||||
|
||||
$source = @"
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class OpenScreenCursorInterop {
|
||||
private const int WH_MOUSE_LL = 14;
|
||||
private const int WM_LBUTTONDOWN = 0x0201;
|
||||
private const int WM_LBUTTONUP = 0x0202;
|
||||
private static readonly object MouseSync = new object();
|
||||
private static int LeftDownCount = 0;
|
||||
private static int LeftUpCount = 0;
|
||||
private static IntPtr MouseHook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc MouseProcDelegate = MouseHookCallback;
|
||||
|
||||
public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
public struct MouseButtonEvents {
|
||||
public int LeftDownCount;
|
||||
public int LeftUpCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT {
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct CURSORINFO {
|
||||
public int cbSize;
|
||||
public int flags;
|
||||
public IntPtr hCursor;
|
||||
public POINT ptScreenPos;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct ICONINFO {
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fIcon;
|
||||
public int xHotspot;
|
||||
public int yHotspot;
|
||||
public IntPtr hbmMask;
|
||||
public IntPtr hbmColor;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
public static bool InstallMouseHook() {
|
||||
if (MouseHook != IntPtr.Zero) {
|
||||
return true;
|
||||
}
|
||||
|
||||
using (Process process = Process.GetCurrentProcess())
|
||||
using (ProcessModule module = process.MainModule) {
|
||||
MouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProcDelegate, GetModuleHandle(module.ModuleName), 0);
|
||||
}
|
||||
|
||||
return MouseHook != IntPtr.Zero;
|
||||
}
|
||||
|
||||
public static MouseButtonEvents ConsumeMouseButtonEvents() {
|
||||
lock (MouseSync) {
|
||||
MouseButtonEvents events = new MouseButtonEvents {
|
||||
LeftDownCount = LeftDownCount,
|
||||
LeftUpCount = LeftUpCount
|
||||
};
|
||||
LeftDownCount = 0;
|
||||
LeftUpCount = 0;
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
|
||||
if (nCode >= 0) {
|
||||
int message = wParam.ToInt32();
|
||||
if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) {
|
||||
lock (MouseSync) {
|
||||
if (message == WM_LBUTTONDOWN) {
|
||||
LeftDownCount += 1;
|
||||
} else {
|
||||
LeftUpCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(MouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[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);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr CopyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
|
||||
|
||||
[DllImport("gdi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool DeleteObject(IntPtr hObject);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
}
|
||||
"@
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) {
|
||||
if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or
|
||||
$hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$opaquePixels = 0
|
||||
$topHalfOpaquePixels = 0
|
||||
$left = $bitmap.Width
|
||||
$top = $bitmap.Height
|
||||
$right = -1
|
||||
$bottom = -1
|
||||
|
||||
for ($y = 0; $y -lt $bitmap.Height; $y++) {
|
||||
for ($x = 0; $x -lt $bitmap.Width; $x++) {
|
||||
if ($bitmap.GetPixel($x, $y).A -le 32) {
|
||||
continue
|
||||
}
|
||||
|
||||
$opaquePixels += 1
|
||||
if ($y -lt ($bitmap.Height / 2)) {
|
||||
$topHalfOpaquePixels += 1
|
||||
}
|
||||
if ($x -lt $left) { $left = $x }
|
||||
if ($x -gt $right) { $right = $x }
|
||||
if ($y -lt $top) { $top = $y }
|
||||
if ($y -gt $bottom) { $bottom = $y }
|
||||
}
|
||||
}
|
||||
|
||||
if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$opaqueWidth = $right - $left + 1
|
||||
$opaqueHeight = $bottom - $top + 1
|
||||
if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or
|
||||
$opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) {
|
||||
return 'closed-hand'
|
||||
}
|
||||
|
||||
return 'open-hand'
|
||||
}
|
||||
|
||||
function Get-TargetBounds() {
|
||||
if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$handleValue = [int64]::Parse($targetWindowHandle)
|
||||
$windowHandle = [IntPtr]::new($handleValue)
|
||||
if (-not [OpenScreenCursorInterop]::IsWindow($windowHandle)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$rect = New-Object OpenScreenCursorInterop+RECT
|
||||
if (-not [OpenScreenCursorInterop]::GetWindowRect($windowHandle, [ref]$rect)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return @{
|
||||
x = $rect.Left
|
||||
y = $rect.Top
|
||||
width = $width
|
||||
height = $height
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorAsset($cursorHandle, $cursorId) {
|
||||
$copiedHandle = [OpenScreenCursorInterop]::CopyIcon($cursorHandle)
|
||||
if ($copiedHandle -eq [IntPtr]::Zero) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$iconInfo = New-Object OpenScreenCursorInterop+ICONINFO
|
||||
$hasIconInfo = [OpenScreenCursorInterop]::GetIconInfo($copiedHandle, [ref]$iconInfo)
|
||||
|
||||
try {
|
||||
$icon = [System.Drawing.Icon]::FromHandle($copiedHandle)
|
||||
$bitmap = New-Object System.Drawing.Bitmap $icon.Width, $icon.Height, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$memoryStream = New-Object System.IO.MemoryStream
|
||||
|
||||
try {
|
||||
$graphics.Clear([System.Drawing.Color]::Transparent)
|
||||
$graphics.DrawIcon($icon, 0, 0)
|
||||
$hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 }
|
||||
$hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 }
|
||||
$customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY
|
||||
$bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$base64 = [System.Convert]::ToBase64String($memoryStream.ToArray())
|
||||
|
||||
return @{
|
||||
id = $cursorId
|
||||
imageDataUrl = "data:image/png;base64,$base64"
|
||||
width = $bitmap.Width
|
||||
height = $bitmap.Height
|
||||
hotspotX = $hotspotX
|
||||
hotspotY = $hotspotY
|
||||
cursorType = $customCursorType
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$memoryStream.Dispose()
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$icon.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($hasIconInfo) {
|
||||
if ($iconInfo.hbmMask -ne [IntPtr]::Zero) {
|
||||
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmMask) | Out-Null
|
||||
}
|
||||
if ($iconInfo.hbmColor -ne [IntPtr]::Zero) {
|
||||
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmColor) | Out-Null
|
||||
}
|
||||
}
|
||||
[OpenScreenCursorInterop]::DestroyIcon($copiedHandle) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
[OpenScreenCursorInterop]::InstallMouseHook() | Out-Null
|
||||
[OpenScreenCursorInterop]::GetAsyncKeyState(0x01) | Out-Null
|
||||
Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
|
||||
|
||||
$lastCursorId = $null
|
||||
while ($true) {
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
$mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents()
|
||||
$cursorInfo = New-Object OpenScreenCursorInterop+CURSORINFO
|
||||
$cursorInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][OpenScreenCursorInterop+CURSORINFO])
|
||||
|
||||
if (-not [OpenScreenCursorInterop]::GetCursorInfo([ref]$cursorInfo)) {
|
||||
Write-JsonLine @{ type = 'error'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); message = 'GetCursorInfo failed' }
|
||||
Start-Sleep -Milliseconds ${sampleIntervalMs}
|
||||
continue
|
||||
}
|
||||
|
||||
$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
|
||||
$leftButtonState = [OpenScreenCursorInterop]::GetAsyncKeyState(0x01)
|
||||
$leftButtonDown = ($leftButtonState -band 0x8000) -ne 0
|
||||
$leftButtonPressed = ($mouseEvents.LeftDownCount -gt 0) -or (($leftButtonState -band 0x0001) -ne 0)
|
||||
$leftButtonReleased = $mouseEvents.LeftUpCount -gt 0
|
||||
$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
|
||||
} elseif ($asset -and $asset.cursorType) {
|
||||
$cursorType = $asset.cursorType
|
||||
}
|
||||
$lastCursorId = $cursorId
|
||||
}
|
||||
|
||||
Write-JsonLine @{
|
||||
type = 'sample'
|
||||
timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
||||
x = $cursorInfo.ptScreenPos.X
|
||||
y = $cursorInfo.ptScreenPos.Y
|
||||
visible = $visible
|
||||
handle = $cursorId
|
||||
cursorType = $cursorType
|
||||
leftButtonDown = $leftButtonDown
|
||||
leftButtonPressed = $leftButtonPressed
|
||||
leftButtonReleased = $leftButtonReleased
|
||||
bounds = Get-TargetBounds
|
||||
asset = $asset
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds ${sampleIntervalMs}
|
||||
}
|
||||
`;
|
||||
|
||||
return script;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { screen } from "electron";
|
||||
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import { buildPowerShellScript } from "./windowsNativeRecordingSession.script";
|
||||
import type {
|
||||
WindowsCursorEvent,
|
||||
WindowsNativeRecordingSessionOptions,
|
||||
} from "./windowsNativeRecordingSession.types";
|
||||
|
||||
const READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface NormalizedSample {
|
||||
sample: CursorRecordingSample;
|
||||
withinBounds: boolean;
|
||||
}
|
||||
|
||||
export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private helperScriptPath: string | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyReject: ((error: Error) => void) | null = null;
|
||||
private readyTimer: NodeJS.Timeout | null = null;
|
||||
private sampleCount = 0;
|
||||
private outOfBoundsSampleCount = 0;
|
||||
private previousLeftButtonDown = false;
|
||||
|
||||
constructor(private readonly options: WindowsNativeRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.assets.clear();
|
||||
this.samples = [];
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.sampleCount = 0;
|
||||
this.outOfBoundsSampleCount = 0;
|
||||
this.previousLeftButtonDown = false;
|
||||
|
||||
const script = buildPowerShellScript(
|
||||
this.options.sampleIntervalMs,
|
||||
parseWindowHandleFromSourceId(this.options.sourceId),
|
||||
);
|
||||
const helperScriptDir = join(tmpdir(), "openscreen-cursor-native");
|
||||
mkdirSync(helperScriptDir, { recursive: true });
|
||||
const helperScriptPath = join(
|
||||
helperScriptDir,
|
||||
`cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`,
|
||||
);
|
||||
writeFileSync(helperScriptPath, script, "utf8");
|
||||
this.helperScriptPath = helperScriptPath;
|
||||
const child = spawn(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
helperScriptPath,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.process = child;
|
||||
this.logDiagnostic("spawn", {
|
||||
pid: child.pid ?? null,
|
||||
sampleIntervalMs: this.options.sampleIntervalMs,
|
||||
sourceId: this.options.sourceId ?? null,
|
||||
windowHandle: parseWindowHandleFromSourceId(this.options.sourceId),
|
||||
});
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
this.handleStdoutChunk(chunk);
|
||||
});
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
const message = chunk.trim();
|
||||
if (message) {
|
||||
this.logDiagnostic("stderr", { message });
|
||||
}
|
||||
console.error("[cursor-native]", message);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
this.logDiagnostic("exit", {
|
||||
code,
|
||||
signal,
|
||||
sampleCount: this.sampleCount,
|
||||
assetCount: this.assets.size,
|
||||
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
|
||||
});
|
||||
this.rejectReady(
|
||||
new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`),
|
||||
);
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
this.logDiagnostic("process-error", { message: error.message });
|
||||
this.rejectReady(error);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.terminateHelperProcess();
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.clearReadyState();
|
||||
|
||||
this.killHelperProcess(child);
|
||||
|
||||
this.logDiagnostic("stop", {
|
||||
sampleCount: this.sampleCount,
|
||||
assetCount: this.assets.size,
|
||||
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
|
||||
});
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: this.assets.size > 0 ? "native" : "none",
|
||||
samples: this.samples,
|
||||
assets: [...this.assets.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string) {
|
||||
this.lineBuffer += chunk;
|
||||
const lines = this.lineBuffer.split(/\r?\n/);
|
||||
this.lineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(trimmedLine) as WindowsCursorEvent;
|
||||
this.handleEvent(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse Windows cursor helper output:", error, trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(payload: WindowsCursorEvent) {
|
||||
if (payload.type === "error") {
|
||||
this.logDiagnostic("helper-error", { message: payload.message });
|
||||
console.error("Windows cursor helper error:", payload.message);
|
||||
this.failHelper(new Error(payload.message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "ready") {
|
||||
this.logDiagnostic("ready", { timestampMs: payload.timestampMs });
|
||||
this.resolveReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.asset?.id && !this.assets.has(payload.asset.id)) {
|
||||
const assetDisplay = screen.getDisplayNearestPoint({ x: payload.x, y: payload.y });
|
||||
this.assets.set(payload.asset.id, {
|
||||
id: payload.asset.id,
|
||||
platform: "win32",
|
||||
imageDataUrl: payload.asset.imageDataUrl,
|
||||
width: payload.asset.width,
|
||||
height: payload.asset.height,
|
||||
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,
|
||||
width: payload.asset.width,
|
||||
height: payload.asset.height,
|
||||
hotspotX: payload.asset.hotspotX,
|
||||
hotspotY: payload.asset.hotspotY,
|
||||
scaleFactor: assetDisplay.scaleFactor,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = this.normalizeSample(payload);
|
||||
this.sampleCount += 1;
|
||||
if (!normalized.withinBounds) {
|
||||
this.outOfBoundsSampleCount += 1;
|
||||
}
|
||||
|
||||
this.samples.push(normalized.sample);
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
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 leftButtonDown = payload.leftButtonDown === true;
|
||||
const leftButtonPressed = payload.leftButtonPressed === true;
|
||||
const leftButtonReleased = payload.leftButtonReleased === true;
|
||||
const interactionType =
|
||||
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
|
||||
? "click"
|
||||
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
|
||||
? "mouseup"
|
||||
: "move";
|
||||
this.previousLeftButtonDown = leftButtonDown;
|
||||
|
||||
if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) {
|
||||
this.logDiagnostic("sample", {
|
||||
rawX: payload.x,
|
||||
rawY: payload.y,
|
||||
normalizedX,
|
||||
normalizedY,
|
||||
visible: payload.visible,
|
||||
withinBounds,
|
||||
bounds,
|
||||
handle: payload.handle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
withinBounds,
|
||||
sample: {
|
||||
timeMs: Math.max(0, payload.timestampMs - this.startTimeMs),
|
||||
cx: normalizedX,
|
||||
cy: normalizedY,
|
||||
assetId: payload.handle,
|
||||
visible: payload.visible && withinBounds,
|
||||
cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null,
|
||||
interactionType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private waitUntilReady() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
this.readyTimer = setTimeout(() => {
|
||||
this.rejectReady(new Error("Timed out waiting for Windows cursor helper readiness"));
|
||||
}, READY_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveReady() {
|
||||
const resolve = this.readyResolve;
|
||||
this.clearReadyState();
|
||||
resolve?.();
|
||||
}
|
||||
|
||||
private rejectReady(error: Error) {
|
||||
const reject = this.readyReject;
|
||||
this.clearReadyState();
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private failHelper(error: Error) {
|
||||
this.rejectReady(error);
|
||||
this.terminateHelperProcess();
|
||||
}
|
||||
|
||||
private terminateHelperProcess() {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.killHelperProcess(child);
|
||||
}
|
||||
|
||||
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable> | null) {
|
||||
if (child && !child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyState() {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer);
|
||||
this.readyTimer = null;
|
||||
}
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
}
|
||||
|
||||
private cleanupHelperScript(scriptPath = this.helperScriptPath) {
|
||||
if (!scriptPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(scriptPath, { force: true });
|
||||
} catch (error) {
|
||||
this.logDiagnostic("script-cleanup-error", {
|
||||
path: scriptPath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (this.helperScriptPath === scriptPath) {
|
||||
this.helperScriptPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logDiagnostic(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[cursor-native][win32]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import type { NativeCursorType } from "../../../../src/native/contracts";
|
||||
|
||||
export interface WindowsCursorSampleEvent {
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
handle: string | null;
|
||||
cursorType?: NativeCursorType | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
bounds?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
asset: WindowsCursorAssetPayload | null;
|
||||
}
|
||||
|
||||
export interface WindowsCursorReadyEvent {
|
||||
type: "ready";
|
||||
timestampMs: number;
|
||||
}
|
||||
|
||||
export interface WindowsCursorErrorEvent {
|
||||
type: "error";
|
||||
timestampMs: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WindowsCursorAssetPayload {
|
||||
id: string;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
}
|
||||
|
||||
export type WindowsCursorEvent =
|
||||
| WindowsCursorSampleEvent
|
||||
| WindowsCursorReadyEvent
|
||||
| WindowsCursorErrorEvent;
|
||||
|
||||
export interface WindowsNativeRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
sourceId?: string | null;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
|
||||
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
|
||||
|
||||
interface TelemetryCursorAdapterOptions {
|
||||
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
|
||||
resolveVideoPath: (videoPath?: string | null) => string | null;
|
||||
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
|
||||
export class TelemetryCursorAdapter implements CursorNativeAdapter {
|
||||
readonly kind = "none" as const;
|
||||
|
||||
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
|
||||
|
||||
async getCapabilities(): Promise<CursorCapabilities> {
|
||||
return {
|
||||
telemetry: true,
|
||||
systemAssets: false,
|
||||
provider: this.kind,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
|
||||
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
|
||||
if (!resolvedVideoPath) {
|
||||
return {
|
||||
version: 2,
|
||||
provider: this.kind,
|
||||
samples: [],
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
return this.options.loadRecordingData(resolvedVideoPath);
|
||||
}
|
||||
|
||||
async getTelemetry(videoPath?: string | null) {
|
||||
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
|
||||
if (!resolvedVideoPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: "No video path is available for cursor telemetry",
|
||||
samples: [],
|
||||
} satisfies CursorTelemetryLoadResult;
|
||||
}
|
||||
|
||||
return this.options.loadTelemetry(resolvedVideoPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
CursorRecordingData,
|
||||
CursorTelemetryPoint,
|
||||
} from "../../../src/native/contracts";
|
||||
import type { CursorNativeAdapter } from "../cursor/adapter";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface CursorServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
adapter: CursorNativeAdapter;
|
||||
}
|
||||
|
||||
export class CursorService {
|
||||
constructor(private readonly options: CursorServiceOptions) {}
|
||||
|
||||
async getCapabilities(): Promise<CursorCapabilities> {
|
||||
const capabilities = await this.options.adapter.getCapabilities();
|
||||
this.options.store.setCursorCapabilities(capabilities);
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
|
||||
const result = await this.options.adapter.getTelemetry(videoPath);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || result.error || "Failed to load cursor telemetry");
|
||||
}
|
||||
|
||||
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
|
||||
if (resolvedVideoPath) {
|
||||
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length);
|
||||
}
|
||||
|
||||
return result.samples;
|
||||
}
|
||||
|
||||
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
|
||||
const data = await this.options.adapter.getRecordingData(videoPath);
|
||||
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
|
||||
if (resolvedVideoPath) {
|
||||
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ProjectContext,
|
||||
ProjectFileResult,
|
||||
ProjectPathResult,
|
||||
} from "../../../src/native/contracts";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface ProjectServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
getCurrentProjectPath: () => string | null;
|
||||
getCurrentVideoPath: () => string | null;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
}
|
||||
|
||||
export class ProjectService {
|
||||
constructor(private readonly options: ProjectServiceOptions) {}
|
||||
|
||||
getCurrentContext(): ProjectContext {
|
||||
const context = {
|
||||
currentProjectPath: this.options.getCurrentProjectPath(),
|
||||
currentVideoPath: this.options.getCurrentVideoPath(),
|
||||
};
|
||||
|
||||
this.options.store.setProjectContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
async saveProjectFile(
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) {
|
||||
const result = await this.options.saveProjectFile(
|
||||
projectData,
|
||||
suggestedName,
|
||||
existingProjectPath,
|
||||
);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectFile() {
|
||||
const result = await this.options.loadProjectFile();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadCurrentProjectFile() {
|
||||
const result = await this.options.loadCurrentProjectFile();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async setCurrentVideoPath(path: string) {
|
||||
const result = await this.options.setCurrentVideoPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
getCurrentVideoPath() {
|
||||
const result = this.options.getCurrentVideoPathResult();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
clearCurrentVideoPath() {
|
||||
const result = this.options.clearCurrentVideoPath();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
NativePlatform,
|
||||
SystemCapabilities,
|
||||
} from "../../../src/native/contracts";
|
||||
import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface SystemServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
getPlatform: () => NativePlatform;
|
||||
getAssetBasePath: () => string | null;
|
||||
getCursorCapabilities: () => Promise<CursorCapabilities>;
|
||||
}
|
||||
|
||||
export class SystemService {
|
||||
constructor(private readonly options: SystemServiceOptions) {}
|
||||
|
||||
getPlatform() {
|
||||
return this.options.getPlatform();
|
||||
}
|
||||
|
||||
getAssetBasePath() {
|
||||
return this.options.getAssetBasePath();
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<SystemCapabilities> {
|
||||
const platform = this.getPlatform();
|
||||
const cursorCapabilities = await this.options.getCursorCapabilities();
|
||||
|
||||
const capabilities: SystemCapabilities = {
|
||||
bridgeVersion: NATIVE_BRIDGE_VERSION,
|
||||
platform,
|
||||
cursor: cursorCapabilities,
|
||||
project: {
|
||||
currentContext: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.options.store.setSystemCapabilities(capabilities);
|
||||
return capabilities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
NativePlatform,
|
||||
ProjectContext,
|
||||
SystemCapabilities,
|
||||
} from "../../src/native/contracts";
|
||||
|
||||
export interface NativeBridgeState {
|
||||
system: {
|
||||
platform: NativePlatform;
|
||||
capabilities: SystemCapabilities | null;
|
||||
};
|
||||
project: ProjectContext;
|
||||
cursor: {
|
||||
capabilities: CursorCapabilities | null;
|
||||
lastTelemetryLoad: {
|
||||
videoPath: string;
|
||||
sampleCount: number;
|
||||
loadedAt: number;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class NativeBridgeStateStore {
|
||||
private state: NativeBridgeState;
|
||||
|
||||
constructor(platform: NativePlatform) {
|
||||
this.state = {
|
||||
system: {
|
||||
platform,
|
||||
capabilities: null,
|
||||
},
|
||||
project: {
|
||||
currentProjectPath: null,
|
||||
currentVideoPath: null,
|
||||
},
|
||||
cursor: {
|
||||
capabilities: null,
|
||||
lastTelemetryLoad: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setProjectContext(project: ProjectContext) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
setSystemCapabilities(capabilities: SystemCapabilities) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
system: {
|
||||
...this.state.system,
|
||||
capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setCursorCapabilities(capabilities: CursorCapabilities) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
cursor: {
|
||||
...this.state.cursor,
|
||||
capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
markCursorTelemetryLoaded(videoPath: string, sampleCount: number) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
cursor: {
|
||||
...this.state.cursor,
|
||||
lastTelemetryLoad: {
|
||||
videoPath,
|
||||
sampleCount,
|
||||
loadedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Native capture helpers
|
||||
|
||||
Windows native recording is resolved from one of these locations:
|
||||
|
||||
1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics.
|
||||
2. `electron/native/wgc-capture/build/wgc-capture.exe`, for a locally built Ninja helper.
|
||||
3. `electron/native/wgc-capture/build/Release/wgc-capture.exe`, for a locally built multi-config helper.
|
||||
4. `electron/native/bin/win32-x64/wgc-capture.exe` or `electron/native/bin/win32-arm64/wgc-capture.exe`, for packaged prebuilt helpers.
|
||||
|
||||
Build the Windows helper with:
|
||||
|
||||
```powershell
|
||||
npm run build:native:win
|
||||
```
|
||||
|
||||
The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`.
|
||||
|
||||
The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: <path>`.
|
||||
|
||||
Current V2 JSON shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"recordingId": 123,
|
||||
"sourceType": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 1,
|
||||
"windowHandle": null,
|
||||
"outputPath": "C:\\path\\recording-123.mp4",
|
||||
"videoWidth": 1920,
|
||||
"videoHeight": 1080,
|
||||
"fps": 60,
|
||||
"captureSystemAudio": false,
|
||||
"captureMic": false,
|
||||
"microphoneDeviceId": "default",
|
||||
"microphoneDeviceName": "Microphone (NVIDIA Broadcast)",
|
||||
"microphoneGain": 1.4,
|
||||
"webcamEnabled": true,
|
||||
"webcamDeviceId": "default",
|
||||
"webcamDeviceName": "Camera (NVIDIA Broadcast)",
|
||||
"webcamWidth": 1280,
|
||||
"webcamHeight": 720,
|
||||
"webcamFps": 30,
|
||||
"outputs": {
|
||||
"screenPath": "C:\\path\\recording-123.mp4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation.
|
||||
|
||||
Smoke-test the helper with:
|
||||
|
||||
```powershell
|
||||
npm run test:wgc-helper:win
|
||||
npm run test:wgc-window:win
|
||||
npm run test:wgc-audio:win
|
||||
npm run test:wgc-mic:win
|
||||
npm run test:wgc-mixed-audio:win
|
||||
npm run test:wgc-webcam:win
|
||||
```
|
||||
|
||||
To validate a specific native webcam manually:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME = "NVIDIA Broadcast"
|
||||
npm run test:wgc-webcam:win
|
||||
Remove-Item Env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME
|
||||
```
|
||||
|
||||
To validate a specific native microphone manually:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME = "Microphone (NVIDIA Broadcast)"
|
||||
npm run test:wgc-mic:win
|
||||
Remove-Item Env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
# The local Windows SDK image used by some contributors can miss gdi32.lib,
|
||||
# while CMake's default MSVC console template links it unconditionally. This
|
||||
# helper does not use GDI, so keep the standard library set minimal and explicit.
|
||||
set(CMAKE_CXX_STANDARD_LIBRARIES
|
||||
"kernel32.lib user32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib"
|
||||
CACHE STRING "" FORCE)
|
||||
|
||||
project(openscreen-wgc-capture LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
add_executable(wgc-capture
|
||||
src/audio_sample_utils.cpp
|
||||
src/audio_sample_utils.h
|
||||
src/dshow_webcam_capture.cpp
|
||||
src/dshow_webcam_capture.h
|
||||
src/main.cpp
|
||||
src/mf_encoder.cpp
|
||||
src/mf_encoder.h
|
||||
src/monitor_utils.cpp
|
||||
src/monitor_utils.h
|
||||
src/wasapi_loopback_capture.cpp
|
||||
src/wasapi_loopback_capture.h
|
||||
src/webcam_capture.cpp
|
||||
src/webcam_capture.h
|
||||
src/wgc_session.cpp
|
||||
src/wgc_session.h
|
||||
)
|
||||
|
||||
target_compile_definitions(wgc-capture PRIVATE
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8)
|
||||
|
||||
target_link_libraries(wgc-capture PRIVATE
|
||||
d3d11
|
||||
dxgi
|
||||
mf
|
||||
mfplat
|
||||
mfreadwrite
|
||||
mfuuid
|
||||
runtimeobject
|
||||
windowsapp
|
||||
)
|
||||
@@ -0,0 +1,420 @@
|
||||
#include "audio_sample_utils.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
|
||||
bool isFloatFormat(const AudioInputFormat& format) {
|
||||
return format.subtype == MFAudioFormat_Float && format.bitsPerSample == 32;
|
||||
}
|
||||
|
||||
bool isPcmFormat(const AudioInputFormat& format, UINT32 bitsPerSample) {
|
||||
return format.subtype == MFAudioFormat_PCM && format.bitsPerSample == bitsPerSample;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T clampTo(double value) {
|
||||
const double minValue = static_cast<double>(std::numeric_limits<T>::min());
|
||||
const double maxValue = static_cast<double>(std::numeric_limits<T>::max());
|
||||
return static_cast<T>(std::clamp(std::round(value), minValue, maxValue));
|
||||
}
|
||||
|
||||
size_t bytesPerSample(const AudioInputFormat& format) {
|
||||
return format.bitsPerSample / 8;
|
||||
}
|
||||
|
||||
double readSampleAsDouble(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex) {
|
||||
if (!source || format.blockAlign == 0 || channelIndex >= format.channels) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
|
||||
if (isFloatFormat(format)) {
|
||||
return static_cast<double>(*reinterpret_cast<const float*>(source + offset));
|
||||
}
|
||||
if (isPcmFormat(format, 16)) {
|
||||
return static_cast<double>(*reinterpret_cast<const int16_t*>(source + offset)) / 32768.0;
|
||||
}
|
||||
if (isPcmFormat(format, 32)) {
|
||||
return static_cast<double>(*reinterpret_cast<const int32_t*>(source + offset)) / 2147483648.0;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
void writeSampleFromDouble(BYTE* destination, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex, double value) {
|
||||
if (!destination || format.blockAlign == 0 || channelIndex >= format.channels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const double clamped = std::clamp(value, -1.0, 1.0);
|
||||
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
|
||||
if (isFloatFormat(format)) {
|
||||
*reinterpret_cast<float*>(destination + offset) = static_cast<float>(clamped);
|
||||
return;
|
||||
}
|
||||
if (isPcmFormat(format, 16)) {
|
||||
*reinterpret_cast<int16_t*>(destination + offset) = clampTo<int16_t>(clamped * 32767.0);
|
||||
return;
|
||||
}
|
||||
if (isPcmFormat(format, 32)) {
|
||||
*reinterpret_cast<int32_t*>(destination + offset) = clampTo<int32_t>(clamped * 2147483647.0);
|
||||
}
|
||||
}
|
||||
|
||||
double readMappedChannel(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 targetChannel, UINT32 targetChannels) {
|
||||
if (format.channels == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
if (format.channels == targetChannels && targetChannel < format.channels) {
|
||||
return readSampleAsDouble(source, format, frameIndex, targetChannel);
|
||||
}
|
||||
if (format.channels == 1) {
|
||||
return readSampleAsDouble(source, format, frameIndex, 0);
|
||||
}
|
||||
if (targetChannels == 1) {
|
||||
double sum = 0.0;
|
||||
for (UINT32 channel = 0; channel < format.channels; ++channel) {
|
||||
sum += readSampleAsDouble(source, format, frameIndex, channel);
|
||||
}
|
||||
return sum / static_cast<double>(format.channels);
|
||||
}
|
||||
return readSampleAsDouble(source, format, frameIndex, std::min(targetChannel, format.channels - 1));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
constexpr int64_t HnsPerSecond = 10'000'000;
|
||||
|
||||
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right) {
|
||||
return left.subtype == right.subtype &&
|
||||
left.sampleRate == right.sampleRate &&
|
||||
left.channels == right.channels &&
|
||||
left.bitsPerSample == right.bitsPerSample &&
|
||||
left.blockAlign == right.blockAlign &&
|
||||
left.avgBytesPerSec == right.avgBytesPerSec;
|
||||
}
|
||||
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) {
|
||||
AudioInputFormat format{};
|
||||
format.subtype = MFAudioFormat_PCM;
|
||||
format.sampleRate = source.sampleRate > 0 ? source.sampleRate : 48000;
|
||||
format.channels = 2;
|
||||
format.bitsPerSample = 16;
|
||||
format.blockAlign = format.channels * (format.bitsPerSample / 8);
|
||||
format.avgBytesPerSec = format.sampleRate * format.blockAlign;
|
||||
return format;
|
||||
}
|
||||
|
||||
void copyAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination) {
|
||||
destination.resize(byteCount);
|
||||
if (!source || byteCount == 0) {
|
||||
std::fill(destination.begin(), destination.end(), static_cast<BYTE>(0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::abs(gain - 1.0) < 0.0001) {
|
||||
std::memcpy(destination.data(), source, byteCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFloatFormat(format)) {
|
||||
const auto* input = reinterpret_cast<const float*>(source);
|
||||
auto* output = reinterpret_cast<float*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(float);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = static_cast<float>(std::clamp(input[index] * gain, -1.0, 1.0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 16)) {
|
||||
const auto* input = reinterpret_cast<const int16_t*>(source);
|
||||
auto* output = reinterpret_cast<int16_t*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(int16_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int16_t>(static_cast<double>(input[index]) * gain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 32)) {
|
||||
const auto* input = reinterpret_cast<const int32_t*>(source);
|
||||
auto* output = reinterpret_cast<int32_t*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(int32_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int32_t>(static_cast<double>(input[index]) * gain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::memcpy(destination.data(), source, byteCount);
|
||||
}
|
||||
|
||||
void convertAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
const AudioInputFormat& targetFormat,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination) {
|
||||
if (!source || byteCount == 0 || sourceFormat.blockAlign == 0 || targetFormat.blockAlign == 0 ||
|
||||
sourceFormat.sampleRate == 0 || targetFormat.sampleRate == 0 || sourceFormat.channels == 0 ||
|
||||
targetFormat.channels == 0) {
|
||||
destination.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sameAudioFormatForMixing(sourceFormat, targetFormat)) {
|
||||
copyAudioWithGain(source, byteCount, targetFormat, gain, destination);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t sourceFrames = byteCount / sourceFormat.blockAlign;
|
||||
if (sourceFrames == 0) {
|
||||
destination.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const double rateRatio = static_cast<double>(targetFormat.sampleRate) /
|
||||
static_cast<double>(sourceFormat.sampleRate);
|
||||
const size_t targetFrames = std::max<size_t>(1, static_cast<size_t>(std::llround(sourceFrames * rateRatio)));
|
||||
destination.assign(targetFrames * targetFormat.blockAlign, 0);
|
||||
|
||||
for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) {
|
||||
const double sourcePosition = static_cast<double>(targetFrame) / rateRatio;
|
||||
const size_t sourceFrame = std::min(
|
||||
sourceFrames - 1,
|
||||
static_cast<size_t>(std::llround(sourcePosition)));
|
||||
for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) {
|
||||
const double sample = readMappedChannel(
|
||||
source,
|
||||
sourceFormat,
|
||||
sourceFrame,
|
||||
channel,
|
||||
targetFormat.channels);
|
||||
writeSampleFromDouble(destination.data(), targetFormat, targetFrame, channel, sample * gain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mixAudioInPlace(
|
||||
std::vector<BYTE>& destination,
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format) {
|
||||
if (!source || byteCount == 0 || destination.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t mixByteCount = std::min(destination.size(), static_cast<size_t>(byteCount));
|
||||
|
||||
if (isFloatFormat(format)) {
|
||||
auto* output = reinterpret_cast<float*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const float*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(float);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = static_cast<float>(std::clamp(output[index] + input[index], -1.0f, 1.0f));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 16)) {
|
||||
auto* output = reinterpret_cast<int16_t*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const int16_t*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(int16_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int16_t>(
|
||||
static_cast<double>(output[index]) + static_cast<double>(input[index]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 32)) {
|
||||
auto* output = reinterpret_cast<int32_t*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const int32_t*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(int32_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int32_t>(
|
||||
static_cast<double>(output[index]) + static_cast<double>(input[index]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioMixer::AudioMixer(
|
||||
const AudioInputFormat& format,
|
||||
const AudioInputFormat& systemFormat,
|
||||
const AudioInputFormat& microphoneFormat,
|
||||
bool includeSystem,
|
||||
bool includeMicrophone,
|
||||
double microphoneGain,
|
||||
OutputCallback output)
|
||||
: format_(format),
|
||||
systemFormat_(systemFormat),
|
||||
microphoneFormat_(microphoneFormat),
|
||||
includeSystem_(includeSystem),
|
||||
includeMicrophone_(includeMicrophone),
|
||||
microphoneGain_(microphoneGain),
|
||||
output_(std::move(output)) {}
|
||||
|
||||
AudioMixer::~AudioMixer() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool AudioMixer::start() {
|
||||
if (!output_ || format_.sampleRate == 0 || format_.blockAlign == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopRequested_ = false;
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = false;
|
||||
thread_ = std::thread([this] {
|
||||
mixLoop();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioMixer::beginTimeline() {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::stop() {
|
||||
stopRequested_ = true;
|
||||
cv_.notify_all();
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
|
||||
if (!includeSystem_ || stopRequested_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
|
||||
if (!includeMicrophone_ || stopRequested_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::append(
|
||||
std::vector<BYTE>& queue,
|
||||
const BYTE* data,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
double gain) {
|
||||
if (!data || byteCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_);
|
||||
queue.insert(queue.end(), gainBuffer_.begin(), gainBuffer_.end());
|
||||
}
|
||||
|
||||
bool AudioMixer::pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount) {
|
||||
if (queue.empty()) {
|
||||
chunk.assign(byteCount, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.assign(byteCount, 0);
|
||||
const size_t copiedBytes = std::min(byteCount, queue.size());
|
||||
std::memcpy(chunk.data(), queue.data(), copiedBytes);
|
||||
queue.erase(queue.begin(), queue.begin() + static_cast<std::ptrdiff_t>(copiedBytes));
|
||||
return copiedBytes > 0;
|
||||
}
|
||||
|
||||
void AudioMixer::mixLoop() {
|
||||
const uint32_t chunkFrames = std::max<uint32_t>(1, format_.sampleRate / 100);
|
||||
const size_t chunkBytes = static_cast<size_t>(chunkFrames) * format_.blockAlign;
|
||||
std::vector<BYTE> mixedChunk;
|
||||
std::vector<BYTE> sourceChunk;
|
||||
std::chrono::steady_clock::time_point audioClockStart;
|
||||
bool audioClockStarted = false;
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock lock(mutex_);
|
||||
cv_.wait_for(lock, std::chrono::milliseconds(20), [&] {
|
||||
const bool hasSystem = !includeSystem_ || systemQueue_.size() >= chunkBytes;
|
||||
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
|
||||
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
return stopRequested_.load() ||
|
||||
(timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
});
|
||||
|
||||
if (stopRequested_) {
|
||||
break;
|
||||
}
|
||||
if (!timelineStarted_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool hasAnyQueuedAudio = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
if (!hasAnyQueuedAudio) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mixedChunk.assign(chunkBytes, 0);
|
||||
if (includeSystem_) {
|
||||
pop(systemQueue_, sourceChunk, chunkBytes);
|
||||
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
|
||||
}
|
||||
if (includeMicrophone_) {
|
||||
pop(microphoneQueue_, sourceChunk, chunkBytes);
|
||||
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioClockStarted) {
|
||||
audioClockStart = std::chrono::steady_clock::now();
|
||||
audioClockStarted = true;
|
||||
}
|
||||
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((emittedFrames_ * HnsPerSecond) / format_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(chunkFrames) * HnsPerSecond) / format_.sampleRate);
|
||||
if (!output_(mixedChunk.data(), static_cast<DWORD>(mixedChunk.size()), timestampHns, durationHns)) {
|
||||
stopRequested_ = true;
|
||||
break;
|
||||
}
|
||||
emittedFrames_ += chunkFrames;
|
||||
|
||||
const auto nextDeadline = audioClockStart +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(static_cast<double>(emittedFrames_) / format_.sampleRate));
|
||||
std::this_thread::sleep_until(nextDeadline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right);
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source);
|
||||
void copyAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void convertAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
const AudioInputFormat& targetFormat,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void mixAudioInPlace(
|
||||
std::vector<BYTE>& destination,
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format);
|
||||
|
||||
class AudioMixer {
|
||||
public:
|
||||
using OutputCallback = std::function<bool(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
AudioMixer(
|
||||
const AudioInputFormat& format,
|
||||
const AudioInputFormat& systemFormat,
|
||||
const AudioInputFormat& microphoneFormat,
|
||||
bool includeSystem,
|
||||
bool includeMicrophone,
|
||||
double microphoneGain,
|
||||
OutputCallback output);
|
||||
~AudioMixer();
|
||||
|
||||
AudioMixer(const AudioMixer&) = delete;
|
||||
AudioMixer& operator=(const AudioMixer&) = delete;
|
||||
|
||||
bool start();
|
||||
void beginTimeline();
|
||||
void stop();
|
||||
void pushSystem(const BYTE* data, DWORD byteCount);
|
||||
void pushMicrophone(const BYTE* data, DWORD byteCount);
|
||||
|
||||
private:
|
||||
void append(
|
||||
std::vector<BYTE>& queue,
|
||||
const BYTE* data,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
double gain);
|
||||
bool pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount);
|
||||
void mixLoop();
|
||||
|
||||
AudioInputFormat format_{};
|
||||
AudioInputFormat systemFormat_{};
|
||||
AudioInputFormat microphoneFormat_{};
|
||||
bool includeSystem_ = false;
|
||||
bool includeMicrophone_ = false;
|
||||
double microphoneGain_ = 1.0;
|
||||
OutputCallback output_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::vector<BYTE> systemQueue_;
|
||||
std::vector<BYTE> microphoneQueue_;
|
||||
std::vector<BYTE> gainBuffer_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
bool timelineStarted_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <initguid.h>
|
||||
#include <dshow.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
|
||||
MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85")
|
||||
ISampleGrabberCB : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0;
|
||||
};
|
||||
|
||||
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
|
||||
ISampleGrabber : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SetOneShot(BOOL oneShot) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetMediaType(const AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetConnectedMediaType(AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0;
|
||||
};
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
if (type.cbFormat != 0) {
|
||||
CoTaskMemFree(type.pbFormat);
|
||||
type.cbFormat = 0;
|
||||
type.pbFormat = nullptr;
|
||||
}
|
||||
if (type.pUnk) {
|
||||
type.pUnk->Release();
|
||||
type.pUnk = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct DirectShowWebcamCapture::Impl {
|
||||
Microsoft::WRL::ComPtr<IGraphBuilder> graph;
|
||||
Microsoft::WRL::ComPtr<ICaptureGraphBuilder2> captureGraph;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> captureFilter;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> sampleGrabberFilter;
|
||||
Microsoft::WRL::ComPtr<ISampleGrabber> sampleGrabber;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> nullRenderer;
|
||||
Microsoft::WRL::ComPtr<IMediaControl> mediaControl;
|
||||
bool comInitialized = false;
|
||||
bool running = false;
|
||||
};
|
||||
|
||||
DirectShowWebcamCapture::~DirectShowWebcamCapture() {
|
||||
stop();
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
(void)deviceId;
|
||||
stop();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
impl_ = new Impl();
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
if (SUCCEEDED(hr)) {
|
||||
impl_->comInitialized = true;
|
||||
} else if (hr != RPC_E_CHANGED_MODE) {
|
||||
return succeeded(hr, "CoInitializeEx(DirectShow webcam)");
|
||||
}
|
||||
|
||||
if (directShowClsid.empty()) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback requires a resolved filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
CLSID selectedClsid{};
|
||||
if (FAILED(CLSIDFromString(directShowClsid.c_str(), &selectedClsid))) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback received an invalid filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
selectedDeviceName_ = deviceName.empty() ? directShowClsid : deviceName;
|
||||
|
||||
if (!succeeded(CoCreateInstance(selectedClsid, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureFilter)),
|
||||
"CoCreateInstance(DirectShow webcam filter)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->graph)),
|
||||
"CoCreateInstance(FilterGraph)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureGraph)),
|
||||
"CoCreateInstance(CaptureGraphBuilder2)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->captureGraph->SetFiltergraph(impl_->graph.Get()), "SetFiltergraph(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->captureFilter.Get(), L"OpenScreen Webcam Source"),
|
||||
"AddFilter(DirectShow webcam source)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(CoCreateInstance(CLSID_SampleGrabberLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->sampleGrabberFilter)),
|
||||
"CoCreateInstance(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->sampleGrabberFilter.As(&impl_->sampleGrabber), "QueryInterface(ISampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE requestedType{};
|
||||
requestedType.majortype = MEDIATYPE_Video;
|
||||
requestedType.subtype = MEDIASUBTYPE_RGB32;
|
||||
requestedType.formattype = FORMAT_VideoInfo;
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->sampleGrabberFilter.Get(), L"OpenScreen Webcam Sample Grabber"),
|
||||
"AddFilter(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_NullRendererLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->nullRenderer)),
|
||||
"CoCreateInstance(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->nullRenderer.Get(), L"OpenScreen Webcam Null Renderer"),
|
||||
"AddFilter(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->captureGraph->RenderStream(
|
||||
&PIN_CATEGORY_CAPTURE,
|
||||
&MEDIATYPE_Video,
|
||||
impl_->captureFilter.Get(),
|
||||
impl_->sampleGrabberFilter.Get(),
|
||||
impl_->nullRenderer.Get()),
|
||||
"RenderStream(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE connectedType{};
|
||||
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
|
||||
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
|
||||
width_ = std::abs(videoInfo->bmiHeader.biWidth);
|
||||
height_ = std::abs(videoInfo->bmiHeader.biHeight);
|
||||
sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0;
|
||||
}
|
||||
freeMediaType(connectedType);
|
||||
if (width_ <= 0 || height_ <= 0) {
|
||||
width_ = requestedWidth > 0 ? requestedWidth : 1280;
|
||||
height_ = requestedHeight > 0 ? requestedHeight : 720;
|
||||
}
|
||||
|
||||
impl_->sampleGrabber->SetBufferSamples(TRUE);
|
||||
impl_->sampleGrabber->SetOneShot(FALSE);
|
||||
if (!succeeded(impl_->graph.As(&impl_->mediaControl), "QueryInterface(IMediaControl)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::start() {
|
||||
if (!impl_ || !impl_->mediaControl || impl_->running) {
|
||||
return false;
|
||||
}
|
||||
HRESULT hr = impl_->mediaControl->Run();
|
||||
if (!succeeded(hr, "Run(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
stopRequested_ = false;
|
||||
try {
|
||||
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
|
||||
} catch (const std::exception& error) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
|
||||
return false;
|
||||
} catch (...) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
|
||||
return false;
|
||||
}
|
||||
impl_->running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
if (impl_->mediaControl && impl_->running) {
|
||||
impl_->mediaControl->Stop();
|
||||
}
|
||||
impl_->running = false;
|
||||
impl_->mediaControl.Reset();
|
||||
impl_->nullRenderer.Reset();
|
||||
impl_->sampleGrabber.Reset();
|
||||
impl_->sampleGrabberFilter.Reset();
|
||||
impl_->captureFilter.Reset();
|
||||
impl_->captureGraph.Reset();
|
||||
impl_->graph.Reset();
|
||||
if (impl_->comInitialized) {
|
||||
CoUninitialize();
|
||||
impl_->comInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::captureLoop() {
|
||||
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
|
||||
long bufferSize = 0;
|
||||
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
|
||||
if (SUCCEEDED(hr) && bufferSize > 0) {
|
||||
std::vector<BYTE> buffer(static_cast<size_t>(bufferSize));
|
||||
hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, reinterpret_cast<long*>(buffer.data()));
|
||||
if (SUCCEEDED(hr)) {
|
||||
storeFrame(buffer.data(), bufferSize);
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
|
||||
}
|
||||
if (SUCCEEDED(coinitHr)) {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
|
||||
const int stride = width_ * 4;
|
||||
const int expectedLength = stride * height_;
|
||||
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BYTE> frame(static_cast<size_t>(expectedLength));
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
|
||||
const BYTE* source = buffer + sourceY * stride;
|
||||
BYTE* destination = frame.data() + y * stride;
|
||||
std::copy(source, source + stride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_ = std::move(frame);
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::width() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::height() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::fps() const {
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& DirectShowWebcamCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class DirectShowWebcamCapture {
|
||||
public:
|
||||
DirectShowWebcamCapture() = default;
|
||||
~DirectShowWebcamCapture();
|
||||
|
||||
DirectShowWebcamCapture(const DirectShowWebcamCapture&) = delete;
|
||||
DirectShowWebcamCapture& operator=(const DirectShowWebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
void storeFrame(const BYTE* buffer, long length);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
void captureLoop();
|
||||
|
||||
Impl* impl_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool sourceTopDown_ = false;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,758 @@
|
||||
#include "audio_sample_utils.h"
|
||||
#include "mf_encoder.h"
|
||||
#include "monitor_utils.h"
|
||||
#include "wasapi_loopback_capture.h"
|
||||
#include "webcam_capture.h"
|
||||
#include "wgc_session.h"
|
||||
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
|
||||
struct CaptureConfig {
|
||||
int schemaVersion = 1;
|
||||
int64_t displayId = 0;
|
||||
int64_t recordingId = 0;
|
||||
std::string sourceType = "display";
|
||||
std::string sourceId;
|
||||
std::string windowHandle;
|
||||
std::string outputPath;
|
||||
int fps = 60;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
MonitorBounds bounds{};
|
||||
bool hasDisplayBounds = false;
|
||||
bool captureSystemAudio = false;
|
||||
bool captureMic = false;
|
||||
bool captureCursor = false;
|
||||
bool webcamEnabled = false;
|
||||
std::string microphoneDeviceId;
|
||||
std::string microphoneDeviceName;
|
||||
double microphoneGain = 1.0;
|
||||
std::string webcamDeviceId;
|
||||
std::string webcamDeviceName;
|
||||
std::string webcamDirectShowClsid;
|
||||
int webcamWidth = 0;
|
||||
int webcamHeight = 0;
|
||||
int webcamFps = 0;
|
||||
};
|
||||
|
||||
std::wstring utf8ToWide(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
||||
std::wstring result(static_cast<size_t>(size), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string wideToUtf8(const std::wstring& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string result(static_cast<size_t>(size), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string jsonEscape(const std::string& value) {
|
||||
std::string result;
|
||||
result.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
result += "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
result += "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
result += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
result += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
result += "\\t";
|
||||
break;
|
||||
default:
|
||||
result.push_back(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool hasVisibleBgraContent(const std::vector<BYTE>& frame) {
|
||||
if (frame.size() < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t lumaTotal = 0;
|
||||
BYTE maxLuma = 0;
|
||||
const size_t pixelCount = frame.size() / 4;
|
||||
const size_t step = std::max<size_t>(1, pixelCount / 4096);
|
||||
size_t sampledPixels = 0;
|
||||
for (size_t pixel = 0; pixel < pixelCount; pixel += step) {
|
||||
const size_t offset = pixel * 4;
|
||||
const BYTE b = frame[offset + 0];
|
||||
const BYTE g = frame[offset + 1];
|
||||
const BYTE r = frame[offset + 2];
|
||||
const BYTE luma = static_cast<BYTE>((static_cast<uint16_t>(r) * 54 + static_cast<uint16_t>(g) * 183 + static_cast<uint16_t>(b) * 19) >> 8);
|
||||
lumaTotal += luma;
|
||||
maxLuma = std::max(maxLuma, luma);
|
||||
sampledPixels += 1;
|
||||
}
|
||||
|
||||
const uint64_t averageLuma = sampledPixels > 0 ? lumaTotal / sampledPixels : 0;
|
||||
return maxLuma > 24 || averageLuma > 4;
|
||||
}
|
||||
|
||||
bool findBool(const std::string& json, const std::string& key, bool fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
if (json.compare(pos, 4, "true") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (json.compare(pos, 5, "false") == 0) {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
try {
|
||||
return std::stoll(json.substr(pos));
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
int findInt(const std::string& json, const std::string& key, int fallback) {
|
||||
return static_cast<int>(findInt64(json, key, fallback));
|
||||
}
|
||||
|
||||
double findDouble(const std::string& json, const std::string& key, double fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
try {
|
||||
return std::stod(json.substr(pos));
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
std::string findString(const std::string& json, const std::string& key) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
if (pos >= json.size() || json[pos] != '"') {
|
||||
return {};
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
std::string result;
|
||||
while (pos < json.size()) {
|
||||
const char c = json[pos++];
|
||||
if (c == '"') {
|
||||
break;
|
||||
}
|
||||
if (c == '\\' && pos < json.size()) {
|
||||
const char escaped = json[pos++];
|
||||
switch (escaped) {
|
||||
case '\\':
|
||||
case '"':
|
||||
case '/':
|
||||
result.push_back(escaped);
|
||||
break;
|
||||
case 'n':
|
||||
result.push_back('\n');
|
||||
break;
|
||||
case 'r':
|
||||
result.push_back('\r');
|
||||
break;
|
||||
case 't':
|
||||
result.push_back('\t');
|
||||
break;
|
||||
default:
|
||||
result.push_back(escaped);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push_back(c);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string parseWindowHandleFromSourceId(const std::string& sourceId) {
|
||||
constexpr char prefix[] = "window:";
|
||||
if (sourceId.rfind(prefix, 0) != 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const size_t start = sizeof(prefix) - 1;
|
||||
const size_t end = sourceId.find(':', start);
|
||||
const std::string handle = sourceId.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
return handle.empty() ? std::string{} : handle;
|
||||
}
|
||||
|
||||
HWND parseWindowHandle(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
try {
|
||||
size_t parsed = 0;
|
||||
const int base = value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0 ? 16 : 10;
|
||||
const uint64_t handleValue = std::stoull(value, &parsed, base);
|
||||
if (parsed != value.size() || handleValue == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<HWND>(static_cast<uintptr_t>(handleValue));
|
||||
} catch (...) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseConfig(const std::string& json, CaptureConfig& config) {
|
||||
config.schemaVersion = findInt(json, "schemaVersion", 1);
|
||||
config.outputPath = findString(json, "screenPath");
|
||||
if (config.outputPath.empty()) {
|
||||
config.outputPath = findString(json, "outputPath");
|
||||
}
|
||||
if (config.outputPath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.recordingId = findInt64(json, "recordingId", 0);
|
||||
config.sourceType = findString(json, "sourceType");
|
||||
if (config.sourceType.empty()) {
|
||||
config.sourceType = "display";
|
||||
}
|
||||
config.sourceId = findString(json, "sourceId");
|
||||
config.windowHandle = findString(json, "windowHandle");
|
||||
if (config.windowHandle.empty()) {
|
||||
config.windowHandle = parseWindowHandleFromSourceId(config.sourceId);
|
||||
}
|
||||
config.displayId = findInt64(json, "displayId", 0);
|
||||
config.fps = std::clamp(findInt(json, "fps", 60), 1, 120);
|
||||
config.width = findInt(json, "videoWidth", findInt(json, "width", 0));
|
||||
config.height = findInt(json, "videoHeight", findInt(json, "height", 0));
|
||||
config.bounds.x = findInt(json, "displayX", 0);
|
||||
config.bounds.y = findInt(json, "displayY", 0);
|
||||
config.bounds.width = findInt(json, "displayW", 0);
|
||||
config.bounds.height = findInt(json, "displayH", 0);
|
||||
config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false);
|
||||
config.captureSystemAudio = findBool(json, "captureSystemAudio", false);
|
||||
config.captureMic = findBool(json, "captureMic", false);
|
||||
config.captureCursor = findBool(json, "captureCursor", false);
|
||||
config.webcamEnabled = findBool(json, "webcamEnabled", false);
|
||||
config.microphoneDeviceId = findString(json, "microphoneDeviceId");
|
||||
config.microphoneDeviceName = findString(json, "microphoneDeviceName");
|
||||
config.microphoneGain = findDouble(json, "microphoneGain", 1.0);
|
||||
config.webcamDeviceId = findString(json, "webcamDeviceId");
|
||||
config.webcamDeviceName = findString(json, "webcamDeviceName");
|
||||
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
|
||||
config.webcamWidth = findInt(json, "webcamWidth", 0);
|
||||
config.webcamHeight = findInt(json, "webcamHeight", 0);
|
||||
config.webcamFps = findInt(json, "webcamFps", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (line == "stop" || line == "q" || line == "quit") {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
||||
|
||||
CaptureConfig config;
|
||||
if (!parseConfig(argv[1], config)) {
|
||||
std::cerr << "ERROR: Failed to parse config JSON" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;
|
||||
|
||||
WgcSession session;
|
||||
if (config.sourceType == "display") {
|
||||
HMONITOR monitor = findMonitorForCapture(
|
||||
config.displayId,
|
||||
config.hasDisplayBounds ? &config.bounds : nullptr);
|
||||
if (!monitor) {
|
||||
std::cerr << "ERROR: Could not resolve monitor" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!session.initialize(monitor, config.fps, config.captureCursor)) {
|
||||
std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
} else if (config.sourceType == "window") {
|
||||
HWND window = parseWindowHandle(config.windowHandle);
|
||||
if (!window || !IsWindow(window)) {
|
||||
std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!session.initialize(window, config.fps, config.captureCursor)) {
|
||||
std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
std::cerr << "ERROR: Unsupported native capture source type: " << config.sourceType << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// WGC owns the captured texture size. Encoding must use that exact size
|
||||
// until a dedicated GPU scaling pass is introduced; CopyResource requires
|
||||
// matching resource dimensions.
|
||||
int width = session.captureWidth();
|
||||
int height = session.captureHeight();
|
||||
width = (std::max(2, width) / 2) * 2;
|
||||
height = (std::max(2, height) / 2) * 2;
|
||||
|
||||
const int pixels = width * height;
|
||||
const int bitrate = pixels >= 3840 * 2160 ? 45'000'000 : pixels >= 2560 * 1440 ? 28'000'000 : 18'000'000;
|
||||
|
||||
WebcamCapture webcamCapture;
|
||||
bool webcamActive = false;
|
||||
if (config.webcamEnabled) {
|
||||
if (!webcamCapture.initialize(
|
||||
utf8ToWide(config.webcamDeviceId),
|
||||
utf8ToWide(config.webcamDeviceName),
|
||||
utf8ToWide(config.webcamDirectShowClsid),
|
||||
config.webcamWidth,
|
||||
config.webcamHeight,
|
||||
config.webcamFps > 0 ? config.webcamFps : config.fps)) {
|
||||
std::cerr << "ERROR: Failed to initialize native webcam capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
std::cout << "{\"event\":\"webcam-format\",\"schemaVersion\":2,\"width\":" << webcamCapture.width()
|
||||
<< ",\"height\":" << webcamCapture.height()
|
||||
<< ",\"fps\":" << webcamCapture.fps()
|
||||
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
|
||||
<< "\"}" << std::endl;
|
||||
}
|
||||
|
||||
WasapiLoopbackCapture loopbackCapture;
|
||||
WasapiLoopbackCapture microphoneCapture;
|
||||
const AudioInputFormat* audioFormat = nullptr;
|
||||
AudioInputFormat encoderAudioFormat{};
|
||||
AudioInputFormat systemAudioFormat{};
|
||||
AudioInputFormat microphoneAudioFormat{};
|
||||
if (config.captureSystemAudio) {
|
||||
if (!loopbackCapture.initializeSystemLoopback()) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
systemAudioFormat = loopbackCapture.inputFormat();
|
||||
audioFormat = &loopbackCapture.inputFormat();
|
||||
}
|
||||
if (config.captureMic) {
|
||||
if (!microphoneCapture.initializeMicrophone(
|
||||
utf8ToWide(config.microphoneDeviceId),
|
||||
utf8ToWide(config.microphoneDeviceName))) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI microphone capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
microphoneAudioFormat = microphoneCapture.inputFormat();
|
||||
if (!audioFormat) {
|
||||
audioFormat = µphoneCapture.inputFormat();
|
||||
}
|
||||
}
|
||||
if (audioFormat) {
|
||||
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":" << audioFormat->sampleRate
|
||||
<< ",\"channels\":" << audioFormat->channels
|
||||
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample
|
||||
<< ",\"system\":" << (config.captureSystemAudio ? "true" : "false")
|
||||
<< ",\"microphone\":" << (config.captureMic ? "true" : "false");
|
||||
if (config.captureMic) {
|
||||
std::cout << ",\"microphoneDeviceName\":\""
|
||||
<< jsonEscape(wideToUtf8(microphoneCapture.selectedDeviceName())) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
encoderAudioFormat = makeAacCompatibleAudioFormat(*audioFormat);
|
||||
std::cout << "{\"event\":\"encoder-audio-format\",\"schemaVersion\":2,\"sampleRate\":"
|
||||
<< encoderAudioFormat.sampleRate
|
||||
<< ",\"channels\":" << encoderAudioFormat.channels
|
||||
<< ",\"bitsPerSample\":" << encoderAudioFormat.bitsPerSample
|
||||
<< "}" << std::endl;
|
||||
}
|
||||
|
||||
MFEncoder encoder;
|
||||
if (!encoder.initialize(
|
||||
utf8ToWide(config.outputPath),
|
||||
width,
|
||||
height,
|
||||
config.fps,
|
||||
bitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
audioFormat ? &encoderAudioFormat : nullptr)) {
|
||||
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::atomic<bool> stopRequested = false;
|
||||
std::atomic<bool> firstFrameWritten = false;
|
||||
std::atomic<bool> encodeFailed = false;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
|
||||
int64_t latestFrameTimestampHns = 0;
|
||||
int64_t firstFrameTimestampHns = -1;
|
||||
std::vector<BYTE> latestWebcamFrame;
|
||||
int latestWebcamWidth = 0;
|
||||
int latestWebcamHeight = 0;
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
if (stopRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::scoped_lock lock(mutex);
|
||||
if (!latestFrameTexture) {
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
texture->GetDesc(&desc);
|
||||
desc.BindFlags = 0;
|
||||
desc.CPUAccessFlags = 0;
|
||||
desc.MiscFlags = 0;
|
||||
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
cv.notify_all();
|
||||
}
|
||||
});
|
||||
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto startedAt = std::chrono::steady_clock::now();
|
||||
uint64_t frameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!stopRequested && !encodeFailed) {
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
if (webcamActive) {
|
||||
std::vector<BYTE> candidateWebcamFrame;
|
||||
int candidateWebcamWidth = 0;
|
||||
int candidateWebcamHeight = 0;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame);
|
||||
latestWebcamWidth = candidateWebcamWidth;
|
||||
latestWebcamHeight = candidateWebcamHeight;
|
||||
hasVisibleWebcamFrame = true;
|
||||
}
|
||||
}
|
||||
const BgraFrameView webcamFrame{
|
||||
hasVisibleWebcamFrame && !latestWebcamFrame.empty() ? latestWebcamFrame.data() : nullptr,
|
||||
latestWebcamWidth,
|
||||
latestWebcamHeight,
|
||||
};
|
||||
const int64_t syntheticTimestampHns =
|
||||
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps);
|
||||
const int64_t sourceTimestampHns =
|
||||
latestFrameTimestampHns > 0 ? latestFrameTimestampHns : syntheticTimestampHns;
|
||||
if (firstFrameTimestampHns < 0) {
|
||||
firstFrameTimestampHns = sourceTimestampHns;
|
||||
}
|
||||
int64_t frameTimestampHns =
|
||||
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
|
||||
}
|
||||
if (latestFrameTexture && !encoder.writeFrame(
|
||||
latestFrameTexture.Get(),
|
||||
frameTimestampHns,
|
||||
webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
lastEncodedVideoTimestampHns = frameTimestampHns;
|
||||
}
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
const auto nextDeadline = startedAt +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(static_cast<double>(frameIndex) / config.fps));
|
||||
std::this_thread::sleep_until(nextDeadline);
|
||||
}
|
||||
};
|
||||
|
||||
std::thread videoWriterThread;
|
||||
|
||||
auto stopVideoWriter = [&]() {
|
||||
if (videoWriterThread.joinable()) {
|
||||
videoWriterThread.join();
|
||||
}
|
||||
};
|
||||
|
||||
auto startVideoWriter = [&]() {
|
||||
videoWriterThread = std::thread(writeVideoFrames);
|
||||
};
|
||||
|
||||
std::unique_ptr<AudioMixer> audioMixer;
|
||||
auto startAudioCaptures = [&]() -> bool {
|
||||
if (!audioFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
audioMixer = std::make_unique<AudioMixer>(
|
||||
encoderAudioFormat,
|
||||
config.captureSystemAudio ? systemAudioFormat : encoderAudioFormat,
|
||||
config.captureMic ? microphoneAudioFormat : encoderAudioFormat,
|
||||
config.captureSystemAudio,
|
||||
config.captureMic,
|
||||
config.microphoneGain,
|
||||
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!audioMixer->start()) {
|
||||
std::cerr << "ERROR: Failed to start native audio mixer" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.captureMic) {
|
||||
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioMixer->pushMicrophone(data, byteCount);
|
||||
})) {
|
||||
std::cerr << "ERROR: Failed to start WASAPI microphone capture" << std::endl;
|
||||
audioMixer->stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.captureSystemAudio) {
|
||||
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioMixer->pushSystem(data, byteCount);
|
||||
})) {
|
||||
std::cerr << "ERROR: Failed to start WASAPI loopback capture" << std::endl;
|
||||
microphoneCapture.stop();
|
||||
audioMixer->stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!startAudioCaptures()) {
|
||||
return 1;
|
||||
}
|
||||
if (config.webcamEnabled) {
|
||||
if (!webcamCapture.start()) {
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
std::cerr << "ERROR: Failed to start native webcam capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
webcamActive = true;
|
||||
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
|
||||
std::vector<BYTE> candidateWebcamFrame;
|
||||
int candidateWebcamWidth = 0;
|
||||
int candidateWebcamHeight = 0;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame);
|
||||
latestWebcamWidth = candidateWebcamWidth;
|
||||
latestWebcamHeight = candidateWebcamHeight;
|
||||
hasVisibleWebcamFrame = true;
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
if (!hasVisibleWebcamFrame) {
|
||||
std::cerr << "WARNING: Native webcam started but no visible frame was available before screen capture"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.start()) {
|
||||
webcamCapture.stop();
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
std::cerr << "ERROR: Failed to start WGC session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || stopRequested.load();
|
||||
});
|
||||
if (!started || !firstFrameWritten) {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
webcamCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
session.stop();
|
||||
std::cerr << "ERROR: Timed out waiting for first WGC frame" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioMixer) {
|
||||
audioMixer->beginTimeline();
|
||||
}
|
||||
startVideoWriter();
|
||||
|
||||
std::cout << "{\"event\":\"recording-started\",\"schemaVersion\":2}" << std::endl;
|
||||
std::cout << "Recording started" << std::endl;
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
cv.wait(lock, [&] {
|
||||
return stopRequested.load();
|
||||
});
|
||||
}
|
||||
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
webcamCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
stopVideoWriter();
|
||||
session.stop();
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
encoder.finalize();
|
||||
}
|
||||
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
|
||||
if (encodeFailed) {
|
||||
std::cerr << "ERROR: Failed to encode WGC frame" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
|
||||
<< jsonEscape(config.outputPath) << "\"}" << std::endl;
|
||||
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include "audio_sample_utils.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
|
||||
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
|
||||
}
|
||||
|
||||
void setFrameRate(IMFMediaType* type, UINT32 fps) {
|
||||
MFSetAttributeRatio(type, MF_MT_FRAME_RATE, fps, 1);
|
||||
}
|
||||
|
||||
void setPixelAspectRatio(IMFMediaType* type) {
|
||||
MFSetAttributeRatio(type, MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
|
||||
}
|
||||
|
||||
void setAudioFormat(IMFMediaType* type, UINT32 channels, UINT32 sampleRate, UINT32 bitsPerSample) {
|
||||
type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, channels);
|
||||
type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, sampleRate);
|
||||
type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, bitsPerSample);
|
||||
}
|
||||
|
||||
void compositeWebcam(BYTE* destination, int width, int height, const BgraFrameView& webcamFrame) {
|
||||
if (!webcamFrame.data || webcamFrame.width <= 0 || webcamFrame.height <= 0 || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int margin = std::max(16, std::min(width, height) / 60);
|
||||
const int maxOverlayWidth = std::max(2, width / 4);
|
||||
int overlayWidth = maxOverlayWidth;
|
||||
int overlayHeight = static_cast<int>(
|
||||
(static_cast<int64_t>(overlayWidth) * webcamFrame.height) / std::max(1, webcamFrame.width));
|
||||
const int maxOverlayHeight = std::max(2, height / 3);
|
||||
if (overlayHeight > maxOverlayHeight) {
|
||||
overlayHeight = maxOverlayHeight;
|
||||
overlayWidth = static_cast<int>(
|
||||
(static_cast<int64_t>(overlayHeight) * webcamFrame.width) / std::max(1, webcamFrame.height));
|
||||
}
|
||||
|
||||
overlayWidth = std::max(2, std::min(overlayWidth, width - margin * 2));
|
||||
overlayHeight = std::max(2, std::min(overlayHeight, height - margin * 2));
|
||||
const int originX = std::max(0, width - overlayWidth - margin);
|
||||
const int originY = std::max(0, height - overlayHeight - margin);
|
||||
|
||||
for (int y = 0; y < overlayHeight; y += 1) {
|
||||
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * webcamFrame.height) / overlayHeight);
|
||||
BYTE* destinationRow = destination + ((originY + y) * width + originX) * 4;
|
||||
for (int x = 0; x < overlayWidth; x += 1) {
|
||||
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * webcamFrame.width) / overlayWidth);
|
||||
const BYTE* source = webcamFrame.data + (sourceY * webcamFrame.width + sourceX) * 4;
|
||||
BYTE* target = destinationRow + x * 4;
|
||||
target[0] = source[0];
|
||||
target[1] = source[1];
|
||||
target[2] = source[2];
|
||||
target[3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MFEncoder::~MFEncoder() {
|
||||
finalize();
|
||||
}
|
||||
|
||||
bool MFEncoder::initialize(
|
||||
const std::wstring& outputPath,
|
||||
int width,
|
||||
int height,
|
||||
int fps,
|
||||
int bitrate,
|
||||
ID3D11Device* device,
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat) {
|
||||
width_ = (std::max(2, width) / 2) * 2;
|
||||
height_ = (std::max(2, height) / 2) * 2;
|
||||
fps_ = std::max(1, fps);
|
||||
device_ = device;
|
||||
context_ = context;
|
||||
|
||||
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
|
||||
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(output)")) {
|
||||
return false;
|
||||
}
|
||||
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
|
||||
outputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast<UINT32>(std::max(1, bitrate)));
|
||||
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
|
||||
setFrameSize(outputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
|
||||
setFrameRate(outputType.Get(), static_cast<UINT32>(fps_));
|
||||
setPixelAspectRatio(outputType.Get());
|
||||
|
||||
if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
|
||||
"MFCreateSinkWriterFromURL")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (audioFormat && !configureAudioStream(*audioFormat)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
|
||||
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) {
|
||||
return false;
|
||||
}
|
||||
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
|
||||
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
|
||||
inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(width_ * 4));
|
||||
setFrameSize(inputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
|
||||
setFrameRate(inputType.Get(), static_cast<UINT32>(fps_));
|
||||
setPixelAspectRatio(inputType.Get());
|
||||
|
||||
if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr),
|
||||
"SetInputMediaType")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) {
|
||||
if (!sinkWriter_) {
|
||||
return false;
|
||||
}
|
||||
if (audioFormat.sampleRate == 0 || audioFormat.channels == 0 || audioFormat.blockAlign == 0) {
|
||||
std::cerr << "ERROR: Invalid audio input format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const AudioInputFormat encoderFormat = makeAacCompatibleAudioFormat(audioFormat);
|
||||
const UINT32 aacBytesPerSecond = 24'000;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
|
||||
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
|
||||
return false;
|
||||
}
|
||||
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
|
||||
setAudioFormat(outputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, 16);
|
||||
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
|
||||
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
|
||||
|
||||
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
|
||||
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
|
||||
return false;
|
||||
}
|
||||
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
inputType->SetGUID(MF_MT_SUBTYPE, encoderFormat.subtype);
|
||||
setAudioFormat(inputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, encoderFormat.bitsPerSample);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, encoderFormat.blockAlign);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, encoderFormat.avgBytesPerSec);
|
||||
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
|
||||
|
||||
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
|
||||
"SetInputMediaType(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAudioStream_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
|
||||
if (stagingTexture_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
texture->GetDesc(&desc);
|
||||
desc.Width = static_cast<UINT>(width_);
|
||||
desc.Height = static_cast<UINT>(height_);
|
||||
desc.MipLevels = 1;
|
||||
desc.ArraySize = 1;
|
||||
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
|
||||
desc.SampleDesc.Count = 1;
|
||||
desc.SampleDesc.Quality = 0;
|
||||
desc.Usage = D3D11_USAGE_STAGING;
|
||||
desc.BindFlags = 0;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
desc.MiscFlags = 0;
|
||||
|
||||
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
|
||||
"CreateTexture2D(staging)");
|
||||
}
|
||||
|
||||
bool MFEncoder::copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame) {
|
||||
if (!ensureStagingTexture(texture)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
context_->CopyResource(stagingTexture_.Get(), texture);
|
||||
|
||||
D3D11_MAPPED_SUBRESOURCE mapped{};
|
||||
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* source = static_cast<const BYTE*>(mapped.pData);
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
|
||||
}
|
||||
if (webcamFrame) {
|
||||
compositeWebcam(destination, width_, height_, *webcamFrame);
|
||||
}
|
||||
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstTimestampHns_ < 0) {
|
||||
firstTimestampHns_ = timestampHns;
|
||||
}
|
||||
|
||||
int64_t sampleTime = timestampHns - firstTimestampHns_;
|
||||
if (sampleTime <= lastTimestampHns_) {
|
||||
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
|
||||
}
|
||||
const int64_t sampleDuration = 10'000'000LL / fps_;
|
||||
lastTimestampHns_ = sampleTime;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
|
||||
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyFrameToBuffer(texture, data, maxLength, webcamFrame);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
|
||||
return false;
|
||||
}
|
||||
if (!data || byteCount == 0 || durationHns <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* destination = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&destination, &maxLength, ¤tLength),
|
||||
"IMFMediaBuffer::Lock(audio)")) {
|
||||
return false;
|
||||
}
|
||||
if (maxLength < byteCount) {
|
||||
buffer->Unlock();
|
||||
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
std::memcpy(destination, data, byteCount);
|
||||
buffer->Unlock();
|
||||
buffer->SetCurrentLength(byteCount);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
|
||||
sample->SetSampleDuration(durationHns);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
|
||||
}
|
||||
|
||||
bool MFEncoder::finalize() {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (finalized_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
finalized_ = true;
|
||||
bool ok = true;
|
||||
if (sinkWriter_) {
|
||||
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
|
||||
sinkWriter_.Reset();
|
||||
}
|
||||
stagingTexture_.Reset();
|
||||
context_.Reset();
|
||||
device_.Reset();
|
||||
MFShutdown();
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <mfapi.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
struct BgraFrameView {
|
||||
const BYTE* data = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct AudioInputFormat {
|
||||
GUID subtype = MFAudioFormat_PCM;
|
||||
UINT32 sampleRate = 0;
|
||||
UINT32 channels = 0;
|
||||
UINT32 bitsPerSample = 0;
|
||||
UINT32 blockAlign = 0;
|
||||
UINT32 avgBytesPerSec = 0;
|
||||
};
|
||||
|
||||
class MFEncoder {
|
||||
public:
|
||||
MFEncoder() = default;
|
||||
~MFEncoder();
|
||||
|
||||
MFEncoder(const MFEncoder&) = delete;
|
||||
MFEncoder& operator=(const MFEncoder&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& outputPath,
|
||||
int width,
|
||||
int height,
|
||||
int fps,
|
||||
int bitrate,
|
||||
ID3D11Device* device,
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat = nullptr);
|
||||
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
|
||||
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
|
||||
bool finalize();
|
||||
|
||||
private:
|
||||
bool ensureStagingTexture(ID3D11Texture2D* texture);
|
||||
bool copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame);
|
||||
bool configureAudioStream(const AudioInputFormat& audioFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> device_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
|
||||
std::mutex writerMutex_;
|
||||
DWORD videoStreamIndex_ = 0;
|
||||
DWORD audioStreamIndex_ = 0;
|
||||
bool hasAudioStream_ = false;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
int64_t firstTimestampHns_ = -1;
|
||||
int64_t lastTimestampHns_ = -1;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "monitor_utils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
struct MonitorCandidate {
|
||||
HMONITOR monitor = nullptr;
|
||||
RECT rect{};
|
||||
};
|
||||
|
||||
std::vector<MonitorCandidate> enumerateMonitors() {
|
||||
std::vector<MonitorCandidate> monitors;
|
||||
EnumDisplayMonitors(
|
||||
nullptr,
|
||||
nullptr,
|
||||
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
|
||||
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
|
||||
result->push_back({monitor, *rect});
|
||||
return TRUE;
|
||||
},
|
||||
reinterpret_cast<LPARAM>(&monitors));
|
||||
return monitors;
|
||||
}
|
||||
|
||||
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
|
||||
return rect.left == bounds.x &&
|
||||
rect.top == bounds.y &&
|
||||
(rect.right - rect.left) == bounds.width &&
|
||||
(rect.bottom - rect.top) == bounds.height;
|
||||
}
|
||||
|
||||
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
|
||||
const LONG left = std::max<LONG>(rect.left, bounds.x);
|
||||
const LONG top = std::max<LONG>(rect.top, bounds.y);
|
||||
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
|
||||
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
|
||||
if (right <= left || bottom <= top) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
|
||||
const auto monitors = enumerateMonitors();
|
||||
if (monitors.empty()) {
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
|
||||
// Electron's display_id is not stable across all Windows capture backends.
|
||||
// Bounds are the most reliable contract because they come from Electron's
|
||||
// selected display and match the WGC monitor coordinate space.
|
||||
if (bounds && bounds->width > 0 && bounds->height > 0) {
|
||||
for (const auto& candidate : monitors) {
|
||||
if (rectMatchesBounds(candidate.rect, *bounds)) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
HMONITOR bestMonitor = nullptr;
|
||||
int64_t bestArea = 0;
|
||||
for (const auto& candidate : monitors) {
|
||||
const int64_t area = overlapArea(candidate.rect, *bounds);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
bestMonitor = candidate.monitor;
|
||||
}
|
||||
}
|
||||
if (bestMonitor) {
|
||||
return bestMonitor;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort fallback for helpers invoked without bounds. Some callers pass
|
||||
// zero-based ids while Win32 monitor handles are pointer values, so only use
|
||||
// this when it exactly matches the HMONITOR value.
|
||||
for (const auto& candidate : monitors) {
|
||||
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct MonitorBounds {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
|
||||
@@ -0,0 +1,411 @@
|
||||
#include "wasapi_loopback_capture.h"
|
||||
|
||||
#include <Functiondiscoverykeys_devpkey.h>
|
||||
#include <ksmedia.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
|
||||
constexpr int64_t HnsPerSecond = 10'000'000;
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
|
||||
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
|
||||
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
|
||||
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
}
|
||||
return GUID_NULL;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring result;
|
||||
result.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
|
||||
for (const wchar_t c : value) {
|
||||
if (std::iswalnum(c)) {
|
||||
result.push_back(static_cast<wchar_t>(std::towlower(c)));
|
||||
lastWasSpace = false;
|
||||
} else if (!lastWasSpace) {
|
||||
result.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.empty() && result.back() == L' ') {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
|
||||
const std::wstring candidate = normalizeDeviceName(candidateName);
|
||||
const std::wstring id = normalizeDeviceName(candidateId);
|
||||
const std::wstring requested = normalizeDeviceName(requestedName);
|
||||
if (requested.empty()) {
|
||||
return 0;
|
||||
}
|
||||
if (candidate == requested) {
|
||||
return 1000;
|
||||
}
|
||||
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
|
||||
return 900;
|
||||
}
|
||||
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
|
||||
return 800;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < requested.size()) {
|
||||
const size_t end = requested.find(L' ', pos);
|
||||
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
|
||||
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
|
||||
if (candidate.find(word) != std::wstring::npos) {
|
||||
score += 100;
|
||||
} else if (id.find(word) != std::wstring::npos) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
std::wstring getDeviceFriendlyName(IMMDevice* device) {
|
||||
if (!device) {
|
||||
return {};
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IPropertyStore> properties;
|
||||
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
|
||||
if (FAILED(hr) || !properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
PROPVARIANT value;
|
||||
PropVariantInit(&value);
|
||||
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
|
||||
std::wstring name;
|
||||
if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) {
|
||||
name = value.pwszVal;
|
||||
}
|
||||
PropVariantClear(&value);
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
|
||||
stop();
|
||||
if (mixFormat_) {
|
||||
CoTaskMemFree(mixFormat_);
|
||||
mixFormat_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeSystemLoopback() {
|
||||
return initialize(WasapiCaptureEndpoint::SystemLoopback, {}, {});
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
return initialize(WasapiCaptureEndpoint::Microphone, deviceId, deviceName);
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
HRESULT hr = CoCreateInstance(
|
||||
__uuidof(MMDeviceEnumerator),
|
||||
nullptr,
|
||||
CLSCTX_ALL,
|
||||
IID_PPV_ARGS(&deviceEnumerator_));
|
||||
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !deviceId.empty() && deviceId != L"default") {
|
||||
hr = deviceEnumerator_->GetDevice(deviceId.c_str(), &device_);
|
||||
if (FAILED(hr)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone device id directly"
|
||||
<< std::endl;
|
||||
device_.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && !deviceName.empty()) {
|
||||
if (!resolveMicrophoneByName(deviceName)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone by name; using default capture endpoint"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!device_) {
|
||||
const EDataFlow flow =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
|
||||
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
|
||||
if (!succeeded(hr, "GetDefaultAudioEndpoint")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
selectedDeviceName_ = getDeviceFriendlyName(device_.Get());
|
||||
|
||||
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
|
||||
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetMixFormat(&mixFormat_);
|
||||
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolveInputFormat(mixFormat_)) {
|
||||
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD streamFlags =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0;
|
||||
hr = audioClient_->Initialize(
|
||||
AUDCLNT_SHAREMODE_SHARED,
|
||||
streamFlags,
|
||||
BufferDurationHns,
|
||||
0,
|
||||
mixFormat_,
|
||||
nullptr);
|
||||
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
|
||||
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveMicrophoneByName(const std::wstring& deviceName) {
|
||||
if (!deviceEnumerator_ || deviceName.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceCollection> devices;
|
||||
HRESULT hr = deviceEnumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices);
|
||||
if (!succeeded(hr, "IMMDeviceEnumerator::EnumAudioEndpoints(eCapture)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT count = 0;
|
||||
hr = devices->GetCount(&count);
|
||||
if (!succeeded(hr, "IMMDeviceCollection::GetCount")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDevice> bestDevice;
|
||||
std::wstring bestId;
|
||||
std::wstring bestName;
|
||||
int bestScore = 0;
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
Microsoft::WRL::ComPtr<IMMDevice> candidate;
|
||||
hr = devices->Item(i, &candidate);
|
||||
if (FAILED(hr) || !candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LPWSTR rawId = nullptr;
|
||||
std::wstring candidateId;
|
||||
if (SUCCEEDED(candidate->GetId(&rawId)) && rawId) {
|
||||
candidateId = rawId;
|
||||
CoTaskMemFree(rawId);
|
||||
}
|
||||
|
||||
const std::wstring candidateName = getDeviceFriendlyName(candidate.Get());
|
||||
const int score = scoreDeviceName(candidateName, candidateId, deviceName);
|
||||
std::wcerr << L"Native microphone candidate: " << candidateName << L" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestDevice = candidate;
|
||||
bestId = candidateId;
|
||||
bestName = candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestDevice || bestScore <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
device_ = bestDevice;
|
||||
std::wcerr << L"Selected native microphone endpoint: " << bestName << L" id=" << bestId << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
|
||||
const GUID subtype = audioSubtypeFromFormat(mixFormat);
|
||||
if (subtype == GUID_NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
inputFormat_.subtype = subtype;
|
||||
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
|
||||
inputFormat_.channels = mixFormat->nChannels;
|
||||
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
|
||||
inputFormat_.blockAlign = mixFormat->nBlockAlign;
|
||||
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
|
||||
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::start(AudioCallback callback) {
|
||||
if (!audioClient_ || !captureClient_ || !callback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
callback_ = std::move(callback);
|
||||
stopRequested_ = false;
|
||||
writtenFrames_ = 0;
|
||||
lastDevicePositionEnd_ = 0;
|
||||
hasLastDevicePosition_ = false;
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
if (!succeeded(hr, "IAudioClient::Start")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
thread_ = std::thread([this] {
|
||||
captureLoop();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (audioClient_) {
|
||||
audioClient_->Stop();
|
||||
}
|
||||
}
|
||||
|
||||
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
|
||||
return inputFormat_;
|
||||
}
|
||||
|
||||
const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::captureLoop() {
|
||||
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
|
||||
constexpr uint64_t MaxSilenceChunkFrames = 4800;
|
||||
uint64_t remainingFrames = frames;
|
||||
int64_t currentTimestampHns = timestampHns;
|
||||
while (remainingFrames > 0 && !stopRequested_) {
|
||||
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
|
||||
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
|
||||
const int64_t chunkDurationHns =
|
||||
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
silenceBuffer_.assign(chunkBytes, 0);
|
||||
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
|
||||
remainingFrames -= chunkFrames;
|
||||
currentTimestampHns += chunkDurationHns;
|
||||
}
|
||||
};
|
||||
|
||||
while (!stopRequested_) {
|
||||
UINT32 packetFrames = 0;
|
||||
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
while (packetFrames > 0 && !stopRequested_) {
|
||||
BYTE* data = nullptr;
|
||||
UINT32 framesAvailable = 0;
|
||||
DWORD flags = 0;
|
||||
UINT64 devicePosition = 0;
|
||||
UINT64 qpcPosition = 0;
|
||||
|
||||
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
(void)qpcPosition;
|
||||
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
|
||||
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
|
||||
const int64_t gapTimestampHns =
|
||||
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
emitSilenceFrames(gapFrames, gapTimestampHns);
|
||||
}
|
||||
}
|
||||
|
||||
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
|
||||
inputFormat_.sampleRate);
|
||||
|
||||
if (byteCount > 0) {
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
|
||||
silenceBuffer_.assign(byteCount, 0);
|
||||
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
|
||||
} else {
|
||||
callback_(data, byteCount, timestampHns, durationHns);
|
||||
}
|
||||
}
|
||||
|
||||
writtenFrames_ += framesAvailable;
|
||||
lastDevicePositionEnd_ = devicePosition + framesAvailable;
|
||||
hasLastDevicePosition_ = true;
|
||||
captureClient_->ReleaseBuffer(framesAvailable);
|
||||
|
||||
hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
|
||||
<< std::hex << hr << std::dec << ")" << std::endl;
|
||||
packetFrames = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <audioclient.h>
|
||||
#include <mmdeviceapi.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
enum class WasapiCaptureEndpoint {
|
||||
SystemLoopback,
|
||||
Microphone,
|
||||
};
|
||||
|
||||
class WasapiLoopbackCapture {
|
||||
public:
|
||||
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
WasapiLoopbackCapture() = default;
|
||||
~WasapiLoopbackCapture();
|
||||
|
||||
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
|
||||
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
|
||||
|
||||
bool initializeSystemLoopback();
|
||||
bool initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool start(AudioCallback callback);
|
||||
void stop();
|
||||
|
||||
const AudioInputFormat& inputFormat() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool resolveMicrophoneByName(const std::wstring& deviceName);
|
||||
void captureLoop();
|
||||
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
|
||||
Microsoft::WRL::ComPtr<IMMDevice> device_;
|
||||
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
|
||||
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
|
||||
WAVEFORMATEX* mixFormat_ = nullptr;
|
||||
AudioInputFormat inputFormat_{};
|
||||
std::wstring selectedDeviceName_;
|
||||
AudioCallback callback_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::vector<BYTE> silenceBuffer_;
|
||||
uint64_t writtenFrames_ = 0;
|
||||
uint64_t lastDevicePositionEnd_ = 0;
|
||||
bool hasLastDevicePosition_ = false;
|
||||
};
|
||||
@@ -0,0 +1,417 @@
|
||||
#include "webcam_capture.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
|
||||
WCHAR* value = nullptr;
|
||||
UINT32 length = 0;
|
||||
if (FAILED(activate->GetAllocatedString(key, &value, &length)) || !value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::wstring result(value, value + length);
|
||||
CoTaskMemFree(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
|
||||
if (haystack.empty() || needle.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring lowerHaystack = haystack;
|
||||
std::wstring lowerNeedle = needle;
|
||||
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
|
||||
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
|
||||
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
|
||||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring normalized;
|
||||
normalized.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
for (const wchar_t ch : value) {
|
||||
if (std::iswalnum(ch)) {
|
||||
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
|
||||
lastWasSpace = false;
|
||||
continue;
|
||||
}
|
||||
if (!lastWasSpace) {
|
||||
normalized.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
while (!normalized.empty() && normalized.back() == L' ') {
|
||||
normalized.pop_back();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::wstring> splitWords(const std::wstring& value) {
|
||||
std::vector<std::wstring> words;
|
||||
size_t start = 0;
|
||||
while (start < value.size()) {
|
||||
const size_t end = value.find(L' ', start);
|
||||
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
|
||||
words.push_back(word);
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
int deviceMatchScore(
|
||||
const std::wstring& candidateName,
|
||||
const std::wstring& candidateLink,
|
||||
const std::wstring& requestedName,
|
||||
const std::wstring& requestedId) {
|
||||
int score = 0;
|
||||
const auto normalizedName = normalizeDeviceName(candidateName);
|
||||
const auto normalizedLink = normalizeDeviceName(candidateLink);
|
||||
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
|
||||
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
|
||||
|
||||
if (!normalizedRequestedName.empty()) {
|
||||
if (normalizedName == normalizedRequestedName) {
|
||||
score = std::max(score, 1000);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
|
||||
score = std::max(score, 900);
|
||||
}
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
|
||||
score = std::max(score, 800);
|
||||
}
|
||||
|
||||
int wordScore = 0;
|
||||
for (const auto& word : splitWords(normalizedRequestedName)) {
|
||||
if (normalizedName.find(word) != std::wstring::npos) {
|
||||
wordScore += 100;
|
||||
} else if (normalizedLink.find(word) != std::wstring::npos) {
|
||||
wordScore += 50;
|
||||
}
|
||||
}
|
||||
score = std::max(score, wordScore);
|
||||
}
|
||||
|
||||
if (!normalizedRequestedId.empty()) {
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedId)) {
|
||||
score = std::max(score, 700);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
|
||||
score = std::max(score, 600);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WebcamCapture::~WebcamCapture() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
usingDirectShow_ = false;
|
||||
selectedMatchScore_ = 0;
|
||||
if (!succeeded(MFStartup(MF_VERSION), "MFStartup(webcam)")) {
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
mfStarted_ = true;
|
||||
if (!selectDevice(deviceId, deviceName)) {
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && selectedMatchScore_ <= 0) {
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
std::cerr << "ERROR: Requested webcam device was not found by native Windows webcam providers"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
return configureReader(requestedWidth, requestedHeight, fps_);
|
||||
}
|
||||
|
||||
bool WebcamCapture::selectDevice(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(webcam enumeration)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(attributes->SetGUID(
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID),
|
||||
"SetGUID(webcam source type)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
IMFActivate** devices = nullptr;
|
||||
UINT32 deviceCount = 0;
|
||||
HRESULT hr = MFEnumDeviceSources(attributes.Get(), &devices, &deviceCount);
|
||||
if (!succeeded(hr, "MFEnumDeviceSources") || deviceCount == 0) {
|
||||
if (devices) {
|
||||
CoTaskMemFree(devices);
|
||||
}
|
||||
std::cerr << "ERROR: No native Windows webcam devices were found" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 selectedIndex = 0;
|
||||
int bestScore = 0;
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
const std::wstring name = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
const std::wstring symbolicLink = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK);
|
||||
const int score = deviceMatchScore(name, symbolicLink, deviceName, deviceId);
|
||||
std::wcerr << L"INFO: Native webcam candidate [" << index << L"] name=\"" << name << L"\" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
selectedIndex = index;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && bestScore <= 0) {
|
||||
std::cerr << "WARNING: Requested webcam device was not found by Media Foundation; trying DirectShow"
|
||||
<< std::endl;
|
||||
}
|
||||
|
||||
selectedMatchScore_ = bestScore;
|
||||
selectedDeviceName_ = readAllocatedString(devices[selectedIndex], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
hr = devices[selectedIndex]->ActivateObject(IID_PPV_ARGS(&mediaSource_));
|
||||
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
devices[index]->Release();
|
||||
}
|
||||
CoTaskMemFree(devices);
|
||||
|
||||
return succeeded(hr, "ActivateObject(webcam)");
|
||||
}
|
||||
|
||||
bool WebcamCapture::configureReader(int requestedWidth, int requestedHeight, int requestedFps) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 2), "MFCreateAttributes(webcam reader)")) {
|
||||
return false;
|
||||
}
|
||||
attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
|
||||
attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);
|
||||
|
||||
if (!succeeded(MFCreateSourceReaderFromMediaSource(mediaSource_.Get(), attributes.Get(), &sourceReader_),
|
||||
"MFCreateSourceReaderFromMediaSource(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> mediaType;
|
||||
if (!succeeded(MFCreateMediaType(&mediaType), "MFCreateMediaType(webcam output)")) {
|
||||
return false;
|
||||
}
|
||||
mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
mediaType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
|
||||
if (requestedWidth > 0 && requestedHeight > 0) {
|
||||
MFSetAttributeSize(mediaType.Get(), MF_MT_FRAME_SIZE, static_cast<UINT32>(requestedWidth), static_cast<UINT32>(requestedHeight));
|
||||
}
|
||||
MFSetAttributeRatio(mediaType.Get(), MF_MT_FRAME_RATE, static_cast<UINT32>(std::max(1, requestedFps)), 1);
|
||||
|
||||
if (!succeeded(sourceReader_->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, mediaType.Get()),
|
||||
"SetCurrentMediaType(webcam RGB32)")) {
|
||||
return false;
|
||||
}
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> currentType;
|
||||
if (!succeeded(sourceReader_->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, ¤tType),
|
||||
"GetCurrentMediaType(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 width = 0;
|
||||
UINT32 height = 0;
|
||||
if (FAILED(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &width, &height)) || width == 0 || height == 0) {
|
||||
width = static_cast<UINT32>(requestedWidth > 0 ? requestedWidth : 1280);
|
||||
height = static_cast<UINT32>(requestedHeight > 0 ? requestedHeight : 720);
|
||||
}
|
||||
width_ = static_cast<int>(width);
|
||||
height_ = static_cast<int>(height);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebcamCapture::start() {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.start();
|
||||
}
|
||||
if (!sourceReader_ || thread_.joinable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopRequested_ = false;
|
||||
thread_ = std::thread(&WebcamCapture::captureLoop, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebcamCapture::stop() {
|
||||
directShowCapture_.stop();
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WebcamCapture::captureLoop() {
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
|
||||
while (!stopRequested_) {
|
||||
DWORD streamIndex = 0;
|
||||
DWORD flags = 0;
|
||||
LONGLONG timestamp = 0;
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
HRESULT hr = sourceReader_->ReadSample(
|
||||
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
|
||||
0,
|
||||
&streamIndex,
|
||||
&flags,
|
||||
×tamp,
|
||||
&sample);
|
||||
(void)streamIndex;
|
||||
(void)timestamp;
|
||||
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "WARNING: Failed to read webcam sample (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
continue;
|
||||
}
|
||||
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) {
|
||||
break;
|
||||
}
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (FAILED(sample->ConvertToContiguousBuffer(&buffer)) || !buffer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (FAILED(buffer->Lock(&data, &maxLength, ¤tLength)) || !data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const DWORD expectedLength = static_cast<DWORD>(std::max(0, width_) * std::max(0, height_) * 4);
|
||||
if (currentLength >= expectedLength && expectedLength > 0) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_.assign(data, data + expectedLength);
|
||||
}
|
||||
|
||||
buffer->Unlock();
|
||||
}
|
||||
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
bool WebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.copyLatestFrame(destination, width, height);
|
||||
}
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int WebcamCapture::width() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.width();
|
||||
}
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WebcamCapture::height() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.height();
|
||||
}
|
||||
return height_;
|
||||
}
|
||||
|
||||
int WebcamCapture::fps() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.fps();
|
||||
}
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& WebcamCapture::selectedDeviceName() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.selectedDeviceName();
|
||||
}
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class WebcamCapture {
|
||||
public:
|
||||
WebcamCapture() = default;
|
||||
~WebcamCapture();
|
||||
|
||||
WebcamCapture(const WebcamCapture&) = delete;
|
||||
WebcamCapture& operator=(const WebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool selectDevice(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool configureReader(int requestedWidth, int requestedHeight, int requestedFps);
|
||||
void captureLoop();
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaSource> mediaSource_;
|
||||
Microsoft::WRL::ComPtr<IMFSourceReader> sourceReader_;
|
||||
DirectShowWebcamCapture directShowCapture_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool mfStarted_ = false;
|
||||
bool usingDirectShow_ = false;
|
||||
int selectedMatchScore_ = 0;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
#include "wgc_session.h"
|
||||
|
||||
#include <Windows.Graphics.Capture.Interop.h>
|
||||
#include <dxgi1_2.h>
|
||||
#include <inspectable.h>
|
||||
#include <winrt/base.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
namespace wf = winrt::Windows::Foundation;
|
||||
namespace wgcap = winrt::Windows::Graphics::Capture;
|
||||
namespace wgdx = winrt::Windows::Graphics::DirectX;
|
||||
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
|
||||
|
||||
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
|
||||
::IDXGIDevice* dxgiDevice,
|
||||
::IInspectable** graphicsDevice);
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t timeSpanToHns(wf::TimeSpan const& value) {
|
||||
return value.count();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WgcSession::~WgcSession() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WgcSession::createD3DDevice() {
|
||||
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
|
||||
#if defined(_DEBUG)
|
||||
flags |= D3D11_CREATE_DEVICE_DEBUG;
|
||||
#endif
|
||||
|
||||
D3D_FEATURE_LEVEL featureLevels[] = {
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_10_1,
|
||||
D3D_FEATURE_LEVEL_10_0,
|
||||
};
|
||||
D3D_FEATURE_LEVEL featureLevel{};
|
||||
|
||||
HRESULT hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
|
||||
#if defined(_DEBUG)
|
||||
if (FAILED(hr)) {
|
||||
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
|
||||
hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!succeeded(hr, "D3D11CreateDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
|
||||
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrt::com_ptr<::IInspectable> inspectableDevice;
|
||||
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
|
||||
"CreateDirect3D11DeviceFromDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HMONITOR monitor) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForMonitor(
|
||||
monitor,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForMonitor")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HWND window) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForWindow(
|
||||
window,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForWindow")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
void WgcSession::applySessionOptions(bool captureCursor) {
|
||||
try {
|
||||
session_.IsCursorCaptureEnabled(captureCursor);
|
||||
} catch (...) {
|
||||
// Older WGC builds can omit this property. They will keep the OS default.
|
||||
}
|
||||
|
||||
try {
|
||||
session_.IsBorderRequired(false);
|
||||
} catch (...) {
|
||||
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
|
||||
}
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(monitor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::setFrameCallback(FrameCallback callback) {
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
frameCallback_ = std::move(callback);
|
||||
}
|
||||
|
||||
bool WgcSession::start() {
|
||||
if (!session_) {
|
||||
return false;
|
||||
}
|
||||
session_.StartCapture();
|
||||
started_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::stop() {
|
||||
if (framePool_) {
|
||||
framePool_.FrameArrived(frameArrivedToken_);
|
||||
}
|
||||
if (session_) {
|
||||
session_.Close();
|
||||
session_ = nullptr;
|
||||
}
|
||||
if (framePool_) {
|
||||
framePool_.Close();
|
||||
framePool_ = nullptr;
|
||||
}
|
||||
item_ = nullptr;
|
||||
winrtDevice_ = nullptr;
|
||||
d3dContext_.Reset();
|
||||
d3dDevice_.Reset();
|
||||
started_ = false;
|
||||
}
|
||||
|
||||
void WgcSession::onFrameArrived(
|
||||
wgcap::Direct3D11CaptureFramePool const& sender,
|
||||
wf::IInspectable const&) {
|
||||
auto frame = sender.TryGetNextFrame();
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto surface = frame.Surface();
|
||||
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
|
||||
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
|
||||
if (FAILED(hr) || !texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrameCallback callback;
|
||||
{
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
callback = frameCallback_;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
|
||||
}
|
||||
frame.Close();
|
||||
}
|
||||
|
||||
int WgcSession::captureWidth() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WgcSession::captureHeight() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
ID3D11Device* WgcSession::device() const {
|
||||
return d3dDevice_.Get();
|
||||
}
|
||||
|
||||
ID3D11DeviceContext* WgcSession::context() const {
|
||||
return d3dContext_.Get();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <windows.graphics.capture.h>
|
||||
#include <windows.graphics.directx.direct3d11.interop.h>
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Graphics.Capture.h>
|
||||
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
|
||||
class WgcSession {
|
||||
public:
|
||||
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
|
||||
|
||||
WgcSession() = default;
|
||||
~WgcSession();
|
||||
|
||||
WgcSession(const WgcSession&) = delete;
|
||||
WgcSession& operator=(const WgcSession&) = delete;
|
||||
|
||||
bool initialize(HMONITOR monitor, int fps, bool captureCursor);
|
||||
bool initialize(HWND window, int fps, bool captureCursor);
|
||||
void setFrameCallback(FrameCallback callback);
|
||||
bool start();
|
||||
void stop();
|
||||
|
||||
int captureWidth() const;
|
||||
int captureHeight() const;
|
||||
ID3D11Device* device() const;
|
||||
ID3D11DeviceContext* context() const;
|
||||
|
||||
private:
|
||||
bool createD3DDevice();
|
||||
bool createCaptureItem(HMONITOR monitor);
|
||||
bool createCaptureItem(HWND window);
|
||||
void applySessionOptions(bool captureCursor);
|
||||
void onFrameArrived(
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
|
||||
winrt::Windows::Foundation::IInspectable const&);
|
||||
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
|
||||
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
|
||||
winrt::event_token frameArrivedToken_{};
|
||||
FrameCallback frameCallback_;
|
||||
std::mutex callbackMutex_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
bool started_ = false;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
|
||||
@@ -10,6 +12,9 @@ const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
assetBaseUrl,
|
||||
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
|
||||
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
|
||||
},
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
@@ -43,10 +48,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestAccessibilityAccess: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
@@ -57,8 +58,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||
setRecordingState: (
|
||||
recording: boolean,
|
||||
recordingId?: number,
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
||||
) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
|
||||
},
|
||||
isNativeWindowsCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-windows-capture-available");
|
||||
},
|
||||
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-windows-recording", request);
|
||||
},
|
||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "openscreen",
|
||||
"version": "1.4.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
@@ -48,7 +47,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.5",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
@@ -188,7 +186,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -397,7 +394,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -721,7 +717,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -770,7 +765,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1202,6 +1196,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1223,6 +1218,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1239,6 +1235,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1253,6 +1250,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -1962,6 +1960,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
|
||||
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6"
|
||||
}
|
||||
@@ -1976,7 +1975,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
|
||||
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/core": {
|
||||
"version": "7.4.3",
|
||||
@@ -2003,7 +2003,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
|
||||
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/filter-drop-shadow": {
|
||||
"version": "5.2.0",
|
||||
@@ -2030,19 +2031,22 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
|
||||
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/runner": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
|
||||
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/settings": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
|
||||
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/constants": "7.4.3",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -2054,6 +2058,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
|
||||
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/extensions": "7.4.3",
|
||||
"@pixi/settings": "7.4.3",
|
||||
@@ -2065,6 +2070,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
|
||||
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/color": "7.4.3",
|
||||
"@pixi/constants": "7.4.3",
|
||||
@@ -3643,7 +3649,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -3718,7 +3725,8 @@
|
||||
"version": "0.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
|
||||
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
@@ -3756,7 +3764,8 @@
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
|
||||
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -3855,7 +3864,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3867,7 +3875,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4149,7 +4156,6 @@
|
||||
"integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
@@ -4342,7 +4348,6 @@
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4868,7 +4873,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5050,6 +5054,7 @@
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
@@ -5400,7 +5405,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -5690,7 +5696,6 @@
|
||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -5783,7 +5788,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -5832,7 +5838,8 @@
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
@@ -6015,6 +6022,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -6035,6 +6043,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -6277,7 +6286,8 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
@@ -7689,6 +7699,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -7908,6 +7919,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -8093,17 +8105,6 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/isexe": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
@@ -8221,6 +8222,7 @@
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -8429,7 +8431,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz",
|
||||
"integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"examples",
|
||||
"playground"
|
||||
@@ -8475,7 +8476,6 @@
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
@@ -8546,7 +8546,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8691,6 +8690,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -8708,6 +8708,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -8718,6 +8719,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -8733,6 +8735,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -8846,6 +8849,7 @@
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -8904,7 +8908,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8917,7 +8920,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8954,7 +8956,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
@@ -9275,6 +9278,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -9481,6 +9485,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -9500,6 +9505,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
@@ -9516,6 +9522,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -9534,6 +9541,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -9874,7 +9882,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -9958,6 +9965,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -10272,19 +10280,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uiohook-napi": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz",
|
||||
"integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
@@ -10358,6 +10353,7 @@
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
|
||||
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^1.4.1",
|
||||
"qs": "^6.12.3"
|
||||
@@ -10370,7 +10366,8 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
@@ -10463,7 +10460,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -10553,8 +10549,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
@@ -10577,7 +10572,6 @@
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
|
||||
@@ -21,17 +21,27 @@
|
||||
"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:native:win": "node scripts/build-windows-wgc-helper.mjs",
|
||||
"build:win": "npm run build:native: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",
|
||||
"test:wgc-helper:win": "node scripts/test-windows-wgc-helper.mjs",
|
||||
"test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window",
|
||||
"test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio",
|
||||
"test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone",
|
||||
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
|
||||
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
|
||||
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
|
||||
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
|
||||
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.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",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"postinstall": "npm run rebuild:native"
|
||||
"test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
@@ -73,7 +83,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.5",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture");
|
||||
const BUILD_DIR = path.join(SOURCE_DIR, "build");
|
||||
const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs");
|
||||
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64");
|
||||
const CMAKE = process.env.CMAKE_EXE ?? "cmake";
|
||||
|
||||
function findVcVarsAll() {
|
||||
const explicit = process.env.VCVARSALL;
|
||||
if (explicit && fs.existsSync(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const roots = [
|
||||
process.env.VSINSTALLDIR,
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\Community",
|
||||
];
|
||||
|
||||
for (const root of roots.filter(Boolean)) {
|
||||
const candidate = path.join(root, "VC", "Auxiliary", "Build", "vcvarsall.bat");
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
...options,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runInVsEnv(command) {
|
||||
const vcvarsAll = findVcVarsAll();
|
||||
if (!vcvarsAll) {
|
||||
throw new Error(
|
||||
"Could not find Visual Studio vcvarsall.bat. Install Visual Studio Build Tools with C++.",
|
||||
);
|
||||
}
|
||||
|
||||
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
"@echo off",
|
||||
`call "${vcvarsAll}" x64`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
|
||||
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
|
||||
command,
|
||||
"exit /b %errorlevel%",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
try {
|
||||
await run("cmd.exe", ["/d", "/c", cmdPath]);
|
||||
} finally {
|
||||
fs.rmSync(cmdPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper build: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
||||
|
||||
await runInVsEnv(
|
||||
`"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`,
|
||||
);
|
||||
await runInVsEnv(`"${CMAKE}" --build "${BUILD_DIR}" --config Release`);
|
||||
|
||||
const outputPath = path.join(BUILD_DIR, "wgc-capture.exe");
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
|
||||
fs.copyFileSync(outputPath, distributablePath);
|
||||
|
||||
console.log(`Built ${outputPath}`);
|
||||
console.log(`Copied ${distributablePath}`);
|
||||
@@ -0,0 +1,258 @@
|
||||
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.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();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const CLICK_ANIMATION_MS = 260;
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
"Usage: node scripts/inspect-native-cursor-click-bounce.mjs <video-or-cursor-json-path> [--bounce=5]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function getCursorJsonPath(inputPath) {
|
||||
if (!inputPath) {
|
||||
usage();
|
||||
}
|
||||
|
||||
const resolved = path.resolve(inputPath);
|
||||
if (resolved.endsWith(".cursor.json")) {
|
||||
return resolved;
|
||||
}
|
||||
return `${resolved}.cursor.json`;
|
||||
}
|
||||
|
||||
function getBounceValue() {
|
||||
const arg = process.argv.find((value) => value.startsWith("--bounce="));
|
||||
const parsed = Number(arg?.slice("--bounce=".length) ?? 5);
|
||||
return Number.isFinite(parsed) ? Math.min(5, Math.max(0, parsed)) : 5;
|
||||
}
|
||||
|
||||
function clickBounceProgress(samples, timeMs) {
|
||||
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
||||
const sample = samples[index];
|
||||
if (sample.timeMs > timeMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ageMs = timeMs - sample.timeMs;
|
||||
if (ageMs > CLICK_ANIMATION_MS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sample.interactionType === "click") {
|
||||
return 1 - ageMs / CLICK_ANIMATION_MS;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clickBounceScale(clickBounce, progress) {
|
||||
if (progress <= 0 || clickBounce <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const intensity = Math.min(5, Math.max(0, clickBounce)) / 5;
|
||||
const elapsed = 1 - Math.min(1, Math.max(0, progress));
|
||||
if (elapsed < 0.38) {
|
||||
const pressProgress = Math.sin((elapsed / 0.38) * Math.PI);
|
||||
return 1 - pressProgress * intensity * 0.24;
|
||||
}
|
||||
|
||||
const reboundProgress = Math.sin(((elapsed - 0.38) / 0.62) * Math.PI);
|
||||
return 1 + reboundProgress * intensity * 0.16;
|
||||
}
|
||||
|
||||
const cursorJsonPath = getCursorJsonPath(process.argv[2]);
|
||||
const clickBounce = getBounceValue();
|
||||
const parsed = JSON.parse(fs.readFileSync(cursorJsonPath, "utf8"));
|
||||
const samples = (Array.isArray(parsed) ? parsed : (parsed.samples ?? [])).sort(
|
||||
(a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0),
|
||||
);
|
||||
const clicks = samples.filter((sample) => sample.interactionType === "click");
|
||||
|
||||
const windows = clicks.slice(0, 8).map((click) => {
|
||||
const times = [0, 33, 66, 100, 133, 166, 200, 233, 260].map(
|
||||
(offsetMs) => click.timeMs + offsetMs,
|
||||
);
|
||||
return {
|
||||
clickTimeMs: click.timeMs,
|
||||
cursorType: click.cursorType ?? null,
|
||||
assetId: click.assetId ?? null,
|
||||
scales: times.map((timeMs) => ({
|
||||
timeMs,
|
||||
progress: Number(clickBounceProgress(samples, timeMs).toFixed(3)),
|
||||
scale: Number(clickBounceScale(clickBounce, clickBounceProgress(samples, timeMs)).toFixed(3)),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const report = {
|
||||
cursorJsonPath,
|
||||
provider: parsed.provider ?? (Array.isArray(parsed) ? "legacy-array" : null),
|
||||
sampleCount: samples.length,
|
||||
assetCount: Array.isArray(parsed.assets) ? parsed.assets.length : 0,
|
||||
clickCount: clicks.length,
|
||||
interactionCounts: samples.reduce((counts, sample) => {
|
||||
const key = sample.interactionType ?? "missing";
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {}),
|
||||
clickBounce,
|
||||
windows,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (clicks.length === 0) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
// uiohook-napi click capture is macOS-only at runtime (gated in
|
||||
// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
|
||||
// without X11 dev headers don't fail npm install. The library's prebuilt
|
||||
// .node binaries are still bundled and loadable; we just don't need a fresh
|
||||
// build against Electron's ABI on platforms where we don't load it.
|
||||
if (process.platform !== "darwin") {
|
||||
console.log(
|
||||
`[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 0);
|
||||
@@ -0,0 +1,344 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const HELPER_PATH =
|
||||
process.env.OPENSCREEN_WGC_CAPTURE_EXE ??
|
||||
path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe");
|
||||
|
||||
const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000);
|
||||
const WITH_SYSTEM_AUDIO =
|
||||
process.env.OPENSCREEN_WGC_TEST_SYSTEM_AUDIO === "true" ||
|
||||
process.argv.includes("--system-audio");
|
||||
const WITH_MICROPHONE =
|
||||
process.env.OPENSCREEN_WGC_TEST_MICROPHONE === "true" ||
|
||||
process.argv.includes("--microphone") ||
|
||||
process.argv.includes("--mic");
|
||||
const WITH_WINDOW =
|
||||
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
|
||||
const WITH_WEBCAM =
|
||||
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
|
||||
|
||||
function runHelper(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(HELPER_PATH, [JSON.stringify(config)], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stopTimer = null;
|
||||
const scheduleStop = () => {
|
||||
if (stopTimer) {
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(() => {
|
||||
child.stdin.write("stop\n");
|
||||
}, DURATION_MS);
|
||||
};
|
||||
const fallbackTimer = setTimeout(scheduleStop, 15_000);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
if (stdout.includes('"recording-started"') || stdout.includes("Recording started")) {
|
||||
scheduleStop();
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(fallbackTimer);
|
||||
if (stopTimer) {
|
||||
clearTimeout(stopTimer);
|
||||
}
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startFixtureWindow() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("mspaint.exe", [], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
const poll = setInterval(() => {
|
||||
const lookup = spawnSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue).MainWindowHandle`,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
const handle = lookup.stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.find((line) => /^\d+$/.test(line.trim()));
|
||||
if (handle && handle !== "0") {
|
||||
clearInterval(poll);
|
||||
clearTimeout(timer);
|
||||
resolve({ child, sourceId: `window:${handle.trim()}:0` });
|
||||
}
|
||||
}, 250);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(poll);
|
||||
child.kill();
|
||||
reject(new Error("Timed out waiting for fixture window handle"));
|
||||
}, 10_000);
|
||||
child.once("error", (error) => {
|
||||
clearInterval(poll);
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function scoreDeviceName(candidateName, candidateId, requestedName) {
|
||||
const candidate = normalizeDeviceName(candidateName ?? "");
|
||||
const id = normalizeDeviceName(candidateId ?? "");
|
||||
const requested = normalizeDeviceName(requestedName ?? "");
|
||||
if (!requested) return 0;
|
||||
if (candidate === requested) return 1000;
|
||||
if (candidate.includes(requested) || requested.includes(candidate)) return 900;
|
||||
if (id.includes(requested) || requested.includes(id)) return 800;
|
||||
return requested
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
|
||||
.reduce((score, word) => {
|
||||
if (candidate.includes(word)) return score + 100;
|
||||
if (id.includes(word)) return score + 50;
|
||||
return score;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function resolveDirectShowWebcamClsid(requestedName) {
|
||||
if (!requestedName) return "";
|
||||
const query = spawnSync(
|
||||
"reg.exe",
|
||||
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (query.status !== 0) return "";
|
||||
const entries = [];
|
||||
let current = {};
|
||||
for (const rawLine of query.stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (/^HKEY_/i.test(line)) {
|
||||
if (current.friendlyName || current.clsid) entries.push(current);
|
||||
current = {};
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
|
||||
if (!match) continue;
|
||||
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
|
||||
if (match[1] === "CLSID") current.clsid = match[2].trim();
|
||||
}
|
||||
if (current.friendlyName || current.clsid) entries.push(current);
|
||||
|
||||
let best = null;
|
||||
for (const entry of entries) {
|
||||
if (!entry.clsid) continue;
|
||||
const score = scoreDeviceName(entry.friendlyName, entry.clsid, requestedName);
|
||||
if (!best || score > best.score) {
|
||||
best = { ...entry, score };
|
||||
}
|
||||
}
|
||||
return best && best.score > 0 ? best.clsid : "";
|
||||
}
|
||||
|
||||
function probeStreams(outputPath) {
|
||||
const ffprobe = spawnSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_streams", "-of", "json", outputPath],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (ffprobe.status !== 0) {
|
||||
throw new Error(`ffprobe failed: ${ffprobe.stderr || ffprobe.stdout}`);
|
||||
}
|
||||
return JSON.parse(ffprobe.stdout).streams ?? [];
|
||||
}
|
||||
|
||||
function measureFirstFrameLuma(outputPath) {
|
||||
const ffmpeg = spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"pipe:1",
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (ffmpeg.status !== 0) {
|
||||
throw new Error(`ffmpeg frame extraction failed: ${ffmpeg.stderr?.toString() ?? ""}`);
|
||||
}
|
||||
const data = ffmpeg.stdout;
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error(`ffmpeg did not return frame data for ${outputPath}`);
|
||||
}
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const value of data) {
|
||||
sum += value;
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
return { average: sum / data.length, max };
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper smoke test: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(HELPER_PATH)) {
|
||||
throw new Error(`WGC helper not found at ${HELPER_PATH}. Run npm run build:native:win first.`);
|
||||
}
|
||||
|
||||
const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
|
||||
);
|
||||
|
||||
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
|
||||
|
||||
const config = {
|
||||
schemaVersion: 2,
|
||||
recordingId: Date.now(),
|
||||
outputPath,
|
||||
sourceType: fixtureWindow ? "window" : "display",
|
||||
sourceId: fixtureWindow ? fixtureWindow.sourceId : "screen:0:0",
|
||||
displayId: 0,
|
||||
fps: 30,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
displayX: 0,
|
||||
displayY: 0,
|
||||
displayW: 1920,
|
||||
displayH: 1080,
|
||||
hasDisplayBounds: true,
|
||||
captureSystemAudio: WITH_SYSTEM_AUDIO,
|
||||
captureMic: WITH_MICROPHONE,
|
||||
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
|
||||
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
|
||||
microphoneGain: 1.4,
|
||||
webcamEnabled: WITH_WEBCAM,
|
||||
webcamDeviceId: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_ID ?? "",
|
||||
webcamDeviceName: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
||||
webcamDirectShowClsid: resolveDirectShowWebcamClsid(
|
||||
process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
||||
),
|
||||
webcamWidth: 640,
|
||||
webcamHeight: 360,
|
||||
webcamFps: 30,
|
||||
outputs: { screenPath: outputPath },
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runHelper(config);
|
||||
} finally {
|
||||
if (fixtureWindow) {
|
||||
fixtureWindow.child.kill();
|
||||
}
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
if (
|
||||
WITH_WEBCAM &&
|
||||
/No native Windows webcam devices were found|Failed to initialize native webcam/.test(
|
||||
result.stderr,
|
||||
)
|
||||
) {
|
||||
console.log("Skipping WGC webcam smoke test: no native Windows webcam device is available.");
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`WGC helper exited with ${result.code}\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
|
||||
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
|
||||
}
|
||||
|
||||
const streams = probeStreams(outputPath);
|
||||
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||
const webcamFormatLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"webcam-format"'));
|
||||
const webcamFormat = webcamFormatLine ? JSON.parse(webcamFormatLine) : null;
|
||||
const audioFormatLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"audio-format"'));
|
||||
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
|
||||
const nativeWebcamDiagnostics = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("Native webcam candidate"));
|
||||
const nativeMicrophoneDiagnostics = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
(line) =>
|
||||
line.includes("Native microphone candidate") ||
|
||||
line.includes("Selected native microphone endpoint"),
|
||||
);
|
||||
if (!hasVideo) {
|
||||
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
|
||||
}
|
||||
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) {
|
||||
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
|
||||
}
|
||||
const frameLuma = measureFirstFrameLuma(outputPath);
|
||||
if (frameLuma.average < 1 && frameLuma.max < 5) {
|
||||
throw new Error(
|
||||
`WGC helper output first frame is black: ${outputPath}\n${result.stdout}\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
outputPath,
|
||||
bytes: fs.statSync(outputPath).size,
|
||||
streams: streams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
|
||||
selectedWebcamDeviceName: webcamFormat?.deviceName,
|
||||
nativeMicrophoneDiagnostics,
|
||||
nativeWebcamDiagnostics,
|
||||
firstFrameLuma: frameLuma,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.501 2.8601L15.884 11.2611C16.937 12.3171 16.19 14.1191 14.699 14.1191H13.475L14.6908 17.0067C14.9038 17.5127 14.9068 18.0727 14.6998 18.5817C14.4918 19.0917 14.0978 19.4897 13.5898 19.7027C13.3338 19.8097 13.0658 19.8637 12.7918 19.8637C11.9608 19.8637 11.2158 19.3687 10.8938 18.6027L9.616 15.565L8.784 16.3031C7.703 17.2591 6 16.4921 6 15.0481V3.4811C6 2.6971 6.947 2.3051 7.501 2.8601Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.9995 4.1292C6.9995 3.9982 7.1585 3.9322 7.2505 4.0252L15.1585 11.9502C15.5895 12.3822 15.2835 13.1192 14.6735 13.1192H11.9695L13.7691 17.3936C13.9961 17.9336 13.7421 18.5546 13.2031 18.7806C12.6621 19.0076 12.0421 18.7546 11.8161 18.2156L9.9985 13.8917L8.1385 15.5392C7.7225 15.9072 7.0806 15.6507 7.0065 15.1274L6.9995 15.0262V4.1292Z" fill="black"/>
|
||||
<circle cx="22" cy="22" r="7.25" stroke="white" stroke-width="3.5"/>
|
||||
<path d="M22 14.75C23.9228 14.75 25.7669 15.5138 27.1265 16.8735" stroke="black" stroke-width="2.3" stroke-linecap="round"/>
|
||||
<path d="M27.1265 16.8735C28.4862 18.2331 29.25 20.0772 29.25 22" stroke="#2563EB" stroke-width="2.3" stroke-linecap="round"/>
|
||||
<path d="M22 29.25C17.9959 29.25 14.75 26.0041 14.75 22" stroke="black" stroke-width="2.3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_1_311)">
|
||||
<path d="M12.0455 7.54446C12.819 7.64044 13.5864 7.88965 14.3038 8.30386C17.1736 9.96071 18.1568 13.6303 16.5 16.5C14.8431 13.6303 11.1736 12.647 8.30384 14.3039C7.58745 14.7175 6.98862 15.2565 6.51907 15.8772C6.74376 12.2206 8.93355 9.09535 12.0455 7.54446Z" fill="url(#paint0_linear_1_311)"/>
|
||||
<path d="M6.51908 15.8772C6.98862 15.2565 7.58745 14.7175 8.30385 14.3038C11.1736 12.647 14.8431 13.6302 16.5 16.5C13.1863 16.5 10.5 19.1863 10.5 22.5C10.5 23.3277 10.6676 24.1162 10.9707 24.8336C8.27601 23.0421 6.5 19.9784 6.5 16.5C6.5 16.2908 6.50642 16.0832 6.51908 15.8772Z" fill="url(#paint1_linear_1_311)"/>
|
||||
<path d="M10.9707 24.8336C10.6676 24.1163 10.5 23.3277 10.5 22.5C10.5 19.1863 13.1863 16.5 16.5 16.5C14.8431 19.3698 15.8264 23.0393 18.6962 24.6962C19.4136 25.1104 20.181 25.3596 20.9545 25.4555C19.6131 26.124 18.1005 26.5 16.5 26.5C14.4556 26.5 12.5545 25.8865 10.9707 24.8336Z" fill="url(#paint2_linear_1_311)"/>
|
||||
<path d="M20.9545 25.4555C20.181 25.3596 19.4136 25.1104 18.6962 24.6962C15.8264 23.0393 14.8432 19.3698 16.5 16.5C18.1569 19.3698 21.8264 20.353 24.6962 18.6962C25.4126 18.2825 26.0114 17.7435 26.4809 17.1228C26.2562 20.7794 24.0665 23.9047 20.9545 25.4555Z" fill="url(#paint3_linear_1_311)"/>
|
||||
<path d="M26.4809 17.1228C26.0114 17.7435 25.4125 18.2825 24.6962 18.6961C21.8264 20.353 18.1569 19.3697 16.5 16.5C19.8137 16.5 22.5 13.8137 22.5 10.5C22.5 9.67229 22.3324 8.88374 22.0293 8.16641C24.724 9.95791 26.5 13.0215 26.5 16.5C26.5 16.7092 26.4936 16.9168 26.4809 17.1228Z" fill="url(#paint4_linear_1_311)"/>
|
||||
<path d="M22.0293 8.16642C22.3324 8.88375 22.5 9.6723 22.5 10.5C22.5 13.8137 19.8137 16.5 16.5 16.5C18.1569 13.6302 17.1736 9.9607 14.3038 8.30385C13.5864 7.88964 12.819 7.64043 12.0455 7.54445C13.3869 6.87599 14.8995 6.5 16.5 6.5C18.5444 6.5 20.4455 7.11349 22.0293 8.16642Z" fill="url(#paint5_linear_1_311)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_1_311" x="4.5" y="5.5" width="24" height="24" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4049 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_311"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_311" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_1_311" x1="545.808" y1="7.54446" x2="545.808" y2="903.099" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFD305" style="stop-color:#FFD305;stop-color:color(display-p3 1.0000 0.8275 0.0196);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FDCF01" style="stop-color:#FDCF01;stop-color:color(display-p3 0.9922 0.8118 0.0039);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1_311" x1="506.5" y1="13.499" x2="506.5" y2="1146.96" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#52CF30" style="stop-color:#52CF30;stop-color:color(display-p3 0.3216 0.8118 0.1882);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#3BBD1C" style="stop-color:#3BBD1C;stop-color:color(display-p3 0.2314 0.7412 0.1098);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1_311" x1="533.223" y1="16.5" x2="533.223" y2="1016.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#14ADF6" style="stop-color:#14ADF6;stop-color:color(display-p3 0.0784 0.6784 0.9647);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#1191F4" style="stop-color:#1191F4;stop-color:color(display-p3 0.0667 0.5686 0.9569);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_1_311" x1="554.984" y1="16.5" x2="554.984" y2="912.055" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#CA70E1" style="stop-color:#CA70E1;stop-color:color(display-p3 0.7922 0.4392 0.8824);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#B452CB" style="stop-color:#B452CB;stop-color:color(display-p3 0.7059 0.3216 0.7961);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_1_311" x1="516.5" y1="8.16641" x2="516.5" y2="1141.62" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF645D" style="stop-color:#FF645D;stop-color:color(display-p3 1.0000 0.3922 0.3647);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF4332" style="stop-color:#FF4332;stop-color:color(display-p3 1.0000 0.2627 0.1961);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_1_311" x1="534.769" y1="6.5" x2="534.769" y2="1006.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FBB114" style="stop-color:#FBB114;stop-color:color(display-p3 0.9843 0.6941 0.0784);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF9508" style="stop-color:#FF9508;stop-color:color(display-p3 1.0000 0.5843 0.0314);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 9C17.328 9 18 9.672 18 10.5V15H22.5C23.2793 15 23.9204 15.5953 23.9931 16.3556L24 16.5C24 17.328 23.328 18 22.5 18H18V22.5C18 23.2793 17.4047 23.9204 16.6444 23.9931L16.5 24C15.672 24 15 23.328 15 22.5V18H10.5C9.72071 18 9.0796 17.4047 9.00687 16.6444L9 16.5C9 15.672 9.672 15 10.5 15H15V10.5C15 9.72071 15.5953 9.0796 16.3556 9.00687L16.5 9Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 10C16.776 10 17 10.224 17 10.5V16H22.5C22.7453 16 22.9496 16.177 22.9919 16.4102L23 16.5C23 16.776 22.776 17 22.5 17H17V22.5C17 22.7453 16.823 22.9496 16.5898 22.9919L16.5 23C16.224 23 16 22.776 16 22.5V17H10.5C10.2547 17 10.0504 16.823 10.0081 16.5898L10 16.5C10 16.224 10.224 16 10.5 16H16V10.5C16 10.2547 16.177 10.0504 16.4102 10.0081L16.5 10Z" fill="#232020" style="fill:#232020;fill:color(display-p3 0.1373 0.1255 0.1255);fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.501 13.8601L24.884 22.2611C25.937 23.3171 25.19 25.1191 23.699 25.1191L22.475 25.119L23.6908 28.0067C23.9038 28.5127 23.9068 29.0727 23.6998 29.5817C23.4918 30.0917 23.0978 30.4897 22.5898 30.7027C22.3338 30.8097 22.0658 30.8637 21.7918 30.8637C20.9608 30.8637 20.2158 30.3687 19.8938 29.6027L18.616 26.565L17.784 27.3031C16.703 28.2591 15 27.4921 15 26.0481V14.4811C15 13.6971 15.947 13.3051 16.501 13.8601Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9995 15.1292C15.9995 14.9982 16.1585 14.9322 16.2505 15.0252L24.1585 22.9502C24.5895 23.3822 24.2835 24.1192 23.6735 24.1192L20.9695 24.1177L22.7691 28.3936C22.9961 28.9336 22.7421 29.5546 22.2031 29.7806C21.6621 30.0076 21.0421 29.7546 20.8161 29.2156L18.9985 24.8917L17.1385 26.5392C16.7225 26.9072 16.0806 26.6507 16.0065 26.1274L15.9995 26.0262V15.1292Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.7191 13.556C23.3671 13.299 22.9751 13.199 22.5421 13.258C22.3341 13.286 22.1621 13.347 22.0191 13.432C22.0241 13.273 22.0121 13.122 21.9761 12.98C21.9011 12.683 21.7611 12.439 21.5561 12.247C21.3511 12.055 21.1051 11.917 20.8181 11.832C20.5581 11.754 20.2931 11.731 20.0231 11.764C19.7521 11.797 19.5101 11.889 19.2951 12.042C19.2251 12.092 19.1731 12.156 19.1171 12.217C19.0781 12.148 19.0421 12.077 18.9871 12.013C18.8211 11.821 18.6101 11.673 18.3531 11.569C18.0961 11.465 17.8231 11.412 17.5371 11.412C17.2441 11.412 16.9681 11.465 16.7121 11.569C16.4551 11.673 16.2431 11.821 16.0771 12.013C16.0231 12.075 15.9891 12.145 15.9491 12.213C15.9121 12.171 15.8811 12.123 15.8381 12.086C15.6621 11.936 15.4551 11.834 15.2171 11.778C14.9801 11.723 14.7411 11.715 14.5001 11.754C14.1941 11.799 13.9191 11.914 13.6741 12.096C13.4301 12.278 13.2501 12.524 13.1321 12.833C13.0231 13.122 13.0101 13.464 13.0771 13.847C12.5071 13.777 11.9771 13.943 11.4921 14.356C10.9911 14.783 10.7431 15.455 10.7501 16.373C10.7561 17.857 11.0381 19.175 11.5941 20.328C12.1511 21.48 12.9341 22.381 13.9431 23.028C14.9521 23.676 16.1211 24 17.4491 24C18.6601 24 19.6951 23.787 20.5551 23.36C21.4141 22.934 22.1101 22.317 22.6401 21.509C23.1701 20.703 23.5561 19.703 23.7971 18.511C23.9271 17.881 24.0291 17.261 24.1051 16.656C24.1801 16.05 24.2241 15.41 24.2361 14.732C24.2431 14.205 24.0701 13.813 23.7191 13.556Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2635 14.9171L13.5095 16.1871C13.5555 16.4281 13.6755 16.5821 13.8705 16.6461C14.0665 16.7121 14.2435 16.6801 14.4035 16.5531C14.5625 16.4261 14.6165 16.2461 14.5645 16.0111L14.0565 13.5801C13.9975 13.3261 14.0715 13.1211 14.2765 12.9651C14.4815 12.8091 14.7085 12.7501 14.9595 12.7891C15.2105 12.8281 15.3715 12.9751 15.4435 13.2291L15.5895 13.7561C15.6415 13.9581 15.7635 14.0851 15.9555 14.1371C16.1485 14.1891 16.3315 14.1661 16.5075 14.0691C16.6835 13.9711 16.7715 13.8281 16.7715 13.6391V12.9651C16.7715 12.7891 16.8445 12.6481 16.9915 12.5401C17.1375 12.4331 17.3155 12.3791 17.5235 12.3791C17.7255 12.3791 17.8995 12.4331 18.0465 12.5401C18.1925 12.6481 18.2655 12.7891 18.2655 12.9651V13.6391C18.2655 13.8211 18.3535 13.9631 18.5295 14.0641C18.7055 14.1651 18.8885 14.1891 19.0815 14.1371C19.2735 14.0851 19.3955 13.9581 19.4475 13.7561L19.6035 13.2091C19.6695 12.9681 19.8255 12.8281 20.0725 12.7891C20.3195 12.7501 20.5465 12.8091 20.7515 12.9651C20.9565 13.1211 21.0325 13.3261 20.9805 13.5801L20.8245 14.4591C20.7785 14.6931 20.8245 14.8761 20.9615 15.0061C21.0975 15.1361 21.2635 15.1981 21.4595 15.1911C21.6545 15.1851 21.8015 15.1131 21.8985 14.9761L22.1525 14.5661C22.2435 14.4291 22.3795 14.3501 22.5575 14.3271C22.7365 14.3041 22.8975 14.3411 23.0415 14.4391C23.1845 14.5371 23.2565 14.6771 23.2565 14.8591C23.2565 15.4001 23.2175 15.9181 23.1385 16.4121C23.0605 16.9071 22.9465 17.4761 22.7975 18.1211L22.7385 18.3941C22.5115 19.4101 22.1845 20.2471 21.7625 20.9041C21.3395 21.5621 20.7775 22.0581 20.0775 22.3931C19.3775 22.7291 18.5125 22.8961 17.4845 22.8961C16.3445 22.8961 15.3455 22.6171 14.4865 22.0561C13.6265 21.4971 12.9655 20.7171 12.5035 19.7171C12.0415 18.7181 11.8105 17.5811 11.8105 16.3041C11.8105 15.8491 11.9095 15.4991 12.1085 15.2551C12.3065 15.0101 12.5445 14.8781 12.8215 14.8541C12.9845 14.8411 13.1285 14.8681 13.2635 14.9171Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5908 23.4186C16.6578 23.4186 15.8038 23.2506 15.0528 22.9196C14.2958 22.5856 13.6148 22.0696 13.0298 21.3876C12.4598 20.7226 11.9558 19.8716 11.5308 18.8596L9.61877 14.1546C9.47777 13.8136 9.46177 13.4866 9.57177 13.1866C9.69177 12.8636 9.92977 12.6346 10.2618 12.5236C10.3818 12.4806 10.5158 12.4556 10.6488 12.4556C10.8128 12.4556 10.9718 12.4926 11.1228 12.5656C11.3728 12.6876 11.5798 12.9046 11.7388 13.2116L13.3258 16.0706C13.4298 16.2506 13.5078 16.3166 13.5358 16.3366C13.5788 16.3656 13.6208 16.3776 13.6768 16.3776C13.7438 16.3776 13.7848 16.3586 13.8308 16.3066C13.8498 16.2846 13.8778 16.1896 13.8428 15.9836L12.7408 8.9466C12.6648 8.5056 12.7988 8.1986 12.9258 8.0206C13.1068 7.7656 13.3658 7.6026 13.6738 7.5486C13.7518 7.5386 13.8058 7.5346 13.8598 7.5346C14.1098 7.5346 14.3428 7.6086 14.5508 7.7526C14.8108 7.9326 14.9818 8.2066 15.0438 8.5456L16.1318 13.6776L16.1918 7.6486C16.1918 7.3076 16.3048 7.0056 16.5208 6.7776C16.7428 6.5416 17.0408 6.4166 17.3798 6.4166C17.7218 6.4166 18.0238 6.5376 18.2528 6.7676C18.4828 6.9966 18.6038 7.3026 18.6038 7.6516L18.5688 13.7016L19.5158 8.4556C19.5638 8.1296 19.7268 7.8486 19.9838 7.6576C20.1908 7.5036 20.4218 7.4256 20.6708 7.4256C20.7368 7.4256 20.8038 7.43159 20.8718 7.4426C21.1958 7.49259 21.4728 7.6556 21.6628 7.9116C21.7988 8.0936 21.9448 8.4066 21.8648 8.8606L20.9988 14.1766L22.2198 10.7096C22.3198 10.3946 22.4928 10.1486 22.7298 9.9916C22.9248 9.8616 23.1408 9.7956 23.3718 9.7956C23.4278 9.7956 23.4838 9.7996 23.5408 9.8076C23.8708 9.8646 24.1278 10.0286 24.3018 10.2856C24.4698 10.5356 24.5298 10.8336 24.4808 11.1696L23.3418 17.7446C23.0218 19.5926 22.4068 21.0076 21.5138 21.9506C20.5928 22.9246 19.2728 23.4186 17.5908 23.4186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.8523 17.66L23.9893 11.097C24.0203 10.886 23.9873 10.708 23.8893 10.564C23.7923 10.419 23.6533 10.331 23.4733 10.3C23.3013 10.276 23.1453 10.312 23.0053 10.405C22.8643 10.499 22.7583 10.655 22.6883 10.874L21.4933 14.273C21.4383 14.422 21.3623 14.527 21.2643 14.589C21.1673 14.652 21.0513 14.672 20.9193 14.648C20.7943 14.625 20.6903 14.56 20.6083 14.455C20.5263 14.349 20.4933 14.23 20.5083 14.097L21.3763 8.776C21.4153 8.55 21.3783 8.36 21.2643 8.208C21.1513 8.056 20.9933 7.964 20.7903 7.933C20.6023 7.901 20.4323 7.942 20.2803 8.056C20.1283 8.169 20.0363 8.331 20.0053 8.542L19.0673 13.851C19.0443 13.984 18.9833 14.087 18.8853 14.161C18.7883 14.236 18.6693 14.261 18.5283 14.238C18.3723 14.214 18.2573 14.155 18.1823 14.062C18.1083 13.968 18.0713 13.847 18.0713 13.698L18.1063 7.651C18.1063 7.433 18.0383 7.255 17.9013 7.118C17.7643 6.982 17.5903 6.913 17.3803 6.913C17.1763 6.913 17.0103 6.982 16.8823 7.118C16.7533 7.255 16.6883 7.433 16.6883 7.651L16.6533 13.745C16.6533 13.886 16.6043 14.003 16.5073 14.097C16.4093 14.191 16.2943 14.238 16.1613 14.238C16.0283 14.238 15.9173 14.196 15.8273 14.114C15.7373 14.032 15.6763 13.921 15.6453 13.78L14.5553 8.636C14.5163 8.425 14.4213 8.267 14.2683 8.161C14.1163 8.056 13.9463 8.015 13.7583 8.038C13.5793 8.069 13.4363 8.159 13.3313 8.308C13.2253 8.456 13.1923 8.644 13.2313 8.87L14.3333 15.902C14.3873 16.23 14.3443 16.474 14.2043 16.634C14.0633 16.795 13.8873 16.875 13.6763 16.875C13.5203 16.875 13.3803 16.832 13.2553 16.746C13.1303 16.66 13.0083 16.515 12.8913 16.312L11.2983 13.441C11.1883 13.23 11.0573 13.087 10.9053 13.013C10.7533 12.939 10.5903 12.933 10.4193 12.995C10.2313 13.058 10.1043 13.179 10.0383 13.359C9.97126 13.538 9.98526 13.741 10.0793 13.968L11.9893 18.668C12.3953 19.636 12.8683 20.435 13.4073 21.064C13.9463 21.693 14.5613 22.16 15.2533 22.464C15.9443 22.769 16.7233 22.921 17.5903 22.921C19.1373 22.921 20.3253 22.484 21.1533 21.609C21.9813 20.734 22.5483 19.418 22.8523 17.66ZM11.0283 19.007L9.16526 14.414C9.03226 14.085 8.97926 13.773 9.00726 13.476C9.03426 13.179 9.12826 12.921 9.28826 12.702C9.44826 12.484 9.64926 12.312 9.89126 12.187C10.1333 12.069 10.3973 12.015 10.6823 12.023C10.9673 12.03 11.2433 12.112 11.5083 12.269C11.7743 12.425 11.9973 12.663 12.1763 12.984L13.1023 14.754C13.1263 14.8 13.1573 14.82 13.1963 14.812C13.2353 14.804 13.2513 14.773 13.2433 14.718L12.2473 9.046C12.1763 8.679 12.1923 8.351 12.2943 8.062C12.3953 7.773 12.5593 7.538 12.7863 7.359C13.0123 7.179 13.2743 7.066 13.5713 7.019C13.8683 6.972 14.1533 7.001 14.4263 7.107C14.7003 7.212 14.9343 7.386 15.1303 7.628C15.3253 7.87 15.4623 8.171 15.5403 8.53L15.6803 9.292V7.651C15.6803 7.136 15.8413 6.714 16.1613 6.386C16.4813 6.058 16.8873 5.894 17.3803 5.894C17.8953 5.894 18.3113 6.062 18.6283 6.398C18.9443 6.734 19.1023 7.167 19.1023 7.698L19.0793 8.202C19.1883 7.734 19.4173 7.384 19.7643 7.153C20.1123 6.923 20.5013 6.843 20.9303 6.913C21.2273 6.96 21.4913 7.073 21.7213 7.253C21.9523 7.433 22.1243 7.665 22.2373 7.95C22.3503 8.235 22.3833 8.562 22.3373 8.929L22.2373 9.85652C22.2179 9.76817 22.3253 9.65233 22.5593 9.509C22.9113 9.294 23.2983 9.222 23.7193 9.292C23.9933 9.347 24.2333 9.458 24.4403 9.626C24.6473 9.794 24.7993 10.019 24.8973 10.3C24.9953 10.581 25.0123 10.901 24.9503 11.261L23.8953 17.765C23.5673 19.789 22.8833 21.334 21.8443 22.4C20.8053 23.466 19.3763 24 17.5553 24C16.0163 24 14.7163 23.58 13.6533 22.74C12.5903 21.9 11.7153 20.656 11.0283 19.007Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.301 31.942C20.359 31.942 21.279 31.722 22.059 31.281C22.841 30.84 23.445 30.195 23.87 29.343C24.296 28.491 24.509 27.449 24.509 26.214V24.564C24.509 23.797 24.389 23.208 24.149 22.794C23.909 22.381 23.563 22.174 23.114 22.174V23.268C23.114 23.42 23.063 23.542 22.963 23.633C22.862 23.724 22.749 23.769 22.621 23.769C22.487 23.769 22.37 23.724 22.27 23.633C22.169 23.542 22.119 23.42 22.119 23.268V21.909C22.119 21.636 22.049 21.424 21.91 21.275C21.769 21.126 21.572 21.052 21.316 21.052C21.116 21.052 20.918 21.095 20.724 21.18V22.913C20.724 23.071 20.673 23.195 20.573 23.287C20.472 23.378 20.359 23.423 20.231 23.423C20.097 23.423 19.98 23.378 19.88 23.287C19.779 23.195 19.729 23.071 19.729 22.913V20.906C19.729 20.639 19.658 20.427 19.515 20.272C19.372 20.117 19.179 20.039 18.936 20.039C18.729 20.039 18.528 20.085 18.334 20.176V22.566C18.334 22.706 18.287 22.825 18.192 22.922C18.098 23.019 17.978 23.068 17.832 23.068C17.692 23.068 17.575 23.019 17.481 22.922C17.387 22.825 17.339 22.706 17.339 22.566V15.981C17.339 15.756 17.277 15.575 17.152 15.438C17.028 15.301 16.859 15.233 16.646 15.233C16.433 15.233 16.262 15.301 16.131 15.438C16 15.575 15.935 15.756 15.935 15.981V25.257C15.935 25.452 15.877 25.611 15.762 25.736C15.646 25.86 15.5 25.922 15.324 25.922C15.165 25.922 15.024 25.883 14.9 25.804C14.775 25.725 14.67 25.579 14.585 25.366L13.363 22.575C13.211 22.21 12.982 22.028 12.679 22.028C12.484 22.028 12.326 22.09 12.204 22.215C12.082 22.339 12.022 22.49 12.022 22.666C12.022 22.812 12.049 22.958 12.104 23.104L13.737 27.701C14.271 29.191 15.014 30.269 15.962 30.939C16.911 31.608 18.024 31.942 19.301 31.942ZM19.265 33C17.707 33 16.383 32.583 15.292 31.751C14.2 30.918 13.366 29.68 12.788 28.038L11.155 23.432C11.106 23.287 11.069 23.135 11.041 22.976C11.014 22.818 11 22.675 11 22.548C11 22.073 11.158 21.706 11.475 21.444C11.79 21.183 12.162 21.052 12.587 21.052C12.898 21.052 13.177 21.142 13.427 21.321C13.676 21.5 13.876 21.77 14.029 22.128L14.749 23.907C14.767 23.949 14.795 23.97 14.831 23.97C14.88 23.97 14.904 23.943 14.904 23.889V16.044C14.904 15.491 15.068 15.052 15.397 14.726C15.725 14.401 16.141 14.238 16.646 14.238C17.145 14.238 17.557 14.401 17.882 14.726C18.207 15.052 18.37 15.491 18.37 16.044V19.155C18.607 19.088 18.838 19.054 19.064 19.054C19.453 19.054 19.784 19.156 20.058 19.36C20.331 19.564 20.523 19.842 20.633 20.195C20.912 20.098 21.186 20.049 21.454 20.049C21.83 20.049 22.148 20.144 22.406 20.336C22.665 20.527 22.843 20.787 22.94 21.116C23.766 21.122 24.407 21.414 24.86 21.991C25.313 22.569 25.54 23.381 25.54 24.426V26.333C25.54 27.743 25.275 28.946 24.746 29.94C24.217 30.935 23.481 31.692 22.539 32.216C21.596 32.739 20.505 33 19.265 33Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.301 31.942C20.359 31.942 21.279 31.722 22.059 31.281C22.841 30.84 23.445 30.195 23.87 29.343C24.296 28.491 24.509 27.449 24.509 26.214V24.564C24.509 23.797 24.389 23.208 24.149 22.794C23.909 22.381 23.563 22.174 23.114 22.174V23.268C23.114 23.42 23.063 23.542 22.963 23.633C22.862 23.724 22.749 23.769 22.621 23.769C22.487 23.769 22.37 23.724 22.27 23.633C22.169 23.542 22.119 23.42 22.119 23.268V21.909C22.119 21.636 22.049 21.424 21.91 21.275C21.769 21.126 21.572 21.052 21.316 21.052C21.116 21.052 20.918 21.095 20.724 21.18V22.913C20.724 23.071 20.673 23.195 20.573 23.287C20.472 23.378 20.359 23.423 20.231 23.423C20.097 23.423 19.98 23.378 19.88 23.287C19.779 23.195 19.729 23.071 19.729 22.913V20.906C19.729 20.639 19.658 20.427 19.515 20.272C19.372 20.117 19.179 20.039 18.936 20.039C18.729 20.039 18.528 20.085 18.334 20.176V22.566C18.334 22.706 18.287 22.825 18.192 22.922C18.098 23.019 17.978 23.068 17.832 23.068C17.692 23.068 17.575 23.019 17.481 22.922C17.387 22.825 17.339 22.706 17.339 22.566V15.981C17.339 15.756 17.277 15.575 17.152 15.438C17.028 15.301 16.859 15.233 16.646 15.233C16.433 15.233 16.262 15.301 16.131 15.438C16 15.575 15.935 15.756 15.935 15.981V25.257C15.935 25.452 15.877 25.611 15.762 25.736C15.646 25.86 15.5 25.922 15.324 25.922C15.165 25.922 15.024 25.883 14.9 25.804C14.775 25.725 14.67 25.579 14.585 25.366L13.363 22.575C13.211 22.21 12.982 22.028 12.679 22.028C12.484 22.028 12.326 22.09 12.204 22.215C12.082 22.339 12.022 22.49 12.022 22.666C12.022 22.812 12.049 22.958 12.104 23.104L13.737 27.701C14.271 29.191 15.014 30.269 15.962 30.939C16.911 31.608 18.024 31.942 19.301 31.942Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.501 2.8601L15.884 11.2611C16.937 12.3171 16.19 14.1191 14.699 14.1191H13.475L14.6908 17.0067C14.9038 17.5127 14.9068 18.0727 14.6998 18.5817C14.4918 19.0917 14.0978 19.4897 13.5898 19.7027C13.3338 19.8097 13.0658 19.8637 12.7918 19.8637C11.9608 19.8637 11.2158 19.3687 10.8938 18.6027L9.616 15.565L8.784 16.3031C7.703 17.2591 6 16.4921 6 15.0481V3.4811C6 2.6971 6.947 2.3051 7.501 2.8601Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.9995 4.1292C6.9995 3.9982 7.1585 3.9322 7.2505 4.0252L15.1585 11.9502C15.5895 12.3822 15.2835 13.1192 14.6735 13.1192H11.9695L13.7691 17.3936C13.9961 17.9336 13.7421 18.5546 13.2031 18.7806C12.6621 19.0076 12.0421 18.7546 11.8161 18.2156L9.9985 13.8917L8.1385 15.5392C7.7225 15.9072 7.0806 15.6507 7.0065 15.1274L6.9995 15.0262V4.1292Z" fill="black"/>
|
||||
<circle cx="23" cy="22" r="7" fill="white"/>
|
||||
<path d="M23 28.25C26.4518 28.25 29.25 25.4518 29.25 22C29.25 18.5482 26.4518 15.75 23 15.75C19.5482 15.75 16.75 18.5482 16.75 22C16.75 25.4518 19.5482 28.25 23 28.25Z" stroke="black" stroke-width="1.8"/>
|
||||
<path d="M20.9 20.1C21.05 18.85 21.85 18.15 23.1 18.15C24.4 18.15 25.25 18.95 25.25 20.05C25.25 21.05 24.7 21.55 23.9 22.05C23.25 22.45 23 22.85 23 23.65" stroke="#2563EB" stroke-width="1.9" stroke-linecap="round"/>
|
||||
<circle cx="23" cy="25.8" r="1.15" fill="#2563EB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="32" height="43" viewBox="0 0 32 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_302)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.501 13.8601L24.884 22.2611C25.937 23.3171 25.19 25.1191 23.699 25.1191L20.252 25.1181L17.784 27.3031C16.703 28.2591 15 27.4921 15 26.0481V14.4811C15 13.6971 15.947 13.3051 16.501 13.8601Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9995 15.1292V26.0262C15.9995 26.6162 16.6965 26.9302 17.1385 26.5392L19.8735 24.1182L23.6735 24.1192C24.2835 24.1192 24.5895 23.3822 24.1585 22.9502L16.2505 15.0252C16.1585 14.9322 15.9995 14.9982 15.9995 15.1292Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8888 42.1186H22.1098C20.9448 42.1186 19.9998 41.1736 19.9998 40.0076V29.2296C19.9998 28.0636 20.9448 27.1186 22.1098 27.1186H29.8888C31.0548 27.1186 31.9998 28.0636 31.9998 29.2296V40.0076C31.9998 41.1736 31.0548 42.1186 29.8888 42.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.7495 41.1186H22.2495C21.5595 41.1186 20.9995 40.5586 20.9995 39.8686V29.3686C20.9995 28.6786 21.5595 28.1186 22.2495 28.1186H29.7495C30.4395 28.1186 30.9995 28.6786 30.9995 29.3686V39.8686C30.9995 40.5586 30.4395 41.1186 29.7495 41.1186Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4995 31.1186H23.4995C23.2235 31.1186 22.9995 30.8946 22.9995 30.6186C22.9995 30.3426 23.2235 30.1186 23.4995 30.1186H28.4995C28.7755 30.1186 28.9995 30.3426 28.9995 30.6186C28.9995 30.8946 28.7755 31.1186 28.4995 31.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4995 39.1186H23.4995C23.2235 39.1186 22.9995 38.8946 22.9995 38.6186C22.9995 38.3426 23.2235 38.1186 23.4995 38.1186H28.4995C28.7755 38.1186 28.9995 38.3426 28.9995 38.6186C28.9995 38.8946 28.7755 39.1186 28.4995 39.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.0608 36.1186H22.9378C22.4198 36.1186 21.9998 35.6986 21.9998 35.1796V34.0576C21.9998 33.5386 22.4198 33.1186 22.9378 33.1186H29.0608C29.5788 33.1186 29.9998 33.5386 29.9998 34.0576V35.1796C29.9998 35.6986 29.5788 36.1186 29.0608 36.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4995 35.1186H23.4995C23.2235 35.1186 22.9995 34.8946 22.9995 34.6186C22.9995 34.3426 23.2235 34.1186 23.4995 34.1186H26.4995C26.7755 34.1186 26.9995 34.3426 26.9995 34.6186C26.9995 34.8946 26.7755 35.1186 26.4995 35.1186Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_302">
|
||||
<rect width="16.9998" height="28.5186" fill="white" style="fill:white;fill-opacity:1;" transform="translate(15 13.6)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0764 8.5093C16.5864 7.9993 17.4134 7.9993 17.9234 8.5093L25.4904 16.0763C26.0004 16.5863 26.0004 17.4133 25.4904 17.9233L17.9234 25.4903C17.4134 26.0003 16.5864 26.0003 16.0764 25.4903L8.50939 17.9233C7.99939 17.4133 7.99939 16.5863 8.50939 16.0763L16.0764 8.5093Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0004 19.8444C13.0004 20.2704 12.4844 20.4844 12.1824 20.1824L9.4064 17.4064C9.1824 17.1824 9.1824 16.8184 9.4064 16.5934L12.1824 13.8174C12.4844 13.5154 13.0004 13.7294 13.0004 14.1554L13.001 16H16V13L14.1556 13.0004C13.758 13.0004 13.5451 12.5509 13.764 12.2454L13.8176 12.1824L16.5936 9.4064C16.8176 9.1824 17.1816 9.1824 17.4066 9.4064L20.1826 12.1824C20.4846 12.4844 20.2706 13.0004 19.8446 13.0004L18 13V16H21V14.1556C21 13.758 21.4495 13.5451 21.7542 13.764L21.817 13.8176L24.594 16.5936C24.818 16.8176 24.818 17.1816 24.594 17.4066L21.817 20.1826C21.516 20.4846 21 20.2706 21 19.8446V18H18V20.999L19.8444 20.9996C20.242 20.9996 20.4549 21.4491 20.236 21.7546L20.1824 21.8176L17.4064 24.5936C17.1824 24.8176 16.8184 24.8176 16.5934 24.5936L13.8174 21.8176C13.5154 21.5156 13.7294 20.9996 14.1554 20.9996L16 20.999V18H13.001L13.0004 19.8444Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="10.75" fill="white"/>
|
||||
<circle cx="16" cy="16" r="9.25" stroke="black" stroke-width="3"/>
|
||||
<path d="M9.55 22.45L22.45 9.55" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="8.25" stroke="#DC2626" stroke-width="2"/>
|
||||
<path d="M10.25 21.75L21.75 10.25" stroke="#DC2626" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 16C23 17.103 22.103 18 21 18H11C9.897 18 9 17.103 9 16C9 14.897 9.897 14 11 14H21C22.103 14 23 14.897 23 16ZM19.7852 21.3564C19.7852 21.5684 19.7342 21.7824 19.6392 21.9734L17.2562 26.7354C17.0212 27.2064 16.5482 27.4994 16.0212 27.5004C15.4932 27.5004 15.0192 27.2074 14.7842 26.7364L12.4012 21.9744C12.3052 21.7834 12.2542 21.5694 12.2542 21.3564C12.2542 20.5934 12.8752 19.9734 13.6382 19.9734H18.4022C18.7812 19.9734 19.1352 20.1234 19.3972 20.3964C19.6472 20.6554 19.7852 20.9974 19.7852 21.3564Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 16C22 16.552 21.552 17 21 17H11C10.448 17 10 16.552 10 16C10 15.448 10.448 15 11 15H21C21.552 15 22 15.448 22 16ZM16.362 26.2884L18.744 21.5274C18.871 21.2734 18.686 20.9734 18.402 20.9734H13.638C13.353 20.9734 13.168 21.2734 13.295 21.5274L15.678 26.2884C15.819 26.5704 16.221 26.5704 16.362 26.2884Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0004 23C14.8974 23 14.0004 22.103 14.0004 21V11C14.0004 9.897 14.8974 9 16.0004 9C17.1034 9 18.0004 9.897 18.0004 11V21C18.0004 22.103 17.1034 23 16.0004 23ZM10.644 19.7852C10.432 19.7852 10.218 19.7342 10.027 19.6392L5.265 17.2562C4.794 17.0212 4.501 16.5482 4.5 16.0212C4.5 15.4932 4.793 15.0192 5.264 14.7842L10.026 12.4012C10.217 12.3052 10.431 12.2542 10.644 12.2542C11.407 12.2542 12.027 12.8752 12.027 13.6382V18.4022C12.027 18.7812 11.877 19.1352 11.604 19.3972C11.345 19.6472 11.003 19.7852 10.644 19.7852Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0004 22C15.4484 22 15.0004 21.552 15.0004 21V11C15.0004 10.448 15.4484 10 16.0004 10C16.5524 10 17.0004 10.448 17.0004 11V21C17.0004 21.552 16.5524 22 16.0004 22ZM5.712 16.362L10.473 18.744C10.727 18.871 11.027 18.686 11.027 18.402V13.638C11.027 13.353 10.727 13.168 10.473 13.295L5.712 15.678C5.43 15.819 5.43 16.221 5.712 16.362Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 23C14.8996 23 14.0047 22.103 14.0047 21V11C14.0047 9.897 14.8996 9 16 9C17.1004 9 17.9953 9.897 17.9953 11V21C17.9953 22.103 17.1004 23 16 23ZM21.3703 19.7852C20.6091 19.7852 19.9905 19.1652 19.9905 18.4022V13.6382C19.9905 12.8752 20.6091 12.2542 21.3703 12.2542C21.5828 12.2542 21.7973 12.3052 21.9878 12.4022L26.7368 14.7842C27.2077 15.0192 27.5 15.4932 27.5 16.0212C27.499 16.5482 27.2067 17.0212 26.7358 17.2572L21.9868 19.6392C21.7963 19.7342 21.5828 19.7852 21.3703 19.7852ZM10.6297 19.7852C10.4182 19.7852 10.2047 19.7342 10.0141 19.6392L5.26322 17.2562C4.79332 17.0212 4.501 16.5482 4.5 16.0212C4.5 15.4932 4.79232 15.0192 5.26222 14.7842L10.0132 12.4012C10.2037 12.3052 10.4172 12.2542 10.6297 12.2542C11.3909 12.2542 12.0095 12.8752 12.0095 13.6382V18.4022C12.0095 18.7812 11.8598 19.1352 11.5875 19.3972C11.3291 19.6472 10.9879 19.7852 10.6297 19.7852Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 22C15.4493 22 15.0023 21.552 15.0023 21V11C15.0023 10.448 15.4493 10 16 10C16.5507 10 16.9977 10.448 16.9977 11V21C16.9977 21.552 16.5507 22 16 22ZM26.2908 15.6775L21.5409 13.2955C21.2875 13.1685 20.9882 13.3535 20.9882 13.6375V18.4015C20.9882 18.6865 21.2875 18.8715 21.5409 18.7445L26.2908 16.3615C26.5722 16.2205 26.5722 15.8185 26.2908 15.6775ZM5.70918 16.362L10.4591 18.744C10.7125 18.871 11.0118 18.686 11.0118 18.402V13.638C11.0118 13.353 10.7125 13.168 10.4591 13.295L5.70918 15.678C5.42783 15.819 5.42783 16.221 5.70918 16.362Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 9C17.103 9 18 9.897 18 11V21C18 22.103 17.103 23 16 23C14.897 23 14 22.103 14 21V11C14 9.897 14.897 9 16 9ZM21.3564 12.2148C21.5684 12.2148 21.7824 12.2658 21.9734 12.3608L26.7354 14.7438C27.2064 14.9788 27.4994 15.4518 27.5004 15.9788C27.5004 16.5068 27.2074 16.9808 26.7364 17.2158L21.9744 19.5988C21.7834 19.6948 21.5694 19.7458 21.3564 19.7458C20.5934 19.7458 19.9734 19.1248 19.9734 18.3618V13.5978C19.9734 13.2188 20.1234 12.8648 20.3964 12.6028C20.6554 12.3528 20.9974 12.2148 21.3564 12.2148Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 10C16.552 10 17 10.448 17 11V21C17 21.552 16.552 22 16 22C15.448 22 15 21.552 15 21V11C15 10.448 15.448 10 16 10ZM26.2884 15.638L21.5274 13.256C21.2734 13.129 20.9734 13.314 20.9734 13.598V18.362C20.9734 18.647 21.2734 18.832 21.5274 18.705L26.2884 16.322C26.5704 16.181 26.5704 15.779 26.2884 15.638Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 16.0004C9 14.8974 9.897 14.0004 11 14.0004H21C22.103 14.0004 23 14.8974 23 16.0004C23 17.1034 22.103 18.0004 21 18.0004H11C9.897 18.0004 9 17.1034 9 16.0004ZM12.2148 10.644C12.2148 10.432 12.2658 10.218 12.3608 10.027L14.7438 5.265C14.9788 4.794 15.4518 4.501 15.9788 4.5C16.5068 4.5 16.9808 4.793 17.2158 5.264L19.5988 10.026C19.6948 10.217 19.7458 10.431 19.7458 10.644C19.7458 11.407 19.1248 12.027 18.3618 12.027H13.5978C13.2188 12.027 12.8648 11.877 12.6028 11.604C12.3528 11.345 12.2148 11.003 12.2148 10.644Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 16.0004C10 15.4484 10.448 15.0004 11 15.0004H21C21.552 15.0004 22 15.4484 22 16.0004C22 16.5524 21.552 17.0004 21 17.0004H11C10.448 17.0004 10 16.5524 10 16.0004ZM15.638 5.712L13.256 10.473C13.129 10.727 13.314 11.027 13.598 11.027H18.362C18.647 11.027 18.832 10.727 18.705 10.473L16.322 5.712C16.181 5.43 15.779 5.43 15.638 5.712Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 16C23 17.1004 22.103 17.9953 21 17.9953H11C9.897 17.9953 9 17.1004 9 16C9 14.8996 9.897 14.0047 11 14.0047H21C22.103 14.0047 23 14.8996 23 16ZM19.7852 10.6297C19.7852 11.3909 19.1652 12.0095 18.4022 12.0095H13.6382C12.8752 12.0095 12.2542 11.3909 12.2542 10.6297C12.2542 10.4172 12.3052 10.2027 12.4022 10.0122L14.7842 5.26322C15.0192 4.79232 15.4932 4.5 16.0212 4.5C16.5482 4.501 17.0212 4.79332 17.2572 5.26422L19.6392 10.0132C19.7342 10.2037 19.7852 10.4172 19.7852 10.6297ZM19.7852 21.3703C19.7852 21.5818 19.7342 21.7953 19.6392 21.9859L17.2562 26.7368C17.0212 27.2067 16.5482 27.499 16.0212 27.5C15.4932 27.5 15.0192 27.2077 14.7842 26.7378L12.4012 21.9868C12.3052 21.7963 12.2542 21.5828 12.2542 21.3703C12.2542 20.6091 12.8752 19.9905 13.6382 19.9905H18.4022C18.7812 19.9905 19.1352 20.1402 19.3972 20.4125C19.6472 20.6709 19.7852 21.0121 19.7852 21.3703Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 16C22 16.5507 21.552 16.9977 21 16.9977H11C10.448 16.9977 10 16.5507 10 16C10 15.4493 10.448 15.0023 11 15.0023H21C21.552 15.0023 22 15.4493 22 16ZM15.6775 5.70918L13.2955 10.4591C13.1685 10.7125 13.3535 11.0118 13.6375 11.0118H18.4015C18.6865 11.0118 18.8715 10.7125 18.7445 10.4591L16.3615 5.70918C16.2205 5.42783 15.8185 5.42783 15.6775 5.70918ZM16.362 26.2908L18.744 21.5409C18.871 21.2875 18.686 20.9882 18.402 20.9882H13.638C13.353 20.9882 13.168 21.2875 13.295 21.5409L15.678 26.2908C15.819 26.5722 16.221 26.5722 16.362 26.2908Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.64 15.4658L16.5353 19.3601C16.8182 19.643 16.9701 20.0188 16.9621 20.4176C16.9551 20.7994 16.8042 21.1573 16.5353 21.4261C16.3754 21.5841 16.1785 21.707 15.9636 21.778L10.1241 23.7241C9.59533 23.9 9.02258 23.7651 8.62875 23.3713C8.23492 22.9774 8.09998 22.4037 8.2759 21.8759L10.2221 16.0366C10.293 15.8217 10.415 15.6238 10.5739 15.4648C11.1437 14.8961 12.0703 14.8961 12.64 15.4658ZM23.3713 8.62873C23.7651 9.02255 23.9 9.59529 23.7241 10.1231L21.781 15.9614C21.71 16.1763 21.587 16.3732 21.4281 16.5322C20.8584 17.1019 19.9318 17.1019 19.362 16.5322L15.4677 12.6379C14.8979 12.0692 14.8979 11.1426 15.4677 10.5718C15.6266 10.4129 15.8245 10.292 16.0394 10.22L21.8769 8.27589C22.4047 8.09997 22.9774 8.2349 23.3713 8.62873Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.80824 22.7821L15.6512 20.8351C15.9762 20.7271 16.0732 20.3141 15.8312 20.0721L11.9352 16.1761C11.6932 15.9331 11.2802 16.0311 11.1712 16.3561L9.22524 22.1991C9.10524 22.5591 9.44824 22.9021 9.80824 22.7821ZM22.199 9.22462L16.358 11.1696C16.033 11.2776 15.936 11.6906 16.178 11.9336L20.074 15.8286C20.316 16.0716 20.729 15.9736 20.837 15.6486L22.782 9.80762C22.902 9.44762 22.559 9.10462 22.199 9.22462Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.204 19.4693C20.203 19.6943 20.15 19.9203 20.049 20.1223L17.294 25.6303C17.045 26.1283 16.544 26.4383 15.987 26.4383C15.43 26.4383 14.929 26.1283 14.68 25.6303L11.925 20.1223C11.824 19.9203 11.77 19.6943 11.77 19.4693C11.77 18.6633 12.426 18.0083 13.232 18.0083H18.742C19.142 18.0083 19.517 18.1663 19.793 18.4543C20.058 18.7283 20.204 19.0893 20.204 19.4693ZM20.2031 12.546C20.2031 13.352 19.5481 14.008 18.7411 14.008H13.2321C12.4261 14.008 11.7701 13.353 11.7701 12.546C11.7701 12.321 11.8241 12.096 11.9251 11.893L14.6801 6.388C14.9291 5.89 15.4301 5.58 15.9871 5.58C16.5441 5.58 17.0451 5.89 17.2941 6.388L20.0491 11.893C20.1501 12.095 20.2031 12.321 20.2031 12.546Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3992 25.1833L19.1542 19.6753C19.3072 19.3683 19.0842 19.0083 18.7412 19.0083H13.2322C12.8892 19.0083 12.6662 19.3683 12.8192 19.6753L15.5742 25.1833C15.7442 25.5233 16.2292 25.5233 16.3992 25.1833ZM15.5743 6.8351L12.8193 12.3401C12.6663 12.6471 12.8893 13.0081 13.2323 13.0081H18.7413C19.0843 13.0081 19.3073 12.6471 19.1543 12.3401L16.3993 6.8351C16.2293 6.4951 15.7443 6.4951 15.5743 6.8351Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.36 15.4658C19.9297 14.8961 20.8563 14.8961 21.4261 15.4648C21.585 15.6238 21.707 15.8217 21.7779 16.0366L23.7241 21.8759C23.9 22.4037 23.7651 22.9774 23.3713 23.3713C22.9774 23.7651 22.4047 23.9 21.8759 23.7241L16.0364 21.778C15.8215 21.707 15.6246 21.5841 15.4647 21.4261C15.1958 21.1573 15.0449 20.7994 15.0379 20.4176C15.0299 20.0188 15.1818 19.643 15.4647 19.3601L19.36 15.4658ZM8.62873 8.62873C9.02256 8.2349 9.59532 8.09997 10.1231 8.27589L15.9606 10.22C16.1755 10.292 16.3734 10.4129 16.5323 10.5718C17.1021 11.1426 17.1021 12.0692 16.5323 12.6379L12.638 16.5322C12.0682 17.1019 11.1416 17.1019 10.5719 16.5322C10.413 16.3732 10.29 16.1763 10.219 15.9614L8.27589 10.1231C8.09996 9.59529 8.2349 9.02255 8.62873 8.62873Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.1926 22.7755L16.3521 20.8294C16.0272 20.7215 15.9303 20.3087 16.1722 20.0668L20.0665 16.1725C20.3084 15.9296 20.7212 16.0276 20.8302 16.3524L22.7753 22.1928C22.8953 22.5526 22.5524 22.8955 22.1926 22.7755ZM9.80703 9.22416L15.6455 11.1683C15.9704 11.2762 16.0683 11.689 15.8254 11.9319L11.9311 15.8252C11.6892 16.0681 11.2764 15.9701 11.1684 15.6453L9.22428 9.80689C9.10433 9.44706 9.44718 9.10421 9.80703 9.22416Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7634 12C19.9884 12.001 20.2144 12.054 20.4164 12.155L25.9244 14.91C26.4224 15.159 26.7324 15.66 26.7324 16.217C26.7324 16.774 26.4224 17.275 25.9244 17.524L20.4164 20.279C20.2144 20.38 19.9884 20.434 19.7634 20.434C18.9574 20.434 18.3024 19.778 18.3024 18.972V13.462C18.3024 13.062 18.4604 12.687 18.7484 12.411C19.0224 12.146 19.3834 12 19.7634 12ZM12.84 12.001C13.646 12.001 14.302 12.656 14.302 13.463V18.972C14.302 19.778 13.647 20.434 12.84 20.434C12.615 20.434 12.39 20.38 12.187 20.279L6.68199 17.524C6.18399 17.275 5.87399 16.774 5.87399 16.217C5.87399 15.66 6.18399 15.159 6.68199 14.91L12.187 12.155C12.389 12.054 12.615 12.001 12.84 12.001Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.4773 15.8049L19.9693 13.0499C19.6623 12.8969 19.3023 13.1199 19.3023 13.4629V18.9719C19.3023 19.3149 19.6623 19.5379 19.9693 19.3849L25.4773 16.6299C25.8173 16.4599 25.8173 15.9749 25.4773 15.8049ZM7.12921 16.6298L12.6342 19.3848C12.9412 19.5378 13.3022 19.3148 13.3022 18.9718V13.4628C13.3022 13.1198 12.9412 12.8968 12.6342 13.0498L7.12921 15.8048C6.78921 15.9748 6.78921 16.4598 7.12921 16.6298Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.315 7.5046C20.315 8.2486 19.759 8.8886 19.023 8.9936C18.431 9.0786 17.984 9.5956 17.984 10.1956V20.4256C17.984 21.0266 18.43 21.5436 19.021 21.6286C19.756 21.7326 20.312 22.3736 20.312 23.1176C20.309 23.5606 20.118 23.9736 19.787 24.2566C19.458 24.5396 19.021 24.6676 18.59 24.6036C17.807 24.4916 17.078 24.1606 16.481 23.6586C15.883 24.1606 15.154 24.4916 14.367 24.6046C13.94 24.6676 13.504 24.5406 13.173 24.2566C12.842 23.9696 12.653 23.5576 12.65 23.1246C12.65 22.3736 13.206 21.7326 13.942 21.6276C14.536 21.5426 14.984 21.0256 14.984 20.4256V10.1956C14.984 9.5956 14.537 9.0786 13.944 8.9936C13.209 8.8886 12.654 8.2486 12.654 7.5046C12.655 7.0646 12.846 6.6506 13.177 6.3656C13.447 6.1326 13.796 6.0016 14.158 6.0016H14.239L14.39 6.0206C15.164 6.1316 15.889 6.4616 16.484 6.9636C17.083 6.4616 17.812 6.1296 18.598 6.0176C19.013 5.9506 19.455 6.0766 19.792 6.3656C20.123 6.6526 20.312 7.0646 20.315 7.4976V7.5016V7.5046Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.087 8.00285C15.169 8.15785 15.985 9.10085 15.985 10.1949V20.4249C15.985 21.5189 15.167 22.4619 14.083 22.6169C13.836 22.6519 13.65 22.8669 13.65 23.1169C13.651 23.2629 13.714 23.4009 13.824 23.4969C13.935 23.5909 14.08 23.6349 14.226 23.6139C15.148 23.4819 15.978 22.9429 16.481 22.1599C16.985 22.9429 17.814 23.4819 18.737 23.6139C18.879 23.6349 19.027 23.5909 19.137 23.4959C19.248 23.4009 19.311 23.2629 19.312 23.1169C19.312 22.8669 19.126 22.6519 18.879 22.6169C17.799 22.4619 16.985 21.5199 16.985 20.4249V10.1949C16.985 9.10085 17.801 8.15785 18.883 8.00285C19.13 7.96785 19.316 7.75285 19.316 7.50285C19.315 7.35785 19.251 7.21885 19.141 7.12285C19.03 7.02885 18.885 6.98285 18.74 7.00585C17.818 7.13885 16.988 7.67685 16.484 8.45985C15.98 7.67685 15.151 7.13885 14.229 7.00585C14.206 7.00285 14.182 7.00085 14.158 7.00085C14.038 7.00085 13.921 7.04385 13.829 7.12285C13.718 7.21885 13.655 7.35685 13.654 7.50385C13.654 7.75285 13.84 7.96785 14.087 8.00285Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 2.5L7.25 12.25H12V28.5H20V12.25H24.75L16 2.5Z" fill="white" stroke="white" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M16 5.25L10 11.95H14V26.5H18V11.95H22L16 5.25Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="10.5" stroke="white" stroke-width="4"/>
|
||||
<path d="M16 5.5C18.7848 5.5 21.4555 6.60625 23.4246 8.57538" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M23.4246 8.57538C25.3938 10.5445 26.5 13.2152 26.5 16" stroke="#2563EB" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M16 26.5C10.201 26.5 5.5 21.799 5.5 16" stroke="black" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 17C24 20.866 20.866 24 17 24C13.134 24 10 20.866 10 17C10 13.134 13.134 10 17 10C20.866 10 24 13.134 24 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2532 17C22.2532 19.901 19.9012 22.253 17.0002 22.253C14.0982 22.253 11.7472 19.901 11.7472 17C11.7472 14.099 14.0982 11.747 17.0002 11.747C19.9012 11.747 22.2532 14.099 22.2532 17Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 20.5C16.448 20.5 16 20.052 16 19.5V14.5C16 13.948 16.448 13.5 17 13.5C17.552 13.5 18 13.948 18 14.5V19.5C18 20.052 17.552 20.5 17 20.5Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 17C13.5 16.448 13.948 16 14.5 16H19.5C20.052 16 20.5 16.448 20.5 17C20.5 17.552 20.052 18 19.5 18H14.5C13.948 18 13.5 17.552 13.5 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.9315 26.9316C27.3215 26.5416 27.3215 25.9076 26.9315 25.5176L22.1895 20.7756L20.7755 22.1896L25.5175 26.9316C25.9075 27.3216 26.5415 27.3216 26.9315 26.9316Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 17C24 20.866 20.866 24 17 24C13.134 24 10 20.866 10 17C10 13.134 13.134 10 17 10C20.866 10 24 13.134 24 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2532 17C22.2532 19.901 19.9012 22.253 17.0002 22.253C14.0982 22.253 11.7472 19.901 11.7472 17C11.7472 14.099 14.0982 11.747 17.0002 11.747C19.9012 11.747 22.2532 14.099 22.2532 17Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 17C13.5 16.448 13.948 16 14.5 16H19.5C20.052 16 20.5 16.448 20.5 17C20.5 17.552 20.052 18 19.5 18H14.5C13.948 18 13.5 17.552 13.5 17ZM26.9315 26.9316C27.3215 26.5416 27.3215 25.9076 26.9315 25.5176L22.1895 20.7756L20.7755 22.1896L25.5175 26.9316C25.9075 27.3216 26.5415 27.3216 26.9315 26.9316Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1002 B |
@@ -10,6 +10,7 @@ import {
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
MdMouse,
|
||||
MdRestartAlt,
|
||||
MdVideocam,
|
||||
MdVideocamOff,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { nativeBridgeClient } from "@/native";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -42,6 +44,7 @@ const ICON_CONFIG = {
|
||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
||||
cursor: { icon: MdMouse, size: ICON_SIZE },
|
||||
pause: { icon: BsPauseCircle, size: ICON_SIZE },
|
||||
resume: { icon: BsPlayCircle, size: ICON_SIZE },
|
||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||
@@ -101,12 +104,16 @@ export function LaunchWindow() {
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
setMicrophoneDeviceName,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
setWebcamDeviceName,
|
||||
cursorCaptureMode,
|
||||
setCursorCaptureMode,
|
||||
} = useScreenRecorder();
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
@@ -120,6 +127,7 @@ export function LaunchWindow() {
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const [isWindows, setIsWindows] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
@@ -148,14 +156,16 @@ export function LaunchWindow() {
|
||||
const selectedMicLabel =
|
||||
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
|
||||
t("audio.defaultMicrophone");
|
||||
const selectedCameraDevice = cameraDevices.find(
|
||||
(d) => d.deviceId === (webcamDeviceId || selectedCameraId),
|
||||
);
|
||||
const selectedCameraLabel = isCameraDevicesLoading
|
||||
? t("webcam.searching")
|
||||
: cameraDevicesError
|
||||
? t("webcam.unavailable")
|
||||
: cameraDevices.length === 0
|
||||
? t("webcam.noneFound")
|
||||
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
|
||||
t("webcam.defaultCamera");
|
||||
: selectedCameraDevice?.label || t("webcam.defaultCamera");
|
||||
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
@@ -165,14 +175,36 @@ export function LaunchWindow() {
|
||||
useEffect(() => {
|
||||
if (selectedMicId && selectedMicId !== "default") {
|
||||
setMicrophoneDeviceId(selectedMicId);
|
||||
setMicrophoneDeviceName(micDevices.find((d) => d.deviceId === selectedMicId)?.label);
|
||||
}
|
||||
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||
}, [selectedMicId, micDevices, setMicrophoneDeviceId, setMicrophoneDeviceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCameraId) {
|
||||
setWebcamDeviceId(selectedCameraId);
|
||||
setWebcamDeviceName(cameraDevices.find((d) => d.deviceId === selectedCameraId)?.label);
|
||||
}
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
}, [selectedCameraId, cameraDevices, setWebcamDeviceId, setWebcamDeviceName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
nativeBridgeClient.system
|
||||
.getPlatform()
|
||||
.then((platform) => {
|
||||
if (!cancelled) {
|
||||
setIsWindows(platform === "win32");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setIsWindows(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
@@ -258,6 +290,7 @@ export function LaunchWindow() {
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [, setRecordPointerDownCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
@@ -293,13 +326,17 @@ export function LaunchWindow() {
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setVideoPathResult.success) {
|
||||
console.error("Failed to set current video path:", setVideoPathResult);
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
@@ -396,8 +433,10 @@ export function LaunchWindow() {
|
||||
<select
|
||||
value={microphoneDeviceId || selectedMicId}
|
||||
onChange={(e) => {
|
||||
const selectedDevice = micDevices.find((d) => d.deviceId === e.target.value);
|
||||
setSelectedMicId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
setMicrophoneDeviceName(selectedDevice?.label);
|
||||
}}
|
||||
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
|
||||
>
|
||||
@@ -455,8 +494,12 @@ export function LaunchWindow() {
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
const device = cameraDevices.find(
|
||||
(item) => item.deviceId === e.target.value,
|
||||
);
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
setWebcamDeviceName(device?.label);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -480,8 +523,10 @@ export function LaunchWindow() {
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
const device = cameraDevices.find((item) => item.deviceId === e.target.value);
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
setWebcamDeviceName(device?.label);
|
||||
}}
|
||||
className="sr-only"
|
||||
>
|
||||
@@ -524,6 +569,7 @@ export function LaunchWindow() {
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
data-testid="launch-system-audio-button"
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
@@ -536,16 +582,21 @@ export function LaunchWindow() {
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
data-testid="launch-microphone-button"
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
onPointerDown={() => {
|
||||
setRecordPointerDownCount((count) => count + 1);
|
||||
}}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
data-testid="launch-webcam-button"
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
@@ -557,10 +608,38 @@ export function LaunchWindow() {
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
{isWindows && (
|
||||
<button
|
||||
data-testid="launch-cursor-mode-button"
|
||||
className={`${hudIconBtnClasses} ${
|
||||
cursorCaptureMode === "editable-overlay"
|
||||
? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
!recording &&
|
||||
setCursorCaptureMode(
|
||||
cursorCaptureMode === "editable-overlay" ? "system" : "editable-overlay",
|
||||
)
|
||||
}
|
||||
disabled={recording}
|
||||
title={
|
||||
cursorCaptureMode === "editable-overlay"
|
||||
? t("cursor.useSystemCursor")
|
||||
: t("cursor.useEditableCursor")
|
||||
}
|
||||
>
|
||||
{getIcon(
|
||||
"cursor",
|
||||
cursorCaptureMode === "editable-overlay" ? "text-green-400" : "text-white/40",
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
data-testid="launch-record-button"
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||
recording
|
||||
? paused
|
||||
@@ -613,6 +692,7 @@ export function LaunchWindow() {
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
data-testid="launch-open-video-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
@@ -623,6 +703,7 @@ export function LaunchWindow() {
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
data-testid="launch-open-project-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
|
||||
@@ -145,6 +145,7 @@ export function SourceSelector() {
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 border-t border-white/[0.06] p-3">
|
||||
<Button
|
||||
data-testid="source-selector-cancel-button"
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="h-8 rounded-lg px-5 text-[11px] text-zinc-400 transition-transform duration-150 hover:bg-white/5 hover:text-white active:scale-95"
|
||||
@@ -152,6 +153,7 @@ export function SourceSelector() {
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="source-selector-share-button"
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95 disabled:bg-zinc-700 disabled:opacity-30"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import {
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Crop,
|
||||
Download,
|
||||
FileDown,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -221,12 +219,6 @@ const GRADIENTS = [
|
||||
];
|
||||
|
||||
interface SettingsPanelProps {
|
||||
cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
onCursorHighlightChange?: (
|
||||
next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
|
||||
) => void;
|
||||
// macOS only — gates the "Only on clicks" toggle (needs uiohook).
|
||||
cursorHighlightSupportsClicks?: boolean;
|
||||
selected: string;
|
||||
onWallpaperChange: (path: string) => void;
|
||||
selectedZoomDepth?: ZoomDepth | null;
|
||||
@@ -309,6 +301,18 @@ interface SettingsPanelProps {
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
onSaveDiagnostic?: () => Promise<void>;
|
||||
showCursor?: boolean;
|
||||
onShowCursorChange?: (show: boolean) => void;
|
||||
cursorSize?: number;
|
||||
onCursorSizeChange?: (size: number) => void;
|
||||
cursorSmoothing?: number;
|
||||
onCursorSmoothingChange?: (smoothing: number) => void;
|
||||
cursorMotionBlur?: number;
|
||||
onCursorMotionBlurChange?: (blur: number) => void;
|
||||
cursorClickBounce?: number;
|
||||
onCursorClickBounceChange?: (bounce: number) => void;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -325,9 +329,6 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
|
||||
|
||||
export function SettingsPanel({
|
||||
cursorHighlight,
|
||||
onCursorHighlightChange,
|
||||
cursorHighlightSupportsClicks = false,
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
@@ -405,6 +406,18 @@ export function SettingsPanel({
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
showCursor = true,
|
||||
onShowCursorChange,
|
||||
cursorSize = 3.0,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = 0.67,
|
||||
onCursorSmoothingChange,
|
||||
cursorMotionBlur = 0.35,
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = 2.5,
|
||||
onCursorClickBounceChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
|
||||
@@ -435,8 +448,6 @@ export function SettingsPanel({
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState("#ADADAD");
|
||||
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
|
||||
const [showCropModal, setShowCropModal] = useState(false);
|
||||
const cropSnapshotRef = useRef<CropRegion | null>(null);
|
||||
const [cropAspectLocked, setCropAspectLocked] = useState(false);
|
||||
const [cropAspectRatio, setCropAspectRatio] = useState("");
|
||||
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
|
||||
@@ -537,10 +548,13 @@ export function SettingsPanel({
|
||||
},
|
||||
[cropRegion, videoWidth, videoHeight],
|
||||
);
|
||||
const [showCropDropdown, setShowCropDropdown] = useState(false);
|
||||
const handleCropToggle = () => setShowCropDropdown((open) => !open);
|
||||
|
||||
const zoomEnabled = Boolean(selectedZoomDepth);
|
||||
const trimEnabled = Boolean(selectedTrimId);
|
||||
const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId);
|
||||
const hasCursorPanel = showCursorSettings && hasCursorData;
|
||||
const panelModes: Array<{
|
||||
id: SettingsPanelMode;
|
||||
label: string;
|
||||
@@ -550,7 +564,15 @@ export function SettingsPanel({
|
||||
{ id: "background", label: t("background.title"), icon: Palette },
|
||||
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
|
||||
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
|
||||
{ id: "cursor", label: t("effects.cursorHighlight.title"), icon: MousePointerClick },
|
||||
...(hasCursorPanel
|
||||
? [
|
||||
{
|
||||
id: "cursor" as const,
|
||||
label: t("effects.title"),
|
||||
icon: MousePointerClick,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const exportPanelMode = {
|
||||
id: "export" as const,
|
||||
@@ -625,20 +647,6 @@ export function SettingsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropToggle = () => {
|
||||
if (!showCropModal && cropRegion) {
|
||||
cropSnapshotRef.current = { ...cropRegion };
|
||||
}
|
||||
setShowCropModal(!showCropModal);
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
if (cropSnapshotRef.current && onCropChange) {
|
||||
onCropChange(cropSnapshotRef.current);
|
||||
}
|
||||
setShowCropModal(false);
|
||||
};
|
||||
|
||||
// Find selected annotation
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
@@ -1265,11 +1273,7 @@ export function SettingsPanel({
|
||||
) : (
|
||||
<SlidersHorizontal className="w-4 h-4 text-[#34B27B]" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{activePanelMode === "cursor"
|
||||
? t("effects.cursorHighlight.title")
|
||||
: t("effects.title")}
|
||||
</span>
|
||||
<span className="text-xs font-medium">{t("effects.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
@@ -1374,218 +1378,90 @@ export function SettingsPanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{activePanelMode === "cursor" && cursorHighlight && onCursorHighlightChange && (
|
||||
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-2">
|
||||
{activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
|
||||
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.cursorHighlight.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
enabled: !cursorHighlight.enabled,
|
||||
})
|
||||
}
|
||||
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
|
||||
cursorHighlight.enabled
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{cursorHighlight.enabled ? t("effects.on") : t("effects.off")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-1 ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
|
||||
>
|
||||
{(["dot", "ring"] as const).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => onCursorHighlightChange({ ...cursorHighlight, style })}
|
||||
className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
|
||||
cursorHighlight.style === style
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{t(`effects.cursorHighlight.${style}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.size")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorHighlight.sizePx}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.sizePx]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
sizePx: values[0],
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={36}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
<div className="text-[10px] font-medium text-slate-300">Show Cursor</div>
|
||||
<Switch
|
||||
checked={showCursor}
|
||||
onCheckedChange={onShowCursorChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
/>
|
||||
</div>
|
||||
{cursorHighlightSupportsClicks && (
|
||||
<div
|
||||
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
|
||||
>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.onlyOnClicks")}
|
||||
{showCursor && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Size</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorSize.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorSize]}
|
||||
onValueChange={(values) => onCursorSizeChange?.(values[0])}
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Smoothing
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorSmoothing * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorSmoothing]}
|
||||
onValueChange={(values) => onCursorSmoothingChange?.(values[0])}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Motion Blur
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorMotionBlur * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorMotionBlur]}
|
||||
onValueChange={(values) => onCursorMotionBlurChange?.(values[0])}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Click Bounce
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorClickBounce.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorClickBounce]}
|
||||
onValueChange={(values) => onCursorClickBounceChange?.(values[0])}
|
||||
min={0}
|
||||
max={5}
|
||||
step={0.1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const turningOn = !cursorHighlight.onlyOnClicks;
|
||||
if (turningOn) {
|
||||
try {
|
||||
const result =
|
||||
await window.electronAPI?.requestAccessibilityAccess?.();
|
||||
if (!result?.granted) {
|
||||
toast.message(
|
||||
t("effects.cursorHighlight.accessibilityPermissionTitle"),
|
||||
{
|
||||
description: t(
|
||||
"effects.cursorHighlight.accessibilityPermissionDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Accessibility request failed:", err);
|
||||
}
|
||||
}
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
onlyOnClicks: turningOn,
|
||||
});
|
||||
}}
|
||||
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
|
||||
cursorHighlight.onlyOnClicks
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{cursorHighlight.onlyOnClicks ? t("effects.on") : t("effects.off")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className="text-[10px] text-slate-400 mb-1">
|
||||
{t("effects.cursorHighlight.color")}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-8 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: cursorHighlight.color }}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-300 truncate flex-1 text-left font-mono">
|
||||
{cursorHighlight.color}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={cursorHighlight.color}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("background.colorWheel"),
|
||||
colorPalette: t("background.colorPalette"),
|
||||
}}
|
||||
onUpdateColor={(color) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
color,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.offsetX")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.offsetXNorm]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
offsetXNorm: values[0],
|
||||
})
|
||||
}
|
||||
min={-0.25}
|
||||
max={0.25}
|
||||
step={0.005}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.offsetY")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.offsetYNorm]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
offsetYNorm: values[0],
|
||||
})
|
||||
}
|
||||
min={-0.25}
|
||||
max={0.25}
|
||||
step={0.005}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
@@ -1745,11 +1621,11 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCropModal && cropRegion && onCropChange && (
|
||||
{showCropDropdown && cropRegion && onCropChange && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
|
||||
onClick={handleCropCancel}
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -1760,7 +1636,7 @@ export function SettingsPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCropCancel}
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
@@ -1856,7 +1732,7 @@ export function SettingsPanel({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCropModal(false)}
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
size="lg"
|
||||
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
VideoExporter,
|
||||
} from "@/lib/exporter";
|
||||
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import {
|
||||
getExportFolder,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
saveUserPreferences,
|
||||
} from "@/lib/userPreferences";
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import { nativeBridgeClient, useCursorRecordingData, useCursorTelemetry } from "@/native";
|
||||
import type { NativePlatform } from "@/native/contracts";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
getNativeAspectRatioValue,
|
||||
@@ -61,12 +64,15 @@ import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -84,6 +90,15 @@ import {
|
||||
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
|
||||
function isClickInteractionType(interactionType: string | null | undefined) {
|
||||
return (
|
||||
interactionType === "click" ||
|
||||
interactionType === "double-click" ||
|
||||
interactionType === "right-click" ||
|
||||
interactionType === "middle-click"
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportDiagnostics {
|
||||
formatLabel: "GIF" | "Video";
|
||||
reason?: string;
|
||||
@@ -158,7 +173,6 @@ export default function VideoEditor() {
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
cursorHighlight,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -176,8 +190,6 @@ export default function VideoEditor() {
|
||||
currentTimeRef.current = currentTime;
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
@@ -202,8 +214,36 @@ export default function VideoEditor() {
|
||||
} | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
const playerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
const { samples: cursorTelemetry, error: cursorTelemetryError } =
|
||||
useCursorTelemetry(cursorTelemetrySourcePath);
|
||||
const { data: cursorRecordingData, error: cursorRecordingDataError } =
|
||||
useCursorRecordingData(cursorTelemetrySourcePath);
|
||||
const cursorClickTimestamps = useMemo<number[]>(() => {
|
||||
const recordingClicks =
|
||||
cursorRecordingData?.samples
|
||||
.filter((sample) => isClickInteractionType(sample.interactionType))
|
||||
.map((sample) => sample.timeMs) ?? [];
|
||||
if (recordingClicks.length > 0) {
|
||||
return recordingClicks;
|
||||
}
|
||||
|
||||
return cursorTelemetry
|
||||
.filter((sample) => isClickInteractionType(sample.interactionType))
|
||||
.map((sample) => sample.timeMs);
|
||||
}, [cursorRecordingData, cursorTelemetry]);
|
||||
|
||||
// Cursor & motion blur visual settings (non-undoable preferences)
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
|
||||
const [nativePlatform, setNativePlatform] = useState<NativePlatform | null>(null);
|
||||
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
|
||||
useState<CursorCaptureMode | null>(null);
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
|
||||
const nextZoomIdRef = useRef(1);
|
||||
@@ -211,12 +251,15 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
|
||||
// renderers while keeping the persisted value intact for round-tripping.
|
||||
const effectiveCursorHighlight = useMemo(
|
||||
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
|
||||
[cursorHighlight, isMac],
|
||||
);
|
||||
// Windows-only: the synthetic cursor overlay + cursor customization settings
|
||||
// only apply when there's an actual native cursor recording (cursor frames +
|
||||
// position samples produced by WindowsNativeRecordingSession). Mac and Linux
|
||||
// keep their telemetry positions for auto-zoom but never render a synthetic
|
||||
// cursor or expose cursor customization settings.
|
||||
const hasEditableCursorRecording =
|
||||
nativePlatform === "win32" && hasNativeCursorRecordingData(cursorRecordingData);
|
||||
const effectiveShowCursor = showCursor && hasEditableCursorRecording;
|
||||
const showCursorSettings = hasEditableCursorRecording;
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
@@ -243,10 +286,18 @@ export default function VideoEditor() {
|
||||
|
||||
const webcamSourcePath =
|
||||
webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null);
|
||||
return webcamSourcePath
|
||||
? { screenVideoPath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath };
|
||||
}, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]);
|
||||
return {
|
||||
screenVideoPath,
|
||||
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
|
||||
...(recordingCursorCaptureMode ? { cursorCaptureMode: recordingCursorCaptureMode } : {}),
|
||||
};
|
||||
}, [
|
||||
videoPath,
|
||||
videoSourcePath,
|
||||
webcamVideoPath,
|
||||
webcamVideoSourcePath,
|
||||
recordingCursorCaptureMode,
|
||||
]);
|
||||
|
||||
const applyLoadedProject = useCallback(
|
||||
async (candidate: unknown, path?: string | null) => {
|
||||
@@ -255,13 +306,21 @@ export default function VideoEditor() {
|
||||
}
|
||||
|
||||
const project = candidate;
|
||||
const media = resolveProjectMedia(project);
|
||||
if (!media) {
|
||||
const projectMedia = resolveProjectMedia(project);
|
||||
if (!projectMedia) {
|
||||
return false;
|
||||
}
|
||||
const sourcePath = fromFileUrl(media.screenVideoPath);
|
||||
const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null;
|
||||
const sourcePath = projectMedia.screenVideoPath;
|
||||
const webcamSourcePath = projectMedia.webcamVideoPath ?? null;
|
||||
const projectCursorCaptureMode = projectMedia.cursorCaptureMode ?? null;
|
||||
const normalizedEditor = normalizeProjectEditor(project.editor);
|
||||
const inferredDurationMs = Math.max(
|
||||
0,
|
||||
...normalizedEditor.zoomRegions.map((region) => region.endMs),
|
||||
...normalizedEditor.trimRegions.map((region) => region.endMs),
|
||||
...normalizedEditor.speedRegions.map((region) => region.endMs),
|
||||
...normalizedEditor.annotationRegions.map((region) => region.endMs),
|
||||
);
|
||||
|
||||
try {
|
||||
videoPlaybackRef.current?.pause();
|
||||
@@ -270,13 +329,14 @@ export default function VideoEditor() {
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0);
|
||||
|
||||
setError(null);
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(webcamSourcePath);
|
||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||
setRecordingCursorCaptureMode(projectCursorCaptureMode);
|
||||
setCurrentProjectPath(path ?? null);
|
||||
|
||||
pushState({
|
||||
@@ -333,9 +393,11 @@ export default function VideoEditor() {
|
||||
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
{
|
||||
screenVideoPath: sourcePath,
|
||||
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
|
||||
...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}),
|
||||
},
|
||||
normalizedEditor,
|
||||
),
|
||||
);
|
||||
@@ -399,7 +461,7 @@ export default function VideoEditor() {
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
|
||||
const currentProjectResult = await nativeBridgeClient.project.loadCurrentProjectFile();
|
||||
if (currentProjectResult.success && currentProjectResult.project) {
|
||||
const restored = await applyLoadedProject(
|
||||
currentProjectResult.project,
|
||||
@@ -421,31 +483,31 @@ export default function VideoEditor() {
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(webcamSourcePath);
|
||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||
setRecordingCursorCaptureMode(session.cursorCaptureMode ?? null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? {
|
||||
screenVideoPath: sourcePath,
|
||||
webcamVideoPath: webcamSourcePath,
|
||||
}
|
||||
: { screenVideoPath: sourcePath },
|
||||
{
|
||||
screenVideoPath: sourcePath,
|
||||
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
|
||||
...(session.cursorCaptureMode
|
||||
? { cursorCaptureMode: session.cursorCaptureMode }
|
||||
: {}),
|
||||
},
|
||||
INITIAL_EDITOR_STATE,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getCurrentVideoPath();
|
||||
const result = await nativeBridgeClient.project.getCurrentVideoPath();
|
||||
if (result.success && result.path) {
|
||||
const sourcePath = fromFileUrl(result.path);
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(null);
|
||||
setWebcamVideoPath(null);
|
||||
setVideoSourcePath(result.path);
|
||||
setVideoPath(toFileUrl(result.path));
|
||||
setRecordingCursorCaptureMode(null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
|
||||
createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE),
|
||||
);
|
||||
} else {
|
||||
setError("No video to load. Please record or select a video.");
|
||||
@@ -516,7 +578,6 @@ export default function VideoEditor() {
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
cursorHighlight,
|
||||
};
|
||||
const projectData = createProjectData(currentProjectMedia, editorState);
|
||||
|
||||
@@ -528,7 +589,7 @@ export default function VideoEditor() {
|
||||
// Match the normalization path used by `currentProjectSnapshot` so the
|
||||
// post-save baseline compares equal and `hasUnsavedChanges` clears.
|
||||
const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
|
||||
const result = await window.electronAPI.saveProjectFile(
|
||||
const result = await nativeBridgeClient.project.saveProjectFile(
|
||||
projectData,
|
||||
fileNameBase,
|
||||
forceSaveAs ? undefined : (currentProjectPath ?? undefined),
|
||||
@@ -578,7 +639,6 @@ export default function VideoEditor() {
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
cursorHighlight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -634,7 +694,7 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
@@ -667,40 +727,37 @@ export default function VideoEditor() {
|
||||
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadCursorTelemetry() {
|
||||
const sourcePath = currentProjectMedia?.screenVideoPath ?? null;
|
||||
|
||||
if (!sourcePath) {
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
let canceled = false;
|
||||
nativeBridgeClient.system
|
||||
.getPlatform()
|
||||
.then((platform) => {
|
||||
if (!canceled) {
|
||||
setNativePlatform(platform);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
|
||||
if (mounted) {
|
||||
setCursorTelemetry(result.success ? result.samples : []);
|
||||
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Unable to resolve native platform for cursor settings:", error);
|
||||
if (!canceled) {
|
||||
setNativePlatform(null);
|
||||
}
|
||||
} catch (telemetryError) {
|
||||
console.warn("Unable to load cursor telemetry:", telemetryError);
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCursorTelemetry();
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
canceled = true;
|
||||
};
|
||||
}, [currentProjectMedia]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursorTelemetryError) {
|
||||
console.warn("Unable to load cursor telemetry:", cursorTelemetryError);
|
||||
}
|
||||
}, [cursorTelemetryError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursorRecordingDataError) {
|
||||
console.warn("Unable to load cursor recording data:", cursorRecordingDataError);
|
||||
}
|
||||
}, [cursorRecordingDataError]);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
@@ -1550,6 +1607,11 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
cursorScale: effectiveShowCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1559,7 +1621,6 @@ export default function VideoEditor() {
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1703,6 +1764,11 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
cursorScale: effectiveShowCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1712,7 +1778,6 @@ export default function VideoEditor() {
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1802,6 +1867,7 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
annotationRegions,
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
@@ -1813,7 +1879,11 @@ export default function VideoEditor() {
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
effectiveCursorHighlight,
|
||||
effectiveShowCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -2069,6 +2139,7 @@ export default function VideoEditor() {
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
@@ -2084,8 +2155,12 @@ export default function VideoEditor() {
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={effectiveShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2108,9 +2183,6 @@ export default function VideoEditor() {
|
||||
|
||||
<div className="editor-settings-rail min-w-0 h-full">
|
||||
<SettingsPanel
|
||||
cursorHighlight={cursorHighlight}
|
||||
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
|
||||
cursorHighlightSupportsClicks={isMac}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
@@ -2231,6 +2303,20 @@ export default function VideoEditor() {
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
showCursor={showCursor}
|
||||
onShowCursorChange={setShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
onCursorSizeChange={setCursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
onCursorSmoothingChange={setCursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
showCursorSettings={showCursorSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,24 @@ import {
|
||||
type WebcamLayoutPreset,
|
||||
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";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import {
|
||||
type AspectRatio,
|
||||
formatAspectRatioForCSS,
|
||||
@@ -36,14 +52,20 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -55,18 +77,13 @@ import {
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import {
|
||||
adaptiveSmoothFactor,
|
||||
interpolateCursorAt,
|
||||
smoothCursorFocus,
|
||||
} from "./videoPlayback/cursorFollowUtils";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
clickEmphasisAlpha,
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
drawCursorHighlightGraphics,
|
||||
} from "./videoPlayback/cursorHighlight";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
PixiCursorOverlay,
|
||||
preloadCursorAssets,
|
||||
} from "./videoPlayback/cursorRenderer";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
@@ -80,13 +97,6 @@ import {
|
||||
type MotionBlurState,
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
|
||||
type BlurPreviewCanvasSource = {
|
||||
clientHeight?: number;
|
||||
clientWidth?: number;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
@@ -118,6 +128,7 @@ interface VideoPlaybackProps {
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
cursorRecordingData?: CursorRecordingData | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
@@ -130,9 +141,13 @@ interface VideoPlaybackProps {
|
||||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
cursorClickTimestamps?: number[];
|
||||
showCursor?: boolean;
|
||||
cursorSize?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -145,6 +160,41 @@ export interface VideoPlaybackRef {
|
||||
pause: () => void;
|
||||
}
|
||||
|
||||
function getResolvedVideoDuration(video: HTMLVideoElement): number | null {
|
||||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||
return video.duration;
|
||||
}
|
||||
|
||||
if (video.seekable.length > 0) {
|
||||
const lastRangeIndex = video.seekable.length - 1;
|
||||
const seekableEnd = video.seekable.end(lastRangeIndex);
|
||||
if (Number.isFinite(seekableEnd) && seekableEnd > 0) {
|
||||
return seekableEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEndedVideoDuration(video: HTMLVideoElement): number | null {
|
||||
const currentTime = video.currentTime;
|
||||
if (!Number.isFinite(currentTime) || currentTime <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (video.ended) {
|
||||
return currentTime;
|
||||
}
|
||||
|
||||
const resolvedDuration = getResolvedVideoDuration(video);
|
||||
const durationEpsilonSeconds = 0.05;
|
||||
if (resolvedDuration && currentTime >= resolvedDuration - durationEpsilonSeconds) {
|
||||
return resolvedDuration;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
(
|
||||
{
|
||||
@@ -178,6 +228,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
aspectRatio,
|
||||
cursorRecordingData,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
@@ -191,8 +242,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
|
||||
cursorClickTimestamps = [],
|
||||
showCursor = false,
|
||||
cursorSize = DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -217,10 +272,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
|
||||
const cursorTelemetryRef = useRef<CursorTelemetryPoint[]>([]);
|
||||
const cursorClickTimestampsRef = useRef<number[]>([]);
|
||||
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -257,18 +310,143 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const motionBlurAmountRef = useRef(motionBlurAmount);
|
||||
const cursorOverlayRef = useRef<PixiCursorOverlay | null>(null);
|
||||
const showCursorRef = useRef(showCursor);
|
||||
const cursorSizeRef = useRef(cursorSize);
|
||||
const cursorSmoothingRef = useRef(cursorSmoothing);
|
||||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
const blurPreviewSnapshotRef = useRef<{
|
||||
bucket: number;
|
||||
canvas: BlurPreviewCanvasSource | null;
|
||||
height: number;
|
||||
width: number;
|
||||
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
|
||||
const durationResolutionTimeoutRef = useRef<number | null>(null);
|
||||
const lastResolvedDurationRef = useRef<number | null>(null);
|
||||
const isResolvingDurationRef = useRef(false);
|
||||
const hasNativeCursorRecordingRef = useRef(false);
|
||||
const cursorRecordingDataRef = useRef(cursorRecordingData);
|
||||
const cropRegionRef = useRef(cropRegion);
|
||||
const nativeCursorSpriteRef = useRef<Sprite | null>(null);
|
||||
const nativeCursorTextureIdRef = useRef<string | null>(null);
|
||||
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const nativeCursorImageIdRef = useRef<string | null>(null);
|
||||
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
|
||||
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
|
||||
|
||||
const hasNativeCursorRecording = useMemo(
|
||||
() => hasNativeCursorRecordingData(cursorRecordingData),
|
||||
[cursorRecordingData],
|
||||
);
|
||||
|
||||
const syncResolvedDuration = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
const resolvedDuration = getResolvedVideoDuration(video);
|
||||
if (!resolvedDuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDuration = Math.round(resolvedDuration * 1000) / 1000;
|
||||
if (lastResolvedDurationRef.current !== normalizedDuration) {
|
||||
lastResolvedDurationRef.current = normalizedDuration;
|
||||
onDurationChange(normalizedDuration);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[onDurationChange],
|
||||
);
|
||||
|
||||
const forceResolveDuration = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
if (isResolvingDurationRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||
return;
|
||||
}
|
||||
|
||||
isResolvingDurationRef.current = true;
|
||||
const previousMuted = video.muted;
|
||||
|
||||
const finalize = () => {
|
||||
video.removeEventListener("durationchange", handleProgress);
|
||||
video.removeEventListener("timeupdate", handleProgress);
|
||||
video.removeEventListener("loadeddata", handleProgress);
|
||||
video.removeEventListener("ended", handleProgress);
|
||||
if (durationResolutionTimeoutRef.current) {
|
||||
clearTimeout(durationResolutionTimeoutRef.current);
|
||||
durationResolutionTimeoutRef.current = null;
|
||||
}
|
||||
video.muted = previousMuted;
|
||||
isResolvingDurationRef.current = false;
|
||||
};
|
||||
|
||||
const resolveCurrentDuration = () => {
|
||||
if (syncResolvedDuration(video)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const endedDuration = getEndedVideoDuration(video);
|
||||
if (endedDuration) {
|
||||
lastResolvedDurationRef.current = null;
|
||||
onDurationChange(Math.round(endedDuration * 1000) / 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
if (!resolveCurrentDuration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
currentTimeRef.current = 0;
|
||||
finalize();
|
||||
};
|
||||
|
||||
video.addEventListener("durationchange", handleProgress);
|
||||
video.addEventListener("timeupdate", handleProgress);
|
||||
video.addEventListener("loadeddata", handleProgress);
|
||||
video.addEventListener("ended", handleProgress);
|
||||
durationResolutionTimeoutRef.current = window.setTimeout(() => {
|
||||
handleProgress();
|
||||
finalize();
|
||||
}, 1500);
|
||||
video.muted = true;
|
||||
|
||||
const playAttempt = video.play();
|
||||
if (playAttempt && typeof playAttempt.catch === "function") {
|
||||
playAttempt.catch(() => {
|
||||
try {
|
||||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||||
} catch {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||||
} catch {
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
[onDurationChange, syncResolvedDuration],
|
||||
);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
}, []);
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||||
@@ -450,7 +628,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -550,13 +728,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorHighlightRef.current = cursorHighlight;
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
|
||||
}
|
||||
}, [cursorHighlight]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickTimestampsRef.current = cursorClickTimestamps;
|
||||
}, [cursorClickTimestamps]);
|
||||
@@ -581,6 +752,55 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionBlurAmountRef.current = motionBlurAmount;
|
||||
}, [motionBlurAmount]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
showCursorRef.current = showCursor;
|
||||
}, [showCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
hasNativeCursorRecordingRef.current = hasNativeCursorRecording;
|
||||
}, [hasNativeCursorRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorRecordingDataRef.current = cursorRecordingData;
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
}, [cursorRecordingData]);
|
||||
|
||||
useEffect(() => {
|
||||
cropRegionRef.current = cropRegion;
|
||||
}, [cropRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorSizeRef.current = cursorSize;
|
||||
}, [cursorSize]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorSmoothingRef.current = cursorSmoothing;
|
||||
}, [cursorSmoothing]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorMotionBlurRef.current = cursorMotionBlur;
|
||||
}, [cursorMotionBlur]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickBounceRef.current = cursorClickBounce;
|
||||
}, [cursorClickBounce]);
|
||||
|
||||
// Sync cursor overlay config when settings change
|
||||
useEffect(() => {
|
||||
const overlay = cursorOverlayRef.current;
|
||||
if (!overlay) return;
|
||||
overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize);
|
||||
overlay.setSmoothingFactor(cursorSmoothing);
|
||||
overlay.setMotionBlur(cursorMotionBlur);
|
||||
overlay.setClickBounce(cursorClickBounce);
|
||||
overlay.reset();
|
||||
}, [cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce]);
|
||||
|
||||
useEffect(() => {
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
}, [onTimeUpdate]);
|
||||
@@ -700,6 +920,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
let app: Application | null = null;
|
||||
|
||||
(async () => {
|
||||
let cursorOverlayEnabled = true;
|
||||
try {
|
||||
await preloadCursorAssets();
|
||||
} catch {
|
||||
cursorOverlayEnabled = false;
|
||||
}
|
||||
|
||||
app = new Application();
|
||||
|
||||
await app.init({
|
||||
@@ -735,12 +962,30 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainerRef.current = videoContainer;
|
||||
cameraContainer.addChild(videoContainer);
|
||||
|
||||
// Cursor overlay - rendered above the masked video
|
||||
if (cursorOverlayEnabled) {
|
||||
const cursorOverlay = new PixiCursorOverlay({
|
||||
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current,
|
||||
smoothingFactor: cursorSmoothingRef.current,
|
||||
motionBlur: cursorMotionBlurRef.current,
|
||||
clickBounce: cursorClickBounceRef.current,
|
||||
});
|
||||
cursorOverlayRef.current = cursorOverlay;
|
||||
}
|
||||
|
||||
setPixiReady(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
setPixiReady(false);
|
||||
if (cursorOverlayRef.current) {
|
||||
cursorOverlayRef.current.destroy();
|
||||
cursorOverlayRef.current = null;
|
||||
}
|
||||
nativeCursorSpriteRef.current = null;
|
||||
nativeCursorTextureIdRef.current = null;
|
||||
nativeCursorImageIdRef.current = null;
|
||||
if (app && app.renderer) {
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
@@ -757,6 +1002,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoPath) {
|
||||
lastResolvedDurationRef.current = null;
|
||||
isResolvingDurationRef.current = false;
|
||||
setVideoReady(false);
|
||||
return;
|
||||
}
|
||||
@@ -767,11 +1014,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
video.currentTime = 0;
|
||||
allowPlaybackRef.current = false;
|
||||
lockedVideoDimensionsRef.current = null;
|
||||
lastResolvedDurationRef.current = null;
|
||||
isResolvingDurationRef.current = false;
|
||||
if (durationResolutionTimeoutRef.current) {
|
||||
clearTimeout(durationResolutionTimeoutRef.current);
|
||||
durationResolutionTimeoutRef.current = null;
|
||||
}
|
||||
setVideoReady(false);
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
video.load();
|
||||
}, [videoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -801,12 +1055,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.addChild(maskGraphics);
|
||||
videoContainer.mask = maskGraphics;
|
||||
maskGraphicsRef.current = maskGraphics;
|
||||
const nativeCursorSprite = new Sprite(Texture.EMPTY);
|
||||
nativeCursorSprite.visible = false;
|
||||
nativeCursorSprite.eventMode = "none";
|
||||
nativeCursorSpriteRef.current = nativeCursorSprite;
|
||||
if (cursorOverlayRef.current) {
|
||||
videoContainer.addChild(cursorOverlayRef.current.container);
|
||||
}
|
||||
|
||||
const cursorHighlightGraphics = new Graphics();
|
||||
cursorHighlightGraphics.visible = false;
|
||||
videoContainer.addChild(cursorHighlightGraphics);
|
||||
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
|
||||
videoContainer.addChild(nativeCursorSprite);
|
||||
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
@@ -870,10 +1127,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.removeChild(maskGraphics);
|
||||
maskGraphics.destroy();
|
||||
}
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
|
||||
cursorHighlightGraphicsRef.current.destroy();
|
||||
cursorHighlightGraphicsRef.current = null;
|
||||
if (nativeCursorSpriteRef.current) {
|
||||
videoContainer.removeChild(nativeCursorSpriteRef.current);
|
||||
nativeCursorSpriteRef.current.destroy();
|
||||
nativeCursorSpriteRef.current = null;
|
||||
nativeCursorTextureIdRef.current = null;
|
||||
}
|
||||
videoContainer.mask = null;
|
||||
maskGraphicsRef.current = null;
|
||||
@@ -959,7 +1217,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = region.focus;
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
@@ -1096,39 +1354,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionVector,
|
||||
);
|
||||
|
||||
const cursorGraphics = cursorHighlightGraphicsRef.current;
|
||||
const cursorConfig = cursorHighlightRef.current;
|
||||
const lockedDims = lockedVideoDimensionsRef.current;
|
||||
if (cursorGraphics) {
|
||||
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
|
||||
const emphasisAlpha = clickEmphasisAlpha(
|
||||
currentTimeRef.current,
|
||||
cursorClickTimestampsRef.current,
|
||||
cursorConfig,
|
||||
);
|
||||
const cursorPoint =
|
||||
emphasisAlpha > 0
|
||||
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
|
||||
: null;
|
||||
if (cursorPoint) {
|
||||
const baseScale = baseScaleRef.current;
|
||||
const baseOffset = baseOffsetRef.current;
|
||||
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
|
||||
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
|
||||
cursorGraphics.position.set(
|
||||
baseOffset.x + cx * lockedDims.width * baseScale,
|
||||
baseOffset.y + cy * lockedDims.height * baseScale,
|
||||
);
|
||||
cursorGraphics.alpha = emphasisAlpha;
|
||||
cursorGraphics.visible = true;
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isMotionBlurActive =
|
||||
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
|
||||
|
||||
@@ -1147,6 +1372,130 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor overlay
|
||||
const cursorOverlay = cursorOverlayRef.current;
|
||||
if (cursorOverlay) {
|
||||
const timeMs = currentTimeRef.current; // already in ms
|
||||
cursorOverlay.update(
|
||||
cursorTelemetryRef.current,
|
||||
timeMs,
|
||||
baseMaskRef.current,
|
||||
showCursorRef.current && !hasNativeCursorRecordingRef.current,
|
||||
!isPlayingRef.current || isSeekingRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the native cursor preview in the same transformed coordinate space as PIXI.
|
||||
const nativeCursorSprite = nativeCursorSpriteRef.current;
|
||||
const nativeCursorImage = nativeCursorImageRef.current;
|
||||
const hideNativeCursorPreview = () => {
|
||||
if (nativeCursorSprite) {
|
||||
nativeCursorSprite.visible = false;
|
||||
}
|
||||
if (nativeCursorImage) {
|
||||
nativeCursorImage.style.display = "none";
|
||||
nativeCursorImage.style.filter = "none";
|
||||
}
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
};
|
||||
if (nativeCursorImage) {
|
||||
if (hasNativeCursorRecordingRef.current && showCursorRef.current) {
|
||||
const timeMs = currentTimeRef.current; // already in ms
|
||||
const frame = resolveInterpolatedNativeCursorFrame(
|
||||
cursorRecordingDataRef.current,
|
||||
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: displaySample,
|
||||
});
|
||||
const projectedStagePoint =
|
||||
cameraContainer && videoContainer
|
||||
? projectNativeCursorToStage({
|
||||
cameraContainer,
|
||||
cropRegion: cropRegionValue,
|
||||
maskRect: baseMaskRef.current,
|
||||
videoContainerPosition: {
|
||||
x: videoContainer.x,
|
||||
y: videoContainer.y,
|
||||
},
|
||||
sample: displaySample,
|
||||
})
|
||||
: null;
|
||||
if (projectedLocalPoint && projectedStagePoint) {
|
||||
const renderAsset = resolveNativeCursorRenderAsset(
|
||||
frame.asset,
|
||||
window.devicePixelRatio || 1,
|
||||
displaySample,
|
||||
);
|
||||
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;
|
||||
}
|
||||
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)`;
|
||||
if (nativeCursorSprite) {
|
||||
nativeCursorSprite.visible = false;
|
||||
if (nativeCursorTextureIdRef.current !== renderAsset.id) {
|
||||
nativeCursorSprite.texture = Texture.from(renderAsset.imageDataUrl);
|
||||
nativeCursorTextureIdRef.current = renderAsset.id;
|
||||
}
|
||||
nativeCursorSprite.position.set(
|
||||
projectedLocalPoint.x - renderAsset.hotspotX * scale,
|
||||
projectedLocalPoint.y - renderAsset.hotspotY * scale,
|
||||
);
|
||||
nativeCursorSprite.width = renderAsset.width * scale;
|
||||
nativeCursorSprite.height = renderAsset.height * scale;
|
||||
}
|
||||
} else {
|
||||
hideNativeCursorPreview();
|
||||
}
|
||||
} else {
|
||||
hideNativeCursorPreview();
|
||||
}
|
||||
} else {
|
||||
hideNativeCursorPreview();
|
||||
}
|
||||
} else {
|
||||
hideNativeCursorPreview();
|
||||
}
|
||||
|
||||
const composite3D = composite3DRef.current;
|
||||
const outerWrapper = outerWrapperRef.current;
|
||||
if (composite3D && outerWrapper) {
|
||||
@@ -1196,8 +1545,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
onDurationChange(video.duration);
|
||||
video.currentTime = 0;
|
||||
const hasResolvedDuration = syncResolvedDuration(video);
|
||||
if (!hasResolvedDuration) {
|
||||
forceResolveDuration(video);
|
||||
} else {
|
||||
video.currentTime = 0;
|
||||
}
|
||||
video.pause();
|
||||
allowPlaybackRef.current = false;
|
||||
currentTimeRef.current = 0;
|
||||
@@ -1210,6 +1563,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const waitForRenderableFrame = () => {
|
||||
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
|
||||
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
||||
if (!syncResolvedDuration(video)) {
|
||||
forceResolveDuration(video);
|
||||
}
|
||||
if (hasDimensions && hasData) {
|
||||
videoReadyRafRef.current = null;
|
||||
setVideoReady(true);
|
||||
@@ -1309,6 +1665,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
if (durationResolutionTimeoutRef.current) {
|
||||
clearTimeout(durationResolutionTimeoutRef.current);
|
||||
durationResolutionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1367,6 +1727,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
ref={nativeCursorImageRef}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 select-none"
|
||||
style={{
|
||||
display: "none",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "0 0",
|
||||
zIndex: 18,
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
@@ -1461,32 +1833,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotBucket = Math.floor(currentTime * 10);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const cached = blurPreviewSnapshotRef.current;
|
||||
if (
|
||||
cached.bucket === previewSnapshotBucket &&
|
||||
cached.width === overlaySize.width &&
|
||||
cached.height === overlaySize.height
|
||||
) {
|
||||
return cached.canvas;
|
||||
}
|
||||
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return cached.canvas;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
const canvas = app.renderer.extract.canvas(app.stage);
|
||||
blurPreviewSnapshotRef.current = {
|
||||
bucket: previewSnapshotBucket,
|
||||
canvas,
|
||||
height: overlaySize.height,
|
||||
width: overlaySize.width,
|
||||
};
|
||||
return canvas;
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return cached.canvas;
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
@@ -1558,7 +1913,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={previewSnapshotBucket}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
@@ -1569,11 +1924,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onDurationChange={(e) => {
|
||||
onDurationChange(e.currentTarget.duration);
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onLoadedData={(e) => {
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onCanPlay={(e) => {
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onError={() => onError("Failed to load video")}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface ProjectEditorState {
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
@@ -119,16 +118,14 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isFileUrl(value: string): boolean {
|
||||
return /^file:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) return "";
|
||||
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
if (!segment) {
|
||||
return segment;
|
||||
}
|
||||
if (keepWindowsDrive && index === 0 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
@@ -138,31 +135,25 @@ function encodePathSegments(pathname: string, keepWindowsDrive = false): string
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
|
||||
// Windows drive path: C:/Users/...
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
||||
if (normalized.match(/^[a-zA-Z]:/)) {
|
||||
return `file:///${encodePathSegments(normalized, true)}`;
|
||||
}
|
||||
|
||||
// UNC path: //server/share/...
|
||||
if (normalized.startsWith("//")) {
|
||||
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
||||
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
||||
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
||||
const withoutPrefix = normalized.slice(2);
|
||||
const [host = "", ...segments] = withoutPrefix.split("/");
|
||||
return `file://${host}/${encodePathSegments(segments.join("/"))}`;
|
||||
}
|
||||
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
}
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
const value = fileUrl.trim();
|
||||
if (!isFileUrl(value)) {
|
||||
if (!fileUrl.startsWith("file://")) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const url = new URL(fileUrl);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (url.host && url.host !== "localhost") {
|
||||
@@ -175,13 +166,7 @@ export function fromFileUrl(fileUrl: string): string {
|
||||
|
||||
return pathname;
|
||||
} catch {
|
||||
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
||||
let fallbackPath = rawFallbackPath;
|
||||
try {
|
||||
fallbackPath = decodeURIComponent(rawFallbackPath);
|
||||
} catch {
|
||||
// Keep raw best-effort path if percent decoding fails.
|
||||
}
|
||||
const fallbackPath = decodeURIComponent(fileUrl.replace(/^file:\/\//, ""));
|
||||
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
||||
}
|
||||
}
|
||||
@@ -503,52 +488,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCursorHighlight(
|
||||
value: unknown,
|
||||
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
|
||||
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
|
||||
return {
|
||||
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
|
||||
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
|
||||
sizePx:
|
||||
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
|
||||
color:
|
||||
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
|
||||
? v.color
|
||||
: fallback.color,
|
||||
opacity:
|
||||
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
|
||||
? v.opacity
|
||||
: fallback.opacity,
|
||||
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
|
||||
clickEmphasisDurationMs:
|
||||
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
|
||||
? v.clickEmphasisDurationMs
|
||||
: fallback.clickEmphasisDurationMs,
|
||||
offsetXNorm:
|
||||
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetXNorm))
|
||||
: fallback.offsetXNorm,
|
||||
offsetYNorm:
|
||||
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetYNorm))
|
||||
: fallback.offsetYNorm,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const SUGGESTION_SPACING_MS = 1800;
|
||||
|
||||
interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
hasVideoSource?: boolean;
|
||||
currentTime: number;
|
||||
onSeek?: (time: number) => void;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
@@ -766,6 +767,7 @@ function Timeline({
|
||||
|
||||
export default function TimelineEditor({
|
||||
videoDuration,
|
||||
hasVideoSource = false,
|
||||
currentTime,
|
||||
onSeek,
|
||||
cursorTelemetry = [],
|
||||
@@ -1439,8 +1441,14 @@ export default function TimelineEditor({
|
||||
<Plus className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
|
||||
<p className="text-sm font-medium text-slate-300">
|
||||
{hasVideoSource ? "Loading Timeline" : "No Video Loaded"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{hasVideoSource
|
||||
? "Video opened, waiting for duration metadata"
|
||||
: "Drag and drop a video to start editing"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -170,8 +170,32 @@ export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup";
|
||||
cursorType?:
|
||||
| "arrow"
|
||||
| "text"
|
||||
| "pointer"
|
||||
| "crosshair"
|
||||
| "open-hand"
|
||||
| "closed-hand"
|
||||
| "resize-ew"
|
||||
| "resize-ns"
|
||||
| "not-allowed";
|
||||
}
|
||||
|
||||
export interface CursorVisualSettings {
|
||||
size: number;
|
||||
smoothing: number;
|
||||
motionBlur: number;
|
||||
clickBounce: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_SIZE = 3.0;
|
||||
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
|
||||
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
|
||||
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
|
||||
|
||||
export interface TrimRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { Graphics } from "pixi.js";
|
||||
|
||||
export type CursorHighlightStyle = "dot" | "ring";
|
||||
|
||||
export interface CursorHighlightConfig {
|
||||
enabled: boolean;
|
||||
style: CursorHighlightStyle;
|
||||
sizePx: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
// Show only on clicks (macOS — depends on click telemetry from uiohook).
|
||||
onlyOnClicks: boolean;
|
||||
clickEmphasisDurationMs: number;
|
||||
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
|
||||
// but window recordings frame a subset of the display so the highlight
|
||||
// lands offset. Users dial these in once to align with the actual cursor.
|
||||
offsetXNorm: number;
|
||||
offsetYNorm: number;
|
||||
}
|
||||
|
||||
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
|
||||
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
|
||||
|
||||
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
|
||||
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
|
||||
|
||||
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
|
||||
// click-only mode; in click-only mode fades 1→0 across each click's window.
|
||||
export function clickEmphasisAlpha(
|
||||
timeMs: number,
|
||||
clickTimestampsMs: number[] | undefined,
|
||||
config: CursorHighlightConfig,
|
||||
): number {
|
||||
if (!config.onlyOnClicks) return 1;
|
||||
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
|
||||
const window = Math.max(1, config.clickEmphasisDurationMs);
|
||||
for (let i = 0; i < clickTimestampsMs.length; i++) {
|
||||
const dt = timeMs - clickTimestampsMs[i];
|
||||
if (dt >= 0 && dt <= window) {
|
||||
return 1 - dt / window;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): number {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length === 3) {
|
||||
const r = cleaned[0];
|
||||
const g = cleaned[1];
|
||||
const b = cleaned[2];
|
||||
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
|
||||
}
|
||||
return Number.parseInt(cleaned.slice(0, 6), 16);
|
||||
}
|
||||
|
||||
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
|
||||
g.clear();
|
||||
if (!config.enabled) return;
|
||||
|
||||
const color = parseHexColor(config.color);
|
||||
const radius = Math.max(1, config.sizePx / 2);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
g.circle(0, 0, radius);
|
||||
g.fill({ color, alpha });
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
g.circle(0, 0, radius);
|
||||
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawCursorHighlightCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
config: CursorHighlightConfig,
|
||||
pixelScale = 1,
|
||||
): void {
|
||||
if (!config.enabled) return;
|
||||
|
||||
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
const color = config.color;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Math.max(2, radius * 0.18);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type { CursorTelemetryPoint } from "../types";
|
||||
import {
|
||||
createSpringState,
|
||||
getCursorSpringConfig,
|
||||
resetSpringState,
|
||||
stepSpringValue,
|
||||
} from "./motionSmoothing";
|
||||
import { UPLOADED_CURSOR_SAMPLE_SIZE, uploadedCursorAssets } from "./uploadedCursorAssets";
|
||||
|
||||
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
|
||||
|
||||
/** System cursor asset from native helper (macOS only). */
|
||||
type SystemCursorAsset = {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
};
|
||||
|
||||
type LoadedCursorAsset = {
|
||||
texture: Texture;
|
||||
image: HTMLImageElement;
|
||||
aspectRatio: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
};
|
||||
|
||||
export interface CursorViewportRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for cursor rendering.
|
||||
*/
|
||||
export interface CursorRenderConfig {
|
||||
/** Base cursor height in pixels (at reference width of 1920px) */
|
||||
dotRadius: number;
|
||||
/** Cursor fill color (hex number for PixiJS) */
|
||||
dotColor: number;
|
||||
/** Cursor opacity (0–1) */
|
||||
dotAlpha: number;
|
||||
/** Unused, kept for interface compatibility */
|
||||
trailLength: number;
|
||||
/** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */
|
||||
smoothingFactor: number;
|
||||
/** Directional cursor motion blur amount. */
|
||||
motionBlur: number;
|
||||
/** Click bounce multiplier. */
|
||||
clickBounce: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
|
||||
dotRadius: 28,
|
||||
dotColor: 0xffffff,
|
||||
dotAlpha: 0.95,
|
||||
trailLength: 0,
|
||||
smoothingFactor: 0.18,
|
||||
motionBlur: 0,
|
||||
clickBounce: 1,
|
||||
};
|
||||
|
||||
const REFERENCE_WIDTH = 1920;
|
||||
const MIN_CURSOR_VIEWPORT_SCALE = 0.55;
|
||||
const CLICK_ANIMATION_MS = 140;
|
||||
const CLICK_RING_FADE_MS = 240;
|
||||
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
|
||||
const CURSOR_TIME_DISCONTINUITY_MS = 100;
|
||||
const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))";
|
||||
const CURSOR_SHADOW_COLOR = 0x000000;
|
||||
const CURSOR_SHADOW_ALPHA = 0.35;
|
||||
const CURSOR_SHADOW_OFFSET_X = 0;
|
||||
const CURSOR_SHADOW_OFFSET_Y = 2;
|
||||
const CURSOR_SHADOW_BLUR = 3;
|
||||
const CURSOR_SHADOW_PADDING = 12;
|
||||
|
||||
let cursorAssetsPromise: Promise<void> | null = null;
|
||||
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
|
||||
const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
|
||||
"arrow",
|
||||
"text",
|
||||
"pointer",
|
||||
"crosshair",
|
||||
"open-hand",
|
||||
"closed-hand",
|
||||
"resize-ew",
|
||||
"resize-ns",
|
||||
"not-allowed",
|
||||
];
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`));
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getNormalizedAnchor(
|
||||
systemAsset: SystemCursorAsset | undefined,
|
||||
fallbackAnchor: { x: number; y: number },
|
||||
) {
|
||||
if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) {
|
||||
return fallbackAnchor;
|
||||
}
|
||||
|
||||
return {
|
||||
x: clamp(systemAsset.hotspotX / systemAsset.width, 0, 1),
|
||||
y: clamp(systemAsset.hotspotY / systemAsset.height, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an SVG at `sampleSize × sampleSize`, crops the trim region out of it,
|
||||
* and returns a PNG data-URL of the cropped result. This is required because
|
||||
* SVG files have their own natural pixel size (e.g. 32×32) which does not
|
||||
* match the 1024-sample coordinate space used by the trim measurements.
|
||||
*/
|
||||
async function rasterizeAndCropSvg(
|
||||
url: string,
|
||||
sampleSize: number,
|
||||
trimX: number,
|
||||
trimY: number,
|
||||
trimWidth: number,
|
||||
trimHeight: number,
|
||||
): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||
const img = await loadImage(url);
|
||||
|
||||
// Draw at full sample size
|
||||
const srcCanvas = document.createElement("canvas");
|
||||
srcCanvas.width = sampleSize;
|
||||
srcCanvas.height = sampleSize;
|
||||
const srcCtx = srcCanvas.getContext("2d")!;
|
||||
srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize);
|
||||
|
||||
// Crop to trim bounds
|
||||
const dstCanvas = document.createElement("canvas");
|
||||
dstCanvas.width = trimWidth;
|
||||
dstCanvas.height = trimHeight;
|
||||
const dstCtx = dstCanvas.getContext("2d")!;
|
||||
dstCtx.drawImage(srcCanvas, trimX, trimY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
|
||||
return {
|
||||
dataUrl: dstCanvas.toDataURL("image/png"),
|
||||
width: dstCanvas.width,
|
||||
height: dstCanvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset {
|
||||
const asset = loadedCursorAssets[key];
|
||||
if (!asset) {
|
||||
throw new Error(`Missing cursor asset for ${key}`);
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
function getAvailableCursorKeys(): CursorAssetKey[] {
|
||||
const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[];
|
||||
return loadedKeys.length > 0 ? loadedKeys : ["arrow"];
|
||||
}
|
||||
|
||||
export async function preloadCursorAssets() {
|
||||
if (!cursorAssetsPromise) {
|
||||
cursorAssetsPromise = (async () => {
|
||||
let systemCursors: Record<string, SystemCursorAsset> = {};
|
||||
|
||||
try {
|
||||
const api = window.electronAPI as Record<string, unknown>;
|
||||
if (typeof api.getSystemCursorAssets === "function") {
|
||||
const result = await (
|
||||
api.getSystemCursorAssets as () => Promise<{
|
||||
success: boolean;
|
||||
cursors?: Record<string, SystemCursorAsset>;
|
||||
}>
|
||||
)();
|
||||
if (result.success && result.cursors) {
|
||||
systemCursors = result.cursors;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[CursorRenderer] Failed to fetch system cursor assets:", error);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
SUPPORTED_CURSOR_KEYS.map(async (key) => {
|
||||
const systemAsset = systemCursors[key];
|
||||
const uploadedAsset = uploadedCursorAssets[key];
|
||||
const assetUrl = uploadedAsset?.url ?? systemAsset?.dataUrl;
|
||||
|
||||
if (!assetUrl) {
|
||||
console.warn(`[CursorRenderer] No cursor image for: ${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let finalUrl: string;
|
||||
let width: number;
|
||||
let height: number;
|
||||
let normalizedAnchor: { x: number; y: number };
|
||||
|
||||
if (uploadedAsset) {
|
||||
const { trim, fallbackAnchor } = uploadedAsset;
|
||||
const rasterized = await rasterizeAndCropSvg(
|
||||
assetUrl,
|
||||
UPLOADED_CURSOR_SAMPLE_SIZE,
|
||||
trim.x,
|
||||
trim.y,
|
||||
trim.width,
|
||||
trim.height,
|
||||
);
|
||||
finalUrl = rasterized.dataUrl;
|
||||
width = rasterized.width;
|
||||
height = rasterized.height;
|
||||
normalizedAnchor = {
|
||||
x: clamp((fallbackAnchor.x * trim.width) / width, 0, 1),
|
||||
y: clamp((fallbackAnchor.y * trim.height) / height, 0, 1),
|
||||
};
|
||||
} else {
|
||||
finalUrl = assetUrl;
|
||||
const img = await loadImage(finalUrl);
|
||||
width = img.naturalWidth;
|
||||
height = img.naturalHeight;
|
||||
normalizedAnchor = getNormalizedAnchor(systemAsset, { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
await Assets.load(finalUrl);
|
||||
const image = await loadImage(finalUrl);
|
||||
const texture = Texture.from(finalUrl);
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
texture,
|
||||
image,
|
||||
aspectRatio: height > 0 ? width / height : 1,
|
||||
anchorX: normalizedAnchor.x,
|
||||
anchorY: normalizedAnchor.y,
|
||||
} satisfies LoadedCursorAsset,
|
||||
] as const;
|
||||
} catch (error) {
|
||||
console.warn(`[CursorRenderer] Failed to load cursor image for: ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
loadedCursorAssets = Object.fromEntries(
|
||||
entries.filter(Boolean).map((entry) => entry!),
|
||||
) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>;
|
||||
|
||||
if (!loadedCursorAssets.arrow) {
|
||||
throw new Error("Failed to initialize the fallback arrow cursor asset");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return cursorAssetsPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates cursor position from telemetry samples at a given time.
|
||||
* Uses linear interpolation between the two nearest samples.
|
||||
*/
|
||||
export function interpolateCursorPosition(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
): { cx: number; cy: number } | null {
|
||||
if (!samples || samples.length === 0) return null;
|
||||
|
||||
if (timeMs <= samples[0].timeMs) {
|
||||
return { cx: samples[0].cx, cy: samples[0].cy };
|
||||
}
|
||||
|
||||
if (timeMs >= samples[samples.length - 1].timeMs) {
|
||||
return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy };
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = samples.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (samples[mid].timeMs <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
const a = samples[lo];
|
||||
const b = samples[hi];
|
||||
const span = b.timeMs - a.timeMs;
|
||||
if (span <= 0) return { cx: a.cx, cy: a.cy };
|
||||
|
||||
const t = (timeMs - a.timeMs) / span;
|
||||
return {
|
||||
cx: a.cx + (b.cx - a.cx) * t,
|
||||
cy: a.cy + (b.cy - a.cy) * t,
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
if (samples.length === 0) return null;
|
||||
|
||||
let lo = 0;
|
||||
let hi = samples.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (samples[mid].timeMs <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return samples[lo]?.timeMs <= timeMs ? samples[lo] : null;
|
||||
}
|
||||
|
||||
function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
||||
const sample = samples[index];
|
||||
if (sample.timeMs > timeMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sample.interactionType === "click" ||
|
||||
sample.interactionType === "double-click" ||
|
||||
sample.interactionType === "right-click" ||
|
||||
sample.interactionType === "middle-click"
|
||||
) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
// Binary search to find position at timeMs, then scan backwards
|
||||
let lo = 0;
|
||||
let hi = samples.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (samples[mid].timeMs <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from the position to find a sample with cursorType
|
||||
// Skip click events only (not mouseup) to avoid transient re-type during clicks
|
||||
for (let index = lo; index >= 0; index -= 1) {
|
||||
const sample = samples[index];
|
||||
if (sample.timeMs > timeMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sample.cursorType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sample.interactionType === "click" ||
|
||||
sample.interactionType === "double-click" ||
|
||||
sample.interactionType === "right-click" ||
|
||||
sample.interactionType === "middle-click"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return sample.cursorType;
|
||||
}
|
||||
|
||||
return findLatestSample(samples, timeMs)?.cursorType ?? "arrow";
|
||||
}
|
||||
|
||||
function getCursorViewportScale(viewport: CursorViewportRect) {
|
||||
return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
|
||||
}
|
||||
|
||||
function getCursorVisualState(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
const latestClick = findLatestInteractionSample(samples, timeMs);
|
||||
const interactionType = latestClick?.interactionType;
|
||||
const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY;
|
||||
const isClickEvent =
|
||||
interactionType === "click" ||
|
||||
interactionType === "double-click" ||
|
||||
interactionType === "right-click" ||
|
||||
interactionType === "middle-click";
|
||||
const clickBounceProgress =
|
||||
latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS ? 1 - ageMs / CLICK_ANIMATION_MS : 0;
|
||||
|
||||
return {
|
||||
cursorType: findLatestStableCursorType(samples, timeMs),
|
||||
clickBounceProgress,
|
||||
clickProgress:
|
||||
latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
|
||||
? 1 - ageMs / CLICK_RING_FADE_MS
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a smoothed cursor state that chases the interpolated target.
|
||||
*/
|
||||
export class SmoothedCursorState {
|
||||
public x = 0.5;
|
||||
public y = 0.5;
|
||||
public trail: Array<{ x: number; y: number }> = [];
|
||||
private smoothingFactor: number;
|
||||
private trailLength: number;
|
||||
private initialized = false;
|
||||
private lastTimeMs: number | null = null;
|
||||
private xSpring = createSpringState(0.5);
|
||||
private ySpring = createSpringState(0.5);
|
||||
|
||||
constructor(config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength">) {
|
||||
this.smoothingFactor = config.smoothingFactor;
|
||||
this.trailLength = config.trailLength;
|
||||
}
|
||||
|
||||
update(targetX: number, targetY: number, timeMs: number): void {
|
||||
if (!this.initialized) {
|
||||
this.x = targetX;
|
||||
this.y = targetY;
|
||||
this.initialized = true;
|
||||
this.lastTimeMs = timeMs;
|
||||
this.xSpring.value = targetX;
|
||||
this.ySpring.value = targetY;
|
||||
this.xSpring.velocity = 0;
|
||||
this.ySpring.velocity = 0;
|
||||
this.xSpring.initialized = true;
|
||||
this.ySpring.initialized = true;
|
||||
this.trail = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) {
|
||||
this.snapTo(targetX, targetY, timeMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.trail.unshift({ x: this.x, y: this.y });
|
||||
if (this.trail.length > this.trailLength) {
|
||||
this.trail.length = this.trailLength;
|
||||
}
|
||||
|
||||
const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs);
|
||||
this.lastTimeMs = timeMs;
|
||||
|
||||
const springConfig = getCursorSpringConfig(this.smoothingFactor);
|
||||
this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig);
|
||||
this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig);
|
||||
}
|
||||
|
||||
setSmoothingFactor(smoothingFactor: number): void {
|
||||
this.smoothingFactor = smoothingFactor;
|
||||
}
|
||||
|
||||
snapTo(targetX: number, targetY: number, timeMs: number): void {
|
||||
this.x = targetX;
|
||||
this.y = targetY;
|
||||
this.initialized = true;
|
||||
this.lastTimeMs = timeMs;
|
||||
this.xSpring.value = targetX;
|
||||
this.ySpring.value = targetY;
|
||||
this.xSpring.velocity = 0;
|
||||
this.ySpring.velocity = 0;
|
||||
this.xSpring.initialized = true;
|
||||
this.ySpring.initialized = true;
|
||||
this.trail = [];
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.initialized = false;
|
||||
this.lastTimeMs = null;
|
||||
this.trail = [];
|
||||
resetSpringState(this.xSpring, this.x);
|
||||
resetSpringState(this.ySpring, this.y);
|
||||
}
|
||||
}
|
||||
|
||||
function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) {
|
||||
void graphics;
|
||||
void px;
|
||||
void py;
|
||||
void h;
|
||||
void progress;
|
||||
}
|
||||
|
||||
export class PixiCursorOverlay {
|
||||
public readonly container: Container;
|
||||
private clickRingGraphics: Graphics;
|
||||
private cursorShadowSprites: Partial<Record<CursorAssetKey, Sprite>>;
|
||||
private cursorShadowFilters: Partial<Record<CursorAssetKey, BlurFilter>>;
|
||||
private cursorSprites: Partial<Record<CursorAssetKey, Sprite>>;
|
||||
private cursorMotionBlurFilter: MotionBlurFilter;
|
||||
private state: SmoothedCursorState;
|
||||
private config: CursorRenderConfig;
|
||||
private lastRenderedPoint: { px: number; py: number } | null = null;
|
||||
private lastRenderedTimeMs: number | null = null;
|
||||
|
||||
constructor(config: Partial<CursorRenderConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CURSOR_CONFIG, ...config };
|
||||
this.state = new SmoothedCursorState(this.config);
|
||||
|
||||
this.container = new Container();
|
||||
this.container.label = "cursor-overlay";
|
||||
|
||||
this.clickRingGraphics = new Graphics();
|
||||
this.cursorShadowSprites = {};
|
||||
this.cursorShadowFilters = {};
|
||||
this.cursorSprites = {};
|
||||
for (const key of getAvailableCursorKeys()) {
|
||||
const asset = getCursorAsset(key);
|
||||
const shadowSprite = new Sprite(asset.texture);
|
||||
shadowSprite.anchor.set(asset.anchorX, asset.anchorY);
|
||||
shadowSprite.visible = false;
|
||||
shadowSprite.tint = CURSOR_SHADOW_COLOR;
|
||||
shadowSprite.alpha = CURSOR_SHADOW_ALPHA;
|
||||
const shadowFilter = new BlurFilter();
|
||||
shadowFilter.blur = CURSOR_SHADOW_BLUR;
|
||||
shadowFilter.quality = 4;
|
||||
shadowFilter.padding = CURSOR_SHADOW_PADDING;
|
||||
shadowSprite.filters = [shadowFilter];
|
||||
this.cursorShadowSprites[key] = shadowSprite;
|
||||
this.cursorShadowFilters[key] = shadowFilter;
|
||||
|
||||
const sprite = new Sprite(asset.texture);
|
||||
sprite.anchor.set(asset.anchorX, asset.anchorY);
|
||||
sprite.visible = false;
|
||||
this.cursorSprites[key] = sprite;
|
||||
}
|
||||
|
||||
this.cursorMotionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
|
||||
this.container.filters = null;
|
||||
|
||||
this.container.addChild(
|
||||
this.clickRingGraphics,
|
||||
...Object.values(this.cursorShadowSprites),
|
||||
...Object.values(this.cursorSprites),
|
||||
);
|
||||
this.setMotionBlur(this.config.motionBlur);
|
||||
}
|
||||
|
||||
setDotRadius(dotRadius: number) {
|
||||
this.config.dotRadius = dotRadius;
|
||||
}
|
||||
|
||||
setSmoothingFactor(smoothingFactor: number) {
|
||||
this.config.smoothingFactor = smoothingFactor;
|
||||
this.state.setSmoothingFactor(smoothingFactor);
|
||||
}
|
||||
|
||||
setMotionBlur(motionBlur: number) {
|
||||
this.config.motionBlur = Math.max(0, motionBlur);
|
||||
this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
|
||||
if (this.config.motionBlur <= 0) {
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
this.cursorMotionBlurFilter.offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
setClickBounce(clickBounce: number) {
|
||||
this.config.clickBounce = Math.max(0, clickBounce);
|
||||
}
|
||||
|
||||
update(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
viewport: CursorViewportRect,
|
||||
visible: boolean,
|
||||
freeze = false,
|
||||
): void {
|
||||
if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
|
||||
this.container.visible = false;
|
||||
this.lastRenderedPoint = null;
|
||||
this.lastRenderedTimeMs = null;
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
const target = interpolateCursorPosition(samples, timeMs);
|
||||
if (!target) {
|
||||
this.container.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const sameFrameTime =
|
||||
this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001;
|
||||
const hasTimeDiscontinuity =
|
||||
this.lastRenderedTimeMs !== null &&
|
||||
Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS;
|
||||
|
||||
if (freeze || hasTimeDiscontinuity) {
|
||||
if (!sameFrameTime || !this.lastRenderedPoint) {
|
||||
this.state.snapTo(target.cx, target.cy, timeMs);
|
||||
}
|
||||
} else {
|
||||
this.state.update(target.cx, target.cy, timeMs);
|
||||
}
|
||||
this.container.visible = true;
|
||||
|
||||
const px = viewport.x + this.state.x * viewport.width;
|
||||
const py = viewport.y + this.state.y * viewport.height;
|
||||
const h = this.config.dotRadius * getCursorViewportScale(viewport);
|
||||
const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState(
|
||||
samples,
|
||||
timeMs,
|
||||
);
|
||||
const spriteKey = (cursorType in this.cursorSprites ? cursorType : "arrow") as CursorAssetKey;
|
||||
const asset = getCursorAsset(spriteKey);
|
||||
const shadowSprite = this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
|
||||
const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!;
|
||||
const bounceScale = Math.max(
|
||||
0.72,
|
||||
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce),
|
||||
);
|
||||
const scaledH = h;
|
||||
|
||||
this.clickRingGraphics.clear();
|
||||
drawClickRing(this.clickRingGraphics, px, py, h, clickProgress);
|
||||
|
||||
for (const [key, currentShadowSprite] of Object.entries(this.cursorShadowSprites) as Array<
|
||||
[CursorAssetKey, Sprite]
|
||||
>) {
|
||||
currentShadowSprite.visible = key === spriteKey;
|
||||
}
|
||||
|
||||
for (const [key, currentSprite] of Object.entries(this.cursorSprites) as Array<
|
||||
[CursorAssetKey, Sprite]
|
||||
>) {
|
||||
currentSprite.visible = key === spriteKey;
|
||||
}
|
||||
|
||||
if (shadowSprite) {
|
||||
shadowSprite.height = scaledH * bounceScale;
|
||||
shadowSprite.width = scaledH * bounceScale * asset.aspectRatio;
|
||||
shadowSprite.position.set(px + CURSOR_SHADOW_OFFSET_X, py + CURSOR_SHADOW_OFFSET_Y);
|
||||
}
|
||||
|
||||
if (sprite) {
|
||||
sprite.alpha = this.config.dotAlpha;
|
||||
sprite.height = scaledH * bounceScale;
|
||||
sprite.width = scaledH * bounceScale * asset.aspectRatio;
|
||||
sprite.position.set(px, py);
|
||||
}
|
||||
|
||||
this.applyCursorMotionBlur(px, py, timeMs, freeze);
|
||||
this.lastRenderedPoint = { px, py };
|
||||
this.lastRenderedTimeMs = timeMs;
|
||||
}
|
||||
|
||||
private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) {
|
||||
if (
|
||||
freeze ||
|
||||
this.config.motionBlur <= 0 ||
|
||||
!this.lastRenderedPoint ||
|
||||
this.lastRenderedTimeMs === null
|
||||
) {
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
this.cursorMotionBlurFilter.offset = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs);
|
||||
const dx = px - this.lastRenderedPoint.px;
|
||||
const dy = py - this.lastRenderedPoint.py;
|
||||
const velocityScale =
|
||||
(1000 / deltaMs) * this.config.motionBlur * CURSOR_MOTION_BLUR_BASE_MULTIPLIER;
|
||||
const velocity = {
|
||||
x: dx * velocityScale,
|
||||
y: dy * velocityScale,
|
||||
};
|
||||
const magnitude = Math.hypot(velocity.x, velocity.y);
|
||||
|
||||
this.cursorMotionBlurFilter.velocity = magnitude > 0.05 ? velocity : { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5;
|
||||
this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.reset();
|
||||
this.clickRingGraphics.clear();
|
||||
for (const shadowSprite of Object.values(this.cursorShadowSprites)) {
|
||||
shadowSprite.visible = false;
|
||||
shadowSprite.scale.set(1);
|
||||
}
|
||||
for (const sprite of Object.values(this.cursorSprites)) {
|
||||
sprite.visible = false;
|
||||
sprite.scale.set(1);
|
||||
}
|
||||
this.container.visible = false;
|
||||
this.lastRenderedPoint = null;
|
||||
this.lastRenderedTimeMs = null;
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
this.cursorMotionBlurFilter.offset = 0;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clickRingGraphics.destroy();
|
||||
for (const shadowFilter of Object.values(this.cursorShadowFilters)) {
|
||||
shadowFilter.destroy();
|
||||
}
|
||||
this.cursorMotionBlurFilter.destroy();
|
||||
this.container.destroy({ children: true });
|
||||
cursorAssetsPromise = null;
|
||||
loadedCursorAssets = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function drawCursorOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
viewport: CursorViewportRect,
|
||||
smoothedState: SmoothedCursorState,
|
||||
config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG,
|
||||
): void {
|
||||
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return;
|
||||
|
||||
const target = interpolateCursorPosition(samples, timeMs);
|
||||
if (!target) return;
|
||||
|
||||
smoothedState.update(target.cx, target.cy, timeMs);
|
||||
|
||||
const px = viewport.x + smoothedState.x * viewport.width;
|
||||
const py = viewport.y + smoothedState.y * viewport.height;
|
||||
const h = config.dotRadius * getCursorViewportScale(viewport);
|
||||
const { cursorType, clickBounceProgress } = getCursorVisualState(samples, timeMs);
|
||||
const spriteKey = (
|
||||
cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow"
|
||||
) as CursorAssetKey;
|
||||
const asset = getCursorAsset(spriteKey);
|
||||
const bounceScale = Math.max(
|
||||
0.72,
|
||||
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce),
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
|
||||
|
||||
const drawHeight = h * bounceScale;
|
||||
const drawWidth = drawHeight * asset.aspectRatio;
|
||||
const hotspotX = asset.anchorX * drawWidth;
|
||||
const hotspotY = asset.anchorY * drawHeight;
|
||||
ctx.globalAlpha = config.dotAlpha;
|
||||
ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { spring } from "motion";
|
||||
|
||||
export interface SpringState {
|
||||
value: number;
|
||||
velocity: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface SpringConfig {
|
||||
stiffness: number;
|
||||
damping: number;
|
||||
mass: number;
|
||||
restDelta?: number;
|
||||
restSpeed?: number;
|
||||
}
|
||||
|
||||
const CURSOR_SMOOTHING_MIN = 0;
|
||||
const CURSOR_SMOOTHING_MAX = 2;
|
||||
const CURSOR_SMOOTHING_LEGACY_MAX = 0.5;
|
||||
|
||||
export function createSpringState(initialValue = 0): SpringState {
|
||||
return {
|
||||
value: initialValue,
|
||||
velocity: 0,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSpringState(state: SpringState, initialValue?: number) {
|
||||
if (typeof initialValue === "number") {
|
||||
state.value = initialValue;
|
||||
}
|
||||
|
||||
state.velocity = 0;
|
||||
state.initialized = false;
|
||||
}
|
||||
|
||||
export function clampDeltaMs(deltaMs: number, fallbackMs = 1000 / 60) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
return Math.min(80, Math.max(1, deltaMs));
|
||||
}
|
||||
|
||||
export function stepSpringValue(
|
||||
state: SpringState,
|
||||
target: number,
|
||||
deltaMs: number,
|
||||
config: SpringConfig,
|
||||
) {
|
||||
const safeDeltaMs = clampDeltaMs(deltaMs);
|
||||
|
||||
if (!state.initialized || !Number.isFinite(state.value)) {
|
||||
state.value = target;
|
||||
state.velocity = 0;
|
||||
state.initialized = true;
|
||||
return state.value;
|
||||
}
|
||||
|
||||
const restDelta = config.restDelta ?? 0.0005;
|
||||
const restSpeed = config.restSpeed ?? 0.02;
|
||||
|
||||
if (Math.abs(target - state.value) <= restDelta && Math.abs(state.velocity) <= restSpeed) {
|
||||
state.value = target;
|
||||
state.velocity = 0;
|
||||
return state.value;
|
||||
}
|
||||
|
||||
const previousValue = state.value;
|
||||
const generator = spring({
|
||||
keyframes: [state.value, target],
|
||||
velocity: state.velocity,
|
||||
stiffness: config.stiffness,
|
||||
damping: config.damping,
|
||||
mass: config.mass,
|
||||
restDelta,
|
||||
restSpeed,
|
||||
});
|
||||
|
||||
const result = generator.next(safeDeltaMs);
|
||||
state.value = result.done ? target : result.value;
|
||||
state.velocity = ((state.value - previousValue) / safeDeltaMs) * 1000;
|
||||
|
||||
if (result.done) {
|
||||
state.velocity = 0;
|
||||
}
|
||||
|
||||
return state.value;
|
||||
}
|
||||
|
||||
export function getCursorSpringConfig(smoothingFactor: number): SpringConfig {
|
||||
const clamped = Math.min(CURSOR_SMOOTHING_MAX, Math.max(CURSOR_SMOOTHING_MIN, smoothingFactor));
|
||||
|
||||
if (clamped <= 0) {
|
||||
return {
|
||||
stiffness: 1000,
|
||||
damping: 100,
|
||||
mass: 1,
|
||||
restDelta: 0.0001,
|
||||
restSpeed: 0.001,
|
||||
};
|
||||
}
|
||||
|
||||
if (clamped <= CURSOR_SMOOTHING_LEGACY_MAX) {
|
||||
const legacyNormalized = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(clamped - CURSOR_SMOOTHING_MIN) / (CURSOR_SMOOTHING_LEGACY_MAX - CURSOR_SMOOTHING_MIN),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
stiffness: 760 - legacyNormalized * 420,
|
||||
damping: 34 + legacyNormalized * 24,
|
||||
mass: 0.55 + legacyNormalized * 0.45,
|
||||
restDelta: 0.0002,
|
||||
restSpeed: 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
const extendedNormalized = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(clamped - CURSOR_SMOOTHING_LEGACY_MAX) /
|
||||
(CURSOR_SMOOTHING_MAX - CURSOR_SMOOTHING_LEGACY_MAX),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
stiffness: 340 - extendedNormalized * 180,
|
||||
damping: 58 + extendedNormalized * 22,
|
||||
mass: 1 + extendedNormalized * 0.35,
|
||||
restDelta: 0.0002,
|
||||
restSpeed: 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
export function getZoomSpringConfig(): SpringConfig {
|
||||
return {
|
||||
stiffness: 320,
|
||||
damping: 40,
|
||||
mass: 0.92,
|
||||
restDelta: 0.0005,
|
||||
restSpeed: 0.015,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 resizeNsUrl from "../../../assets/cursors/Cursor=Resize-North-South.svg";
|
||||
import resizeEwUrl from "../../../assets/cursors/Cursor=Resize-West-East.svg";
|
||||
import textUrl from "../../../assets/cursors/Cursor=Text-Cursor.svg";
|
||||
import type { CursorTelemetryPoint } from "../types";
|
||||
|
||||
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
|
||||
|
||||
export type UploadedCursorAsset = {
|
||||
url: string;
|
||||
trim: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
fallbackAnchor: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const UPLOADED_CURSOR_SAMPLE_SIZE = 1024;
|
||||
|
||||
export const uploadedCursorAssets: Partial<Record<CursorAssetKey, UploadedCursorAsset>> = {
|
||||
arrow: {
|
||||
url: arrowUrl,
|
||||
trim: { x: 480, y: 435, width: 333, height: 553 },
|
||||
fallbackAnchor: { x: 0.18, y: 0.1 },
|
||||
},
|
||||
text: {
|
||||
url: textUrl,
|
||||
trim: { x: 404, y: 192, width: 247, height: 596 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
pointer: {
|
||||
url: pointerUrl,
|
||||
trim: { x: 352, y: 441, width: 466, height: 583 },
|
||||
fallbackAnchor: { x: 0.37, y: 0.08 },
|
||||
},
|
||||
crosshair: {
|
||||
url: crosshairUrl,
|
||||
trim: { x: 288, y: 288, width: 480, height: 480 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
"open-hand": {
|
||||
url: openHandUrl,
|
||||
trim: { x: 288, y: 188, width: 512, height: 580 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.28 },
|
||||
},
|
||||
"closed-hand": {
|
||||
url: closedHandUrl,
|
||||
trim: { x: 344, y: 365, width: 432, height: 403 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.28 },
|
||||
},
|
||||
"resize-ew": {
|
||||
url: resizeEwUrl,
|
||||
trim: { x: 187, y: 384, width: 669, height: 270 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
"resize-ns": {
|
||||
url: resizeNsUrl,
|
||||
trim: { x: 376, y: 178, width: 271, height: 669 },
|
||||
fallbackAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
};
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
} from "@/components/video-editor/videoPlayback/cursorHighlight";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
@@ -43,7 +39,6 @@ export interface EditorState {
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
cursorHighlight: CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
@@ -63,7 +58,6 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import {
|
||||
type NativeWindowsRecordingRequest,
|
||||
parseWindowHandleFromSourceId,
|
||||
} from "@/lib/nativeWindowsRecording";
|
||||
import type { CursorCaptureMode } from "@/lib/recordingSession";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
@@ -51,12 +56,18 @@ type UseScreenRecorderReturn = {
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
microphoneDeviceId: string | undefined;
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
microphoneDeviceName: string | undefined;
|
||||
setMicrophoneDeviceName: (deviceName: string | undefined) => void;
|
||||
webcamDeviceId: string | undefined;
|
||||
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||
webcamDeviceName: string | undefined;
|
||||
setWebcamDeviceName: (deviceName: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
webcamEnabled: boolean;
|
||||
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
|
||||
cursorCaptureMode: CursorCaptureMode;
|
||||
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
||||
};
|
||||
|
||||
type RecorderHandle = {
|
||||
@@ -64,6 +75,11 @@ type RecorderHandle = {
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
};
|
||||
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
};
|
||||
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
@@ -93,11 +109,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [microphoneDeviceName, setMicrophoneDeviceName] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceName, setWebcamDeviceName] = useState<string | undefined>(undefined);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||
const [cursorCaptureMode, setCursorCaptureMode] = useState<CursorCaptureMode>("editable-overlay");
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
||||
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const screenStream = useRef<MediaStream | null>(null);
|
||||
const microphoneStream = useRef<MediaStream | null>(null);
|
||||
@@ -174,6 +194,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopWebcamPreviewStream = useCallback(() => {
|
||||
if (!webcamStream.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
webcamAcquireId.current++;
|
||||
webcamStream.current.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
webcamStream.current = null;
|
||||
webcamReady.current = true;
|
||||
}, []);
|
||||
|
||||
const setWebcamEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
@@ -338,6 +372,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
: undefined,
|
||||
createdAt: activeRecordingId,
|
||||
cursorCaptureMode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -364,10 +399,68 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
})();
|
||||
},
|
||||
[teardownMedia],
|
||||
[cursorCaptureMode, teardownMedia],
|
||||
);
|
||||
|
||||
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder) {
|
||||
return;
|
||||
@@ -433,6 +526,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
allowAutoFinalize.current = false;
|
||||
restarting.current = false;
|
||||
discardRecordingId.current = null;
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
}
|
||||
|
||||
if (
|
||||
screenRecorder.current?.recorder.state === "recording" ||
|
||||
@@ -458,7 +554,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
};
|
||||
}, [teardownMedia, safeHideCountdownOverlay]);
|
||||
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
|
||||
|
||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||
try {
|
||||
@@ -488,6 +584,99 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const isCountdownRunActive = (runId?: number) =>
|
||||
runId === undefined || countdownRunId.current === runId;
|
||||
|
||||
const startNativeWindowsRecordingIfAvailable = async (
|
||||
selectedSource: ProcessedDesktopSource,
|
||||
countdownRunToken?: number,
|
||||
) => {
|
||||
try {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
if (platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const availability = await window.electronAPI.isNativeWindowsCaptureAvailable();
|
||||
if (!availability.success || !availability.available) {
|
||||
if (availability.reason === "unsupported-os") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native Windows capture helper is not available."
|
||||
: (availability.error ?? "Native Windows capture is not available."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeRecordingId = Date.now();
|
||||
const displayId = Number(selectedSource.display_id);
|
||||
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
||||
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
|
||||
if (webcamEnabled) {
|
||||
stopWebcamPreviewStream();
|
||||
}
|
||||
const request: NativeWindowsRecordingRequest = {
|
||||
recordingId: activeRecordingId,
|
||||
source: {
|
||||
type: sourceType,
|
||||
sourceId: selectedSource.id,
|
||||
...(Number.isFinite(displayId) ? { displayId } : {}),
|
||||
...(windowHandle ? { windowHandle } : {}),
|
||||
},
|
||||
video: {
|
||||
fps: TARGET_FRAME_RATE,
|
||||
width: TARGET_WIDTH,
|
||||
height: TARGET_HEIGHT,
|
||||
},
|
||||
audio: {
|
||||
system: {
|
||||
enabled: systemAudioEnabled,
|
||||
},
|
||||
microphone: {
|
||||
enabled: microphoneEnabled,
|
||||
deviceId: microphoneDeviceId,
|
||||
deviceName: microphoneDeviceName,
|
||||
gain: MIC_GAIN_BOOST,
|
||||
},
|
||||
},
|
||||
webcam: {
|
||||
enabled: webcamEnabled,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
mode: cursorCaptureMode,
|
||||
},
|
||||
};
|
||||
const result = await window.electronAPI.startNativeWindowsRecording(request);
|
||||
if (!result.success || !result.recordingId) {
|
||||
throw new Error(result.error ?? "Native Windows capture failed.");
|
||||
}
|
||||
|
||||
recordingId.current = result.recordingId;
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
};
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Native Windows capture failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecordCountdown = async () => {
|
||||
if (countdownActive || recording) {
|
||||
return;
|
||||
@@ -575,43 +764,63 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
|
||||
const videoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
};
|
||||
if (platform === "win32") {
|
||||
// getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the
|
||||
// pre-selected source. Editable cursor mode excludes the system cursor so
|
||||
// the editor can render a replacement; system mode bakes it into the video.
|
||||
screenMediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always",
|
||||
width: { max: TARGET_WIDTH },
|
||||
height: { max: TARGET_HEIGHT },
|
||||
frameRate: { ideal: TARGET_FRAME_RATE },
|
||||
} as MediaTrackConstraints,
|
||||
audio: systemAudioEnabled,
|
||||
} as DisplayMediaStreamOptions);
|
||||
} else {
|
||||
const videoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
};
|
||||
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error(t("recording.systemAudioUnavailable"));
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error(t("recording.systemAudioUnavailable"));
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
@@ -744,6 +953,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
recordingId.current = Date.now();
|
||||
const activeRecordingId = recordingId.current;
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
@@ -766,18 +977,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
});
|
||||
}
|
||||
|
||||
recordingId.current = Date.now();
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
window.electronAPI?.setRecordingState(true, recordingId.current);
|
||||
window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode);
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const activeRecordingId = recordingId.current;
|
||||
if (activeScreenRecorder) {
|
||||
activeScreenRecorder.recorder.addEventListener(
|
||||
"stop",
|
||||
@@ -871,6 +1080,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const restartRecording = async () => {
|
||||
if (restarting.current) return;
|
||||
|
||||
if (nativeWindowsRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
try {
|
||||
await finalizeNativeWindowsRecording(true);
|
||||
await startRecording();
|
||||
} finally {
|
||||
restarting.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||
|
||||
@@ -928,6 +1150,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}, [getRecordingDurationMs, paused, recording]);
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (nativeWindowsRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (
|
||||
activeScreenRecorder?.recorder.state === "recording" ||
|
||||
@@ -959,11 +1189,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
microphoneDeviceName,
|
||||
setMicrophoneDeviceName,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
webcamDeviceName,
|
||||
setWebcamDeviceName,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
cursorCaptureMode,
|
||||
setCursorCaptureMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,20 +38,7 @@
|
||||
"on": "تشغيل",
|
||||
"shadow": "ظل",
|
||||
"roundness": "الاستدارة",
|
||||
"padding": "المسافة البادئة",
|
||||
"cursorHighlight": {
|
||||
"title": "تمييز المؤشر",
|
||||
"style": "النمط",
|
||||
"dot": "نقطة",
|
||||
"ring": "حلقة",
|
||||
"size": "الحجم",
|
||||
"onlyOnClicks": "عند النقر فقط",
|
||||
"color": "اللون",
|
||||
"offsetX": "إزاحة X (لتسجيلات النوافذ)",
|
||||
"offsetY": "إزاحة Y",
|
||||
"accessibilityPermissionTitle": "مطلوب إذن الوصول",
|
||||
"accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق."
|
||||
}
|
||||
"padding": "المسافة البادئة"
|
||||
},
|
||||
"background": {
|
||||
"title": "الخلفية",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "No camera found",
|
||||
"unavailable": "Camera unavailable"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Use editable cursor",
|
||||
"useSystemCursor": "Use system cursor"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Loading sources...",
|
||||
"screens": "Screens ({{count}})",
|
||||
|
||||
@@ -54,20 +54,7 @@
|
||||
"on": "on",
|
||||
"shadow": "Shadow",
|
||||
"roundness": "Roundness",
|
||||
"padding": "Padding",
|
||||
"cursorHighlight": {
|
||||
"title": "Cursor highlight",
|
||||
"style": "Style",
|
||||
"dot": "Dot",
|
||||
"ring": "Ring",
|
||||
"size": "Size",
|
||||
"onlyOnClicks": "Only on clicks",
|
||||
"color": "Color",
|
||||
"offsetX": "Offset X (window recordings)",
|
||||
"offsetY": "Offset Y",
|
||||
"accessibilityPermissionTitle": "Accessibility permission needed",
|
||||
"accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
|
||||
}
|
||||
"padding": "Padding"
|
||||
},
|
||||
"background": {
|
||||
"title": "Background",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "No se encontró cámara",
|
||||
"unavailable": "Cámara no disponible"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Usar cursor editable",
|
||||
"useSystemCursor": "Usar cursor del sistema"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Cargando fuentes...",
|
||||
"screens": "Pantallas ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "Aucune caméra trouvée",
|
||||
"unavailable": "Caméra non disponible"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Utiliser le curseur éditable",
|
||||
"useSystemCursor": "Utiliser le curseur système"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Chargement des sources...",
|
||||
"screens": "Écrans ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "カメラが見つかりません",
|
||||
"unavailable": "カメラが利用できません"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "編集可能なカーソルを使う",
|
||||
"useSystemCursor": "システムカーソルを使う"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "ソースを読み込み中...",
|
||||
"screens": "画面 ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "카메라를 찾을 수 없음",
|
||||
"unavailable": "카메라를 사용할 수 없음"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "편집 가능한 커서 사용",
|
||||
"useSystemCursor": "시스템 커서 사용"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "소스 불러오는 중...",
|
||||
"screens": "화면 ({{count}}개)",
|
||||
|
||||
@@ -46,20 +46,7 @@
|
||||
"on": "вкл",
|
||||
"shadow": "Тень",
|
||||
"roundness": "Скругление",
|
||||
"padding": "Отступ",
|
||||
"cursorHighlight": {
|
||||
"title": "Подсветка курсора",
|
||||
"style": "Стиль",
|
||||
"dot": "Точка",
|
||||
"ring": "Кольцо",
|
||||
"size": "Размер",
|
||||
"onlyOnClicks": "Только при кликах",
|
||||
"color": "Цвет",
|
||||
"offsetX": "Смещение X (записи окон)",
|
||||
"offsetY": "Смещение Y",
|
||||
"accessibilityPermissionTitle": "Требуется разрешение на доступность",
|
||||
"accessibilityPermissionDescription": "Откройте Системные настройки → Конфиденциальность и безопасность → Универсальный доступ, включите Openscreen, затем перезапустите приложение."
|
||||
}
|
||||
"padding": "Отступ"
|
||||
},
|
||||
"background": {
|
||||
"title": "Фон",
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"noneFound": "Kamera bulunamadı",
|
||||
"unavailable": "Kamera kullanılamıyor"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Düzenlenebilir imleci kullan",
|
||||
"useSystemCursor": "Sistem imlecini kullan"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Kaynaklar yükleniyor...",
|
||||
"screens": "Ekranlar ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "未找到摄像头",
|
||||
"unavailable": "摄像头不可用"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "使用可编辑光标",
|
||||
"useSystemCursor": "使用系统光标"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在加载源...",
|
||||
"screens": "屏幕 ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "未找到攝影機",
|
||||
"unavailable": "攝影機不可用"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "使用可編輯游標",
|
||||
"useSystemCursor": "使用系統游標"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在載入來源...",
|
||||
"screens": "螢幕 ({{count}})",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNativeCursorClickBounceProgress,
|
||||
getNativeCursorClickBounceScale,
|
||||
} from "./nativeCursor";
|
||||
|
||||
describe("native cursor click bounce", () => {
|
||||
it("keeps click progress visible across several frames", () => {
|
||||
const recordingData = {
|
||||
version: 2,
|
||||
provider: "native" as const,
|
||||
assets: [],
|
||||
samples: [
|
||||
{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const },
|
||||
{ timeMs: 100, cx: 0.5, cy: 0.5, interactionType: "click" as const },
|
||||
{ timeMs: 133, cx: 0.5, cy: 0.5, interactionType: "move" as const },
|
||||
{ timeMs: 166, cx: 0.5, cy: 0.5, interactionType: "move" as const },
|
||||
{ timeMs: 200, cx: 0.5, cy: 0.5, interactionType: "move" as const },
|
||||
{ timeMs: 300, cx: 0.5, cy: 0.5, interactionType: "move" as const },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getNativeCursorClickBounceProgress(recordingData, 133)).toBeGreaterThan(0);
|
||||
expect(getNativeCursorClickBounceProgress(recordingData, 200)).toBeGreaterThan(0);
|
||||
expect(getNativeCursorClickBounceProgress(recordingData, 400)).toBe(0);
|
||||
});
|
||||
|
||||
it("applies a visible press and rebound scale at high intensity", () => {
|
||||
expect(getNativeCursorClickBounceScale(5, 1)).toBe(1);
|
||||
expect(getNativeCursorClickBounceScale(5, 0.82)).toBeLessThan(0.9);
|
||||
expect(getNativeCursorClickBounceScale(5, 0.28)).toBeGreaterThan(1.05);
|
||||
expect(getNativeCursorClickBounceScale(5, 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
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 = 260;
|
||||
const NATIVE_CURSOR_MOTION_BLUR_MAX_PX = 6;
|
||||
const nativeCursorAssetMapCache = new WeakMap<
|
||||
CursorRecordingData,
|
||||
Map<string, NativeCursorAsset>
|
||||
>();
|
||||
|
||||
function findNativeCursorSampleIndexAtOrBefore(samples: CursorRecordingSample[], timeMs: number) {
|
||||
let low = 0;
|
||||
let high = samples.length - 1;
|
||||
let result = -1;
|
||||
|
||||
while (low <= high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (samples[middle].timeMs <= timeMs) {
|
||||
result = middle;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getNativeCursorAssetMap(recordingData: CursorRecordingData) {
|
||||
const cached = nativeCursorAssetMapCache.get(recordingData);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const assetMap = new Map(recordingData.assets.map((asset) => [asset.id, asset]));
|
||||
nativeCursorAssetMapCache.set(recordingData, assetMap);
|
||||
return assetMap;
|
||||
}
|
||||
|
||||
function getNativeCursorAsset(recordingData: CursorRecordingData, assetId: string) {
|
||||
return getNativeCursorAssetMap(recordingData).get(assetId) ?? null;
|
||||
}
|
||||
|
||||
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 (!recordingData || recordingData.provider !== "native" || recordingData.samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = findNativeCursorSampleIndexAtOrBefore(recordingData.samples, timeMs);
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const sample = recordingData.samples[index];
|
||||
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 intensity = clamp(clickBounce, 0, 5) / 5;
|
||||
const elapsed = 1 - clamp(progress, 0, 1);
|
||||
if (elapsed < 0.38) {
|
||||
const pressProgress = Math.sin((elapsed / 0.38) * Math.PI);
|
||||
return 1 - pressProgress * intensity * 0.24;
|
||||
}
|
||||
|
||||
const reboundProgress = Math.sin(((elapsed - 0.38) / 0.62) * Math.PI);
|
||||
return 1 + reboundProgress * intensity * 0.16;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const index = findNativeCursorSampleIndexAtOrBefore(recordingData.samples, timeMs);
|
||||
if (index >= 0) {
|
||||
const sample = recordingData.samples[index];
|
||||
|
||||
if (sample.visible === false || !sample.assetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = getNativeCursorAsset(recordingData, 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;
|
||||
const activeIndex = findNativeCursorSampleIndexAtOrBefore(samples, timeMs);
|
||||
|
||||
if (activeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSample = samples[activeIndex];
|
||||
if (activeSample.visible === false || !activeSample.assetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = getNativeCursorAsset(recordingData, 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,
|
||||
};
|
||||
}
|
||||