Merge remote-tracking branch 'origin/main' into codex/allow-png-background-upload
# Conflicts: # electron/ipc/handlers.ts # electron/main.ts
@@ -1,45 +1,60 @@
|
||||
# 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/screencapturekit/build/
|
||||
/electron/native/screencapturekit/.build/
|
||||
/electron/native/screencapturekit/.swiftpm/
|
||||
/electron/native/bin/
|
||||
|
||||
# Native macOS generated files
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
# 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,210 @@
|
||||
# macOS Native Recorder Roadmap
|
||||
|
||||
OpenScreen's macOS recorder should follow the same architecture boundaries as the Windows native recorder: Electron owns session orchestration and persistence, while a platform-native helper owns capture, timing, encoding, and platform-specific permissions.
|
||||
|
||||
This work is intentionally scoped as a macOS-only port. Windows native capture remains owned by the WGC helper, and Linux remains on the existing Electron path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Capture displays and windows through ScreenCaptureKit.
|
||||
- Exclude the real system cursor during capture when using the editable OpenScreen cursor overlay.
|
||||
- Preserve the current high-quality cursor overlay path in preview and export.
|
||||
- Capture macOS system audio through ScreenCaptureKit on supported macOS versions.
|
||||
- Capture microphone audio through the same native timing domain where the OS supports it, or through an explicit companion path until it can be moved into the helper.
|
||||
- Mix system audio and microphone audio into the primary MP4 without renderer-side track assembly.
|
||||
- Capture webcam video natively and compose it into the helper-owned MP4 during the native-recording migration.
|
||||
- Keep screen video, audio, webcam, and cursor aligned to one native timing origin.
|
||||
- Package per-architecture helper binaries with macOS builds.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the editor/export pipeline.
|
||||
- Changing Windows native capture behavior.
|
||||
- Adding Linux native capture.
|
||||
- Shipping a silent fallback from native macOS capture to Electron capture when the user explicitly requested a native-only feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
The renderer keeps the existing recording controls. On macOS, `useScreenRecorder` should eventually send a complete recording request to Electron instead of assembling display, audio, microphone, webcam, and cursor streams in the browser.
|
||||
|
||||
Electron owns the native recording session:
|
||||
|
||||
- resolves the selected display/window source;
|
||||
- resolves output paths;
|
||||
- starts cursor telemetry capture when editable cursor mode is selected;
|
||||
- starts the ScreenCaptureKit helper process;
|
||||
- sends pause/resume/stop/cancel commands;
|
||||
- writes `RecordingSession` manifests;
|
||||
- reports explicit errors when a macOS-native capability is unavailable.
|
||||
|
||||
The helper owns macOS media capture:
|
||||
|
||||
- ScreenCaptureKit display/window frames;
|
||||
- ScreenCaptureKit system audio where supported;
|
||||
- microphone capture or helper-owned companion audio capture;
|
||||
- webcam capture and initial picture-in-picture composition;
|
||||
- AVFoundation/VideoToolbox encoding and muxing;
|
||||
- stream timestamp normalization.
|
||||
|
||||
## Helper Contract V1
|
||||
|
||||
The helper receives a single JSON argument:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recordingId": 1234567890,
|
||||
"source": {
|
||||
"type": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 1,
|
||||
"windowId": null,
|
||||
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
|
||||
},
|
||||
"video": {
|
||||
"fps": 60,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"bitrate": 18000000,
|
||||
"hideSystemCursor": true
|
||||
},
|
||||
"audio": {
|
||||
"system": { "enabled": true },
|
||||
"microphone": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "MacBook Pro Microphone",
|
||||
"gain": 1.4
|
||||
}
|
||||
},
|
||||
"webcam": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "FaceTime HD Camera",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30
|
||||
},
|
||||
"cursor": {
|
||||
"mode": "editable-overlay"
|
||||
},
|
||||
"outputs": {
|
||||
"screenPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.mp4",
|
||||
"manifestPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.session.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The helper emits newline-delimited JSON events to stdout:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "schemaVersion": 1 }
|
||||
{ "event": "recording-started", "timestampMs": 1234567890 }
|
||||
{ "event": "warning", "code": "microphone-unavailable", "message": "..." }
|
||||
{ "event": "recording-stopped", "screenPath": "..." }
|
||||
{ "event": "error", "code": "screen-permission-denied", "message": "..." }
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
Current PR status: macOS screen/window capture routes through the ScreenCaptureKit helper when it is available so editable-cursor recordings can hide the system cursor. The helper now writes ScreenCaptureKit system audio into the primary MP4 and attempts runtime-gated native microphone capture on macOS versions that expose ScreenCaptureKit microphone output. Webcam capture is currently an Electron-recorded sidecar attached to the same recording session; native AVFoundation webcam composition remains the target end state.
|
||||
|
||||
### 1. Native Session Boundary
|
||||
|
||||
- Add a structured macOS native recording request type.
|
||||
- Add a macOS helper resolver and build script placeholders.
|
||||
- Keep the helper contract process-based, matching the Windows helper boundary.
|
||||
- Do not route production macOS recording through this helper until the helper is available and validated.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- TypeScript build passes.
|
||||
- The macOS helper path and request contract are documented and testable without affecting Windows/Linux behavior.
|
||||
|
||||
### 2. ScreenCaptureKit Display Capture
|
||||
|
||||
- Implement a Swift helper using ScreenCaptureKit.
|
||||
- Select display captures by `displayId`.
|
||||
- Encode H.264 MP4 through AVFoundation/VideoToolbox.
|
||||
- Set `showsCursor = false` when editable cursor overlay mode is selected.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Display-only recording produces a valid MP4.
|
||||
- The real cursor is not baked into editable-cursor recordings.
|
||||
|
||||
### 3. ScreenCaptureKit Window Capture
|
||||
|
||||
- Resolve Electron `window:*` selections to ScreenCaptureKit window ids.
|
||||
- Capture `SCContentFilter(desktopIndependentWindow:)`.
|
||||
- Handle closed/minimized/protected windows with explicit errors.
|
||||
- Keep window selection and capture source resolution in Electron/main, not the renderer.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Capturing a normal app window works with cursor/audio/webcam disabled.
|
||||
- Unsupported windows return clear native errors.
|
||||
|
||||
### 4. System Audio
|
||||
|
||||
- Enable ScreenCaptureKit system audio on supported macOS versions.
|
||||
- Keep audio format and timing owned by the helper.
|
||||
- Encode or mux AAC audio into the primary MP4.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- System-audio-only recordings produce a valid AAC track.
|
||||
- Unsupported macOS versions return an explicit capability error.
|
||||
|
||||
### 5. Microphone
|
||||
|
||||
- Resolve the selected microphone device from the renderer-provided browser `deviceId` and user-visible label.
|
||||
- Capture microphone audio in the helper timing domain.
|
||||
- Apply OpenScreen microphone gain policy.
|
||||
- Mix system and microphone audio before final AAC output.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Mic-only and mic-plus-system recordings produce a valid, balanced AAC track.
|
||||
- Device selection honors the selected microphone, not only the default device.
|
||||
|
||||
### 6. Webcam Composition
|
||||
|
||||
- Capture the selected camera natively through AVFoundation.
|
||||
- Match browser device id first where possible, then user-visible label.
|
||||
- Compose an initial picture-in-picture overlay into the primary MP4.
|
||||
- Hide webcam output until the first usable frame to avoid black startup flashes.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Native display/window recordings can include webcam without returning to Electron capture.
|
||||
- Selected camera is honored.
|
||||
|
||||
### 7. Runtime Controls
|
||||
|
||||
- Add pause/resume commands to the helper.
|
||||
- Add cancel command that removes partial outputs.
|
||||
- Keep restart as stop-discard-start until the helper exposes a native restart operation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Pause/resume keeps output duration coherent.
|
||||
- Cancel leaves no stale media/session files.
|
||||
|
||||
### 8. Test Pipeline
|
||||
|
||||
- `npm run build:native:mac`: builds Swift helper binaries on macOS.
|
||||
- `npm run test:sck-helper:mac`: display-only helper smoke test.
|
||||
- `npm run test:sck-window:mac`: window capture smoke test.
|
||||
- `npm run test:sck-audio:mac`: system audio smoke test when supported.
|
||||
- `npm run test:sck-mic:mac`: microphone smoke test.
|
||||
- `npm run test:sck-webcam:mac`: webcam smoke test when a webcam is available.
|
||||
- Packaging check: confirms helpers are available under `electron/native/bin/darwin-${arch}` in packaged builds.
|
||||
|
||||
## SSOT Rules
|
||||
|
||||
- `src/lib/nativeMacRecording.ts` is the renderer/main TypeScript request contract.
|
||||
- This document is the feature-level contract and phase checklist.
|
||||
- The Swift helper owns ScreenCaptureKit/AVFoundation media timing.
|
||||
- Electron owns output paths, session manifests, and selected source/device resolution.
|
||||
- Renderer code must use existing hooks/client APIs and should not bind directly to helper process details.
|
||||
@@ -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,130 @@
|
||||
# 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-helper:win -- --capture-cursor
|
||||
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",
|
||||
@@ -47,6 +46,13 @@
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/native/bin",
|
||||
"to": "electron/native/bin",
|
||||
"filter": ["darwin-*/*"]
|
||||
}
|
||||
],
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
@@ -69,7 +75,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": ["win32-*/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -24,11 +24,23 @@ 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>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<{
|
||||
opened: boolean;
|
||||
reason?: string;
|
||||
access?: {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
};
|
||||
}>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
requestCameraAccess: () => Promise<{
|
||||
@@ -37,9 +49,16 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityAccess: () => Promise<{
|
||||
requestScreenAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestNativeMacCursorAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
assetBaseUrl: string;
|
||||
@@ -68,7 +87,75 @@ 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;
|
||||
}>;
|
||||
isNativeMacCaptureAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
available: boolean;
|
||||
helperPath?: string;
|
||||
reason?: "unsupported-platform" | "missing-helper" | 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;
|
||||
}>;
|
||||
pauseNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeMacRecording: (
|
||||
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
|
||||
pauseNativeMacRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeMacRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
stopNativeMacRecording: (discard?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
attachNativeMacWebcamRecording: (payload: {
|
||||
screenVideoPath: string;
|
||||
recordingId: number;
|
||||
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -118,6 +205,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
preparePreviewAudioTrack: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string | null;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
@@ -158,6 +251,7 @@ interface Window {
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
|
||||
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||
|
||||
@@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json";
|
||||
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
|
||||
import commonFr from "../src/i18n/locales/fr/common.json";
|
||||
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
|
||||
import commonIt from "../src/i18n/locales/it/common.json";
|
||||
import dialogsIt from "../src/i18n/locales/it/dialogs.json";
|
||||
import commonJa from "../src/i18n/locales/ja-JP/common.json";
|
||||
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
|
||||
import commonKo from "../src/i18n/locales/ko-KR/common.json";
|
||||
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
|
||||
import commonRu from "../src/i18n/locales/ru/common.json";
|
||||
import dialogsRu from "../src/i18n/locales/ru/dialogs.json";
|
||||
import commonTr from "../src/i18n/locales/tr/common.json";
|
||||
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
|
||||
import commonVi from "../src/i18n/locales/vi/common.json";
|
||||
@@ -22,21 +26,35 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
|
||||
type Locale =
|
||||
| "en"
|
||||
| "ar"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "it"
|
||||
| "ja-JP"
|
||||
| "ko-KR"
|
||||
| "ru"
|
||||
| "tr"
|
||||
| "vi"
|
||||
| "zh-CN"
|
||||
| "zh-TW";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
en: { common: commonEn, dialogs: dialogsEn },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
es: { common: commonEs, dialogs: dialogsEs },
|
||||
fr: { common: commonFr, dialogs: dialogsFr },
|
||||
it: { common: commonIt, dialogs: dialogsIt },
|
||||
"ja-JP": { common: commonJa, dialogs: dialogsJa },
|
||||
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||
ru: { common: commonRu, dialogs: dialogsRu },
|
||||
tr: { common: commonTr, dialogs: dialogsTr },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
@@ -44,15 +62,17 @@ let currentLocale: Locale = "en";
|
||||
export function setMainLocale(locale: string) {
|
||||
if (
|
||||
locale === "en" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW" ||
|
||||
locale === "ar" ||
|
||||
locale === "es" ||
|
||||
locale === "fr" ||
|
||||
locale === "it" ||
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "ru" ||
|
||||
locale === "tr" ||
|
||||
locale === "ar" ||
|
||||
locale === "vi"
|
||||
locale === "vi" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
@@ -477,23 +476,30 @@ app.whenReady().then(async () => {
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
const source = getSelectedDesktopSource();
|
||||
if (!request.videoRequested || !source) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
callback({
|
||||
video: source,
|
||||
...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}),
|
||||
});
|
||||
},
|
||||
{ useSystemPicker: false },
|
||||
);
|
||||
|
||||
// Request microphone permission from macOS. Screen Recording is requested
|
||||
// lazily from the source-picker action so the system prompt is not hidden
|
||||
// behind OpenScreen's source selector window.
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
|
||||
// triggered by the first desktopCapturer.getSources() call. Firing it here
|
||||
// at startup settles the permission state early and prevents repeated prompts
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {
|
||||
// Ignore prompt trigger failures; later capture attempts report status.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
|
||||
@@ -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,46 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession";
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.platform === "darwin") {
|
||||
return new MacNativeCursorRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Linux: capture cursor positions via Electron's `screen` API on an interval.
|
||||
// No cursor sprites/assets and no clicks — just position telemetry.
|
||||
return new TelemetryRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { accessSync, constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { type Rectangle, screen, systemPreferences } from "electron";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorType,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface MacNativeCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
type MacCursorEvent =
|
||||
| {
|
||||
type: "ready";
|
||||
timestampMs: number;
|
||||
accessibilityTrusted?: boolean;
|
||||
mouseTapReady?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
};
|
||||
|
||||
const HELPER_NAME = "openscreen-macos-cursor-helper";
|
||||
const READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
function helperCandidates() {
|
||||
const envPath = process.env.OPENSCREEN_MAC_CURSOR_HELPER_EXE?.trim();
|
||||
const appRoot = process.env.APP_ROOT ? path.resolve(process.env.APP_ROOT) : process.cwd();
|
||||
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
const resourceRoot =
|
||||
typeof process.resourcesPath === "string"
|
||||
? process.resourcesPath
|
||||
: path.join(appRoot, "resources");
|
||||
|
||||
return [
|
||||
envPath,
|
||||
path.join(appRoot, "electron", "native", "screencapturekit", "build", HELPER_NAME),
|
||||
path.join(appRoot, "electron", "native", "bin", archTag, HELPER_NAME),
|
||||
path.join(resourceRoot, "electron", "native", "bin", archTag, HELPER_NAME),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
}
|
||||
|
||||
export function findMacCursorHelperPath() {
|
||||
for (const candidate of helperCandidates()) {
|
||||
try {
|
||||
accessSync(candidate, fsConstants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try the next helper location.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requestMacCursorAccessibilityAccess() {
|
||||
if (process.platform !== "darwin") {
|
||||
return { success: true, granted: true, status: "granted" };
|
||||
}
|
||||
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Continue with helper probing; it can trigger the same macOS prompt.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
if (!helperPath) {
|
||||
return { success: true, granted: false, status: "missing-helper" };
|
||||
}
|
||||
|
||||
return new Promise<{ success: boolean; granted: boolean; status: string; error?: string }>(
|
||||
(resolve) => {
|
||||
const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let settled = false;
|
||||
let lineBuffer = "";
|
||||
const finish = (result: {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "timeout",
|
||||
error: "Timed out waiting for macOS cursor helper",
|
||||
});
|
||||
}, READY_TIMEOUT_MS);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
lineBuffer += chunk;
|
||||
const lines = lineBuffer.split(/\r?\n/);
|
||||
lineBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(trimmed) as MacCursorEvent;
|
||||
if (event.type === "ready") {
|
||||
finish({
|
||||
success: true,
|
||||
granted: event.accessibilityTrusted === true,
|
||||
status: event.accessibilityTrusted === true ? "granted" : "not-determined",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON helper output.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "error",
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "exited",
|
||||
error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeCursorType(value: unknown): NativeCursorType | null {
|
||||
return value === "arrow" || value === "pointer" || value === "text" ? value : null;
|
||||
}
|
||||
|
||||
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
private fallbackInterval: NodeJS.Timeout | null = null;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyReject: ((error: Error) => void) | null = null;
|
||||
private readyTimer: NodeJS.Timeout | null = null;
|
||||
private previousLeftButtonDown = false;
|
||||
private consecutiveOutsideSamples = 0;
|
||||
// Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval).
|
||||
// Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing.
|
||||
private static readonly OUTSIDE_HIDE_THRESHOLD = 3;
|
||||
|
||||
constructor(private readonly options: MacNativeCursorRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.previousLeftButtonDown = false;
|
||||
this.consecutiveOutsideSamples = 0;
|
||||
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Link cursor detection degrades to arrow when Accessibility is unavailable.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
if (!helperPath) {
|
||||
this.startPositionOnlyFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
helperPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
sampleIntervalMs: this.options.sampleIntervalMs,
|
||||
}),
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
this.process = child;
|
||||
|
||||
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) {
|
||||
console.error("[cursor-macos]", message);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
this.rejectReady(
|
||||
new Error(`macOS cursor helper exited before ready (code=${code}, signal=${signal})`),
|
||||
);
|
||||
this.process = null;
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
this.rejectReady(error);
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.killHelperProcess(child);
|
||||
this.process = null;
|
||||
console.warn("[cursor-macos] falling back to position-only cursor telemetry:", error);
|
||||
this.startPositionOnlyFallback();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.clearReadyState();
|
||||
|
||||
if (this.fallbackInterval) {
|
||||
clearInterval(this.fallbackInterval);
|
||||
this.fallbackInterval = null;
|
||||
}
|
||||
|
||||
if (child) {
|
||||
this.killHelperProcess(child);
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: "none",
|
||||
samples: this.samples,
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
private startPositionOnlyFallback() {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
this.fallbackInterval = setInterval(() => {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
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 {
|
||||
this.handleEvent(JSON.parse(trimmedLine) as MacCursorEvent);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse macOS cursor helper output:", error, trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(payload: MacCursorEvent) {
|
||||
if (payload.type === "ready") {
|
||||
if (payload.accessibilityTrusted === false) {
|
||||
console.warn(
|
||||
"[cursor-macos] Accessibility is not trusted; cursor shape detection will be arrow-only.",
|
||||
);
|
||||
}
|
||||
this.resolveReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "sample") {
|
||||
this.captureSample(
|
||||
payload.timestampMs,
|
||||
normalizeCursorType(payload.cursorType),
|
||||
payload.leftButtonDown === true,
|
||||
payload.leftButtonPressed === true,
|
||||
payload.leftButtonReleased === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private captureSample(
|
||||
timestampMs: number,
|
||||
cursorType: NativeCursorType | null,
|
||||
leftButtonDown: boolean,
|
||||
leftButtonPressed: boolean,
|
||||
leftButtonReleased: boolean,
|
||||
) {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const bounds = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
|
||||
const width = Math.max(1, bounds.width);
|
||||
const height = Math.max(1, bounds.height);
|
||||
const normalizedX = (cursor.x - bounds.x) / width;
|
||||
const normalizedY = (cursor.y - bounds.y) / height;
|
||||
const isOutsideDisplay =
|
||||
normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1;
|
||||
// Fast swipes that briefly exit the display (<THRESHOLD samples) are handled by
|
||||
// clip-path — the cursor clips to the canvas edge instead of snapping invisible.
|
||||
// Sustained exits (≥THRESHOLD samples, ≈100ms) mark visible=false to prevent
|
||||
// ghost cursors and motion trails from multi-display movement.
|
||||
if (isOutsideDisplay) {
|
||||
this.consecutiveOutsideSamples++;
|
||||
} else {
|
||||
this.consecutiveOutsideSamples = 0;
|
||||
}
|
||||
const visible =
|
||||
this.consecutiveOutsideSamples < MacNativeCursorRecordingSession.OUTSIDE_HIDE_THRESHOLD;
|
||||
const interactionType =
|
||||
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
|
||||
? "click"
|
||||
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
|
||||
? "mouseup"
|
||||
: "move";
|
||||
this.previousLeftButtonDown = leftButtonDown;
|
||||
|
||||
this.samples.push({
|
||||
timeMs: Math.max(0, timestampMs - this.startTimeMs),
|
||||
cx: clamp(normalizedX, 0, 1),
|
||||
cy: clamp(normalizedY, 0, 1),
|
||||
visible,
|
||||
interactionType,
|
||||
...(cursorType ? { cursorType } : {}),
|
||||
});
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
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 macOS cursor helper"));
|
||||
}, 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 clearReadyState() {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer);
|
||||
this.readyTimer = null;
|
||||
}
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
}
|
||||
|
||||
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable>) {
|
||||
if (child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 500).unref();
|
||||
}
|
||||
}
|
||||
@@ -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,326 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { app, screen } from "electron";
|
||||
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import type {
|
||||
WindowsCursorEvent,
|
||||
WindowsNativeRecordingSessionOptions,
|
||||
} from "./windowsNativeRecordingSession.types";
|
||||
|
||||
function getCursorSamplerCandidates(): string[] {
|
||||
const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim();
|
||||
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
||||
const resolve = (...segs: string[]) => {
|
||||
const p = join(app.getAppPath(), ...segs);
|
||||
return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p;
|
||||
};
|
||||
return [
|
||||
envPath,
|
||||
resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"),
|
||||
resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"),
|
||||
].filter((c): c is string => Boolean(c));
|
||||
}
|
||||
|
||||
function findCursorSamplerPath(): string | null {
|
||||
for (const candidate of getCursorSamplerCandidates()) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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 helperPath = findCursorSamplerPath();
|
||||
if (!helperPath) {
|
||||
throw new Error("Windows cursor sampler helper is not available.");
|
||||
}
|
||||
|
||||
const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId);
|
||||
const args = [String(this.options.sampleIntervalMs)];
|
||||
if (windowHandle) args.push(windowHandle);
|
||||
|
||||
const child = spawn(helperPath, args, {
|
||||
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,
|
||||
});
|
||||
|
||||
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.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.logDiagnostic("process-error", { message: error.message });
|
||||
this.rejectReady(error);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.terminateHelperProcess();
|
||||
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 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,111 @@
|
||||
# Native capture helpers
|
||||
|
||||
## macOS
|
||||
|
||||
macOS native recording will use a ScreenCaptureKit helper with the same process boundary as the Windows WGC helper:
|
||||
|
||||
1. Electron resolves the selected source, output paths, and user-selected devices.
|
||||
2. The helper receives one structured JSON request.
|
||||
3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing.
|
||||
4. Electron persists the resulting media/session manifest and reports helper errors explicitly.
|
||||
|
||||
Helper locations:
|
||||
|
||||
1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics.
|
||||
2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output.
|
||||
3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers.
|
||||
|
||||
The macOS cursor-shape helper is resolved from `OPENSCREEN_MAC_CURSOR_HELPER_EXE` first, then the matching `openscreen-macos-cursor-helper` binary in the same local build and packaged `electron/native/bin/darwin-${arch}` directories.
|
||||
|
||||
Build the macOS helper with:
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binaries to `electron/native/screencapturekit/build`, and copies redistributable binaries to `electron/native/bin/darwin-${arch}`.
|
||||
|
||||
The current helper implementation supports display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, MP4 muxing, and ScreenCaptureKit system audio. It also attempts native ScreenCaptureKit microphone capture when the running macOS version exposes that capability. Webcam recording currently stays as an Electron sidecar and is attached to the same recording session after the native screen capture stops.
|
||||
|
||||
Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`.
|
||||
|
||||
See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules.
|
||||
|
||||
## Windows
|
||||
|
||||
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,30 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OpenScreenScreenCaptureKitHelper",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(
|
||||
name: "openscreen-screencapturekit-helper",
|
||||
targets: ["OpenScreenScreenCaptureKitHelper"]
|
||||
),
|
||||
.executable(
|
||||
name: "openscreen-macos-cursor-helper",
|
||||
targets: ["OpenScreenMacOSCursorHelper"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "OpenScreenScreenCaptureKitHelper",
|
||||
path: "Sources/OpenScreenScreenCaptureKitHelper"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "OpenScreenMacOSCursorHelper",
|
||||
path: "Sources/OpenScreenMacOSCursorHelper"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
struct CursorHelperRequest: Decodable {
|
||||
let sampleIntervalMs: Int?
|
||||
}
|
||||
|
||||
final class MouseButtonTracker {
|
||||
private let lock = NSLock()
|
||||
private var leftDownCount = 0
|
||||
private var leftUpCount = 0
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
|
||||
struct Events {
|
||||
let leftDownCount: Int
|
||||
let leftUpCount: Int
|
||||
}
|
||||
|
||||
func start() -> Bool {
|
||||
let mask =
|
||||
(1 << CGEventType.leftMouseDown.rawValue) |
|
||||
(1 << CGEventType.leftMouseUp.rawValue)
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .listenOnly,
|
||||
eventsOfInterest: CGEventMask(mask),
|
||||
callback: { _, type, event, userInfo in
|
||||
if let userInfo {
|
||||
let tracker = Unmanaged<MouseButtonTracker>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
tracker.record(type)
|
||||
}
|
||||
return Unmanaged.passUnretained(event)
|
||||
},
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
|
||||
return false
|
||||
}
|
||||
|
||||
eventTap = tap
|
||||
runLoopSource = source
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
return true
|
||||
}
|
||||
|
||||
func pump() {
|
||||
CFRunLoopRunInMode(.defaultMode, 0.001, false)
|
||||
}
|
||||
|
||||
func consume() -> Events {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let events = Events(leftDownCount: leftDownCount, leftUpCount: leftUpCount)
|
||||
leftDownCount = 0
|
||||
leftUpCount = 0
|
||||
return events
|
||||
}
|
||||
|
||||
private func record(_ type: CGEventType) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||
reenableTap()
|
||||
return
|
||||
}
|
||||
if type == .leftMouseDown {
|
||||
leftDownCount += 1
|
||||
} else if type == .leftMouseUp {
|
||||
leftUpCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func reenableTap() {
|
||||
if let eventTap {
|
||||
CGEvent.tapEnable(tap: eventTap, enable: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ fields: [String: Any?]) {
|
||||
let compacted = fields.compactMapValues { $0 }
|
||||
if let data = try? JSONSerialization.data(withJSONObject: compacted, options: []),
|
||||
let line = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value as? String
|
||||
}
|
||||
|
||||
func parentElement(_ element: AXUIElement) -> AXUIElement? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard CFGetTypeID(value) == AXUIElementGetTypeID() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (value as! AXUIElement)
|
||||
}
|
||||
|
||||
func roleDescription(_ element: AXUIElement) -> String? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, kAXRoleDescriptionAttribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value as? String
|
||||
}
|
||||
|
||||
func actionNames(_ element: AXUIElement) -> [String] {
|
||||
var value: CFArray?
|
||||
let result = AXUIElementCopyActionNames(element, &value)
|
||||
guard result == .success, let value else {
|
||||
return []
|
||||
}
|
||||
|
||||
return (value as NSArray).compactMap { $0 as? String }
|
||||
}
|
||||
func isTextInputRole(_ role: String?) -> Bool {
|
||||
role == "AXTextField" ||
|
||||
role == "AXTextArea" ||
|
||||
role == "AXTextView" ||
|
||||
role == "AXComboBox"
|
||||
}
|
||||
|
||||
func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) -> Bool {
|
||||
if role == "AXLink" ||
|
||||
subrole?.localizedCaseInsensitiveContains("link") == true ||
|
||||
description?.contains("link") == true
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
return role == "AXButton" ||
|
||||
role == "AXMenuButton" ||
|
||||
role == "AXPopUpButton" ||
|
||||
role == "AXCheckBox" ||
|
||||
role == "AXRadioButton" ||
|
||||
role == "AXSwitch" ||
|
||||
role == "AXDisclosureTriangle" ||
|
||||
role == "AXTab" ||
|
||||
role == "AXMenuItem"
|
||||
}
|
||||
|
||||
func cursorTypeForElement(_ element: AXUIElement) -> String? {
|
||||
var current: AXUIElement? = element
|
||||
|
||||
for _ in 0..<5 {
|
||||
guard let element = current else {
|
||||
break
|
||||
}
|
||||
|
||||
let role = stringAttribute(element, kAXRoleAttribute)
|
||||
let subrole = stringAttribute(element, kAXSubroleAttribute)
|
||||
let description = roleDescription(element)?.lowercased()
|
||||
|
||||
if isTextInputRole(role) {
|
||||
return "text"
|
||||
}
|
||||
|
||||
if isPointerRole(role, subrole, description) {
|
||||
return "pointer"
|
||||
}
|
||||
|
||||
current = parentElement(element)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func accessibilityPointForMouse() -> CGPoint {
|
||||
let mouse = NSEvent.mouseLocation
|
||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
|
||||
return CGPoint(x: mouse.x, y: primaryHeight - mouse.y)
|
||||
}
|
||||
|
||||
func currentCursorType() -> String? {
|
||||
guard AXIsProcessTrusted() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let point = accessibilityPointForMouse()
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
var element: AXUIElement?
|
||||
let result = AXUIElementCopyElementAtPosition(
|
||||
systemWide,
|
||||
Float(point.x),
|
||||
Float(point.y),
|
||||
&element
|
||||
)
|
||||
|
||||
guard result == .success, let element else {
|
||||
return "arrow"
|
||||
}
|
||||
|
||||
return cursorTypeForElement(element) ?? "arrow"
|
||||
}
|
||||
|
||||
func timestampMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
func leftButtonDown() -> Bool {
|
||||
CGEventSource.buttonState(.hidSystemState, button: .left)
|
||||
}
|
||||
|
||||
func requestAccessibilityTrust() -> Bool {
|
||||
let options = [
|
||||
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true
|
||||
] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
let request: CursorHelperRequest
|
||||
if CommandLine.arguments.count >= 2,
|
||||
let data = CommandLine.arguments[1].data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(CursorHelperRequest.self, from: data)
|
||||
{
|
||||
request = decoded
|
||||
} else {
|
||||
request = CursorHelperRequest(sampleIntervalMs: nil)
|
||||
}
|
||||
|
||||
let intervalMs = max(8, request.sampleIntervalMs ?? 33)
|
||||
let accessibilityTrusted = requestAccessibilityTrust()
|
||||
let mouseTracker = MouseButtonTracker()
|
||||
let mouseTapReady = mouseTracker.start()
|
||||
emit([
|
||||
"type": "ready",
|
||||
"timestampMs": timestampMs(),
|
||||
"accessibilityTrusted": accessibilityTrusted,
|
||||
"mouseTapReady": mouseTapReady,
|
||||
])
|
||||
|
||||
while true {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
struct Rectangle: Decodable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
||||
|
||||
struct RecordingRequest: Decodable {
|
||||
struct Source: Decodable {
|
||||
let type: String
|
||||
let sourceId: String
|
||||
let displayId: UInt32?
|
||||
let windowId: UInt32?
|
||||
let bounds: Rectangle?
|
||||
}
|
||||
|
||||
struct Video: Decodable {
|
||||
let fps: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
let bitrate: Int?
|
||||
let hideSystemCursor: Bool
|
||||
}
|
||||
|
||||
struct Audio: Decodable {
|
||||
struct SystemAudio: Decodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
struct Microphone: Decodable {
|
||||
let enabled: Bool
|
||||
let deviceId: String?
|
||||
let deviceName: String?
|
||||
let gain: Double
|
||||
}
|
||||
|
||||
let system: SystemAudio
|
||||
let microphone: Microphone
|
||||
}
|
||||
|
||||
struct Webcam: Decodable {
|
||||
let enabled: Bool
|
||||
let deviceId: String?
|
||||
let deviceName: String?
|
||||
let width: Int
|
||||
let height: Int
|
||||
let fps: Int
|
||||
}
|
||||
|
||||
struct Cursor: Decodable {
|
||||
let mode: String
|
||||
}
|
||||
|
||||
struct Outputs: Decodable {
|
||||
let screenPath: String
|
||||
let manifestPath: String?
|
||||
}
|
||||
|
||||
let schemaVersion: Int?
|
||||
let recordingId: Int?
|
||||
let source: Source
|
||||
let video: Video
|
||||
let audio: Audio
|
||||
let webcam: Webcam
|
||||
let cursor: Cursor
|
||||
let outputs: Outputs
|
||||
}
|
||||
|
||||
enum HelperError: Error, CustomStringConvertible {
|
||||
case invalidArguments
|
||||
case unsupportedMacOS
|
||||
case unsupportedFeature(String)
|
||||
case sourceNotFound(String)
|
||||
case invalidSourceType(String)
|
||||
case permissionDenied(String)
|
||||
case writerSetupFailed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidArguments:
|
||||
return "Expected one JSON recording request argument."
|
||||
case .unsupportedMacOS:
|
||||
return "ScreenCaptureKit recording requires macOS 13 or newer."
|
||||
case .unsupportedFeature(let message):
|
||||
return message
|
||||
case .sourceNotFound(let message):
|
||||
return message
|
||||
case .invalidSourceType(let sourceType):
|
||||
return "Unsupported source type: \(sourceType)."
|
||||
case .permissionDenied(let message):
|
||||
return message
|
||||
case .writerSetupFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ fields: [String: Any]) {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: fields, options: []),
|
||||
let line = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func emitError(code: String, message: String) {
|
||||
emit([
|
||||
"event": "error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
|
||||
@available(macOS 13.0, *)
|
||||
final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
private struct CaptureTarget {
|
||||
let filter: SCContentFilter
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
private let request: RecordingRequest
|
||||
private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples")
|
||||
private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state")
|
||||
private var stream: SCStream?
|
||||
private var writer: AVAssetWriter?
|
||||
private var videoInput: AVAssetWriterInput?
|
||||
private var systemAudioInput: AVAssetWriterInput?
|
||||
private var microphoneAudioInput: AVAssetWriterInput?
|
||||
private var didStartWriting = false
|
||||
private var didEmitRecordingStarted = false
|
||||
private var isStopping = false
|
||||
private var isPaused = false
|
||||
private var pauseStartedAt: CMTime?
|
||||
private var totalPausedDuration = CMTime.zero
|
||||
private var nativeMicrophoneEnabled = false
|
||||
private var outputWidth = 1920
|
||||
private var outputHeight = 1080
|
||||
private let microphoneOutputTypeRawValue = 2
|
||||
private let hostClock = CMClockGetHostTimeClock()
|
||||
|
||||
init(request: RecordingRequest) {
|
||||
self.request = request
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try ensureRequestedPermissions()
|
||||
|
||||
let content = try await SCShareableContent.excludingDesktopWindows(
|
||||
false,
|
||||
onScreenWindowsOnly: true
|
||||
)
|
||||
let target = try makeCaptureTarget(from: content)
|
||||
outputWidth = target.width
|
||||
outputHeight = target.height
|
||||
let configuration = makeStreamConfiguration()
|
||||
let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self)
|
||||
|
||||
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
|
||||
if request.audio.system.enabled {
|
||||
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
|
||||
}
|
||||
if nativeMicrophoneEnabled {
|
||||
guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else {
|
||||
throw HelperError.unsupportedFeature(
|
||||
"Native microphone capture requires a macOS version with ScreenCaptureKit microphone output."
|
||||
)
|
||||
}
|
||||
try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: sampleQueue)
|
||||
}
|
||||
try setupWriter()
|
||||
|
||||
self.stream = stream
|
||||
emit(["event": "ready", "schemaVersion": 1])
|
||||
try await stream.startCapture()
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
let shouldStop = stateQueue.sync {
|
||||
if isStopping {
|
||||
return false
|
||||
}
|
||||
isStopping = true
|
||||
return true
|
||||
}
|
||||
if !shouldStop {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await stream?.stopCapture()
|
||||
} catch {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "stop-capture-failed",
|
||||
"message": "\(error)",
|
||||
])
|
||||
}
|
||||
|
||||
await finishWriter()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
let didPause = stateQueue.sync {
|
||||
if isStopping || isPaused {
|
||||
return false
|
||||
}
|
||||
|
||||
isPaused = true
|
||||
pauseStartedAt = CMClockGetTime(hostClock)
|
||||
return true
|
||||
}
|
||||
|
||||
if didPause {
|
||||
emit([
|
||||
"event": "recording-paused",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func resume() {
|
||||
let didResume = stateQueue.sync {
|
||||
if isStopping || !isPaused {
|
||||
return false
|
||||
}
|
||||
|
||||
if let pauseStartedAt {
|
||||
let now = CMClockGetTime(hostClock)
|
||||
totalPausedDuration = CMTimeAdd(
|
||||
totalPausedDuration,
|
||||
CMTimeSubtract(now, pauseStartedAt)
|
||||
)
|
||||
}
|
||||
isPaused = false
|
||||
pauseStartedAt = nil
|
||||
return true
|
||||
}
|
||||
|
||||
if didResume {
|
||||
emit([
|
||||
"event": "recording-resumed",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
||||
emitError(code: "capture-stopped-with-error", message: "\(error)")
|
||||
Task {
|
||||
await stop()
|
||||
}
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
||||
guard CMSampleBufferDataIsReady(sampleBuffer) else {
|
||||
return
|
||||
}
|
||||
let pauseState = currentPauseState()
|
||||
if pauseState.paused {
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else {
|
||||
return
|
||||
}
|
||||
|
||||
if type == .audio {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput)
|
||||
return
|
||||
}
|
||||
|
||||
if type.rawValue == microphoneOutputTypeRawValue {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: microphoneAudioInput)
|
||||
return
|
||||
}
|
||||
|
||||
guard type == .screen else {
|
||||
return
|
||||
}
|
||||
guard isCompleteFrame(sampleBuffer) else {
|
||||
return
|
||||
}
|
||||
guard let videoInput, let writer else {
|
||||
return
|
||||
}
|
||||
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if !didStartWriting {
|
||||
writer.startWriting()
|
||||
writer.startSession(atSourceTime: presentationTime)
|
||||
didStartWriting = true
|
||||
}
|
||||
|
||||
if videoInput.isReadyForMoreMediaData {
|
||||
if videoInput.append(sampleBuffer), !didEmitRecordingStarted {
|
||||
didEmitRecordingStarted = true
|
||||
emit([
|
||||
"event": "recording-started",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
"width": outputWidth,
|
||||
"height": outputHeight,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureRequestedPermissions() throws {
|
||||
if !CGPreflightScreenCaptureAccess() {
|
||||
let granted = CGRequestScreenCaptureAccess()
|
||||
if !granted {
|
||||
throw HelperError.permissionDenied("Screen recording permission is required for ScreenCaptureKit capture.")
|
||||
}
|
||||
}
|
||||
|
||||
if request.audio.microphone.enabled {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
break
|
||||
case .notDetermined:
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { _ in
|
||||
semaphore.signal()
|
||||
}
|
||||
let waitResult = semaphore.wait(timeout: .now() + 30)
|
||||
if waitResult == .timedOut || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
||||
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
|
||||
}
|
||||
default:
|
||||
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget {
|
||||
switch request.source.type {
|
||||
case "display":
|
||||
guard let displayId = request.source.displayId else {
|
||||
throw HelperError.sourceNotFound("Display capture requires source.displayId.")
|
||||
}
|
||||
guard let display = content.displays.first(where: { $0.displayID == displayId }) else {
|
||||
throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).")
|
||||
}
|
||||
let width = Int(CGDisplayPixelsWide(display.displayID))
|
||||
let height = Int(CGDisplayPixelsHigh(display.displayID))
|
||||
return CaptureTarget(
|
||||
filter: SCContentFilter(display: display, excludingWindows: []),
|
||||
width: clampCaptureDimension(width, fallback: request.video.width),
|
||||
height: clampCaptureDimension(height, fallback: request.video.height)
|
||||
)
|
||||
case "window":
|
||||
guard let windowId = request.source.windowId else {
|
||||
throw HelperError.sourceNotFound("Window capture requires source.windowId.")
|
||||
}
|
||||
guard let window = content.windows.first(where: { $0.windowID == windowId }) else {
|
||||
throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).")
|
||||
}
|
||||
let candidateDisplay = content.displays.first {
|
||||
$0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY))
|
||||
}
|
||||
let scaleFactor = Self.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID())
|
||||
let width = Int(window.frame.width) * scaleFactor
|
||||
let height = Int(window.frame.height) * scaleFactor
|
||||
return CaptureTarget(
|
||||
filter: SCContentFilter(desktopIndependentWindow: window),
|
||||
width: clampCaptureDimension(width, fallback: request.video.width),
|
||||
height: clampCaptureDimension(height, fallback: request.video.height)
|
||||
)
|
||||
default:
|
||||
throw HelperError.invalidSourceType(request.source.type)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeStreamConfiguration() -> SCStreamConfiguration {
|
||||
let configuration = SCStreamConfiguration()
|
||||
configuration.width = outputWidth
|
||||
configuration.height = outputHeight
|
||||
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps)))
|
||||
configuration.queueDepth = 6
|
||||
configuration.showsCursor = !request.video.hideSystemCursor
|
||||
configuration.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
configuration.sampleRate = 48_000
|
||||
configuration.channelCount = 2
|
||||
configuration.excludesCurrentProcessAudio = true
|
||||
configuration.capturesAudio = request.audio.system.enabled
|
||||
|
||||
if request.audio.microphone.enabled {
|
||||
guard supportsNativeMicrophoneCapture(streamConfig: configuration) else {
|
||||
nativeMicrophoneEnabled = false
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "microphone-unavailable",
|
||||
"message": "Native microphone capture requires ScreenCaptureKit microphone support on this macOS version.",
|
||||
])
|
||||
return configuration
|
||||
}
|
||||
nativeMicrophoneEnabled = true
|
||||
configuration.capturesAudio = true
|
||||
configuration.setValue(true, forKey: "captureMicrophone")
|
||||
if let deviceId = resolveMicrophoneCaptureDeviceID() {
|
||||
configuration.setValue(deviceId, forKey: "microphoneCaptureDeviceID")
|
||||
}
|
||||
} else {
|
||||
nativeMicrophoneEnabled = false
|
||||
}
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
private func setupWriter() throws {
|
||||
let outputUrl = URL(fileURLWithPath: request.outputs.screenPath)
|
||||
try? FileManager.default.removeItem(at: outputUrl)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outputUrl.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: outputWidth,
|
||||
AVVideoHeightKey: outputHeight,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000,
|
||||
AVVideoExpectedSourceFrameRateKey: request.video.fps,
|
||||
],
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.")
|
||||
}
|
||||
|
||||
writer.add(input)
|
||||
self.writer = writer
|
||||
self.videoInput = input
|
||||
|
||||
if request.audio.system.enabled {
|
||||
systemAudioInput = try addAudioInput(to: writer, bitRate: 192_000)
|
||||
}
|
||||
if nativeMicrophoneEnabled {
|
||||
microphoneAudioInput = try addAudioInput(to: writer, bitRate: 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishWriter() async {
|
||||
guard let writer else {
|
||||
return
|
||||
}
|
||||
|
||||
videoInput?.markAsFinished()
|
||||
systemAudioInput?.markAsFinished()
|
||||
microphoneAudioInput?.markAsFinished()
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
writer.finishWriting {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
if writer.status == .completed {
|
||||
emit([
|
||||
"event": "recording-stopped",
|
||||
"screenPath": request.outputs.screenPath,
|
||||
])
|
||||
} else {
|
||||
emitError(
|
||||
code: "writer-failed",
|
||||
message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addAudioInput(to writer: AVAssetWriter, bitRate: Int) throws -> AVAssetWriterInput {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 48_000,
|
||||
AVNumberOfChannelsKey: 2,
|
||||
AVEncoderBitRateKey: bitRate,
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw HelperError.writerSetupFailed("Unable to add AAC audio input to AVAssetWriter.")
|
||||
}
|
||||
|
||||
writer.add(input)
|
||||
return input
|
||||
}
|
||||
|
||||
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput?) {
|
||||
guard didStartWriting else {
|
||||
return
|
||||
}
|
||||
guard let input, input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
|
||||
input.append(sampleBuffer)
|
||||
}
|
||||
|
||||
private func currentPauseState() -> (paused: Bool, offset: CMTime) {
|
||||
stateQueue.sync {
|
||||
(isPaused, totalPausedDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private func retimedSampleBuffer(_ sampleBuffer: CMSampleBuffer, subtracting offset: CMTime) -> CMSampleBuffer? {
|
||||
if !offset.isValid || offset == .zero {
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer)
|
||||
if sampleCount <= 0 {
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
var timing = Array(repeating: CMSampleTimingInfo(), count: sampleCount)
|
||||
let timingStatus = CMSampleBufferGetSampleTimingInfoArray(
|
||||
sampleBuffer,
|
||||
entryCount: sampleCount,
|
||||
arrayToFill: &timing,
|
||||
entriesNeededOut: nil
|
||||
)
|
||||
if timingStatus != noErr {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "sample-retime-failed",
|
||||
"message": "Unable to read sample timing info: \(timingStatus).",
|
||||
])
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
for index in timing.indices {
|
||||
if timing[index].presentationTimeStamp.isValid {
|
||||
timing[index].presentationTimeStamp = CMTimeSubtract(
|
||||
timing[index].presentationTimeStamp,
|
||||
offset
|
||||
)
|
||||
}
|
||||
if timing[index].decodeTimeStamp.isValid {
|
||||
timing[index].decodeTimeStamp = CMTimeSubtract(timing[index].decodeTimeStamp, offset)
|
||||
}
|
||||
}
|
||||
|
||||
var retimedBuffer: CMSampleBuffer?
|
||||
let copyStatus = CMSampleBufferCreateCopyWithNewTiming(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sampleBuffer: sampleBuffer,
|
||||
sampleTimingEntryCount: sampleCount,
|
||||
sampleTimingArray: &timing,
|
||||
sampleBufferOut: &retimedBuffer
|
||||
)
|
||||
if copyStatus != noErr {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "sample-retime-failed",
|
||||
"message": "Unable to copy sample timing info: \(copyStatus).",
|
||||
])
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
return retimedBuffer
|
||||
}
|
||||
|
||||
private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
|
||||
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||
sampleBuffer,
|
||||
createIfNecessary: false
|
||||
) as? [[SCStreamFrameInfo: Any]],
|
||||
let attachment = attachments.first,
|
||||
let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int,
|
||||
let status = SCFrameStatus(rawValue: statusRawValue)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
return status == .complete
|
||||
}
|
||||
|
||||
private func clampCaptureDimension(_ value: Int, fallback: Int) -> Int {
|
||||
let requested = max(2, fallback)
|
||||
let candidate = value > 0 ? value : requested
|
||||
let clamped = min(candidate, requested)
|
||||
return max(2, clamped - (clamped % 2))
|
||||
}
|
||||
|
||||
private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int {
|
||||
guard let mode = CGDisplayCopyDisplayMode(displayId) else {
|
||||
return 1
|
||||
}
|
||||
|
||||
return max(1, mode.pixelWidth / max(1, mode.width))
|
||||
}
|
||||
|
||||
private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
|
||||
streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) &&
|
||||
streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) &&
|
||||
SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil
|
||||
}
|
||||
|
||||
private func resolveMicrophoneCaptureDeviceID() -> String? {
|
||||
let devices = AVCaptureDevice.devices(for: .audio)
|
||||
|
||||
if let deviceName = request.audio.microphone.deviceName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!deviceName.isEmpty,
|
||||
let device = devices.first(where: { $0.localizedName == deviceName })
|
||||
{
|
||||
return device.uniqueID
|
||||
}
|
||||
|
||||
if let deviceId = request.audio.microphone.deviceId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!deviceId.isEmpty,
|
||||
devices.contains(where: { $0.uniqueID == deviceId })
|
||||
{
|
||||
return deviceId
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct OpenScreenScreenCaptureKitHelper {
|
||||
static func main() async {
|
||||
do {
|
||||
guard CommandLine.arguments.count == 2 else {
|
||||
throw HelperError.invalidArguments
|
||||
}
|
||||
|
||||
guard #available(macOS 13.0, *) else {
|
||||
throw HelperError.unsupportedMacOS
|
||||
}
|
||||
|
||||
let requestData = Data(CommandLine.arguments[1].utf8)
|
||||
let decoder = JSONDecoder()
|
||||
let request = try decoder.decode(RecordingRequest.self, from: requestData)
|
||||
let recorder = ScreenCaptureRecorder(request: request)
|
||||
let stopTask = Task.detached {
|
||||
while let line = readLine() {
|
||||
let command = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch command {
|
||||
case "pause":
|
||||
recorder.pause()
|
||||
case "resume":
|
||||
recorder.resume()
|
||||
case "stop":
|
||||
await recorder.stop()
|
||||
exit(0)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await recorder.start()
|
||||
await stopTask.value
|
||||
} catch let error as HelperError {
|
||||
emitError(code: "helper-error", message: error.description)
|
||||
exit(1)
|
||||
} catch {
|
||||
emitError(code: "helper-error", message: "\(error)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
)
|
||||
|
||||
add_executable(cursor-sampler
|
||||
src/cursor-sampler.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(cursor-sampler PRIVATE
|
||||
NOMINMAX
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8)
|
||||
|
||||
target_link_libraries(cursor-sampler PRIVATE
|
||||
gdi32
|
||||
gdiplus
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
#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;
|
||||
paused_ = 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::setPaused(bool paused) {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
paused_ = paused;
|
||||
if (paused_) {
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
}
|
||||
}
|
||||
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_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
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_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
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_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
});
|
||||
|
||||
if (stopRequested_) {
|
||||
break;
|
||||
}
|
||||
if (!timelineStarted_ || paused_) {
|
||||
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,87 @@
|
||||
#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 setPaused(bool paused);
|
||||
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;
|
||||
bool paused_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,482 @@
|
||||
#include <windows.h>
|
||||
#include <gdiplus.h>
|
||||
#include <objbase.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Global mouse-hook state
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static HHOOK g_mouseHook = nullptr;
|
||||
static DWORD g_mainThreadId = 0;
|
||||
static std::atomic<int> g_leftDownCount{0};
|
||||
static std::atomic<int> g_leftUpCount{0};
|
||||
static std::atomic<bool> g_stop{false};
|
||||
static std::mutex g_stdoutMtx;
|
||||
|
||||
static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
|
||||
if (nCode >= 0) {
|
||||
if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed);
|
||||
else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static int64_t nowMs() {
|
||||
return static_cast<int64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
static void writeJsonLine(const std::string& json) {
|
||||
std::lock_guard<std::mutex> lock(g_stdoutMtx);
|
||||
std::cout << json << '\n';
|
||||
std::cout.flush();
|
||||
}
|
||||
|
||||
static std::string jsonEscape(const std::string& s) {
|
||||
std::string r;
|
||||
r.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': r += "\\\""; break;
|
||||
case '\\': r += "\\\\"; break;
|
||||
case '\n': r += "\\n"; break;
|
||||
case '\r': r += "\\r"; break;
|
||||
case '\t': r += "\\t"; break;
|
||||
default: r.push_back(static_cast<char>(c)); break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static const char kBase64Chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static std::string base64Encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
const uint32_t b =
|
||||
(static_cast<uint32_t>(data[i]) << 16) |
|
||||
(i + 1 < len ? static_cast<uint32_t>(data[i + 1]) << 8 : 0u) |
|
||||
(i + 2 < len ? static_cast<uint32_t>(data[i + 2]) : 0u);
|
||||
out.push_back(kBase64Chars[(b >> 18) & 0x3F]);
|
||||
out.push_back(kBase64Chars[(b >> 12) & 0x3F]);
|
||||
out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '=');
|
||||
out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GDI+ PNG encoder CLSID
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static bool getPngClsid(CLSID& out) {
|
||||
UINT num = 0, sz = 0;
|
||||
if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false;
|
||||
std::vector<uint8_t> buf(sz);
|
||||
auto* enc = reinterpret_cast<Gdiplus::ImageCodecInfo*>(buf.data());
|
||||
if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false;
|
||||
for (UINT i = 0; i < num; ++i) {
|
||||
if (std::wstring(enc[i].MimeType) == L"image/png") {
|
||||
out = enc[i].Clsid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Standard cursor-type lookup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* standardCursorType(HCURSOR hc) {
|
||||
if (!hc) return nullptr;
|
||||
static const struct { WORD id; const char* name; } kMap[] = {
|
||||
{32512, "arrow"},
|
||||
{32513, "text"},
|
||||
{32514, "wait"},
|
||||
{32515, "crosshair"},
|
||||
{32516, "up-arrow"},
|
||||
{32642, "resize-nwse"},
|
||||
{32643, "resize-nesw"},
|
||||
{32644, "resize-ew"},
|
||||
{32645, "resize-ns"},
|
||||
{32646, "move"},
|
||||
{32648, "not-allowed"},
|
||||
{32649, "pointer"},
|
||||
{32650, "app-starting"},
|
||||
{32651, "help"},
|
||||
};
|
||||
static constexpr int N = static_cast<int>(sizeof(kMap) / sizeof(kMap[0]));
|
||||
static HCURSOR g_handles[N] = {};
|
||||
static bool g_init = false;
|
||||
if (!g_init) {
|
||||
for (int i = 0; i < N; ++i)
|
||||
g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id));
|
||||
g_init = true;
|
||||
}
|
||||
for (int i = 0; i < N; ++i)
|
||||
if (g_handles[i] && g_handles[i] == hc) return kMap[i].name;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom cursor-type detection (replicates the PowerShell heuristic)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* detectCustomCursorType(
|
||||
const uint32_t* pixels, int w, int h, int hotX, int hotY)
|
||||
{
|
||||
if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr;
|
||||
if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr;
|
||||
if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr;
|
||||
|
||||
int opaque = 0, topHalf = 0;
|
||||
int left = w, top = h, right = -1, bottom = -1;
|
||||
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const uint8_t a = static_cast<uint8_t>(pixels[y * w + x] >> 24);
|
||||
if (a <= 32) continue;
|
||||
++opaque;
|
||||
if (y < h / 2) ++topHalf;
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (opaque < 90 || right < left || bottom < top) return nullptr;
|
||||
|
||||
const int ow = right - left + 1;
|
||||
const int oh = bottom - top + 1;
|
||||
if (ow < w * 0.35 || ow > w * 0.9) return nullptr;
|
||||
if (oh < h * 0.45 || oh > static_cast<double>(h)) return nullptr;
|
||||
if (top > h * 0.45 || bottom < h * 0.65) return nullptr;
|
||||
|
||||
return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build asset JSON for the given cursor (returns empty string on failure)
|
||||
//
|
||||
// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section
|
||||
// and then encodes to PNG — matching the PowerShell approach of
|
||||
// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves
|
||||
// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which
|
||||
// can produce incorrect alpha for cursor handles.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static std::string buildAssetJson(
|
||||
HCURSOR hCursor,
|
||||
const std::string& handleStr,
|
||||
const CLSID& pngClsid,
|
||||
const char** outCustomType)
|
||||
{
|
||||
*outCustomType = nullptr;
|
||||
|
||||
// Get hotspot and cursor dimensions from the icon info.
|
||||
// For color cursors hbmColor gives the size; for monochrome cursors the
|
||||
// mask bitmap is twice the cursor height (AND mask stacked on XOR mask).
|
||||
ICONINFO ii{};
|
||||
if (!GetIconInfo(hCursor, &ii)) return {};
|
||||
const int hotX = static_cast<int>(ii.xHotspot);
|
||||
const int hotY = static_cast<int>(ii.yHotspot);
|
||||
|
||||
int w = 0, h = 0;
|
||||
if (ii.hbmColor) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; }
|
||||
}
|
||||
if (ii.hbmMask && (w == 0 || h == 0)) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmMask, sizeof(bm), &bm)) {
|
||||
w = bm.bmWidth;
|
||||
h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2;
|
||||
}
|
||||
}
|
||||
if (ii.hbmMask) DeleteObject(ii.hbmMask);
|
||||
if (ii.hbmColor) DeleteObject(ii.hbmColor);
|
||||
if (w <= 0 || h <= 0) return {};
|
||||
|
||||
// Copy the cursor handle so DrawIconEx cannot affect the live system cursor.
|
||||
const HICON hCopy = CopyIcon(hCursor);
|
||||
if (!hCopy) return {};
|
||||
|
||||
// Allocate a 32-bpp top-down DIB section and clear it to transparent black,
|
||||
// then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows
|
||||
// writes correct per-pixel alpha into the high byte of each BGRA pixel.
|
||||
const int stride = w * 4;
|
||||
BITMAPINFOHEADER bih{};
|
||||
bih.biSize = sizeof(bih);
|
||||
bih.biWidth = w;
|
||||
bih.biHeight = -h; // negative = top-down scanline order
|
||||
bih.biPlanes = 1;
|
||||
bih.biBitCount = 32;
|
||||
bih.biCompression = BI_RGB;
|
||||
|
||||
void* pBits = nullptr;
|
||||
HDC hDC = CreateCompatibleDC(nullptr);
|
||||
HBITMAP hBmp = hDC ? CreateDIBSection(hDC,
|
||||
reinterpret_cast<const BITMAPINFO*>(&bih),
|
||||
DIB_RGB_COLORS, &pBits, nullptr, 0)
|
||||
: nullptr;
|
||||
|
||||
if (!hBmp || !pBits) {
|
||||
if (hBmp) DeleteObject(hBmp);
|
||||
if (hDC) DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
return {};
|
||||
}
|
||||
|
||||
HGDIOBJ hOld = SelectObject(hDC, hBmp);
|
||||
std::memset(pBits, 0, static_cast<size_t>(stride * h)); // transparent black
|
||||
DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL);
|
||||
GdiFlush();
|
||||
SelectObject(hDC, hOld);
|
||||
DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
|
||||
// GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s
|
||||
// PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is
|
||||
// identical to BGRA on little-endian, so the alpha byte is always >> 24.
|
||||
{
|
||||
const auto* px = static_cast<const uint32_t*>(pBits);
|
||||
*outCustomType = detectCustomCursorType(px, w, h, hotX, hotY);
|
||||
}
|
||||
|
||||
// Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG.
|
||||
// Keep hBmp alive until after gBmp is destroyed so pBits remains valid.
|
||||
std::vector<uint8_t> pngData;
|
||||
{
|
||||
Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB,
|
||||
static_cast<BYTE*>(pBits));
|
||||
if (gBmp.GetLastStatus() == Gdiplus::Ok) {
|
||||
IStream* pStream = nullptr;
|
||||
if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) {
|
||||
if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) {
|
||||
ULARGE_INTEGER sz{};
|
||||
LARGE_INTEGER zero{};
|
||||
pStream->Seek(zero, STREAM_SEEK_END, &sz);
|
||||
pStream->Seek(zero, STREAM_SEEK_SET, nullptr);
|
||||
pngData.resize(static_cast<size_t>(sz.QuadPart));
|
||||
ULONG n = 0;
|
||||
pStream->Read(pngData.data(), static_cast<ULONG>(pngData.size()), &n);
|
||||
pngData.resize(n);
|
||||
}
|
||||
pStream->Release();
|
||||
}
|
||||
}
|
||||
} // gBmp destroyed here; pBits (owned by hBmp) still valid
|
||||
DeleteObject(hBmp);
|
||||
|
||||
if (pngData.empty()) return {};
|
||||
|
||||
const std::string dataUrl =
|
||||
"data:image/png;base64," + base64Encode(pngData.data(), pngData.size());
|
||||
|
||||
std::string json;
|
||||
json.reserve(dataUrl.size() + 128);
|
||||
json = "{\"id\":\"" + handleStr + "\"";
|
||||
json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\"";
|
||||
json += ",\"width\":" + std::to_string(w);
|
||||
json += ",\"height\":" + std::to_string(h);
|
||||
json += ",\"hotspotX\":" + std::to_string(hotX);
|
||||
json += ",\"hotspotY\":" + std::to_string(hotY);
|
||||
if (*outCustomType) {
|
||||
json += ",\"cursorType\":\"";
|
||||
json += *outCustomType;
|
||||
json += "\"";
|
||||
} else {
|
||||
json += ",\"cursorType\":null";
|
||||
}
|
||||
json += "}";
|
||||
return json;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sampling loop (background thread)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) {
|
||||
HCURSOR lastCursor = nullptr;
|
||||
|
||||
while (!g_stop.load(std::memory_order_relaxed)) {
|
||||
const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed);
|
||||
const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed);
|
||||
|
||||
CURSORINFO ci{};
|
||||
ci.cbSize = sizeof(ci);
|
||||
if (!GetCursorInfo(&ci)) {
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}",
|
||||
nowMs());
|
||||
writeJsonLine(buf);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool visible = (ci.flags & CURSOR_SHOWING) != 0;
|
||||
const HCURSOR hc = ci.hCursor;
|
||||
|
||||
// Handle string ("0xHEX" or empty for null cursor)
|
||||
char handleBuf[32] = {};
|
||||
if (hc)
|
||||
std::snprintf(handleBuf, sizeof(handleBuf),
|
||||
"0x%" PRIX64, static_cast<uint64_t>(reinterpret_cast<uintptr_t>(hc)));
|
||||
const std::string handleStr = hc ? handleBuf : "";
|
||||
|
||||
// Standard cursor type
|
||||
const char* cursorType = standardCursorType(hc);
|
||||
|
||||
// Mouse button state
|
||||
const SHORT ks = GetAsyncKeyState(VK_LBUTTON);
|
||||
const bool leftDown = (ks & 0x8000) != 0;
|
||||
const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0;
|
||||
const bool leftReleased = upCount > 0;
|
||||
|
||||
// Asset — only when the cursor handle changes
|
||||
std::string assetJson;
|
||||
if (visible && hc && hc != lastCursor) {
|
||||
const char* customType = nullptr;
|
||||
assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType);
|
||||
if (!assetJson.empty() && !cursorType && customType)
|
||||
cursorType = customType;
|
||||
lastCursor = hc;
|
||||
}
|
||||
|
||||
// Window bounds
|
||||
std::string boundsJson = "null";
|
||||
if (targetWindow && IsWindow(targetWindow)) {
|
||||
RECT r{};
|
||||
if (GetWindowRect(targetWindow, &r)) {
|
||||
const int bw = r.right - r.left;
|
||||
const int bh = r.bottom - r.top;
|
||||
if (bw > 0 && bh > 0) {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}",
|
||||
r.left, r.top, bw, bh);
|
||||
boundsJson = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sample JSON
|
||||
std::string out;
|
||||
out.reserve(256);
|
||||
out += "{\"type\":\"sample\"";
|
||||
out += ",\"timestampMs\":"; out += std::to_string(nowMs());
|
||||
out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x);
|
||||
out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y);
|
||||
out += ",\"visible\":"; out += visible ? "true" : "false";
|
||||
out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null";
|
||||
out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null";
|
||||
out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false";
|
||||
out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false";
|
||||
out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false";
|
||||
out += ",\"bounds\":"; out += boundsJson;
|
||||
out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson;
|
||||
out += "}";
|
||||
|
||||
writeJsonLine(out);
|
||||
|
||||
// Exit if stdout pipe is broken (parent process died)
|
||||
if (std::cout.fail()) {
|
||||
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// main
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: cursor-sampler <intervalMs> [windowHandle]" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int intervalMs = std::max(1, std::atoi(argv[1]));
|
||||
|
||||
HWND targetWindow = nullptr;
|
||||
if (argc >= 3) {
|
||||
const std::string arg = argv[2];
|
||||
if (!arg.empty() && arg != "null") {
|
||||
try {
|
||||
const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10;
|
||||
const uint64_t v = std::stoull(arg, nullptr, base);
|
||||
if (v) targetWindow = reinterpret_cast<HWND>(static_cast<uintptr_t>(v));
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GDI+
|
||||
Gdiplus::GdiplusStartupInput gdipInput{};
|
||||
ULONG_PTR gdipToken = 0;
|
||||
if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) {
|
||||
std::cerr << "GDI+ init failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
CLSID pngClsid{};
|
||||
if (!getPngClsid(pngClsid)) {
|
||||
std::cerr << "PNG encoder not found" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Install global low-level mouse hook on this thread
|
||||
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0);
|
||||
if (!g_mouseHook) {
|
||||
std::cerr << "SetWindowsHookEx failed" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits
|
||||
GetAsyncKeyState(VK_LBUTTON);
|
||||
|
||||
// Signal readiness
|
||||
g_mainThreadId = GetCurrentThreadId();
|
||||
{
|
||||
char buf[80];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs());
|
||||
writeJsonLine(buf);
|
||||
}
|
||||
|
||||
// Start sampling on a background thread
|
||||
std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid));
|
||||
|
||||
// Run the message pump on the main thread — required for WH_MOUSE_LL callbacks
|
||||
MSG msg;
|
||||
while (GetMessage(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
g_stop.store(true, std::memory_order_relaxed);
|
||||
if (sampler.joinable()) sampler.join();
|
||||
UnhookWindowsHookEx(g_mouseHook);
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <initguid.h>
|
||||
#include <dshow.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
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("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(IUnknown* 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;
|
||||
}
|
||||
|
||||
std::string guidToString(const GUID& guid) {
|
||||
if (guid == MEDIASUBTYPE_RGB32) {
|
||||
return "RGB32";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_YUY2) {
|
||||
return "YUY2";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_NV12) {
|
||||
return "NV12";
|
||||
}
|
||||
|
||||
std::ostringstream stream;
|
||||
stream << std::hex << std::setfill('0')
|
||||
<< '{' << std::setw(8) << guid.Data1
|
||||
<< '-' << std::setw(4) << guid.Data2
|
||||
<< '-' << std::setw(4) << guid.Data3
|
||||
<< '-';
|
||||
for (int index = 0; index < 2; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '-';
|
||||
for (int index = 2; index < 8; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '}';
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
BYTE clampToByte(int value) {
|
||||
return static_cast<BYTE>(std::clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
std::array<BYTE, 3> yuvToBgr(int y, int u, int v) {
|
||||
const int c = y - 16;
|
||||
const int d = u - 128;
|
||||
const int e = v - 128;
|
||||
const int blue = (298 * c + 516 * d + 128) >> 8;
|
||||
const int green = (298 * c - 100 * d - 208 * e + 128) >> 8;
|
||||
const int red = (298 * c + 409 * e + 128) >> 8;
|
||||
return {clampToByte(blue), clampToByte(green), clampToByte(red)};
|
||||
}
|
||||
|
||||
} // 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.formattype = FORMAT_VideoInfo;
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) {
|
||||
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.subtype == MEDIASUBTYPE_YUY2) {
|
||||
pixelFormat_ = PixelFormat::Yuy2;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_NV12) {
|
||||
pixelFormat_ = PixelFormat::Nv12;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_RGB32) {
|
||||
pixelFormat_ = PixelFormat::Bgra;
|
||||
} else {
|
||||
std::cerr << "ERROR: Unsupported DirectShow webcam media subtype "
|
||||
<< guidToString(connectedType.subtype) << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
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);
|
||||
const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16;
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
sourceStride_ = ((width_ + 3) / 4) * 4;
|
||||
} else {
|
||||
sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4;
|
||||
}
|
||||
sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0;
|
||||
}
|
||||
std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype)
|
||||
<< " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
if (width_ <= 0 || height_ <= 0) {
|
||||
width_ = requestedWidth > 0 ? requestedWidth : 1280;
|
||||
height_ = requestedHeight > 0 ? requestedHeight : 720;
|
||||
}
|
||||
if (sourceStride_ <= 0) {
|
||||
sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4;
|
||||
}
|
||||
|
||||
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 destinationStride = width_ * 4;
|
||||
const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride;
|
||||
const int expectedLength = pixelFormat_ == PixelFormat::Nv12
|
||||
? sourceStride * height_ + sourceStride * ((height_ + 1) / 2)
|
||||
: sourceStride * height_;
|
||||
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BYTE> frame(static_cast<size_t>(destinationStride * height_));
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
|
||||
const BYTE* source = buffer + sourceY * sourceStride;
|
||||
BYTE* destination = frame.data() + y * destinationStride;
|
||||
if (pixelFormat_ == PixelFormat::Bgra) {
|
||||
std::copy(source, source + destinationStride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
const BYTE* yPlane = buffer + sourceY * sourceStride;
|
||||
const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int uvX = (x / 2) * 2;
|
||||
const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int x = 0; x + 1 < width_; x += 2) {
|
||||
const BYTE y0 = source[x * 2];
|
||||
const BYTE u = source[x * 2 + 1];
|
||||
const BYTE y1 = source[x * 2 + 2];
|
||||
const BYTE v = source[x * 2 + 3];
|
||||
const auto first = yuvToBgr(y0, u, v);
|
||||
const auto second = yuvToBgr(y1, u, v);
|
||||
BYTE* firstPixel = destination + x * 4;
|
||||
BYTE* secondPixel = firstPixel + 4;
|
||||
firstPixel[0] = first[0];
|
||||
firstPixel[1] = first[1];
|
||||
firstPixel[2] = first[2];
|
||||
firstPixel[3] = 255;
|
||||
secondPixel[0] = second[0];
|
||||
secondPixel[1] = second[1];
|
||||
secondPixel[2] = second[2];
|
||||
secondPixel[3] = 255;
|
||||
}
|
||||
if (width_ % 2 == 1) {
|
||||
const int x = width_ - 1;
|
||||
const int previousPairStart = ((x - 1) / 2) * 4;
|
||||
const BYTE y = source[x * 2];
|
||||
const BYTE u = source[previousPairStart + 1];
|
||||
const BYTE v = source[previousPairStart + 3];
|
||||
const auto color = yuvToBgr(y, u, v);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_ = std::move(frame);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
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,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct WebcamFrameSnapshot {
|
||||
std::vector<BYTE> data;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
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(WebcamFrameSnapshot& destination);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
void storeFrame(const BYTE* buffer, long length);
|
||||
|
||||
private:
|
||||
enum class PixelFormat {
|
||||
Bgra,
|
||||
Nv12,
|
||||
Yuy2,
|
||||
};
|
||||
|
||||
struct Impl;
|
||||
void captureLoop();
|
||||
|
||||
Impl* impl_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
int sourceStride_ = 0;
|
||||
bool sourceTopDown_ = false;
|
||||
PixelFormat pixelFormat_ = PixelFormat::Bgra;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,859 @@
|
||||
#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 <functional>
|
||||
#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;
|
||||
std::string webcamOutputPath;
|
||||
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;
|
||||
};
|
||||
|
||||
struct CaptureControl {
|
||||
std::atomic<bool> stopRequested = false;
|
||||
std::atomic<bool> paused = false;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::chrono::steady_clock::time_point pauseStartedAt;
|
||||
std::chrono::steady_clock::duration totalPausedDuration{};
|
||||
|
||||
int64_t pausedDurationHns() {
|
||||
std::scoped_lock lock(mutex);
|
||||
auto total = totalPausedDuration;
|
||||
if (paused.load()) {
|
||||
total += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(total).count() / 100;
|
||||
}
|
||||
|
||||
void setPaused(bool nextPaused) {
|
||||
std::scoped_lock lock(mutex);
|
||||
if (nextPaused == paused.load()) {
|
||||
return;
|
||||
}
|
||||
if (nextPaused) {
|
||||
pauseStartedAt = std::chrono::steady_clock::now();
|
||||
} else {
|
||||
totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
paused = nextPaused;
|
||||
}
|
||||
};
|
||||
|
||||
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.webcamOutputPath = findString(json, "webcamPath");
|
||||
config.webcamWidth = findInt(json, "webcamWidth", 0);
|
||||
config.webcamHeight = findInt(json, "webcamHeight", 0);
|
||||
config.webcamFps = findInt(json, "webcamFps", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void readCaptureCommands(CaptureControl& control, const std::function<void(bool)>& onPauseChanged) {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (line == "stop" || line == "q" || line == "quit") {
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (line == "pause") {
|
||||
control.setPaused(true);
|
||||
onPauseChanged(true);
|
||||
std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
if (line == "resume") {
|
||||
control.setPaused(false);
|
||||
onPauseChanged(false);
|
||||
std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
control.stopRequested = true;
|
||||
control.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;
|
||||
bool writeSeparateWebcam = 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;
|
||||
writeSeparateWebcam = !config.webcamOutputPath.empty();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
MFEncoder webcamEncoder;
|
||||
if (writeSeparateWebcam) {
|
||||
const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height());
|
||||
const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000;
|
||||
if (!webcamEncoder.initialize(
|
||||
utf8ToWide(config.webcamOutputPath),
|
||||
webcamCapture.width(),
|
||||
webcamCapture.height(),
|
||||
webcamCapture.fps(),
|
||||
webcamBitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
nullptr)) {
|
||||
std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
CaptureControl control;
|
||||
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;
|
||||
uint64_t latestWebcamSequence = 0;
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
if (control.stopRequested || control.paused) {
|
||||
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;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
control.cv.notify_all();
|
||||
}
|
||||
});
|
||||
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto frameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(1.0 / config.fps));
|
||||
uint64_t frameIndex = 0;
|
||||
uint64_t lastWrittenWebcamSequence = 0;
|
||||
uint64_t webcamOutputFrameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!control.stopRequested && !encodeFailed) {
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load() ||
|
||||
encodeFailed.load() ||
|
||||
(!control.paused.load() && latestFrameTexture);
|
||||
});
|
||||
if (control.stopRequested || encodeFailed) {
|
||||
break;
|
||||
}
|
||||
if (webcamActive) {
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
candidateWebcamFrame.sequence != latestWebcamSequence &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
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 - control.pausedDurationHns());
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
|
||||
}
|
||||
if (writeSeparateWebcam && webcamFrame.data &&
|
||||
latestWebcamSequence != lastWrittenWebcamSequence) {
|
||||
const int64_t webcamTimestampHns = static_cast<int64_t>(
|
||||
(webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps()));
|
||||
if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
lastWrittenWebcamSequence = latestWebcamSequence;
|
||||
webcamOutputFrameIndex += 1;
|
||||
}
|
||||
if (latestFrameTexture && !encoder.writeFrame(
|
||||
latestFrameTexture.Get(),
|
||||
frameTimestampHns,
|
||||
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
lastEncodedVideoTimestampHns = frameTimestampHns;
|
||||
}
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
std::this_thread::sleep_for(frameDuration);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
control.stopRequested = true;
|
||||
control.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 (control.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 (control.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) {
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
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(readCaptureCommands, std::ref(control), [&](bool isPaused) {
|
||||
if (audioMixer) {
|
||||
audioMixer->setPaused(isPaused);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || control.stopRequested.load();
|
||||
});
|
||||
if (!started || !firstFrameWritten) {
|
||||
control.stopRequested = true;
|
||||
control.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);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load();
|
||||
});
|
||||
}
|
||||
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
webcamCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
stopVideoWriter();
|
||||
session.stop();
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
encoder.finalize();
|
||||
if (writeSeparateWebcam) {
|
||||
webcamEncoder.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) << "\"";
|
||||
if (writeSeparateWebcam) {
|
||||
std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
#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::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) {
|
||||
if (!frame.data || frame.width <= 0 || frame.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame.width == width_ && frame.height == height_) {
|
||||
for (DWORD i = 0; i < requiredBytes; i += 4) {
|
||||
destination[i] = frame.data[i];
|
||||
destination[i + 1] = frame.data[i + 1];
|
||||
destination[i + 2] = frame.data[i + 2];
|
||||
destination[i + 3] = 255;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * frame.height) / height_);
|
||||
BYTE* destinationRow = destination + rowBytes * y;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * frame.width) / width_);
|
||||
const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4;
|
||||
BYTE* target = destinationRow + x * 4;
|
||||
target[0] = source[0];
|
||||
target[1] = source[1];
|
||||
target[2] = source[2];
|
||||
target[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
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::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) {
|
||||
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(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyBgraFrameToBuffer(frame, data, maxLength);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)");
|
||||
}
|
||||
|
||||
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,75 @@
|
||||
#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 writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns);
|
||||
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 copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize);
|
||||
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,419 @@
|
||||
#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);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
buffer->Unlock();
|
||||
}
|
||||
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.copyLatestFrame(destination);
|
||||
}
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
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,61 @@
|
||||
#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(WebcamFrameSnapshot& destination);
|
||||
|
||||
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_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool mfStarted_ = false;
|
||||
bool usingDirectShow_ = false;
|
||||
int selectedMatchScore_ = 0;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
bool WgcSession::applySessionOptions(bool captureCursor) {
|
||||
captureCursor_ = captureCursor;
|
||||
|
||||
try {
|
||||
auto session2 = session_.try_as<wgcap::IGraphicsCaptureSession2>();
|
||||
if (!session2) {
|
||||
if (!captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor suppression is not supported by this Windows runtime"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
session2.IsCursorCaptureEnabled(captureCursor);
|
||||
const bool appliedCursorCapture = session2.IsCursorCaptureEnabled();
|
||||
std::cout << "{\"event\":\"cursor-capture\",\"schemaVersion\":2,\"requested\":"
|
||||
<< (captureCursor ? "true" : "false")
|
||||
<< ",\"applied\":" << (appliedCursorCapture ? "true" : "false") << "}"
|
||||
<< std::endl;
|
||||
|
||||
if (appliedCursorCapture != captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor capture setting did not apply" << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (winrt::hresult_error const& error) {
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture (hr=0x" << std::hex
|
||||
<< static_cast<uint32_t>(error.code()) << std::dec << ")" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
} catch (...) {
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
session_.IsBorderRequired(false);
|
||||
} catch (...) {
|
||||
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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_);
|
||||
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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_);
|
||||
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!applySessionOptions(captureCursor_)) {
|
||||
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,59 @@
|
||||
#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);
|
||||
bool 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 captureCursor_ = false;
|
||||
bool started_ = false;
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||
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 +13,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");
|
||||
},
|
||||
@@ -19,6 +25,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
||||
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
||||
},
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
|
||||
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
@@ -43,10 +52,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestAccessibilityAccess: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-access");
|
||||
requestScreenAccess: () => {
|
||||
return ipcRenderer.invoke("request-screen-access");
|
||||
},
|
||||
requestNativeMacCursorAccess: () => {
|
||||
return ipcRenderer.invoke("request-native-mac-cursor-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
@@ -57,8 +68,50 @@ 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");
|
||||
},
|
||||
isNativeMacCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-mac-capture-available");
|
||||
},
|
||||
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-windows-recording", request);
|
||||
},
|
||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
||||
},
|
||||
pauseNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-windows-recording");
|
||||
},
|
||||
resumeNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-windows-recording");
|
||||
},
|
||||
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-mac-recording", request);
|
||||
},
|
||||
pauseNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-mac-recording");
|
||||
},
|
||||
resumeNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-mac-recording");
|
||||
},
|
||||
stopNativeMacRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-mac-recording", discard);
|
||||
},
|
||||
attachNativeMacWebcamRecording: (payload: {
|
||||
screenVideoPath: string;
|
||||
recordingId: number;
|
||||
webcam: { fileName: string; videoData: ArrayBuffer };
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
||||
}) => {
|
||||
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
@@ -98,6 +151,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
readBinaryFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-binary-file", filePath);
|
||||
},
|
||||
preparePreviewAudioTrack: (filePath: string) => {
|
||||
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
|
||||
@@ -30,6 +30,20 @@ ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => {
|
||||
if (
|
||||
!hudOverlayWindow ||
|
||||
hudOverlayWindow.isDestroyed() ||
|
||||
!Number.isFinite(deltaX) ||
|
||||
!Number.isFinite(deltaY)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = hudOverlayWindow.getPosition();
|
||||
hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates the always-on-top HUD overlay window centred at the bottom of the
|
||||
* primary display. The window is frameless, transparent, and follows the user
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,18 +20,29 @@
|
||||
"format": "biome format --write .",
|
||||
"i18n:check": "node scripts/i18n-check.mjs",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
|
||||
"build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac",
|
||||
"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 +84,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,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const helperName = "openscreen-screencapturekit-helper";
|
||||
const cursorHelperName = "openscreen-macos-cursor-helper";
|
||||
const packageDir = path.join(root, "electron", "native", "screencapturekit");
|
||||
const buildDir = path.join(packageDir, "build");
|
||||
const swiftBuildDir = path.join(buildDir, "swiftpm");
|
||||
const builtHelperPath = path.join(swiftBuildDir, "release", helperName);
|
||||
const localHelperPath = path.join(buildDir, helperName);
|
||||
const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName);
|
||||
const localCursorHelperPath = path.join(buildDir, cursorHelperName);
|
||||
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
const distributableDir = path.join(root, "electron", "native", "bin", archTag);
|
||||
const distributablePath = path.join(distributableDir, helperName);
|
||||
const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName);
|
||||
|
||||
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (xcodebuildVersion.status !== 0) {
|
||||
const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim();
|
||||
console.error(
|
||||
[
|
||||
"Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.",
|
||||
"",
|
||||
message,
|
||||
"",
|
||||
"Install Xcode from the App Store or Apple Developer downloads, then run:",
|
||||
" sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer",
|
||||
" sudo xcodebuild -license accept",
|
||||
"",
|
||||
"Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"swift",
|
||||
["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to start Swift build: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
fs.mkdirSync(distributableDir, { recursive: true });
|
||||
for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) {
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
fs.copyFileSync(builtHelperPath, localHelperPath);
|
||||
fs.copyFileSync(builtHelperPath, distributablePath);
|
||||
fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath);
|
||||
fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath);
|
||||
fs.chmodSync(localHelperPath, 0o755);
|
||||
fs.chmodSync(distributablePath, 0o755);
|
||||
fs.chmodSync(localCursorHelperPath, 0o755);
|
||||
fs.chmodSync(distributableCursorHelperPath, 0o755);
|
||||
|
||||
console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`);
|
||||
console.log(`Copied redistributable helper: ${distributablePath}`);
|
||||
console.log(`Built macOS cursor helper: ${localCursorHelperPath}`);
|
||||
console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`);
|
||||
@@ -0,0 +1,139 @@
|
||||
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 findWindowsSdkUmLibDir() {
|
||||
const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib";
|
||||
if (!fs.existsSync(sdkLibRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(sdkLibRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64"))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib")))
|
||||
.sort()
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
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 sdkUmLibDir = findWindowsSdkUmLibDir();
|
||||
|
||||
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 gdiplus.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=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`,
|
||||
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.`);
|
||||
}
|
||||
|
||||
const cursorSamplerOutputPath = path.join(BUILD_DIR, "cursor-sampler.exe");
|
||||
if (!fs.existsSync(cursorSamplerOutputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${cursorSamplerOutputPath} was not found.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
|
||||
fs.copyFileSync(outputPath, distributablePath);
|
||||
|
||||
const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe");
|
||||
fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath);
|
||||
|
||||
console.log(`Built ${outputPath}`);
|
||||
console.log(`Copied ${distributablePath}`);
|
||||
console.log(`Built ${cursorSamplerOutputPath}`);
|
||||
console.log(`Copied ${cursorSamplerDistributablePath}`);
|
||||
@@ -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,387 @@
|
||||
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");
|
||||
const CAPTURE_CURSOR =
|
||||
process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" ||
|
||||
process.argv.includes("--capture-cursor");
|
||||
|
||||
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 webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null;
|
||||
|
||||
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,
|
||||
captureCursor: CAPTURE_CURSOR,
|
||||
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,
|
||||
...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) {
|
||||
throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`);
|
||||
}
|
||||
|
||||
const streams = probeStreams(outputPath);
|
||||
const webcamStreams =
|
||||
webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : [];
|
||||
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 cursorCaptureLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"cursor-capture"'));
|
||||
const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : 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_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) {
|
||||
throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`);
|
||||
}
|
||||
if (
|
||||
(CAPTURE_CURSOR && !cursorCapture) ||
|
||||
(cursorCapture &&
|
||||
(cursorCapture.requested !== CAPTURE_CURSOR || cursorCapture.applied !== CAPTURE_CURSOR))
|
||||
) {
|
||||
throw new Error(
|
||||
`WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
webcamOutputPath,
|
||||
bytes: fs.statSync(outputPath).size,
|
||||
webcamBytes:
|
||||
webcamOutputPath && fs.existsSync(webcamOutputPath)
|
||||
? fs.statSync(webcamOutputPath).size
|
||||
: undefined,
|
||||
streams: streams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
webcamStreams: webcamStreams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
cursorCapture,
|
||||
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 |
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
@@ -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 },
|
||||
@@ -95,18 +98,23 @@ export function LaunchWindow() {
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
canPauseRecording,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
setMicrophoneDeviceName,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
setWebcamDeviceName,
|
||||
cursorCaptureMode,
|
||||
setCursorCaptureMode,
|
||||
} = useScreenRecorder();
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
@@ -120,6 +128,7 @@ export function LaunchWindow() {
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
@@ -148,14 +157,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 +176,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) {
|
||||
setSupportsCursorModeToggle(platform === "win32" || platform === "darwin");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSupportsCursorModeToggle(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
@@ -249,15 +282,29 @@ export function LaunchWindow() {
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
const hudMouseEventsEnabledRef = useRef<boolean | undefined>(undefined);
|
||||
const setHudMouseEventsEnabled = useCallback((enabled: boolean) => {
|
||||
if (hudMouseEventsEnabledRef.current === enabled) {
|
||||
return;
|
||||
}
|
||||
hudMouseEventsEnabledRef.current = enabled;
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
|
||||
setHudMouseEventsEnabled(false);
|
||||
return () => {
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
|
||||
};
|
||||
}, []);
|
||||
}, [setHudMouseEventsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setHudMouseEventsEnabled(isLanguageMenuOpen);
|
||||
}, [isLanguageMenuOpen, setHudMouseEventsEnabled]);
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [, setRecordPointerDownCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
@@ -293,13 +340,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();
|
||||
};
|
||||
@@ -320,6 +371,29 @@ export function LaunchWindow() {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
}
|
||||
};
|
||||
const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const handleHudDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHudMouseEventsEnabled(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
|
||||
};
|
||||
const handleHudDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const lastPosition = dragLastPositionRef.current;
|
||||
if (!lastPosition) return;
|
||||
const deltaX = event.screenX - lastPosition.x;
|
||||
const deltaY = event.screenY - lastPosition.y;
|
||||
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
|
||||
window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY);
|
||||
};
|
||||
const handleHudDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
dragLastPositionRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
setHudMouseEventsEnabled(false);
|
||||
};
|
||||
|
||||
return (
|
||||
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||
@@ -330,13 +404,19 @@ export function LaunchWindow() {
|
||||
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||
onPointerMove={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']"));
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture);
|
||||
const shouldCapture =
|
||||
isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']"));
|
||||
setHudMouseEventsEnabled(shouldCapture);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!isLanguageMenuOpen) {
|
||||
setHudMouseEventsEnabled(false);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
|
||||
>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
data-hud-interactive="true"
|
||||
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className="text-[13px] font-semibold text-white">
|
||||
@@ -396,8 +476,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 +537,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 +566,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"
|
||||
>
|
||||
@@ -502,9 +590,23 @@ export function LaunchWindow() {
|
||||
<div
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerDown={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!isLanguageMenuOpen) {
|
||||
setHudMouseEventsEnabled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<div
|
||||
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
onPointerDown={handleHudDragPointerDown}
|
||||
onPointerMove={handleHudDragPointerMove}
|
||||
onPointerUp={handleHudDragPointerEnd}
|
||||
onPointerCancel={handleHudDragPointerEnd}
|
||||
>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
@@ -524,6 +626,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 +639,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 +665,38 @@ export function LaunchWindow() {
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
{supportsCursorModeToggle && (
|
||||
<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
|
||||
@@ -588,13 +724,18 @@ export function LaunchWindow() {
|
||||
|
||||
{recording && (
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canPauseRecording && (
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(
|
||||
paused ? "resume" : "pause",
|
||||
paused ? "text-amber-400" : "text-white/60",
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
@@ -613,6 +754,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 +765,7 @@ export function LaunchWindow() {
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
data-testid="launch-open-project-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
@@ -655,6 +798,7 @@ export function LaunchWindow() {
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
data-hud-interactive="true"
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
@@ -667,6 +811,12 @@ export function LaunchWindow() {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerMove={() => setHudMouseEventsEnabled(true)}
|
||||
onWheel={(event) => {
|
||||
setHudMouseEventsEnabled(true);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<button
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
@@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
|
||||
@@ -161,7 +161,9 @@ export function ExportDialog({
|
||||
<div className="p-1 bg-red-500/20 rounded-full">
|
||||
<X className="w-3 h-3 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-red-400 leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -42,7 +40,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
import {
|
||||
calculateEffectiveSourceDimensions,
|
||||
GIF_FRAME_RATES,
|
||||
GIF_SIZE_PRESETS,
|
||||
} from "@/lib/exporter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
@@ -52,6 +54,15 @@ import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -69,8 +80,6 @@ import type {
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -89,37 +98,38 @@ function CustomSpeedInput({
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
const result = parseCustomPlaybackSpeedInput(e.target.value);
|
||||
if (result.status === "too-fast") {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
|
||||
setDraft(result.draft);
|
||||
if (result.status === "valid") {
|
||||
onChange(result.speed);
|
||||
}
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
const result = parseCustomPlaybackSpeedInput(draft);
|
||||
if (result.status === "valid") {
|
||||
setDraft(String(result.speed));
|
||||
} else {
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
@@ -127,8 +137,8 @@ function CustomSpeedInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*[.]?[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
@@ -222,12 +232,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;
|
||||
@@ -310,6 +314,20 @@ 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;
|
||||
cursorClipToBounds?: boolean;
|
||||
onCursorClipToBoundsChange?: (clip: boolean) => void;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -325,10 +343,24 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
|
||||
|
||||
const MP4_EXPORT_SHORT_SIDES = {
|
||||
medium: 720,
|
||||
good: 1080,
|
||||
} as const;
|
||||
|
||||
function formatSourceDimensions(videoElement?: HTMLVideoElement | null, cropRegion?: CropRegion) {
|
||||
const width = videoElement?.videoWidth ?? 0;
|
||||
const height = videoElement?.videoHeight ?? 0;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dimensions = calculateEffectiveSourceDimensions(width, height, cropRegion);
|
||||
return { ...dimensions, shortSide: Math.min(dimensions.width, dimensions.height) };
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
cursorHighlight,
|
||||
onCursorHighlightChange,
|
||||
cursorHighlightSupportsClicks = false,
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
@@ -359,24 +391,24 @@ export function SettingsPanel({
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
onBorderRadiusCommit,
|
||||
padding = 50,
|
||||
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
onPaddingChange,
|
||||
onPaddingCommit,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
videoElement,
|
||||
exportQuality = "good",
|
||||
exportQuality = DEFAULT_EXPORT_SETTINGS.quality,
|
||||
onExportQualityChange,
|
||||
exportFormat = "mp4",
|
||||
exportFormat = DEFAULT_EXPORT_SETTINGS.format,
|
||||
onExportFormatChange,
|
||||
gifFrameRate = 15,
|
||||
gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate,
|
||||
onGifFrameRateChange,
|
||||
gifLoop = true,
|
||||
gifLoop = DEFAULT_GIF_SETTINGS.loop,
|
||||
onGifLoopChange,
|
||||
gifSizePreset = "medium",
|
||||
gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
|
||||
onExport,
|
||||
unsavedExport,
|
||||
onSaveUnsavedExport,
|
||||
@@ -398,17 +430,32 @@ export function SettingsPanel({
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
showCursor = DEFAULT_CURSOR_SETTINGS.show,
|
||||
onShowCursorChange,
|
||||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||||
onCursorSmoothingChange,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||||
onCursorClickBounceChange,
|
||||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
onCursorClipToBoundsChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
|
||||
const sourceDimensions = formatSourceDimensions(videoElement, cropRegion);
|
||||
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
|
||||
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
|
||||
// on click — never the machine-specific file:// URL.
|
||||
@@ -436,14 +483,12 @@ 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);
|
||||
|
||||
const videoWidth = videoElement?.videoWidth || 1920;
|
||||
const videoHeight = videoElement?.videoHeight || 1080;
|
||||
const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const handleCropNumericChange = useCallback(
|
||||
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
|
||||
@@ -538,10 +583,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;
|
||||
@@ -551,7 +599,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,
|
||||
@@ -624,20 +680,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)
|
||||
@@ -657,7 +699,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
{t("links.reportBug")}
|
||||
{t("support.reportBug")}
|
||||
</button>
|
||||
{onSaveDiagnostic && (
|
||||
<button
|
||||
@@ -666,7 +708,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<FileDown className="w-3 h-3 text-slate-400" />
|
||||
Save Diagnostics
|
||||
{t("support.saveDiagnostics")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -677,7 +719,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Star className="w-3 h-3 text-yellow-400" />
|
||||
{t("links.starOnGithub")}
|
||||
{t("support.starOnGithub")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -773,6 +815,7 @@ export function SettingsPanel({
|
||||
<Crop className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
data-testid={getTestId("export-panel-button")}
|
||||
type="button"
|
||||
title={exportPanelMode.label}
|
||||
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
||||
@@ -1264,11 +1307,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">
|
||||
@@ -1373,218 +1412,107 @@ 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")}
|
||||
{t("cursor.show")}
|
||||
</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"
|
||||
<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")}
|
||||
</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,
|
||||
})
|
||||
}
|
||||
{showCursor && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.clipToBounds")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={cursorClipToBounds}
|
||||
onCheckedChange={onCursorClipToBoundsChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
aria-label={t("cursor.clipToBounds")}
|
||||
/>
|
||||
</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 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">
|
||||
{t("cursor.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">
|
||||
{t("cursor.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">
|
||||
{t("cursor.motionBlur")}
|
||||
</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">
|
||||
{t("cursor.clickBounce")}
|
||||
</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>
|
||||
</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>
|
||||
@@ -1744,11 +1672,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">
|
||||
@@ -1759,7 +1687,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" />
|
||||
@@ -1855,7 +1783,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"
|
||||
>
|
||||
@@ -1872,6 +1800,7 @@ export function SettingsPanel({
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button
|
||||
data-testid={getTestId("mp4-format-button")}
|
||||
onClick={() => onExportFormatChange?.("mp4")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
||||
@@ -1899,40 +1828,82 @@ export function SettingsPanel({
|
||||
</div>
|
||||
|
||||
{exportFormat === "mp4" && (
|
||||
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.low")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.medium")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.high")}
|
||||
</button>
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{sourceDimensions && (
|
||||
<div className="flex items-center justify-between px-0.5 text-[10px] leading-none text-slate-500">
|
||||
<span>{t("exportQuality.title")}</span>
|
||||
<span>
|
||||
Source {sourceDimensions.width}x{sourceDimensions.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-9 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.low")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.medium && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "medium" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.medium")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.good && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "good" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.high")}</span>
|
||||
{sourceDimensions && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "source" ? "text-black/55" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{sourceDimensions.shortSide}p
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ 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 {
|
||||
calculateEffectiveSourceDimensions,
|
||||
calculateMp4ExportSettings,
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
type ExportProgress,
|
||||
@@ -29,7 +32,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,12 +41,20 @@ 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,
|
||||
isPortraitAspectRatio,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
} from "./editorDefaults";
|
||||
import PlaybackControls from "./PlaybackControls";
|
||||
import {
|
||||
createProjectData,
|
||||
@@ -61,7 +72,6 @@ import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
@@ -84,6 +94,62 @@ 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;
|
||||
sourcePath?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
codec?: string;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
function getFileNameForDiagnostics(filePath?: string | null) {
|
||||
if (!filePath) return "unknown";
|
||||
|
||||
try {
|
||||
const url = new URL(filePath);
|
||||
if (url.protocol === "file:") {
|
||||
return decodeURIComponent(url.pathname).split(/[\\/]/).pop() || filePath;
|
||||
}
|
||||
} catch {
|
||||
// Treat non-URL values as filesystem paths.
|
||||
}
|
||||
|
||||
return filePath.split(/[\\/]/).pop() || filePath;
|
||||
}
|
||||
|
||||
function buildExportDiagnosticMessage(diagnostics: ExportDiagnostics) {
|
||||
const details = [
|
||||
diagnostics.reason ? `Reason: ${diagnostics.reason}` : null,
|
||||
`Source: ${getFileNameForDiagnostics(diagnostics.sourcePath)}`,
|
||||
diagnostics.width && diagnostics.height
|
||||
? `Output: ${diagnostics.width}x${diagnostics.height}${
|
||||
diagnostics.frameRate ? ` @ ${diagnostics.frameRate} fps` : ""
|
||||
}`
|
||||
: null,
|
||||
diagnostics.codec ? `Codec: ${diagnostics.codec}` : null,
|
||||
diagnostics.bitrate ? `Bitrate: ${Math.round(diagnostics.bitrate / 1_000_000)} Mbps` : null,
|
||||
`VideoEncoder: ${"VideoEncoder" in window ? "available" : "unavailable"}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return `${diagnostics.formatLabel} export failed\n${details.join("\n")}`;
|
||||
}
|
||||
|
||||
function buildSaveDiagnosticMessage(formatLabel: "GIF" | "Video", reason?: string) {
|
||||
return `${formatLabel} export save failed${reason ? `\nReason: ${reason}` : ""}`;
|
||||
}
|
||||
|
||||
export default function VideoEditor() {
|
||||
const {
|
||||
state: editorState,
|
||||
@@ -111,7 +177,6 @@ export default function VideoEditor() {
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
cursorHighlight,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -129,8 +194,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);
|
||||
@@ -141,11 +204,15 @@ export default function VideoEditor() {
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
const [gifLoop, setGifLoop] = useState(true);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>(
|
||||
DEFAULT_EXPORT_SETTINGS.quality,
|
||||
);
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>(DEFAULT_EXPORT_SETTINGS.format);
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(DEFAULT_GIF_SETTINGS.frameRate);
|
||||
const [gifLoop, setGifLoop] = useState(DEFAULT_GIF_SETTINGS.loop);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
|
||||
DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
);
|
||||
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
||||
const [unsavedExport, setUnsavedExport] = useState<{
|
||||
@@ -155,8 +222,39 @@ 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(DEFAULT_CURSOR_SETTINGS.show);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SETTINGS.size);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SETTINGS.smoothing);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_SETTINGS.motionBlur);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_SETTINGS.clickBounce);
|
||||
const [cursorClipToBounds, setCursorClipToBounds] = useState(
|
||||
DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
);
|
||||
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);
|
||||
@@ -164,12 +262,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],
|
||||
);
|
||||
// Native Windows recordings include captured cursor assets. Native macOS
|
||||
// recordings hide the system cursor in ScreenCaptureKit and use telemetry
|
||||
// samples with OpenScreen's default arrow asset for the editable overlay.
|
||||
const hasEditableCursorRecording =
|
||||
recordingCursorCaptureMode === "editable-overlay" &&
|
||||
(nativePlatform === "win32" || nativePlatform === "darwin") &&
|
||||
hasNativeCursorRecordingData(cursorRecordingData);
|
||||
const effectiveShowCursor = showCursor && hasEditableCursorRecording;
|
||||
const showCursorSettings = hasEditableCursorRecording;
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
@@ -196,10 +297,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) => {
|
||||
@@ -208,13 +317,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();
|
||||
@@ -223,13 +340,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({
|
||||
@@ -286,9 +404,11 @@ export default function VideoEditor() {
|
||||
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
{
|
||||
screenVideoPath: sourcePath,
|
||||
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
|
||||
...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}),
|
||||
},
|
||||
normalizedEditor,
|
||||
),
|
||||
);
|
||||
@@ -352,7 +472,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,
|
||||
@@ -374,31 +494,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.");
|
||||
@@ -469,7 +589,6 @@ export default function VideoEditor() {
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
cursorHighlight,
|
||||
};
|
||||
const projectData = createProjectData(currentProjectMedia, editorState);
|
||||
|
||||
@@ -481,7 +600,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),
|
||||
@@ -531,7 +650,6 @@ export default function VideoEditor() {
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
cursorHighlight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -587,7 +705,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;
|
||||
@@ -620,40 +738,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;
|
||||
@@ -756,11 +871,10 @@ export default function VideoEditor() {
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
// Bulk suggest must not steal selection — keeping a zoom selected hides
|
||||
// the export panel (SettingsPanel gates it on !hasTimelineSelection),
|
||||
// trapping users who just want to export after auto-zoom.
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -1411,11 +1525,21 @@ export default function VideoEditor() {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
|
||||
} else {
|
||||
toast.error(saveResult.message || "Failed to save export");
|
||||
toast.error(
|
||||
buildSaveDiagnosticMessage(
|
||||
unsavedExport.format === "gif" ? "GIF" : "Video",
|
||||
saveResult.message || "Failed to save export",
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving unsaved export:", error);
|
||||
toast.error("Failed to save exported video");
|
||||
toast.error(
|
||||
buildSaveDiagnosticMessage(
|
||||
unsavedExport.format === "gif" ? "GIF" : "Video",
|
||||
error instanceof Error ? error.message : "Failed to save exported video",
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [unsavedExport, handleExportSaved]);
|
||||
|
||||
@@ -1458,8 +1582,13 @@ export default function VideoEditor() {
|
||||
videoPlaybackRef.current?.pause();
|
||||
}
|
||||
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropRegion,
|
||||
);
|
||||
const aspectRatioValue =
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
|
||||
@@ -1468,8 +1597,8 @@ export default function VideoEditor() {
|
||||
// Get preview CONTAINER dimensions for scaling
|
||||
const playbackRef = videoPlaybackRef.current;
|
||||
const containerElement = playbackRef?.containerRef?.current;
|
||||
const previewWidth = containerElement?.clientWidth || 1920;
|
||||
const previewHeight = containerElement?.clientHeight || 1080;
|
||||
const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const previewHeight = containerElement?.clientHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
if (settings.format === "gif" && settings.gifConfig) {
|
||||
// GIF Export
|
||||
@@ -1493,6 +1622,12 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
cursorScale: effectiveShowCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1502,7 +1637,6 @@ export default function VideoEditor() {
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1527,93 +1661,38 @@ export default function VideoEditor() {
|
||||
handleExportSaved("GIF", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
|
||||
setExportError(saveResult.message || "Failed to save GIF");
|
||||
toast.error(saveResult.message || "Failed to save GIF");
|
||||
const message = buildSaveDiagnosticMessage(
|
||||
"GIF",
|
||||
saveResult.message || "Failed to save GIF",
|
||||
);
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || "GIF export failed");
|
||||
toast.error(result.error || "GIF export failed");
|
||||
const message = buildExportDiagnosticMessage({
|
||||
formatLabel: "GIF",
|
||||
reason: result.error || "GIF export failed",
|
||||
sourcePath: videoSourcePath ?? videoPath,
|
||||
width: settings.gifConfig.width,
|
||||
height: settings.gifConfig.height,
|
||||
frameRate: settings.gifConfig.frameRate,
|
||||
});
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} else {
|
||||
// MP4 Export
|
||||
const quality = settings.quality || exportQuality;
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === "source") {
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Use the source's longer dimension as the long axis of the export so
|
||||
// a landscape recording can still fill a portrait target (and vice versa).
|
||||
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportHeight = baseHeight;
|
||||
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Quality presets target the SHORT side; the long side derives from the
|
||||
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
|
||||
const targetShortDim = quality === "medium" ? 720 : 1080;
|
||||
|
||||
if (aspectRatioValue >= 1) {
|
||||
exportHeight = Math.floor(targetShortDim / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
} else {
|
||||
exportWidth = Math.floor(targetShortDim / 2) * 2;
|
||||
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
} else if (totalPixels <= 1920 * 1080) {
|
||||
bitrate = 20_000_000;
|
||||
} else {
|
||||
bitrate = 30_000_000;
|
||||
}
|
||||
}
|
||||
const {
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
bitrate,
|
||||
} = calculateMp4ExportSettings({
|
||||
quality,
|
||||
sourceWidth: effectiveSourceDimensions.width,
|
||||
sourceHeight: effectiveSourceDimensions.height,
|
||||
aspectRatioValue,
|
||||
});
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
@@ -1634,6 +1713,12 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
cursorScale: effectiveShowCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1643,7 +1728,6 @@ export default function VideoEditor() {
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1668,12 +1752,26 @@ export default function VideoEditor() {
|
||||
handleExportSaved("Video", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
|
||||
setExportError(saveResult.message || "Failed to save video");
|
||||
toast.error(saveResult.message || "Failed to save video");
|
||||
const message = buildSaveDiagnosticMessage(
|
||||
"Video",
|
||||
saveResult.message || "Failed to save video",
|
||||
);
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || "Export failed");
|
||||
toast.error(result.error || "Export failed");
|
||||
const message = buildExportDiagnosticMessage({
|
||||
formatLabel: "Video",
|
||||
reason: result.error || "Export failed",
|
||||
sourcePath: videoSourcePath ?? videoPath,
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
frameRate: 60,
|
||||
codec: "avc1.640033",
|
||||
bitrate,
|
||||
});
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1688,8 +1786,13 @@ export default function VideoEditor() {
|
||||
toast.error(message);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
setExportError(errorMessage);
|
||||
toast.error(t("errors.exportFailedWithError", { error: errorMessage }));
|
||||
const message = buildExportDiagnosticMessage({
|
||||
formatLabel: settings.format === "gif" ? "GIF" : "Video",
|
||||
reason: errorMessage,
|
||||
sourcePath: videoSourcePath ?? videoPath,
|
||||
});
|
||||
setExportError(message);
|
||||
toast.error(t("errors.exportFailedWithError", { error: message }));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
@@ -1702,6 +1805,7 @@ export default function VideoEditor() {
|
||||
},
|
||||
[
|
||||
videoPath,
|
||||
videoSourcePath,
|
||||
webcamVideoPath,
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
@@ -1713,6 +1817,7 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
cursorRecordingData,
|
||||
annotationRegions,
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
@@ -1724,7 +1829,12 @@ export default function VideoEditor() {
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
effectiveCursorHighlight,
|
||||
effectiveShowCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1742,15 +1852,20 @@ export default function VideoEditor() {
|
||||
}
|
||||
|
||||
// Build export settings from current state
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropRegion,
|
||||
);
|
||||
const aspectRatioValue =
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
|
||||
: getAspectRatioValue(aspectRatio);
|
||||
const gifDimensions = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
effectiveSourceDimensions.width,
|
||||
effectiveSourceDimensions.height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatioValue,
|
||||
@@ -1942,8 +2057,10 @@ export default function VideoEditor() {
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
@@ -1980,6 +2097,7 @@ export default function VideoEditor() {
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
@@ -1995,8 +2113,13 @@ export default function VideoEditor() {
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={effectiveShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2019,9 +2142,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={
|
||||
@@ -2105,14 +2225,28 @@ export default function VideoEditor() {
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).width,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
@@ -2142,6 +2276,22 @@ 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}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
onCursorClipToBoundsChange={setCursorClipToBounds}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
showCursorSettings={showCursorSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
|
||||
describe("parseCustomPlaybackSpeedInput", () => {
|
||||
it("accepts decimal playback speeds", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single decimal point while typing", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.23",
|
||||
speed: 1.23,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows sub-1 custom speeds down to the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "0.1",
|
||||
speed: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds below the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
|
||||
status: "too-slow",
|
||||
draft: "0.09",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts comma decimal input by normalizing to a dot", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds above the editor maximum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
|
||||
status: "too-fast",
|
||||
draft: "16.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
|
||||
export type CustomPlaybackSpeedInputResult =
|
||||
| { status: "empty"; draft: string }
|
||||
| { status: "too-fast"; draft: string }
|
||||
| { status: "too-slow"; draft: string }
|
||||
| { status: "valid"; draft: string; speed: PlaybackSpeed };
|
||||
|
||||
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
|
||||
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||||
const [whole = "", ...fractionParts] = decimalDraft.split(".");
|
||||
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
|
||||
|
||||
if (draft === "" || draft === ".") {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
const speed = Number(draft);
|
||||
if (!Number.isFinite(speed)) {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
if (speed > MAX_PLAYBACK_SPEED) {
|
||||
return { status: "too-fast", draft };
|
||||
}
|
||||
|
||||
if (speed < MIN_PLAYBACK_SPEED) {
|
||||
return { status: "too-slow", draft };
|
||||
}
|
||||
|
||||
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
import { DEFAULT_PREFS } from "@/lib/userPreferences";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { normalizeProjectEditor } from "./projectPersistence";
|
||||
|
||||
describe("editor defaults SSOT", () => {
|
||||
it("keeps history defaults aligned with editor defaults", () => {
|
||||
expect(INITIAL_EDITOR_STATE).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user preference defaults aligned with editor and export defaults", () => {
|
||||
expect(DEFAULT_PREFS).toMatchObject({
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps project fallback normalization aligned with editor defaults", () => {
|
||||
expect(normalizeProjectEditor({})).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type CursorVisualSettings,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
} from "./types";
|
||||
|
||||
export const DEFAULT_SOURCE_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_GIF_OUTPUT_DIMENSIONS = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
} = {
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
cropRegion: typeof DEFAULT_CROP_REGION;
|
||||
wallpaper: string;
|
||||
} = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBCAM_SETTINGS = {
|
||||
layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
maskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
sizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
position: DEFAULT_WEBCAM_POSITION,
|
||||
} as const satisfies {
|
||||
layoutPreset: WebcamLayoutPreset;
|
||||
maskShape: WebcamMaskShape;
|
||||
sizePreset: WebcamSizePreset;
|
||||
position: WebcamPosition | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = {
|
||||
show: true,
|
||||
size: DEFAULT_CURSOR_SIZE,
|
||||
smoothing: DEFAULT_CURSOR_SMOOTHING,
|
||||
motionBlur: DEFAULT_CURSOR_MOTION_BLUR,
|
||||
clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
};
|
||||
|
||||
export const DEFAULT_EXPORT_SETTINGS: {
|
||||
quality: ExportQuality;
|
||||
format: ExportFormat;
|
||||
} = {
|
||||
quality: "good",
|
||||
format: "mp4",
|
||||
};
|
||||
|
||||
export const DEFAULT_GIF_SETTINGS: {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS;
|
||||
} = {
|
||||
frameRate: 15,
|
||||
loop: true,
|
||||
sizePreset: "medium",
|
||||
outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS,
|
||||
};
|
||||
@@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
@@ -15,14 +22,10 @@ import {
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_MOTION_BLUR,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
@@ -80,7 +83,6 @@ export interface ProjectEditorState {
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
@@ -105,13 +107,13 @@ function computeNormalizedWebcamLayoutPreset(
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
: DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
case "dual-frame":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
||||
? DEFAULT_WEBCAM_SETTINGS.layoutPreset
|
||||
: webcamLayoutPreset;
|
||||
default:
|
||||
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
return DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +121,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 +138,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 +169,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");
|
||||
}
|
||||
}
|
||||
@@ -226,7 +214,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.aspectRatio as AspectRatio,
|
||||
)
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9";
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio;
|
||||
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
||||
editor.webcamLayoutPreset,
|
||||
normalizedAspectRatio,
|
||||
@@ -241,7 +229,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||
}
|
||||
: DEFAULT_WEBCAM_POSITION;
|
||||
: DEFAULT_WEBCAM_SETTINGS.position;
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
@@ -428,16 +416,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
||||
? editor.cropRegion.x
|
||||
: DEFAULT_CROP_REGION.x;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
||||
? editor.cropRegion.y
|
||||
: DEFAULT_CROP_REGION.y;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
||||
? editor.cropRegion.width
|
||||
: DEFAULT_CROP_REGION.width;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height;
|
||||
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
@@ -448,18 +436,29 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
wallpaper:
|
||||
typeof editor.wallpaper === "string"
|
||||
? normalizeWallpaperValue(editor.wallpaper)
|
||||
: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
shadowIntensity:
|
||||
typeof editor.shadowIntensity === "number"
|
||||
? editor.shadowIntensity
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
|
||||
showBlur:
|
||||
typeof editor.showBlur === "boolean"
|
||||
? editor.showBlur
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
||||
? 0.35
|
||||
: 0
|
||||
: 0,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
? DEFAULT_ZOOM_MOTION_BLUR
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
|
||||
borderRadius:
|
||||
typeof editor.borderRadius === "number"
|
||||
? editor.borderRadius
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
|
||||
padding: isFiniteNumber(editor.padding)
|
||||
? clamp(editor.padding, 0, 100)
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
@@ -478,77 +477,31 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "square" ||
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: normalizedWebcamPosition,
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" ||
|
||||
editor.gifSizePreset === "large" ||
|
||||
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,
|
||||
: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const SUGGESTION_SPACING_MS = 1800;
|
||||
|
||||
interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
hasVideoSource?: boolean;
|
||||
currentTime: number;
|
||||
onSeek?: (time: number) => void;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
@@ -236,6 +237,31 @@ function formatPlayheadTime(ms: number): string {
|
||||
return `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function shouldStartTimelineScrub(target: EventTarget | null, timelineElement: HTMLElement) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let element: HTMLElement | null = target; element && element !== timelineElement; ) {
|
||||
const className = element.className;
|
||||
const classText = typeof className === "string" ? className : "";
|
||||
|
||||
if (
|
||||
classText.split(/\s+/).includes("group") ||
|
||||
classText.includes("cursor-grab") ||
|
||||
classText.includes("cursor-grabbing") ||
|
||||
classText.includes("cursor-ew-resize") ||
|
||||
element.style.cursor === "col-resize"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
@@ -562,6 +588,8 @@ function Timeline({
|
||||
const t = useScopedT("timeline");
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const isScrubbingTimelineRef = useRef(false);
|
||||
const scrubPointerIdRef = useRef<number | null>(null);
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -571,43 +599,105 @@ function Timeline({
|
||||
[setTimelineRef],
|
||||
);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
const seekTimelineAtClientX = useCallback(
|
||||
(timelineElement: HTMLDivElement, clientX: number) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return false;
|
||||
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
const rect = timelineElement.getBoundingClientRect();
|
||||
const clickX = clientX - rect.left - sidebarWidth;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
if (clickX < 0) return;
|
||||
if (clickX < 0) return false;
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
onSeek(absoluteMs / 1000);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
pixelsToValue,
|
||||
],
|
||||
[onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start],
|
||||
);
|
||||
|
||||
const clearTimelineSelection = useCallback(() => {
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
}, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
clearTimelineSelection();
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
},
|
||||
[clearTimelineSelection, seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelinePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldStartTimelineScrub(e.target, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seekTimelineAtClientX(e.currentTarget, e.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimelineSelection();
|
||||
isScrubbingTimelineRef.current = true;
|
||||
scrubPointerIdRef.current = e.pointerId;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
},
|
||||
[clearTimelineSelection, seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelinePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
e.preventDefault();
|
||||
},
|
||||
[seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const stopTimelineScrub = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isScrubbingTimelineRef.current = false;
|
||||
scrubPointerIdRef.current = null;
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelinePointerLeave = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isScrubbingTimelineRef.current && scrubPointerIdRef.current === e.pointerId) {
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
}
|
||||
},
|
||||
[seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelineLostPointerCapture = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (scrubPointerIdRef.current === e.pointerId) {
|
||||
isScrubbingTimelineRef.current = false;
|
||||
scrubPointerIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelineWheel = useCallback(
|
||||
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
||||
@@ -657,9 +747,15 @@ function Timeline({
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
style={{ ...style, touchAction: "none" }}
|
||||
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
||||
onClick={handleTimelineClick}
|
||||
onPointerDown={handleTimelinePointerDown}
|
||||
onPointerMove={handleTimelinePointerMove}
|
||||
onPointerUp={stopTimelineScrub}
|
||||
onPointerCancel={stopTimelineScrub}
|
||||
onPointerLeave={handleTimelinePointerLeave}
|
||||
onLostPointerCapture={handleTimelineLostPointerCapture}
|
||||
onWheel={handleTimelineWheel}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
||||
@@ -766,6 +862,7 @@ function Timeline({
|
||||
|
||||
export default function TimelineEditor({
|
||||
videoDuration,
|
||||
hasVideoSource = false,
|
||||
currentTime,
|
||||
onSeek,
|
||||
cursorTelemetry = [],
|
||||
@@ -1439,8 +1536,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,36 @@ 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;
|
||||
clipToBounds: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
// false = allow the cursor to overflow into the background by default.
|
||||
// true = clip the native cursor to the video canvas bounds.
|
||||
export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false;
|
||||
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();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ interface LayoutResult {
|
||||
baseScale: number;
|
||||
baseOffset: { x: number; y: number };
|
||||
maskRect: RenderRect;
|
||||
maskBorderRadius: number;
|
||||
webcamRect: StyledRenderRect | null;
|
||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||
}
|
||||
@@ -150,6 +151,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
baseScale: scale,
|
||||
baseOffset: { x: spriteX, y: spriteY },
|
||||
maskRect: compositeLayout.screenRect,
|
||||
maskBorderRadius:
|
||||
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
|
||||
webcamRect: compositeLayout.webcamRect,
|
||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||