merge: upstream main into feature/video-transcriptions

Merge siddharthvaddem/openscreen main and resolve conflicts in VideoEditor.tsx (export diagnostics, save-diagnostic handler, auto captions, toolbar chrome) and package-lock.json. Apply Biome formatting after merge so hooks pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parse-nip
2026-05-14 11:34:11 +08:00
163 changed files with 17493 additions and 2986 deletions
+118
View File
@@ -0,0 +1,118 @@
name: Bump Nix package on release
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to bump (e.g. v1.5.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
bump:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
- name: Resolve tag and version
id: meta
env:
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved from release event or workflow input"
exit 1
fi
VERSION="${TAG#v}"
BRANCH="chore/bump-nix-${VERSION}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: |
experimental-features = nix-command flakes
- name: Compute npmDepsHash
id: hash
run: |
set -euo pipefail
HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
if [[ -z "$HASH" ]]; then
echo "::error::prefetch-npm-deps returned an empty hash"
exit 1
fi
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: Update nix/package.nix
env:
VERSION: ${{ steps.meta.outputs.version }}
HASH: ${{ steps.hash.outputs.hash }}
run: |
set -euo pipefail
# Update version line: ` version = "<anything>";`
sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix
# Update npmDepsHash line: ` npmDepsHash = "<anything>";`
sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix
echo "=== diff ==="
git --no-pager diff nix/package.nix || true
- name: Create PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.meta.outputs.version }}
HASH: ${{ steps.hash.outputs.hash }}
BRANCH: ${{ steps.meta.outputs.branch }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
set -euo pipefail
if git diff --quiet -- nix/package.nix; then
echo "nix/package.nix already at v${VERSION} with this hash — nothing to do."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Replace any prior bump branch to keep the workflow idempotent.
git push origin --delete "$BRANCH" 2>/dev/null || true
git checkout -b "$BRANCH"
git add nix/package.nix
git commit -m "chore: bump nix package to v${VERSION}"
git push -u origin "$BRANCH"
gh pr create \
--title "chore: bump nix package to v${VERSION}" \
--base main \
--head "$BRANCH" \
--body "$(cat <<EOF
Automated bump triggered by release \`${TAG}\`.
- \`version\` → \`${VERSION}\`
- \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`)
Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release.
> Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR.
EOF
)"
+1
View File
@@ -41,6 +41,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test
- run: npm run test:browser:install
- run: npm run test:browser
+26
View File
@@ -0,0 +1,26 @@
name: Publish release to WinGet
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to winget (e.g. v1.4.0)"
required: true
type: string
jobs:
publish:
runs-on: windows-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: SiddharthVaddem.OpenScreen
# Match the Windows installer asset attached to each release.
# Today: "Openscreen.Setup.latest.exe". Adjust this regex if you
# ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe").
installers-regex: 'Setup\..*\.exe$'
release-tag: ${{ inputs.tag || github.event.release.tag_name }}
token: ${{ secrets.WINGET_ACC_TOKEN }}
+168
View File
@@ -0,0 +1,168 @@
name: Update Homebrew Cask
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to the tap (e.g. v1.4.0)"
required: true
type: string
permissions:
contents: read
jobs:
update-cask:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
env:
TAP_OWNER: siddharthvaddem
TAP_REPO: homebrew-openscreen
CASK_NAME: openscreen
steps:
- name: Resolve tag and version
id: meta
env:
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved from release event or workflow input"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Find macOS DMG assets
id: assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')
# arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg
# whose name does NOT contain "x64" or non-mac platform markers.
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true)
if [[ -z "$ARM_NAME" ]]; then
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true)
fi
# x64 DMG
X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true)
if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then
echo "::error::Could not locate both arm64 and x64 DMGs in release assets"
echo "Available assets:"
echo "$NAMES"
exit 1
fi
echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT"
echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT"
echo "Found arm64 asset: $ARM_NAME"
echo "Found x64 asset: $X64_NAME"
- name: Download DMGs and compute sha256
id: shas
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
run: |
set -euo pipefail
BASE="https://github.com/${REPO}/releases/download/${TAG}"
curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}"
curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}"
ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}')
X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}')
echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT"
echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT"
- name: Checkout tap
uses: actions/checkout@v4
with:
repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }}
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
- name: Write cask file
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
VERSION: ${{ steps.meta.outputs.version }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
ARM_SHA: ${{ steps.shas.outputs.arm_sha }}
X64_SHA: ${{ steps.shas.outputs.x64_sha }}
run: |
set -euo pipefail
mkdir -p tap/Casks
BASE="https://github.com/${REPO}/releases/download/${TAG}"
# #{version} is Ruby interpolation written literally to the cask
# file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA},
# etc. are bash variables expanded by the heredoc. The literal
# #{version} fixes Homebrew's "URL is unversioned" audit warning by
# making the version string statically detectable.
cat > "tap/Casks/${CASK_NAME}.rb" <<EOF
cask "${CASK_NAME}" do
version "${VERSION}"
on_arm do
sha256 "${ARM_SHA}"
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
end
on_intel do
sha256 "${X64_SHA}"
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}"
end
name "Openscreen"
desc "Screen recorder and video editor"
homepage "https://github.com/${REPO}"
auto_updates false
depends_on macos: ">= :big_sur"
app "Openscreen.app"
zap trash: [
"~/Library/Application Support/Openscreen",
"~/Library/Caches/com.siddharthvaddem.openscreen",
"~/Library/Logs/Openscreen",
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
"~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState",
]
end
EOF
- name: Commit and push to tap
working-directory: tap
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "Casks/${CASK_NAME}.rb"
if git diff --cached --quiet; then
echo "Cask already up to date for ${VERSION} — nothing to commit."
exit 0
fi
git commit -m "Bump ${CASK_NAME} to ${VERSION}"
git push
+52 -45
View File
@@ -1,45 +1,52 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
.zed/
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
# shell files
/shell.sh
# Nix
result
result-*
.direnv/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
.env
# Native helper build outputs
/electron/native/wgc-capture/build/
/electron/native/bin/
# Editor directories and files
.vscode/*
.zed/
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
# shell files
/shell.sh
# Nix
result
result-*
.direnv/
#kilocode
.kilo/
+87 -17
View File
@@ -1,5 +1,5 @@
> [!WARNING]
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.
<p align="center">
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
@@ -21,11 +21,11 @@
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well!
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.
<p align="center">
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
@@ -33,16 +33,19 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
</p>
## Core Features
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record a specific window, region, or your whole screen.
- Record microphone and system audio.
- Crop video recordings to hide parts.
- Choose between wallpapers, solid colors, gradients or a custom background.
- Motion blur for smoother pan and zoom effects.
- Add annotations (text, arrows, images).
- Trim sections of the clip.
- Customize the speed of different segments.
- Export in different aspect ratios and resolutions.
- Webcam overlay with picture-in-picture, drag-to-position, and shape options.
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position.
- Wallpapers, solid colors, gradients, or a custom background.
- Motion blur for smoother pan and zoom transitions.
- Crop, trim, and per-segment speed control on the timeline.
- Blur effects to hide sensitive parts of the screen.
- Cursor and click highlighting.
- Text, arrow, and image annotations.
- Save and reopen projects without re-recording.
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
## Installation
@@ -50,7 +53,20 @@ Download the latest installer for your platform from the [GitHub Releases](https
### macOS
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
The easiest way to install on macOS is via [Homebrew](https://brew.sh):
```bash
brew install --cask siddharthvaddem/openscreen/openscreen
```
Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it.
To update later: `brew upgrade --cask openscreen`
To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data)
#### Manual install (if you prefer)
If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation:
```bash
xattr -rd com.apple.quarantine /Applications/Openscreen.app
@@ -60,18 +76,72 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
### Windows
Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
```bash
winget install SiddharthVaddem.OpenScreen
```
To update later: `winget upgrade SiddharthVaddem.OpenScreen`
To uninstall: `winget uninstall SiddharthVaddem.OpenScreen`
If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases).
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro:
**Debian / Ubuntu / Pop!_OS (`.deb`)**
```bash
sudo apt install ./Openscreen-Linux-latest.deb
```
**Arch / Manjaro (`.pacman`)**
```bash
sudo pacman -U Openscreen-Linux-latest.pacman
```
**Any distro (`.AppImage`)**
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
**NixOS / Nix (flake)**
Try without installing:
```bash
nix run github:siddharthvaddem/openscreen
```
Install into your user profile:
```bash
nix profile install github:siddharthvaddem/openscreen
```
For a NixOS system config (flake):
```nix
{
inputs.openscreen.url = "github:siddharthvaddem/openscreen";
outputs = { nixpkgs, openscreen, ... }: {
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
modules = [
openscreen.nixosModules.default
{ programs.openscreen.enable = true; }
];
};
};
}
```
For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`.
You may need to grant screen recording permissions depending on your desktop environment.
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`:
```bash
./Openscreen-Linux-*.AppImage --no-sandbox
```
@@ -94,16 +164,16 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron
---
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
## Documentation
See the documentation here:
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
Refresh if outdated.
## Contributing
Contributions are welcome! If youd like to help out or see whats currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
## Star History
+39
View File
@@ -0,0 +1,39 @@
# Native Bridge Architecture
## Goal
Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified.
## Layers
1. Native adapters
Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery.
2. Main-process services
Services orchestrate adapters, own runtime state, and expose domain-level operations.
3. Unified IPC transport
Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts.
4. Renderer client
React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs.
## Principles
- Single source of truth: runtime-native state lives in the Electron main process.
- Capability-first: renderer can query support before attempting native behavior.
- Versioned contracts: requests and responses are explicit and evolve predictably.
- Resilience: every response uses a consistent result envelope with stable error codes.
## Current rollout
This repository now contains the initial scaffold:
- shared contracts in `src/native/contracts.ts`
- renderer SDK in `src/native/client.ts`
- main-process state store in `electron/native-bridge/store.ts`
- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts`
- domain services in `electron/native-bridge/services/*`
- unified handler registration in `electron/ipc/nativeBridge.ts`
The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client.
@@ -0,0 +1,248 @@
# Windows Native Recorder Roadmap
OpenScreen's Windows recorder should be owned by one native backend. Electron capture can remain available for non-Windows platforms and temporary developer diagnostics, but Windows production recording should not silently fall back to `getDisplayMedia` / `MediaRecorder`.
## Goals
- Capture displays and windows through Windows Graphics Capture (WGC).
- Render the native Windows cursor as OpenScreen's high-quality scalable cursor overlay.
- Capture system audio through WASAPI loopback.
- Capture microphone audio through WASAPI.
- Mix system audio and microphone audio into the primary screen recording.
- Capture webcam video natively and compose it into the Windows helper MP4 during the native-recording migration.
- Keep preview/export aligned because screen video, audio, webcam, and cursor share one native timing origin.
- Keep exported MP4s Windows-friendly: H.264 video plus AAC audio. Opus-in-MP4 is not an acceptable Windows export target.
- Package the native helper with the Windows app.
## Non-Goals
- Replacing the editor/export pipeline.
- Replacing the editor/export pipeline. A later pass can reintroduce a separate editable native `webcamVideoPath`; the current Windows-native milestone prioritizes a helper-owned multi-flux MP4 with deterministic screen/audio/mic/webcam sync.
- Adding a native fallback for macOS or Linux in this branch.
## Target Architecture
The renderer keeps the existing recording controls. On Windows, `useScreenRecorder` sends a complete recording request to Electron and does not assemble Windows `MediaStream` tracks with `MediaRecorder`.
Electron owns the native recording session:
- resolves the selected source;
- resolves output paths;
- starts cursor sampling;
- starts the helper process;
- sends pause/resume/stop/cancel commands;
- writes `RecordingSession` manifests;
- reports explicit errors when a Windows-native capability is unavailable.
The helper owns Windows media capture:
- WGC screen/window frames;
- WASAPI system loopback;
- WASAPI microphone input;
- Media Foundation webcam capture;
- DirectShow webcam fallback for virtual cameras not visible to Media Foundation;
- Media Foundation encoding/muxing;
- stream timestamp normalization.
## Helper Contract V2
The helper receives a single JSON argument:
```json
{
"schemaVersion": 2,
"recordingId": 1234567890,
"source": {
"type": "display",
"sourceId": "screen:0:0",
"displayId": 123,
"windowHandle": null,
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
},
"video": {
"fps": 60,
"width": 1920,
"height": 1080,
"bitrate": 18000000
},
"audio": {
"system": { "enabled": true },
"microphone": { "enabled": true, "deviceId": "default", "gain": 1.4 }
},
"webcam": {
"enabled": true,
"deviceId": "default",
"deviceName": "Camera (NVIDIA Broadcast)",
"width": 1280,
"height": 720,
"fps": 30,
"bitrate": 18000000
},
"outputs": {
"screenPath": "C:\\Users\\me\\recording-123.mp4",
"manifestPath": "C:\\Users\\me\\recording-123.session.json"
}
}
```
The helper emits newline-delimited JSON events to stdout:
```json
{ "event": "ready", "schemaVersion": 2 }
{ "event": "recording-started", "timestampMs": 1234567890 }
{ "event": "warning", "code": "audio-device-unavailable", "message": "..." }
{ "event": "recording-stopped", "screenPath": "..." }
{ "event": "error", "code": "unsupported-window-source", "message": "..." }
```
During migration, Electron also accepts the current textual helper messages so existing display-only smoke tests keep working.
## Implementation Phases
### 1. Native Session Boundary
- Add a structured Windows native recording request type.
- Pass source kind, audio flags, microphone device, webcam flags, and output paths into the helper.
- On Windows, do not silently fall back to Electron capture. If the helper is unavailable or a native feature is missing, show a clear error.
- Keep Electron fallback only for non-Windows and optional developer diagnostics.
Acceptance:
- Display-only recording still works.
- Enabling an unsupported native feature returns an explicit native error instead of recording through Electron.
### 2. WASAPI System Audio
Status: initial implementation landed. The helper captures the default render endpoint with WASAPI loopback, passes the runtime mix format into `MFEncoder`, and muxes AAC audio into the primary MP4. Long-run drift correction and explicit silence insertion remain follow-up hardening work.
- Add `WasapiLoopbackCapture`.
- Capture the default render endpoint in shared loopback mode.
- Keep `WasapiLoopbackCapture` responsible only for device activation, packet capture, and packet timestamps.
- Keep `MFEncoder` responsible for all Media Foundation stream definitions and muxing.
- Feed the endpoint mix format into `MFEncoder` as the single source of truth for audio stream shape: sample rate, channel count, bits per sample, block alignment, average bytes/sec, and subtype (`PCM` or `Float`).
- Encode the primary screen MP4 with H.264 video and AAC audio through one `IMFSinkWriter`.
- Timestamp audio from the captured frame count in 100ns units. The first implementation uses the WASAPI packet timeline; later drift correction will add explicit silence or resampling if long recordings show measurable clock skew.
- Treat microphone mixing as a later phase. System loopback must land first without introducing renderer-side audio code.
Acceptance:
- Screen MP4 has an AAC audio track when system audio is enabled.
- A 5-minute recording has audio/video duration drift below one frame.
SSOT rules for this phase:
- `src/lib/nativeWindowsRecording.ts` is the renderer/main TypeScript request contract.
- `docs/engineering/windows-native-recorder-roadmap.md` is the feature-level contract and phase checklist.
- `WgcSession::captureWidth()/captureHeight()` is the encoded screen frame size until a dedicated native scaling stage exists.
- `WasapiLoopbackCapture::inputFormat()` is the runtime audio format source used by `MFEncoder`.
- The renderer passes both the browser webcam `deviceId` and selected display label as `deviceName`; `electron/native/wgc-capture/src/webcam_capture.*` is the only place that maps those values to Media Foundation devices.
- Electron resolves the selected label to a DirectShow filter CLSID once and passes it as `webcamDirectShowClsid`; the helper must not independently guess among DirectShow filters.
- No duplicated hard-coded audio format assumptions in `main.cpp`.
### 3. WASAPI Microphone
Status: initial implementation in progress. The helper can open the default WASAPI capture endpoint, apply the OpenScreen microphone gain, encode mic-only audio, and mix system loopback plus microphone through a single queued `AudioMixer` timeline when both endpoints expose the same runtime format. Audio endpoints are warmed before WGC starts, the mixer drops pre-roll and begins its paced timeline on the first encoded video frame, then cuts queued tail audio on stop so the MP4 does not drift past the video. Browser `deviceId` to MMDevice id mapping, resampling between mismatched endpoint formats, and drift correction remain follow-up hardening work.
- Add microphone device enumeration and stable device-id mapping.
- Capture selected/default microphone through WASAPI.
- Apply OpenScreen's current mic gain policy.
- Mix microphone and system audio before AAC encoding.
Acceptance:
- Mic-only, system-only, and mixed audio recordings produce a valid AAC track.
- Device unplug/permission failure produces an explicit error or warning.
### 4. Webcam Capture
- Add Media Foundation webcam source reader.
- Select requested dimensions/fps or the nearest format accepted by Media Foundation.
- Convert webcam samples to BGRA and compose them into the primary helper MP4 as an initial bottom-right picture-in-picture overlay.
- Ignore black webcam warmup frames and keep the overlay hidden until the first visible frame is available, so virtual cameras do not flash a black picture-in-picture rectangle at recording start.
- Keep the helper process as the SSOT for screen/window, WASAPI system audio, microphone, webcam, and mux timing.
- Match the requested webcam through Media Foundation friendly names first, then browser device ids/symbolic links, so UI selection remains stable across Chromium and Windows native device namespaces.
- Use the Electron-resolved DirectShow CLSID when the selected virtual camera, for example NVIDIA Broadcast, is registered for DirectShow but absent from Media Foundation enumeration.
- Later: promote the same webcam capture source to a separate editable native `webcamVideoPath` if product requirements need post-recording layout edits.
Acceptance:
- Native display/window recordings can include webcam without returning to Electron capture.
- `npm run test:wgc-webcam:win` validates the helper path when a webcam is available and skips explicitly when no webcam device exists.
- Combined webcam + system audio + microphone produces one MP4 with H.264 video and AAC audio.
### 5. Native Window Capture
Status: initial implementation in progress. Electron parses the `window:<HWND>:...` desktop source id through the shared native Windows recording contract and passes `windowHandle` to the helper. The helper resolves the `HWND`, validates it with `IsWindow`, and creates the WGC item with `CreateForWindow(HWND)`. Resize/minimize/move hardening and protected-window diagnostics remain follow-up work.
- Resolve Electron `window:*` selections to an `HWND`.
- Use WGC `CreateForWindow(HWND)`.
- Handle window close, minimize, resize, DPI scaling, and monitor moves.
- Return clear errors for unsupported protected windows.
Acceptance:
- Capturing a normal app window works with cursor/audio/mic/webcam.
- Window resize and movement do not corrupt the recording.
### 6. Runtime Controls
- Add pause/resume commands to the helper.
- Add cancel command that removes partial screen/webcam outputs.
- Keep restart as stop-discard-start from Electron until the helper supports a native restart event.
Acceptance:
- Pause/resume keeps preview duration coherent.
- Cancel leaves no stale media/session/cursor files.
### 7. Test Pipeline
- `npm run test:wgc-helper:win`: display-only helper smoke test.
- `npm run test:wgc-audio:win`: validates AAC track presence and duration.
- `npm run test:wgc-window:win`: captures a fixture window by HWND.
- `npm run test:wgc-webcam:win`: validates webcam output when a webcam is available, otherwise skips explicitly.
- Packaging check: confirms the helper is in `app.asar.unpacked`.
- Export check: exported MP4s generated from native recordings keep an AAC audio track when the source has audio.
- `npm run test:wgc-mic:win`: validates default-microphone capture writes an AAC track when an input endpoint is available.
- `npm run test:wgc-mixed-audio:win`: validates system loopback plus microphone writes one mixed AAC track when endpoint formats are compatible.
## Backlog
### Native Cursor Click Bounce Is Not Visibly Applied
Status: open. Do not treat Windows native cursor `Click Bounce` as shipped.
Problem:
- The cursor settings UI exposes `Size`, `Smoothing`, `Motion Blur`, and `Click Bounce`.
- On Windows native cursor recordings, `Size`, `Smoothing`, and `Motion Blur` are visibly applied in preview/export.
- `Click Bounce` still has no visible effect in manual packaged-app testing, even after adding click-related sample metadata.
What has already been tried:
- Added `interactionType: "click" | "mouseup" | "move"` to native cursor samples.
- Added polling-based left-button state through `GetAsyncKeyState`.
- Added the `GetAsyncKeyState` low-bit path to catch quick clicks between samples.
- Added a PowerShell/C# `WH_MOUSE_LL` mouse hook experiment and launched the sampler through a temporary `.ps1` file to avoid Windows command-line length limits.
- Updated `npm run test:cursor-native:win` so the diagnostic can observe a synthetic short click and emit `clickSampleCount`.
Current diagnosis:
- The diagnostic can observe synthetic click events, but this has not translated into a visible `Click Bounce` effect in the real packaged app.
- The test currently proves that some click metadata can be recorded, not that the full OpenScreen record -> preview -> export path displays a bounce at the expected time.
- The current native implementation may be animating from metadata that is not present in the real recording session, may be using the wrong timestamp origin, or may be applying a scale change too subtle to notice on the DOM/native cursor path.
Next investigation when resumed:
- Inspect the actual `.cursor.json`/session sidecar generated by a packaged-app manual recording and confirm whether real clicks produce `interactionType: "click"` at the right `timeMs`.
- Add a targeted end-to-end fixture that records a known click, loads the generated project, and asserts the preview/export cursor scale changes across adjacent frames.
- Compare the native DOM cursor path against the older `PixiCursorOverlay` click visual state and decide whether native cursor bounce should be a scale-only animation, an additional click ring, or a short explicit keyframe animation independent of sample cadence.
- If event capture remains unreliable in the PowerShell sampler, move click events into a small native cursor helper instead of PowerShell/C# script injection.
## Ship Criteria
- Windows display capture works with cursor, system audio, microphone, and webcam.
- Windows window capture works with cursor, system audio, microphone, and webcam.
- Preview and export show no cursor position drift.
- Preview and export show no measurable audio/video/webcam drift.
- Windows production builds do not depend on Electron capture fallback.
+129
View File
@@ -0,0 +1,129 @@
# Windows native cursor test pipeline
This branch includes two Windows-focused diagnostics for fast iteration on native cursor capture and rendering. They are intentionally local developer tools: they create short videos and JSON reports so cursor changes can be inspected without doing a full manual record/edit/export cycle.
## Native sampler diagnostic
```powershell
npm run test:cursor-native:win
```
This script does not launch OpenScreen. It:
- starts a Windows `GetCursorInfo` sampler
- moves the real OS pointer with `SetCursorPos`
- captures native cursor handles, hotspots, assets, and standard `IDC_*` cursor types
- writes normalized `CursorRecordingData`
- generates an abstract preview video
- generates a real-screen preview video using screenshots of the current desktop
The output directory is printed in the command result, for example:
```text
C:\Users\<user>\AppData\Local\Temp\openscreen-cursor-native-...
```
Useful files:
- `report.json`: sample counts, asset counts, cursor handles, and generated artifact paths
- `cursor-recording-data.json`: sidecar-compatible cursor data
- `preview.webm`: abstract path/asset/hotspot preview
- `real-capture-preview.webm`: real desktop screenshot background with reconstructed cursor overlay
- `assets/*.png`: raw cursor bitmaps captured from Windows
Environment overrides:
```powershell
$env:CURSOR_TEST_DURATION_MS = "3000"
$env:CURSOR_TEST_SAMPLE_INTERVAL_MS = "16"
$env:CURSOR_TEST_SCREEN_FRAME_INTERVAL_MS = "80"
$env:CURSOR_TEST_OUTPUT_DIR = "C:\temp\openscreen-cursor-test"
npm run test:cursor-native:win
```
## OpenScreen preview capture
```powershell
npm run capture:openscreen-preview
```
This script launches the real Electron app, injects a fixture video plus cursor sidecar data, opens the editor, captures frames from the actual OpenScreen preview UI, and encodes them into a WebM.
By default it uses the latest `cursor-recording-data.json` generated by `npm run test:cursor-native:win`. To force a specific sidecar:
```powershell
$env:CURSOR_RECORDING_DATA_PATH = "C:\path\to\cursor-recording-data.json"
npm run capture:openscreen-preview
```
Useful environment overrides:
```powershell
$env:OPENSCREEN_PREVIEW_SKIP_BUILD = "true"
$env:OPENSCREEN_PREVIEW_FRAME_COUNT = "120"
$env:OPENSCREEN_PREVIEW_FPS = "30"
$env:OPENSCREEN_PREVIEW_OUTPUT_DIR = "C:\temp\openscreen-preview"
npm run capture:openscreen-preview
```
Useful files:
- `openscreen-preview.webm`: video of the real OpenScreen editor preview
- `frames/*.png`: captured preview frames
- `report.json`: fixture paths, source sidecar, frame count, and output path
## What these tests validate
Together, the scripts make it quick to inspect:
- whether Windows cursor samples are visible and continuous
- whether native hotspots stay anchored when scaling to `3x`
- whether standard Windows cursors are recognized via `IDC_*`
- whether high-quality SVG cursor replacements follow the native hotspot
- whether the real OpenScreen preview renders the same cursor behavior as the diagnostic pipeline
They are not a full substitute for an end-to-end manual recording pass. Before shipping cursor changes, also test a real capture session and export from the packaged app.
## Known Gap
Windows native cursor `Click Bounce` is currently backlogged. `Size`, `Smoothing`, and `Motion Blur` can be validated through preview/export, but `Click Bounce` has not shown a visible effect in packaged-app manual testing. The current diagnostic can observe synthetic click metadata, but that is not enough to validate the real OpenScreen record -> preview -> export path.
Track the open item in `docs/engineering/windows-native-recorder-roadmap.md` under `Native Cursor Click Bounce Is Not Visibly Applied`.
## Native Windows capture backend
The app now routes Windows recordings through an external WGC helper instead of Electron `getDisplayMedia`. This is meant to remove the coordinate and clock split that made the reconstructed cursor drift in the preview/export path.
Current native availability rules:
- Windows 10 build 19041 or newer
- a helper executable is available
The helper currently implements display/window video capture, system audio loopback, default microphone capture, Media Foundation webcam capture, and DirectShow fallback for selected virtual cameras such as NVIDIA Broadcast. Webcam frames are composed into the primary MP4 as a bottom-right picture-in-picture overlay, and black webcam warmup frames are ignored until the first visible frame is available.
Build OpenScreen's helper locally:
```powershell
npm run build:native:win
```
Smoke-test the helper directly:
```powershell
npm run test:wgc-helper:win
npm run test:wgc-window:win
npm run test:wgc-audio:win
npm run test:wgc-mic:win
npm run test:wgc-mixed-audio:win
npm run test:wgc-webcam:win
```
For local diagnostics with another compatible helper, point OpenScreen at that executable:
```powershell
$env:OPENSCREEN_WGC_CAPTURE_EXE = "C:\path\to\wgc-capture.exe"
npm run build-vite
npm run dev
```
The helper receives one JSON config argument, emits JSON lifecycle events, prints the legacy `Recording started` marker, accepts `stop` on stdin, and prints `Recording stopped. Output path: <path>`. See `electron/native/README.md` for the exact contract and build output paths.
+149
View File
@@ -0,0 +1,149 @@
# Writing Tests
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
## Unit tests
**Config:** `vitest.config.ts`
**Runs in:** jsdom (simulated DOM, no real browser)
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
**CI command:** `npm run test`
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
### File placement
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
```
src/lib/compositeLayout.ts
src/lib/compositeLayout.test.ts # co-located
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
```
### Example
```ts
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
});
```
### Path aliases
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
```ts
import { SUPPORTED_LOCALES } from "@/i18n/config";
```
### Running locally
```bash
npm run test # run once
npm run test:watch # watch mode
```
---
## Browser tests
**Config:** `vitest.browser.config.ts`
**Runs in:** real Chromium via Playwright (headless)
**File pattern:** `src/**/*.browser.test.ts`
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
### File placement
Name the file `<subject>.browser.test.ts` and place it next to the source file.
```
src/lib/exporter/videoExporter.ts
src/lib/exporter/videoExporter.browser.test.ts
```
### Loading fixture assets
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
```ts
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
```
### Example
```ts
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
});
});
```
### Timeouts
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
### Running locally
First install the browser (one-time):
```bash
npm run test:browser:install
```
Then run the tests:
```bash
npm run test:browser
```
---
## Choosing the right type
| Situation | Use |
|---|---|
| Pure function / data transformation | Unit test |
| i18n key coverage | Unit test |
| React hook logic (no real browser APIs) | Unit test |
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
| File export producing a real `Blob` | Browser test |
+10 -4
View File
@@ -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",
@@ -51,8 +50,8 @@
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
"linux": {
@@ -69,7 +68,14 @@
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
"icon": "icons/icons/win/icon.ico",
"extraResources": [
{
"from": "electron/native/bin",
"to": "electron/native/bin",
"filter": ["**/*"]
}
]
},
"nsis": {
"oneClick": false,
+53 -9
View File
@@ -24,6 +24,9 @@ declare namespace NodeJS {
// Used in Renderer process, expose in `preload.ts`
interface Window {
electronAPI: {
invokeNativeBridge: <TData = unknown>(
request: import("../src/native/contracts").NativeBridgeRequest,
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
@@ -37,11 +40,6 @@ interface Window {
status: string;
error?: string;
}>;
requestAccessibilityAccess: () => Promise<{
success: boolean;
granted: boolean;
error?: string;
}>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
@@ -68,7 +66,29 @@ interface Window {
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
setRecordingState: (
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => Promise<void>;
isNativeWindowsCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
helperPath?: string;
reason?: string;
error?: string;
}>;
startNativeWindowsRecording: (
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
stopNativeWindowsRecording: (discard?: boolean) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
discarded?: boolean;
error?: string;
}>;
discardCursorTelemetry: (recordingId: number) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
@@ -79,10 +99,25 @@ interface Window {
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
pickExportSavePath: (
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
exportFolder?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
writeExportToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
@@ -142,6 +177,7 @@ interface Window {
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
@@ -149,7 +185,15 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
onRequestCloseConfirm: (callback: () => void) => () => void;
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
setLocale: (locale: string) => Promise<void>;
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
};
}
+6 -2
View File
@@ -15,12 +15,14 @@ import commonKo from "../src/i18n/locales/ko-KR/common.json";
import dialogsKo from "../src/i18n/locales/ko-KR/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";
import dialogsVi from "../src/i18n/locales/vi/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
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";
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
@@ -34,6 +36,7 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
"ko-KR": { common: commonKo, dialogs: dialogsKo },
tr: { common: commonTr, dialogs: dialogsTr },
ar: { common: commonAr, dialogs: dialogsAr },
vi: { common: commonVi, dialogs: dialogsVi },
};
let currentLocale: Locale = "en";
@@ -48,7 +51,8 @@ export function setMainLocale(locale: string) {
locale === "ja-JP" ||
locale === "ko-KR" ||
locale === "tr" ||
locale === "ar"
locale === "ar" ||
locale === "vi"
) {
currentLocale = locale;
}
+1285 -689
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -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,
);
}
});
}
+55 -31
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
desktopCapturer,
ipcMain,
Menu,
nativeImage,
@@ -333,6 +333,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
let isCloseConfirmInFlight = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -364,39 +365,35 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
isCloseConfirmInFlight = true;
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
// Ask renderer to show the custom in-app dialog
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
if (event.sender.id !== windowToClose?.webContents.id) return;
isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// Tell renderer to save the project, then close when done
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
if (event.sender.id !== windowToClose?.webContents.id) return;
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
}
// "cancel": flag reset, window stays open
});
});
}
@@ -453,23 +450,50 @@ app.whenReady().then(async () => {
app.dock?.show();
}
// Allow microphone/media permission checks
// Allow microphone/media/screen permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
callback(allowed.includes(permission));
});
// Request microphone permission from macOS
// Request microphone and screen recording permissions from macOS
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(() => {
// This only triggers the system prompt; permission state is read separately.
});
}
}
// Listen for HUD overlay quit event (macOS only)
+20
View File
@@ -0,0 +1,20 @@
import type {
CursorCapabilities,
CursorProviderKind,
CursorRecordingData,
CursorTelemetryPoint,
} from "../../../src/native/contracts";
export interface CursorTelemetryLoadResult {
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}
export interface CursorNativeAdapter {
readonly kind: CursorProviderKind;
getCapabilities(): Promise<CursorCapabilities>;
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
}
@@ -0,0 +1,37 @@
import type { Rectangle } from "electron";
import type { CursorRecordingSession } from "./session";
import { TelemetryRecordingSession } from "./telemetryRecordingSession";
import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession";
interface CreateCursorRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
platform: NodeJS.Platform;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
export function createCursorRecordingSession(
options: CreateCursorRecordingSessionOptions,
): CursorRecordingSession {
if (options.platform === "win32") {
return new WindowsNativeRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
sourceId: options.sourceId,
startTimeMs: options.startTimeMs,
});
}
// macOS / Linux: capture cursor positions via Electron's `screen` API on an
// interval. No cursor sprites/assets and no clicks — just position telemetry,
// which is what auto-zoom and other features consume.
return new TelemetryRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
startTimeMs: options.startTimeMs,
});
}
@@ -0,0 +1,6 @@
import type { CursorRecordingData } from "../../../../src/native/contracts";
export interface CursorRecordingSession {
start(): Promise<void>;
stop(): Promise<CursorRecordingData>;
}
@@ -0,0 +1,63 @@
import { type Rectangle, screen } from "electron";
import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
interface TelemetryRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
sampleIntervalMs: number;
startTimeMs?: number;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export class TelemetryRecordingSession implements CursorRecordingSession {
private samples: CursorRecordingSample[] = [];
private interval: NodeJS.Timeout | null = null;
private startTimeMs = 0;
constructor(private readonly options: TelemetryRecordingSessionOptions) {}
async start(): Promise<void> {
this.samples = [];
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.captureSample();
this.interval = setInterval(() => {
this.captureSample();
}, this.options.sampleIntervalMs);
}
async stop(): Promise<CursorRecordingData> {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
return {
version: 2,
provider: "none",
samples: this.samples,
assets: [],
};
}
private captureSample() {
const cursor = screen.getCursorScreenPoint();
const display = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
const width = Math.max(1, display.width);
const height = Math.max(1, display.height);
this.samples.push({
timeMs: Math.max(0, Date.now() - this.startTimeMs),
cx: clamp((cursor.x - display.x) / width, 0, 1),
cy: clamp((cursor.y - display.y) / height, 0, 1),
visible: true,
});
if (this.samples.length > this.options.maxSamples) {
this.samples.shift();
}
}
}
@@ -0,0 +1,391 @@
export function buildPowerShellScript(sampleIntervalMs: number, windowHandle?: string | null) {
const targetWindowHandle =
typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle)
? `'${windowHandle}'`
: "$null";
const script = String.raw`
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
$targetWindowHandle = ${targetWindowHandle}
$source = @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
public static class OpenScreenCursorInterop {
private const int WH_MOUSE_LL = 14;
private const int WM_LBUTTONDOWN = 0x0201;
private const int WM_LBUTTONUP = 0x0202;
private static readonly object MouseSync = new object();
private static int LeftDownCount = 0;
private static int LeftUpCount = 0;
private static IntPtr MouseHook = IntPtr.Zero;
private static LowLevelMouseProc MouseProcDelegate = MouseHookCallback;
public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
public struct MouseButtonEvents {
public int LeftDownCount;
public int LeftUpCount;
}
[StructLayout(LayoutKind.Sequential)]
public struct POINT {
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
public struct CURSORINFO {
public int cbSize;
public int flags;
public IntPtr hCursor;
public POINT ptScreenPos;
}
[StructLayout(LayoutKind.Sequential)]
public struct ICONINFO {
[MarshalAs(UnmanagedType.Bool)]
public bool fIcon;
public int xHotspot;
public int yHotspot;
public IntPtr hbmMask;
public IntPtr hbmColor;
}
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
public static bool InstallMouseHook() {
if (MouseHook != IntPtr.Zero) {
return true;
}
using (Process process = Process.GetCurrentProcess())
using (ProcessModule module = process.MainModule) {
MouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProcDelegate, GetModuleHandle(module.ModuleName), 0);
}
return MouseHook != IntPtr.Zero;
}
public static MouseButtonEvents ConsumeMouseButtonEvents() {
lock (MouseSync) {
MouseButtonEvents events = new MouseButtonEvents {
LeftDownCount = LeftDownCount,
LeftUpCount = LeftUpCount
};
LeftDownCount = 0;
LeftUpCount = 0;
return events;
}
}
private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
if (nCode >= 0) {
int message = wParam.ToInt32();
if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) {
lock (MouseSync) {
if (message == WM_LBUTTONDOWN) {
LeftDownCount += 1;
} else {
LeftUpCount += 1;
}
}
}
}
return CallNextHookEx(MouseHook, nCode, wParam, lParam);
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetCursorInfo(ref CURSORINFO pci);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr CopyIcon(IntPtr hIcon);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DestroyIcon(IntPtr hIcon);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
[DllImport("gdi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeleteObject(IntPtr hObject);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
}
"@
Add-Type -TypeDefinition $source
$standardCursors = @{
arrow = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32512))
text = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32513))
wait = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32514))
crosshair = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32515))
'up-arrow' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32516))
'resize-nwse' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32642))
'resize-nesw' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32643))
'resize-ew' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32644))
'resize-ns' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32645))
move = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32646))
'not-allowed' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32648))
pointer = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32649))
'app-starting' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32650))
help = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32651))
}
function Get-StandardCursorType($cursorHandle) {
if ($cursorHandle -eq [IntPtr]::Zero) {
return $null
}
foreach ($entry in $standardCursors.GetEnumerator()) {
if ($entry.Value -eq $cursorHandle) {
return $entry.Key
}
}
return $null
}
function Write-JsonLine($payload) {
[Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6))
}
function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) {
if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) {
return $null
}
if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or
$hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) {
return $null
}
$opaquePixels = 0
$topHalfOpaquePixels = 0
$left = $bitmap.Width
$top = $bitmap.Height
$right = -1
$bottom = -1
for ($y = 0; $y -lt $bitmap.Height; $y++) {
for ($x = 0; $x -lt $bitmap.Width; $x++) {
if ($bitmap.GetPixel($x, $y).A -le 32) {
continue
}
$opaquePixels += 1
if ($y -lt ($bitmap.Height / 2)) {
$topHalfOpaquePixels += 1
}
if ($x -lt $left) { $left = $x }
if ($x -gt $right) { $right = $x }
if ($y -lt $top) { $top = $y }
if ($y -gt $bottom) { $bottom = $y }
}
}
if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) {
return $null
}
$opaqueWidth = $right - $left + 1
$opaqueHeight = $bottom - $top + 1
if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or
$opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) {
return $null
}
if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) {
return $null
}
if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) {
return 'closed-hand'
}
return 'open-hand'
}
function Get-TargetBounds() {
if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) {
return $null
}
try {
$handleValue = [int64]::Parse($targetWindowHandle)
$windowHandle = [IntPtr]::new($handleValue)
if (-not [OpenScreenCursorInterop]::IsWindow($windowHandle)) {
return $null
}
$rect = New-Object OpenScreenCursorInterop+RECT
if (-not [OpenScreenCursorInterop]::GetWindowRect($windowHandle, [ref]$rect)) {
return $null
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 0 -or $height -le 0) {
return $null
}
return @{
x = $rect.Left
y = $rect.Top
width = $width
height = $height
}
}
catch {
return $null
}
}
function Get-CursorAsset($cursorHandle, $cursorId) {
$copiedHandle = [OpenScreenCursorInterop]::CopyIcon($cursorHandle)
if ($copiedHandle -eq [IntPtr]::Zero) {
return $null
}
$iconInfo = New-Object OpenScreenCursorInterop+ICONINFO
$hasIconInfo = [OpenScreenCursorInterop]::GetIconInfo($copiedHandle, [ref]$iconInfo)
try {
$icon = [System.Drawing.Icon]::FromHandle($copiedHandle)
$bitmap = New-Object System.Drawing.Bitmap $icon.Width, $icon.Height, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$memoryStream = New-Object System.IO.MemoryStream
try {
$graphics.Clear([System.Drawing.Color]::Transparent)
$graphics.DrawIcon($icon, 0, 0)
$hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 }
$hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 }
$customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY
$bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png)
$base64 = [System.Convert]::ToBase64String($memoryStream.ToArray())
return @{
id = $cursorId
imageDataUrl = "data:image/png;base64,$base64"
width = $bitmap.Width
height = $bitmap.Height
hotspotX = $hotspotX
hotspotY = $hotspotY
cursorType = $customCursorType
}
}
finally {
$memoryStream.Dispose()
$graphics.Dispose()
$bitmap.Dispose()
$icon.Dispose()
}
}
finally {
if ($hasIconInfo) {
if ($iconInfo.hbmMask -ne [IntPtr]::Zero) {
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmMask) | Out-Null
}
if ($iconInfo.hbmColor -ne [IntPtr]::Zero) {
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmColor) | Out-Null
}
}
[OpenScreenCursorInterop]::DestroyIcon($copiedHandle) | Out-Null
}
}
[OpenScreenCursorInterop]::InstallMouseHook() | Out-Null
[OpenScreenCursorInterop]::GetAsyncKeyState(0x01) | Out-Null
Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
$lastCursorId = $null
while ($true) {
[System.Windows.Forms.Application]::DoEvents()
$mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents()
$cursorInfo = New-Object OpenScreenCursorInterop+CURSORINFO
$cursorInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][OpenScreenCursorInterop+CURSORINFO])
if (-not [OpenScreenCursorInterop]::GetCursorInfo([ref]$cursorInfo)) {
Write-JsonLine @{ type = 'error'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); message = 'GetCursorInfo failed' }
Start-Sleep -Milliseconds ${sampleIntervalMs}
continue
}
$visible = ($cursorInfo.flags -band 1) -ne 0
$cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) }
$cursorType = Get-StandardCursorType $cursorInfo.hCursor
$leftButtonState = [OpenScreenCursorInterop]::GetAsyncKeyState(0x01)
$leftButtonDown = ($leftButtonState -band 0x8000) -ne 0
$leftButtonPressed = ($mouseEvents.LeftDownCount -gt 0) -or (($leftButtonState -band 0x0001) -ne 0)
$leftButtonReleased = $mouseEvents.LeftUpCount -gt 0
$asset = $null
if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) {
$asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId
if ($asset -and $cursorType) {
$asset.cursorType = $cursorType
} elseif ($asset -and $asset.cursorType) {
$cursorType = $asset.cursorType
}
$lastCursorId = $cursorId
}
Write-JsonLine @{
type = 'sample'
timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
x = $cursorInfo.ptScreenPos.X
y = $cursorInfo.ptScreenPos.Y
visible = $visible
handle = $cursorId
cursorType = $cursorType
leftButtonDown = $leftButtonDown
leftButtonPressed = $leftButtonPressed
leftButtonReleased = $leftButtonReleased
bounds = Get-TargetBounds
asset = $asset
}
Start-Sleep -Milliseconds ${sampleIntervalMs}
}
`;
return script;
}
@@ -0,0 +1,346 @@
import { type ChildProcessByStdio, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Readable } from "node:stream";
import { screen } from "electron";
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
} from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
import { buildPowerShellScript } from "./windowsNativeRecordingSession.script";
import type {
WindowsCursorEvent,
WindowsNativeRecordingSessionOptions,
} from "./windowsNativeRecordingSession.types";
const READY_TIMEOUT_MS = 5_000;
interface NormalizedSample {
sample: CursorRecordingSample;
withinBounds: boolean;
}
export class WindowsNativeRecordingSession implements CursorRecordingSession {
private assets = new Map<string, NativeCursorAsset>();
private samples: CursorRecordingSample[] = [];
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
private helperScriptPath: string | null = null;
private lineBuffer = "";
private startTimeMs = 0;
private readyResolve: (() => void) | null = null;
private readyReject: ((error: Error) => void) | null = null;
private readyTimer: NodeJS.Timeout | null = null;
private sampleCount = 0;
private outOfBoundsSampleCount = 0;
private previousLeftButtonDown = false;
constructor(private readonly options: WindowsNativeRecordingSessionOptions) {}
async start(): Promise<void> {
this.assets.clear();
this.samples = [];
this.lineBuffer = "";
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.sampleCount = 0;
this.outOfBoundsSampleCount = 0;
this.previousLeftButtonDown = false;
const script = buildPowerShellScript(
this.options.sampleIntervalMs,
parseWindowHandleFromSourceId(this.options.sourceId),
);
const helperScriptDir = join(tmpdir(), "openscreen-cursor-native");
mkdirSync(helperScriptDir, { recursive: true });
const helperScriptPath = join(
helperScriptDir,
`cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`,
);
writeFileSync(helperScriptPath, script, "utf8");
this.helperScriptPath = helperScriptPath;
const child = spawn(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
helperScriptPath,
],
{
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
},
);
this.process = child;
this.logDiagnostic("spawn", {
pid: child.pid ?? null,
sampleIntervalMs: this.options.sampleIntervalMs,
sourceId: this.options.sourceId ?? null,
windowHandle: parseWindowHandleFromSourceId(this.options.sourceId),
});
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
this.handleStdoutChunk(chunk);
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
const message = chunk.trim();
if (message) {
this.logDiagnostic("stderr", { message });
}
console.error("[cursor-native]", message);
});
child.once("exit", (code, signal) => {
this.cleanupHelperScript(helperScriptPath);
this.logDiagnostic("exit", {
code,
signal,
sampleCount: this.sampleCount,
assetCount: this.assets.size,
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
});
this.rejectReady(
new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`),
);
});
child.once("error", (error) => {
this.cleanupHelperScript(helperScriptPath);
this.logDiagnostic("process-error", { message: error.message });
this.rejectReady(error);
});
try {
await this.waitUntilReady();
} catch (error) {
this.terminateHelperProcess();
this.cleanupHelperScript(helperScriptPath);
throw error;
}
}
async stop(): Promise<CursorRecordingData> {
const child = this.process;
this.process = null;
this.clearReadyState();
this.killHelperProcess(child);
this.logDiagnostic("stop", {
sampleCount: this.sampleCount,
assetCount: this.assets.size,
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
});
return {
version: 2,
provider: this.assets.size > 0 ? "native" : "none",
samples: this.samples,
assets: [...this.assets.values()],
};
}
private handleStdoutChunk(chunk: string) {
this.lineBuffer += chunk;
const lines = this.lineBuffer.split(/\r?\n/);
this.lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
try {
const payload = JSON.parse(trimmedLine) as WindowsCursorEvent;
this.handleEvent(payload);
} catch (error) {
console.error("Failed to parse Windows cursor helper output:", error, trimmedLine);
}
}
}
private handleEvent(payload: WindowsCursorEvent) {
if (payload.type === "error") {
this.logDiagnostic("helper-error", { message: payload.message });
console.error("Windows cursor helper error:", payload.message);
this.failHelper(new Error(payload.message));
return;
}
if (payload.type === "ready") {
this.logDiagnostic("ready", { timestampMs: payload.timestampMs });
this.resolveReady();
return;
}
if (payload.asset?.id && !this.assets.has(payload.asset.id)) {
const assetDisplay = screen.getDisplayNearestPoint({ x: payload.x, y: payload.y });
this.assets.set(payload.asset.id, {
id: payload.asset.id,
platform: "win32",
imageDataUrl: payload.asset.imageDataUrl,
width: payload.asset.width,
height: payload.asset.height,
hotspotX: payload.asset.hotspotX,
hotspotY: payload.asset.hotspotY,
scaleFactor: assetDisplay.scaleFactor,
cursorType: payload.asset.cursorType ?? payload.cursorType ?? null,
});
this.logDiagnostic("asset", {
id: payload.asset.id,
width: payload.asset.width,
height: payload.asset.height,
hotspotX: payload.asset.hotspotX,
hotspotY: payload.asset.hotspotY,
scaleFactor: assetDisplay.scaleFactor,
});
}
const normalized = this.normalizeSample(payload);
this.sampleCount += 1;
if (!normalized.withinBounds) {
this.outOfBoundsSampleCount += 1;
}
this.samples.push(normalized.sample);
if (this.samples.length > this.options.maxSamples) {
this.samples.shift();
}
}
private normalizeSample(
payload: Extract<WindowsCursorEvent, { type: "sample" }>,
): NormalizedSample {
const bounds =
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const normalizedX = (payload.x - bounds.x) / width;
const normalizedY = (payload.y - bounds.y) / height;
const withinBounds =
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
const leftButtonDown = payload.leftButtonDown === true;
const leftButtonPressed = payload.leftButtonPressed === true;
const leftButtonReleased = payload.leftButtonReleased === true;
const interactionType =
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
? "click"
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
? "mouseup"
: "move";
this.previousLeftButtonDown = leftButtonDown;
if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) {
this.logDiagnostic("sample", {
rawX: payload.x,
rawY: payload.y,
normalizedX,
normalizedY,
visible: payload.visible,
withinBounds,
bounds,
handle: payload.handle,
});
}
return {
withinBounds,
sample: {
timeMs: Math.max(0, payload.timestampMs - this.startTimeMs),
cx: normalizedX,
cy: normalizedY,
assetId: payload.handle,
visible: payload.visible && withinBounds,
cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null,
interactionType,
},
};
}
private waitUntilReady() {
return new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
this.readyTimer = setTimeout(() => {
this.rejectReady(new Error("Timed out waiting for Windows cursor helper readiness"));
}, READY_TIMEOUT_MS);
});
}
private resolveReady() {
const resolve = this.readyResolve;
this.clearReadyState();
resolve?.();
}
private rejectReady(error: Error) {
const reject = this.readyReject;
this.clearReadyState();
reject?.(error);
}
private failHelper(error: Error) {
this.rejectReady(error);
this.terminateHelperProcess();
}
private terminateHelperProcess() {
const child = this.process;
this.process = null;
this.killHelperProcess(child);
}
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable> | null) {
if (child && !child.killed) {
child.kill();
}
}
private clearReadyState() {
if (this.readyTimer) {
clearTimeout(this.readyTimer);
this.readyTimer = null;
}
this.readyResolve = null;
this.readyReject = null;
}
private cleanupHelperScript(scriptPath = this.helperScriptPath) {
if (!scriptPath) {
return;
}
try {
rmSync(scriptPath, { force: true });
} catch (error) {
this.logDiagnostic("script-cleanup-error", {
path: scriptPath,
message: error instanceof Error ? error.message : String(error),
});
} finally {
if (this.helperScriptPath === scriptPath) {
this.helperScriptPath = null;
}
}
}
private logDiagnostic(event: string, data: Record<string, unknown>) {
console.info(
"[cursor-native][win32]",
JSON.stringify({
event,
...data,
}),
);
}
}
@@ -0,0 +1,56 @@
import type { Rectangle } from "electron";
import type { NativeCursorType } from "../../../../src/native/contracts";
export interface WindowsCursorSampleEvent {
type: "sample";
timestampMs: number;
x: number;
y: number;
visible: boolean;
handle: string | null;
cursorType?: NativeCursorType | null;
leftButtonDown?: boolean;
leftButtonPressed?: boolean;
leftButtonReleased?: boolean;
bounds?: {
x: number;
y: number;
width: number;
height: number;
} | null;
asset: WindowsCursorAssetPayload | null;
}
export interface WindowsCursorReadyEvent {
type: "ready";
timestampMs: number;
}
export interface WindowsCursorErrorEvent {
type: "error";
timestampMs: number;
message: string;
}
export interface WindowsCursorAssetPayload {
id: string;
imageDataUrl: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
cursorType?: NativeCursorType | null;
}
export type WindowsCursorEvent =
| WindowsCursorSampleEvent
| WindowsCursorReadyEvent
| WindowsCursorErrorEvent;
export interface WindowsNativeRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
@@ -0,0 +1,49 @@
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
interface TelemetryCursorAdapterOptions {
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}
export class TelemetryCursorAdapter implements CursorNativeAdapter {
readonly kind = "none" as const;
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
async getCapabilities(): Promise<CursorCapabilities> {
return {
telemetry: true,
systemAssets: false,
provider: this.kind,
};
}
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
if (!resolvedVideoPath) {
return {
version: 2,
provider: this.kind,
samples: [],
assets: [],
};
}
return this.options.loadRecordingData(resolvedVideoPath);
}
async getTelemetry(videoPath?: string | null) {
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
if (!resolvedVideoPath) {
return {
success: false,
message: "No video path is available for cursor telemetry",
samples: [],
} satisfies CursorTelemetryLoadResult;
}
return this.options.loadTelemetry(resolvedVideoPath);
}
}
@@ -0,0 +1,46 @@
import type {
CursorCapabilities,
CursorRecordingData,
CursorTelemetryPoint,
} from "../../../src/native/contracts";
import type { CursorNativeAdapter } from "../cursor/adapter";
import type { NativeBridgeStateStore } from "../store";
interface CursorServiceOptions {
store: NativeBridgeStateStore;
adapter: CursorNativeAdapter;
}
export class CursorService {
constructor(private readonly options: CursorServiceOptions) {}
async getCapabilities(): Promise<CursorCapabilities> {
const capabilities = await this.options.adapter.getCapabilities();
this.options.store.setCursorCapabilities(capabilities);
return capabilities;
}
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
const result = await this.options.adapter.getTelemetry(videoPath);
if (!result.success) {
throw new Error(result.message || result.error || "Failed to load cursor telemetry");
}
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
if (resolvedVideoPath) {
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length);
}
return result.samples;
}
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
const data = await this.options.adapter.getRecordingData(videoPath);
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
if (resolvedVideoPath) {
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length);
}
return data;
}
}
@@ -0,0 +1,80 @@
import type {
ProjectContext,
ProjectFileResult,
ProjectPathResult,
} from "../../../src/native/contracts";
import type { NativeBridgeStateStore } from "../store";
interface ProjectServiceOptions {
store: NativeBridgeStateStore;
getCurrentProjectPath: () => string | null;
getCurrentVideoPath: () => string | null;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => Promise<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
getCurrentVideoPathResult: () => ProjectPathResult;
clearCurrentVideoPath: () => ProjectPathResult;
}
export class ProjectService {
constructor(private readonly options: ProjectServiceOptions) {}
getCurrentContext(): ProjectContext {
const context = {
currentProjectPath: this.options.getCurrentProjectPath(),
currentVideoPath: this.options.getCurrentVideoPath(),
};
this.options.store.setProjectContext(context);
return context;
}
async saveProjectFile(
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) {
const result = await this.options.saveProjectFile(
projectData,
suggestedName,
existingProjectPath,
);
this.getCurrentContext();
return result;
}
async loadProjectFile() {
const result = await this.options.loadProjectFile();
this.getCurrentContext();
return result;
}
async loadCurrentProjectFile() {
const result = await this.options.loadCurrentProjectFile();
this.getCurrentContext();
return result;
}
async setCurrentVideoPath(path: string) {
const result = await this.options.setCurrentVideoPath(path);
this.getCurrentContext();
return result;
}
getCurrentVideoPath() {
const result = this.options.getCurrentVideoPathResult();
this.getCurrentContext();
return result;
}
clearCurrentVideoPath() {
const result = this.options.clearCurrentVideoPath();
this.getCurrentContext();
return result;
}
}
@@ -0,0 +1,43 @@
import type {
CursorCapabilities,
NativePlatform,
SystemCapabilities,
} from "../../../src/native/contracts";
import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts";
import type { NativeBridgeStateStore } from "../store";
interface SystemServiceOptions {
store: NativeBridgeStateStore;
getPlatform: () => NativePlatform;
getAssetBasePath: () => string | null;
getCursorCapabilities: () => Promise<CursorCapabilities>;
}
export class SystemService {
constructor(private readonly options: SystemServiceOptions) {}
getPlatform() {
return this.options.getPlatform();
}
getAssetBasePath() {
return this.options.getAssetBasePath();
}
async getCapabilities(): Promise<SystemCapabilities> {
const platform = this.getPlatform();
const cursorCapabilities = await this.options.getCursorCapabilities();
const capabilities: SystemCapabilities = {
bridgeVersion: NATIVE_BRIDGE_VERSION,
platform,
cursor: cursorCapabilities,
project: {
currentContext: true,
},
};
this.options.store.setSystemCapabilities(capabilities);
return capabilities;
}
}
+88
View File
@@ -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(),
},
},
};
}
}
+78
View File
@@ -0,0 +1,78 @@
# Native capture helpers
Windows native recording is resolved from one of these locations:
1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics.
2. `electron/native/wgc-capture/build/wgc-capture.exe`, for a locally built Ninja helper.
3. `electron/native/wgc-capture/build/Release/wgc-capture.exe`, for a locally built multi-config helper.
4. `electron/native/bin/win32-x64/wgc-capture.exe` or `electron/native/bin/win32-arm64/wgc-capture.exe`, for packaged prebuilt helpers.
Build the Windows helper with:
```powershell
npm run build:native:win
```
The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`.
The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: <path>`.
Current V2 JSON shape:
```json
{
"schemaVersion": 2,
"recordingId": 123,
"sourceType": "display",
"sourceId": "screen:0:0",
"displayId": 1,
"windowHandle": null,
"outputPath": "C:\\path\\recording-123.mp4",
"videoWidth": 1920,
"videoHeight": 1080,
"fps": 60,
"captureSystemAudio": false,
"captureMic": false,
"microphoneDeviceId": "default",
"microphoneDeviceName": "Microphone (NVIDIA Broadcast)",
"microphoneGain": 1.4,
"webcamEnabled": true,
"webcamDeviceId": "default",
"webcamDeviceName": "Camera (NVIDIA Broadcast)",
"webcamWidth": 1280,
"webcamHeight": 720,
"webcamFps": 30,
"outputs": {
"screenPath": "C:\\path\\recording-123.mp4"
}
}
```
The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation.
Smoke-test the helper with:
```powershell
npm run test:wgc-helper:win
npm run test:wgc-window:win
npm run test:wgc-audio:win
npm run test:wgc-mic:win
npm run test:wgc-mixed-audio:win
npm run test:wgc-webcam:win
```
To validate a specific native webcam manually:
```powershell
$env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME = "NVIDIA Broadcast"
npm run test:wgc-webcam:win
Remove-Item Env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME
```
To validate a specific native microphone manually:
```powershell
$env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME = "Microphone (NVIDIA Broadcast)"
npm run test:wgc-mic:win
Remove-Item Env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME
```
@@ -0,0 +1,51 @@
cmake_minimum_required(VERSION 3.20)
# The local Windows SDK image used by some contributors can miss gdi32.lib,
# while CMake's default MSVC console template links it unconditionally. This
# helper does not use GDI, so keep the standard library set minimal and explicit.
set(CMAKE_CXX_STANDARD_LIBRARIES
"kernel32.lib user32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib"
CACHE STRING "" FORCE)
project(openscreen-wgc-capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(wgc-capture
src/audio_sample_utils.cpp
src/audio_sample_utils.h
src/dshow_webcam_capture.cpp
src/dshow_webcam_capture.h
src/main.cpp
src/mf_encoder.cpp
src/mf_encoder.h
src/monitor_utils.cpp
src/monitor_utils.h
src/wasapi_loopback_capture.cpp
src/wasapi_loopback_capture.h
src/webcam_capture.cpp
src/webcam_capture.h
src/wgc_session.cpp
src/wgc_session.h
)
target_compile_definitions(wgc-capture PRIVATE
NOMINMAX
WIN32_LEAN_AND_MEAN
_WIN32_WINNT=0x0A00
)
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8)
target_link_libraries(wgc-capture PRIVATE
d3d11
dxgi
mf
mfplat
mfreadwrite
mfuuid
runtimeobject
windowsapp
)
@@ -0,0 +1,420 @@
#include "audio_sample_utils.h"
#include <mfapi.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstring>
#include <limits>
namespace {
bool isFloatFormat(const AudioInputFormat& format) {
return format.subtype == MFAudioFormat_Float && format.bitsPerSample == 32;
}
bool isPcmFormat(const AudioInputFormat& format, UINT32 bitsPerSample) {
return format.subtype == MFAudioFormat_PCM && format.bitsPerSample == bitsPerSample;
}
template <typename T>
T clampTo(double value) {
const double minValue = static_cast<double>(std::numeric_limits<T>::min());
const double maxValue = static_cast<double>(std::numeric_limits<T>::max());
return static_cast<T>(std::clamp(std::round(value), minValue, maxValue));
}
size_t bytesPerSample(const AudioInputFormat& format) {
return format.bitsPerSample / 8;
}
double readSampleAsDouble(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex) {
if (!source || format.blockAlign == 0 || channelIndex >= format.channels) {
return 0.0;
}
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
if (isFloatFormat(format)) {
return static_cast<double>(*reinterpret_cast<const float*>(source + offset));
}
if (isPcmFormat(format, 16)) {
return static_cast<double>(*reinterpret_cast<const int16_t*>(source + offset)) / 32768.0;
}
if (isPcmFormat(format, 32)) {
return static_cast<double>(*reinterpret_cast<const int32_t*>(source + offset)) / 2147483648.0;
}
return 0.0;
}
void writeSampleFromDouble(BYTE* destination, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex, double value) {
if (!destination || format.blockAlign == 0 || channelIndex >= format.channels) {
return;
}
const double clamped = std::clamp(value, -1.0, 1.0);
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
if (isFloatFormat(format)) {
*reinterpret_cast<float*>(destination + offset) = static_cast<float>(clamped);
return;
}
if (isPcmFormat(format, 16)) {
*reinterpret_cast<int16_t*>(destination + offset) = clampTo<int16_t>(clamped * 32767.0);
return;
}
if (isPcmFormat(format, 32)) {
*reinterpret_cast<int32_t*>(destination + offset) = clampTo<int32_t>(clamped * 2147483647.0);
}
}
double readMappedChannel(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 targetChannel, UINT32 targetChannels) {
if (format.channels == 0) {
return 0.0;
}
if (format.channels == targetChannels && targetChannel < format.channels) {
return readSampleAsDouble(source, format, frameIndex, targetChannel);
}
if (format.channels == 1) {
return readSampleAsDouble(source, format, frameIndex, 0);
}
if (targetChannels == 1) {
double sum = 0.0;
for (UINT32 channel = 0; channel < format.channels; ++channel) {
sum += readSampleAsDouble(source, format, frameIndex, channel);
}
return sum / static_cast<double>(format.channels);
}
return readSampleAsDouble(source, format, frameIndex, std::min(targetChannel, format.channels - 1));
}
} // namespace
constexpr int64_t HnsPerSecond = 10'000'000;
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right) {
return left.subtype == right.subtype &&
left.sampleRate == right.sampleRate &&
left.channels == right.channels &&
left.bitsPerSample == right.bitsPerSample &&
left.blockAlign == right.blockAlign &&
left.avgBytesPerSec == right.avgBytesPerSec;
}
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) {
AudioInputFormat format{};
format.subtype = MFAudioFormat_PCM;
format.sampleRate = source.sampleRate > 0 ? source.sampleRate : 48000;
format.channels = 2;
format.bitsPerSample = 16;
format.blockAlign = format.channels * (format.bitsPerSample / 8);
format.avgBytesPerSec = format.sampleRate * format.blockAlign;
return format;
}
void copyAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format,
double gain,
std::vector<BYTE>& destination) {
destination.resize(byteCount);
if (!source || byteCount == 0) {
std::fill(destination.begin(), destination.end(), static_cast<BYTE>(0));
return;
}
if (std::abs(gain - 1.0) < 0.0001) {
std::memcpy(destination.data(), source, byteCount);
return;
}
if (isFloatFormat(format)) {
const auto* input = reinterpret_cast<const float*>(source);
auto* output = reinterpret_cast<float*>(destination.data());
const size_t sampleCount = byteCount / sizeof(float);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = static_cast<float>(std::clamp(input[index] * gain, -1.0, 1.0));
}
return;
}
if (isPcmFormat(format, 16)) {
const auto* input = reinterpret_cast<const int16_t*>(source);
auto* output = reinterpret_cast<int16_t*>(destination.data());
const size_t sampleCount = byteCount / sizeof(int16_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int16_t>(static_cast<double>(input[index]) * gain);
}
return;
}
if (isPcmFormat(format, 32)) {
const auto* input = reinterpret_cast<const int32_t*>(source);
auto* output = reinterpret_cast<int32_t*>(destination.data());
const size_t sampleCount = byteCount / sizeof(int32_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int32_t>(static_cast<double>(input[index]) * gain);
}
return;
}
std::memcpy(destination.data(), source, byteCount);
}
void convertAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
const AudioInputFormat& targetFormat,
double gain,
std::vector<BYTE>& destination) {
if (!source || byteCount == 0 || sourceFormat.blockAlign == 0 || targetFormat.blockAlign == 0 ||
sourceFormat.sampleRate == 0 || targetFormat.sampleRate == 0 || sourceFormat.channels == 0 ||
targetFormat.channels == 0) {
destination.clear();
return;
}
if (sameAudioFormatForMixing(sourceFormat, targetFormat)) {
copyAudioWithGain(source, byteCount, targetFormat, gain, destination);
return;
}
const size_t sourceFrames = byteCount / sourceFormat.blockAlign;
if (sourceFrames == 0) {
destination.clear();
return;
}
const double rateRatio = static_cast<double>(targetFormat.sampleRate) /
static_cast<double>(sourceFormat.sampleRate);
const size_t targetFrames = std::max<size_t>(1, static_cast<size_t>(std::llround(sourceFrames * rateRatio)));
destination.assign(targetFrames * targetFormat.blockAlign, 0);
for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) {
const double sourcePosition = static_cast<double>(targetFrame) / rateRatio;
const size_t sourceFrame = std::min(
sourceFrames - 1,
static_cast<size_t>(std::llround(sourcePosition)));
for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) {
const double sample = readMappedChannel(
source,
sourceFormat,
sourceFrame,
channel,
targetFormat.channels);
writeSampleFromDouble(destination.data(), targetFormat, targetFrame, channel, sample * gain);
}
}
}
void mixAudioInPlace(
std::vector<BYTE>& destination,
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format) {
if (!source || byteCount == 0 || destination.empty()) {
return;
}
const size_t mixByteCount = std::min(destination.size(), static_cast<size_t>(byteCount));
if (isFloatFormat(format)) {
auto* output = reinterpret_cast<float*>(destination.data());
const auto* input = reinterpret_cast<const float*>(source);
const size_t sampleCount = mixByteCount / sizeof(float);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = static_cast<float>(std::clamp(output[index] + input[index], -1.0f, 1.0f));
}
return;
}
if (isPcmFormat(format, 16)) {
auto* output = reinterpret_cast<int16_t*>(destination.data());
const auto* input = reinterpret_cast<const int16_t*>(source);
const size_t sampleCount = mixByteCount / sizeof(int16_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int16_t>(
static_cast<double>(output[index]) + static_cast<double>(input[index]));
}
return;
}
if (isPcmFormat(format, 32)) {
auto* output = reinterpret_cast<int32_t*>(destination.data());
const auto* input = reinterpret_cast<const int32_t*>(source);
const size_t sampleCount = mixByteCount / sizeof(int32_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int32_t>(
static_cast<double>(output[index]) + static_cast<double>(input[index]));
}
}
}
AudioMixer::AudioMixer(
const AudioInputFormat& format,
const AudioInputFormat& systemFormat,
const AudioInputFormat& microphoneFormat,
bool includeSystem,
bool includeMicrophone,
double microphoneGain,
OutputCallback output)
: format_(format),
systemFormat_(systemFormat),
microphoneFormat_(microphoneFormat),
includeSystem_(includeSystem),
includeMicrophone_(includeMicrophone),
microphoneGain_(microphoneGain),
output_(std::move(output)) {}
AudioMixer::~AudioMixer() {
stop();
}
bool AudioMixer::start() {
if (!output_ || format_.sampleRate == 0 || format_.blockAlign == 0) {
return false;
}
stopRequested_ = false;
emittedFrames_ = 0;
timelineStarted_ = false;
thread_ = std::thread([this] {
mixLoop();
});
return true;
}
void AudioMixer::beginTimeline() {
{
std::scoped_lock lock(mutex_);
systemQueue_.clear();
microphoneQueue_.clear();
emittedFrames_ = 0;
timelineStarted_ = true;
}
cv_.notify_all();
}
void AudioMixer::stop() {
stopRequested_ = true;
cv_.notify_all();
if (thread_.joinable()) {
thread_.join();
}
}
void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
if (!includeSystem_ || stopRequested_) {
return;
}
{
std::scoped_lock lock(mutex_);
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
}
cv_.notify_all();
}
void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
if (!includeMicrophone_ || stopRequested_) {
return;
}
{
std::scoped_lock lock(mutex_);
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
}
cv_.notify_all();
}
void AudioMixer::append(
std::vector<BYTE>& queue,
const BYTE* data,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
double gain) {
if (!data || byteCount == 0) {
return;
}
convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_);
queue.insert(queue.end(), gainBuffer_.begin(), gainBuffer_.end());
}
bool AudioMixer::pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount) {
if (queue.empty()) {
chunk.assign(byteCount, 0);
return false;
}
chunk.assign(byteCount, 0);
const size_t copiedBytes = std::min(byteCount, queue.size());
std::memcpy(chunk.data(), queue.data(), copiedBytes);
queue.erase(queue.begin(), queue.begin() + static_cast<std::ptrdiff_t>(copiedBytes));
return copiedBytes > 0;
}
void AudioMixer::mixLoop() {
const uint32_t chunkFrames = std::max<uint32_t>(1, format_.sampleRate / 100);
const size_t chunkBytes = static_cast<size_t>(chunkFrames) * format_.blockAlign;
std::vector<BYTE> mixedChunk;
std::vector<BYTE> sourceChunk;
std::chrono::steady_clock::time_point audioClockStart;
bool audioClockStarted = false;
while (true) {
{
std::unique_lock lock(mutex_);
cv_.wait_for(lock, std::chrono::milliseconds(20), [&] {
const bool hasSystem = !includeSystem_ || systemQueue_.size() >= chunkBytes;
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
return stopRequested_.load() ||
(timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource);
});
if (stopRequested_) {
break;
}
if (!timelineStarted_) {
continue;
}
const bool hasAnyQueuedAudio = !systemQueue_.empty() || !microphoneQueue_.empty();
if (!hasAnyQueuedAudio) {
continue;
}
mixedChunk.assign(chunkBytes, 0);
if (includeSystem_) {
pop(systemQueue_, sourceChunk, chunkBytes);
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
}
if (includeMicrophone_) {
pop(microphoneQueue_, sourceChunk, chunkBytes);
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
}
}
if (!audioClockStarted) {
audioClockStart = std::chrono::steady_clock::now();
audioClockStarted = true;
}
const int64_t timestampHns =
static_cast<int64_t>((emittedFrames_ * HnsPerSecond) / format_.sampleRate);
const int64_t durationHns =
static_cast<int64_t>((static_cast<uint64_t>(chunkFrames) * HnsPerSecond) / format_.sampleRate);
if (!output_(mixedChunk.data(), static_cast<DWORD>(mixedChunk.size()), timestampHns, durationHns)) {
stopRequested_ = true;
break;
}
emittedFrames_ += chunkFrames;
const auto nextDeadline = audioClockStart +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(static_cast<double>(emittedFrames_) / format_.sampleRate));
std::this_thread::sleep_until(nextDeadline);
}
}
@@ -0,0 +1,85 @@
#pragma once
#include "mf_encoder.h"
#include <Windows.h>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right);
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source);
void copyAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format,
double gain,
std::vector<BYTE>& destination);
void convertAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
const AudioInputFormat& targetFormat,
double gain,
std::vector<BYTE>& destination);
void mixAudioInPlace(
std::vector<BYTE>& destination,
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format);
class AudioMixer {
public:
using OutputCallback = std::function<bool(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
AudioMixer(
const AudioInputFormat& format,
const AudioInputFormat& systemFormat,
const AudioInputFormat& microphoneFormat,
bool includeSystem,
bool includeMicrophone,
double microphoneGain,
OutputCallback output);
~AudioMixer();
AudioMixer(const AudioMixer&) = delete;
AudioMixer& operator=(const AudioMixer&) = delete;
bool start();
void beginTimeline();
void stop();
void pushSystem(const BYTE* data, DWORD byteCount);
void pushMicrophone(const BYTE* data, DWORD byteCount);
private:
void append(
std::vector<BYTE>& queue,
const BYTE* data,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
double gain);
bool pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount);
void mixLoop();
AudioInputFormat format_{};
AudioInputFormat systemFormat_{};
AudioInputFormat microphoneFormat_{};
bool includeSystem_ = false;
bool includeMicrophone_ = false;
double microphoneGain_ = 1.0;
OutputCallback output_;
std::mutex mutex_;
std::condition_variable cv_;
std::vector<BYTE> systemQueue_;
std::vector<BYTE> microphoneQueue_;
std::vector<BYTE> gainBuffer_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
bool timelineStarted_ = false;
uint64_t emittedFrames_ = 0;
};
@@ -0,0 +1,312 @@
#include "dshow_webcam_capture.h"
#include <initguid.h>
#include <dshow.h>
#include <wrl/client.h>
#include <algorithm>
#include <chrono>
#include <exception>
#include <iostream>
namespace {
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85")
ISampleGrabberCB : public IUnknown {
public:
virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0;
virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0;
};
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
ISampleGrabber : public IUnknown {
public:
virtual HRESULT STDMETHODCALLTYPE SetOneShot(BOOL oneShot) = 0;
virtual HRESULT STDMETHODCALLTYPE SetMediaType(const AM_MEDIA_TYPE* type) = 0;
virtual HRESULT STDMETHODCALLTYPE GetConnectedMediaType(AM_MEDIA_TYPE* type) = 0;
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0;
};
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
void freeMediaType(AM_MEDIA_TYPE& type) {
if (type.cbFormat != 0) {
CoTaskMemFree(type.pbFormat);
type.cbFormat = 0;
type.pbFormat = nullptr;
}
if (type.pUnk) {
type.pUnk->Release();
type.pUnk = nullptr;
}
}
} // namespace
struct DirectShowWebcamCapture::Impl {
Microsoft::WRL::ComPtr<IGraphBuilder> graph;
Microsoft::WRL::ComPtr<ICaptureGraphBuilder2> captureGraph;
Microsoft::WRL::ComPtr<IBaseFilter> captureFilter;
Microsoft::WRL::ComPtr<IBaseFilter> sampleGrabberFilter;
Microsoft::WRL::ComPtr<ISampleGrabber> sampleGrabber;
Microsoft::WRL::ComPtr<IBaseFilter> nullRenderer;
Microsoft::WRL::ComPtr<IMediaControl> mediaControl;
bool comInitialized = false;
bool running = false;
};
DirectShowWebcamCapture::~DirectShowWebcamCapture() {
stop();
delete impl_;
}
bool DirectShowWebcamCapture::initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps) {
(void)deviceId;
stop();
delete impl_;
impl_ = nullptr;
impl_ = new Impl();
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (SUCCEEDED(hr)) {
impl_->comInitialized = true;
} else if (hr != RPC_E_CHANGED_MODE) {
return succeeded(hr, "CoInitializeEx(DirectShow webcam)");
}
if (directShowClsid.empty()) {
std::cerr << "ERROR: DirectShow webcam fallback requires a resolved filter CLSID" << std::endl;
return false;
}
CLSID selectedClsid{};
if (FAILED(CLSIDFromString(directShowClsid.c_str(), &selectedClsid))) {
std::cerr << "ERROR: DirectShow webcam fallback received an invalid filter CLSID" << std::endl;
return false;
}
selectedDeviceName_ = deviceName.empty() ? directShowClsid : deviceName;
if (!succeeded(CoCreateInstance(selectedClsid, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureFilter)),
"CoCreateInstance(DirectShow webcam filter)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->graph)),
"CoCreateInstance(FilterGraph)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureGraph)),
"CoCreateInstance(CaptureGraphBuilder2)")) {
return false;
}
if (!succeeded(impl_->captureGraph->SetFiltergraph(impl_->graph.Get()), "SetFiltergraph(DirectShow webcam)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->captureFilter.Get(), L"OpenScreen Webcam Source"),
"AddFilter(DirectShow webcam source)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_SampleGrabberLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->sampleGrabberFilter)),
"CoCreateInstance(SampleGrabber)")) {
return false;
}
if (!succeeded(impl_->sampleGrabberFilter.As(&impl_->sampleGrabber), "QueryInterface(ISampleGrabber)")) {
return false;
}
AM_MEDIA_TYPE requestedType{};
requestedType.majortype = MEDIATYPE_Video;
requestedType.subtype = MEDIASUBTYPE_RGB32;
requestedType.formattype = FORMAT_VideoInfo;
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->sampleGrabberFilter.Get(), L"OpenScreen Webcam Sample Grabber"),
"AddFilter(SampleGrabber)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_NullRendererLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->nullRenderer)),
"CoCreateInstance(NullRenderer)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->nullRenderer.Get(), L"OpenScreen Webcam Null Renderer"),
"AddFilter(NullRenderer)")) {
return false;
}
if (!succeeded(impl_->captureGraph->RenderStream(
&PIN_CATEGORY_CAPTURE,
&MEDIATYPE_Video,
impl_->captureFilter.Get(),
impl_->sampleGrabberFilter.Get(),
impl_->nullRenderer.Get()),
"RenderStream(DirectShow webcam)")) {
return false;
}
AM_MEDIA_TYPE connectedType{};
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
return false;
}
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
width_ = std::abs(videoInfo->bmiHeader.biWidth);
height_ = std::abs(videoInfo->bmiHeader.biHeight);
sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0;
}
freeMediaType(connectedType);
if (width_ <= 0 || height_ <= 0) {
width_ = requestedWidth > 0 ? requestedWidth : 1280;
height_ = requestedHeight > 0 ? requestedHeight : 720;
}
impl_->sampleGrabber->SetBufferSamples(TRUE);
impl_->sampleGrabber->SetOneShot(FALSE);
if (!succeeded(impl_->graph.As(&impl_->mediaControl), "QueryInterface(IMediaControl)")) {
return false;
}
return true;
}
bool DirectShowWebcamCapture::start() {
if (!impl_ || !impl_->mediaControl || impl_->running) {
return false;
}
HRESULT hr = impl_->mediaControl->Run();
if (!succeeded(hr, "Run(DirectShow webcam)")) {
return false;
}
stopRequested_ = false;
try {
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
} catch (const std::exception& error) {
stopRequested_ = true;
impl_->mediaControl->Stop();
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
return false;
} catch (...) {
stopRequested_ = true;
impl_->mediaControl->Stop();
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
return false;
}
impl_->running = true;
return true;
}
void DirectShowWebcamCapture::stop() {
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (!impl_) {
return;
}
if (impl_->mediaControl && impl_->running) {
impl_->mediaControl->Stop();
}
impl_->running = false;
impl_->mediaControl.Reset();
impl_->nullRenderer.Reset();
impl_->sampleGrabber.Reset();
impl_->sampleGrabberFilter.Reset();
impl_->captureFilter.Reset();
impl_->captureGraph.Reset();
impl_->graph.Reset();
if (impl_->comInitialized) {
CoUninitialize();
impl_->comInitialized = false;
}
}
void DirectShowWebcamCapture::captureLoop() {
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
long bufferSize = 0;
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
if (SUCCEEDED(hr) && bufferSize > 0) {
std::vector<BYTE> buffer(static_cast<size_t>(bufferSize));
hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, reinterpret_cast<long*>(buffer.data()));
if (SUCCEEDED(hr)) {
storeFrame(buffer.data(), bufferSize);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
}
if (SUCCEEDED(coinitHr)) {
CoUninitialize();
}
}
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
const int stride = width_ * 4;
const int expectedLength = stride * height_;
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
return;
}
std::vector<BYTE> frame(static_cast<size_t>(expectedLength));
for (int y = 0; y < height_; y += 1) {
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
const BYTE* source = buffer + sourceY * stride;
BYTE* destination = frame.data() + y * stride;
std::copy(source, source + stride, destination);
for (int x = 0; x < width_; x += 1) {
destination[x * 4 + 3] = 255;
}
}
std::scoped_lock lock(frameMutex_);
latestFrame_ = std::move(frame);
}
bool DirectShowWebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false;
}
destination = latestFrame_;
width = width_;
height = height_;
return true;
}
int DirectShowWebcamCapture::width() const {
return width_;
}
int DirectShowWebcamCapture::height() const {
return height_;
}
int DirectShowWebcamCapture::fps() const {
return fps_;
}
const std::wstring& DirectShowWebcamCapture::selectedDeviceName() const {
return selectedDeviceName_;
}
@@ -0,0 +1,50 @@
#pragma once
#include <Windows.h>
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class DirectShowWebcamCapture {
public:
DirectShowWebcamCapture() = default;
~DirectShowWebcamCapture();
DirectShowWebcamCapture(const DirectShowWebcamCapture&) = delete;
DirectShowWebcamCapture& operator=(const DirectShowWebcamCapture&) = delete;
bool initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps);
bool start();
void stop();
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
int width() const;
int height() const;
int fps() const;
const std::wstring& selectedDeviceName() const;
void storeFrame(const BYTE* buffer, long length);
private:
struct Impl;
void captureLoop();
Impl* impl_ = nullptr;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_;
std::vector<BYTE> latestFrame_;
int width_ = 0;
int height_ = 0;
int fps_ = 30;
bool sourceTopDown_ = false;
std::wstring selectedDeviceName_;
};
+758
View File
@@ -0,0 +1,758 @@
#include "audio_sample_utils.h"
#include "mf_encoder.h"
#include "monitor_utils.h"
#include "wasapi_loopback_capture.h"
#include "webcam_capture.h"
#include "wgc_session.h"
#include <winrt/Windows.Foundation.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cctype>
#include <cstdint>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
namespace {
struct CaptureConfig {
int schemaVersion = 1;
int64_t displayId = 0;
int64_t recordingId = 0;
std::string sourceType = "display";
std::string sourceId;
std::string windowHandle;
std::string outputPath;
int fps = 60;
int width = 0;
int height = 0;
MonitorBounds bounds{};
bool hasDisplayBounds = false;
bool captureSystemAudio = false;
bool captureMic = false;
bool captureCursor = false;
bool webcamEnabled = false;
std::string microphoneDeviceId;
std::string microphoneDeviceName;
double microphoneGain = 1.0;
std::string webcamDeviceId;
std::string webcamDeviceName;
std::string webcamDirectShowClsid;
int webcamWidth = 0;
int webcamHeight = 0;
int webcamFps = 0;
};
std::wstring utf8ToWide(const std::string& value) {
if (value.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(static_cast<size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string wideToUtf8(const std::wstring& value) {
if (value.empty()) {
return {};
}
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string result(static_cast<size_t>(size), '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::string jsonEscape(const std::string& value) {
std::string result;
result.reserve(value.size());
for (const char c : value) {
switch (c) {
case '\\':
result += "\\\\";
break;
case '"':
result += "\\\"";
break;
case '\n':
result += "\\n";
break;
case '\r':
result += "\\r";
break;
case '\t':
result += "\\t";
break;
default:
result.push_back(c);
break;
}
}
return result;
}
bool hasVisibleBgraContent(const std::vector<BYTE>& frame) {
if (frame.size() < 4) {
return false;
}
uint64_t lumaTotal = 0;
BYTE maxLuma = 0;
const size_t pixelCount = frame.size() / 4;
const size_t step = std::max<size_t>(1, pixelCount / 4096);
size_t sampledPixels = 0;
for (size_t pixel = 0; pixel < pixelCount; pixel += step) {
const size_t offset = pixel * 4;
const BYTE b = frame[offset + 0];
const BYTE g = frame[offset + 1];
const BYTE r = frame[offset + 2];
const BYTE luma = static_cast<BYTE>((static_cast<uint16_t>(r) * 54 + static_cast<uint16_t>(g) * 183 + static_cast<uint16_t>(b) * 19) >> 8);
lumaTotal += luma;
maxLuma = std::max(maxLuma, luma);
sampledPixels += 1;
}
const uint64_t averageLuma = sampledPixels > 0 ? lumaTotal / sampledPixels : 0;
return maxLuma > 24 || averageLuma > 4;
}
bool findBool(const std::string& json, const std::string& key, bool fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (json.compare(pos, 4, "true") == 0) {
return true;
}
if (json.compare(pos, 5, "false") == 0) {
return false;
}
return fallback;
}
int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stoll(json.substr(pos));
} catch (...) {
return fallback;
}
}
int findInt(const std::string& json, const std::string& key, int fallback) {
return static_cast<int>(findInt64(json, key, fallback));
}
double findDouble(const std::string& json, const std::string& key, double fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stod(json.substr(pos));
} catch (...) {
return fallback;
}
}
std::string findString(const std::string& json, const std::string& key) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return {};
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return {};
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (pos >= json.size() || json[pos] != '"') {
return {};
}
pos += 1;
std::string result;
while (pos < json.size()) {
const char c = json[pos++];
if (c == '"') {
break;
}
if (c == '\\' && pos < json.size()) {
const char escaped = json[pos++];
switch (escaped) {
case '\\':
case '"':
case '/':
result.push_back(escaped);
break;
case 'n':
result.push_back('\n');
break;
case 'r':
result.push_back('\r');
break;
case 't':
result.push_back('\t');
break;
default:
result.push_back(escaped);
break;
}
continue;
}
result.push_back(c);
}
return result;
}
std::string parseWindowHandleFromSourceId(const std::string& sourceId) {
constexpr char prefix[] = "window:";
if (sourceId.rfind(prefix, 0) != 0) {
return {};
}
const size_t start = sizeof(prefix) - 1;
const size_t end = sourceId.find(':', start);
const std::string handle = sourceId.substr(start, end == std::string::npos ? std::string::npos : end - start);
return handle.empty() ? std::string{} : handle;
}
HWND parseWindowHandle(const std::string& value) {
if (value.empty()) {
return nullptr;
}
try {
size_t parsed = 0;
const int base = value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0 ? 16 : 10;
const uint64_t handleValue = std::stoull(value, &parsed, base);
if (parsed != value.size() || handleValue == 0) {
return nullptr;
}
return reinterpret_cast<HWND>(static_cast<uintptr_t>(handleValue));
} catch (...) {
return nullptr;
}
}
bool parseConfig(const std::string& json, CaptureConfig& config) {
config.schemaVersion = findInt(json, "schemaVersion", 1);
config.outputPath = findString(json, "screenPath");
if (config.outputPath.empty()) {
config.outputPath = findString(json, "outputPath");
}
if (config.outputPath.empty()) {
return false;
}
config.recordingId = findInt64(json, "recordingId", 0);
config.sourceType = findString(json, "sourceType");
if (config.sourceType.empty()) {
config.sourceType = "display";
}
config.sourceId = findString(json, "sourceId");
config.windowHandle = findString(json, "windowHandle");
if (config.windowHandle.empty()) {
config.windowHandle = parseWindowHandleFromSourceId(config.sourceId);
}
config.displayId = findInt64(json, "displayId", 0);
config.fps = std::clamp(findInt(json, "fps", 60), 1, 120);
config.width = findInt(json, "videoWidth", findInt(json, "width", 0));
config.height = findInt(json, "videoHeight", findInt(json, "height", 0));
config.bounds.x = findInt(json, "displayX", 0);
config.bounds.y = findInt(json, "displayY", 0);
config.bounds.width = findInt(json, "displayW", 0);
config.bounds.height = findInt(json, "displayH", 0);
config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false);
config.captureSystemAudio = findBool(json, "captureSystemAudio", false);
config.captureMic = findBool(json, "captureMic", false);
config.captureCursor = findBool(json, "captureCursor", false);
config.webcamEnabled = findBool(json, "webcamEnabled", false);
config.microphoneDeviceId = findString(json, "microphoneDeviceId");
config.microphoneDeviceName = findString(json, "microphoneDeviceName");
config.microphoneGain = findDouble(json, "microphoneGain", 1.0);
config.webcamDeviceId = findString(json, "webcamDeviceId");
config.webcamDeviceName = findString(json, "webcamDeviceName");
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
config.webcamWidth = findInt(json, "webcamWidth", 0);
config.webcamHeight = findInt(json, "webcamHeight", 0);
config.webcamFps = findInt(json, "webcamFps", 0);
return true;
}
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "stop" || line == "q" || line == "quit") {
stopRequested = true;
cv.notify_all();
return;
}
}
stopRequested = true;
cv.notify_all();
}
} // namespace
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
return 1;
}
winrt::init_apartment(winrt::apartment_type::multi_threaded);
CaptureConfig config;
if (!parseConfig(argv[1], config)) {
std::cerr << "ERROR: Failed to parse config JSON" << std::endl;
return 1;
}
std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;
WgcSession session;
if (config.sourceType == "display") {
HMONITOR monitor = findMonitorForCapture(
config.displayId,
config.hasDisplayBounds ? &config.bounds : nullptr);
if (!monitor) {
std::cerr << "ERROR: Could not resolve monitor" << std::endl;
return 1;
}
if (!session.initialize(monitor, config.fps, config.captureCursor)) {
std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl;
return 1;
}
} else if (config.sourceType == "window") {
HWND window = parseWindowHandle(config.windowHandle);
if (!window || !IsWindow(window)) {
std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl;
return 1;
}
if (!session.initialize(window, config.fps, config.captureCursor)) {
std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl;
return 1;
}
} else {
std::cerr << "ERROR: Unsupported native capture source type: " << config.sourceType << std::endl;
return 1;
}
// WGC owns the captured texture size. Encoding must use that exact size
// until a dedicated GPU scaling pass is introduced; CopyResource requires
// matching resource dimensions.
int width = session.captureWidth();
int height = session.captureHeight();
width = (std::max(2, width) / 2) * 2;
height = (std::max(2, height) / 2) * 2;
const int pixels = width * height;
const int bitrate = pixels >= 3840 * 2160 ? 45'000'000 : pixels >= 2560 * 1440 ? 28'000'000 : 18'000'000;
WebcamCapture webcamCapture;
bool webcamActive = false;
if (config.webcamEnabled) {
if (!webcamCapture.initialize(
utf8ToWide(config.webcamDeviceId),
utf8ToWide(config.webcamDeviceName),
utf8ToWide(config.webcamDirectShowClsid),
config.webcamWidth,
config.webcamHeight,
config.webcamFps > 0 ? config.webcamFps : config.fps)) {
std::cerr << "ERROR: Failed to initialize native webcam capture" << std::endl;
return 1;
}
std::cout << "{\"event\":\"webcam-format\",\"schemaVersion\":2,\"width\":" << webcamCapture.width()
<< ",\"height\":" << webcamCapture.height()
<< ",\"fps\":" << webcamCapture.fps()
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
<< "\"}" << std::endl;
}
WasapiLoopbackCapture loopbackCapture;
WasapiLoopbackCapture microphoneCapture;
const AudioInputFormat* audioFormat = nullptr;
AudioInputFormat encoderAudioFormat{};
AudioInputFormat systemAudioFormat{};
AudioInputFormat microphoneAudioFormat{};
if (config.captureSystemAudio) {
if (!loopbackCapture.initializeSystemLoopback()) {
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
return 1;
}
systemAudioFormat = loopbackCapture.inputFormat();
audioFormat = &loopbackCapture.inputFormat();
}
if (config.captureMic) {
if (!microphoneCapture.initializeMicrophone(
utf8ToWide(config.microphoneDeviceId),
utf8ToWide(config.microphoneDeviceName))) {
std::cerr << "ERROR: Failed to initialize WASAPI microphone capture" << std::endl;
return 1;
}
microphoneAudioFormat = microphoneCapture.inputFormat();
if (!audioFormat) {
audioFormat = &microphoneCapture.inputFormat();
}
}
if (audioFormat) {
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":" << audioFormat->sampleRate
<< ",\"channels\":" << audioFormat->channels
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample
<< ",\"system\":" << (config.captureSystemAudio ? "true" : "false")
<< ",\"microphone\":" << (config.captureMic ? "true" : "false");
if (config.captureMic) {
std::cout << ",\"microphoneDeviceName\":\""
<< jsonEscape(wideToUtf8(microphoneCapture.selectedDeviceName())) << "\"";
}
std::cout << "}" << std::endl;
encoderAudioFormat = makeAacCompatibleAudioFormat(*audioFormat);
std::cout << "{\"event\":\"encoder-audio-format\",\"schemaVersion\":2,\"sampleRate\":"
<< encoderAudioFormat.sampleRate
<< ",\"channels\":" << encoderAudioFormat.channels
<< ",\"bitsPerSample\":" << encoderAudioFormat.bitsPerSample
<< "}" << std::endl;
}
MFEncoder encoder;
if (!encoder.initialize(
utf8ToWide(config.outputPath),
width,
height,
config.fps,
bitrate,
session.device(),
session.context(),
audioFormat ? &encoderAudioFormat : nullptr)) {
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
return 1;
}
std::mutex mutex;
std::condition_variable cv;
std::atomic<bool> stopRequested = false;
std::atomic<bool> firstFrameWritten = false;
std::atomic<bool> encodeFailed = false;
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
int64_t latestFrameTimestampHns = 0;
int64_t firstFrameTimestampHns = -1;
std::vector<BYTE> latestWebcamFrame;
int latestWebcamWidth = 0;
int latestWebcamHeight = 0;
bool hasVisibleWebcamFrame = false;
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
if (stopRequested) {
return;
}
std::scoped_lock lock(mutex);
if (!latestFrameTexture) {
D3D11_TEXTURE2D_DESC desc{};
texture->GetDesc(&desc);
desc.BindFlags = 0;
desc.CPUAccessFlags = 0;
desc.MiscFlags = 0;
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
return;
}
}
session.context()->CopyResource(latestFrameTexture.Get(), texture);
latestFrameTimestampHns = timestampHns;
if (!firstFrameWritten.exchange(true)) {
cv.notify_all();
}
});
auto writeVideoFrames = [&]() {
const auto startedAt = std::chrono::steady_clock::now();
uint64_t frameIndex = 0;
int64_t lastEncodedVideoTimestampHns = -1;
while (!stopRequested && !encodeFailed) {
{
std::scoped_lock lock(mutex);
if (webcamActive) {
std::vector<BYTE> candidateWebcamFrame;
int candidateWebcamWidth = 0;
int candidateWebcamHeight = 0;
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
hasVisibleBgraContent(candidateWebcamFrame)) {
latestWebcamFrame = std::move(candidateWebcamFrame);
latestWebcamWidth = candidateWebcamWidth;
latestWebcamHeight = candidateWebcamHeight;
hasVisibleWebcamFrame = true;
}
}
const BgraFrameView webcamFrame{
hasVisibleWebcamFrame && !latestWebcamFrame.empty() ? latestWebcamFrame.data() : nullptr,
latestWebcamWidth,
latestWebcamHeight,
};
const int64_t syntheticTimestampHns =
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps);
const int64_t sourceTimestampHns =
latestFrameTimestampHns > 0 ? latestFrameTimestampHns : syntheticTimestampHns;
if (firstFrameTimestampHns < 0) {
firstFrameTimestampHns = sourceTimestampHns;
}
int64_t frameTimestampHns =
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
if (lastEncodedVideoTimestampHns >= 0 &&
frameTimestampHns <= lastEncodedVideoTimestampHns) {
frameTimestampHns =
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
}
if (latestFrameTexture && !encoder.writeFrame(
latestFrameTexture.Get(),
frameTimestampHns,
webcamFrame.data ? &webcamFrame : nullptr)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
return;
}
if (latestFrameTexture) {
lastEncodedVideoTimestampHns = frameTimestampHns;
}
}
frameIndex += 1;
const auto nextDeadline = startedAt +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(static_cast<double>(frameIndex) / config.fps));
std::this_thread::sleep_until(nextDeadline);
}
};
std::thread videoWriterThread;
auto stopVideoWriter = [&]() {
if (videoWriterThread.joinable()) {
videoWriterThread.join();
}
};
auto startVideoWriter = [&]() {
videoWriterThread = std::thread(writeVideoFrames);
};
std::unique_ptr<AudioMixer> audioMixer;
auto startAudioCaptures = [&]() -> bool {
if (!audioFormat) {
return true;
}
audioMixer = std::make_unique<AudioMixer>(
encoderAudioFormat,
config.captureSystemAudio ? systemAudioFormat : encoderAudioFormat,
config.captureMic ? microphoneAudioFormat : encoderAudioFormat,
config.captureSystemAudio,
config.captureMic,
config.microphoneGain,
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
return false;
}
return true;
});
if (!audioMixer->start()) {
std::cerr << "ERROR: Failed to start native audio mixer" << std::endl;
return false;
}
if (config.captureMic) {
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (stopRequested || !audioMixer) {
return;
}
audioMixer->pushMicrophone(data, byteCount);
})) {
std::cerr << "ERROR: Failed to start WASAPI microphone capture" << std::endl;
audioMixer->stop();
return false;
}
}
if (config.captureSystemAudio) {
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (stopRequested || !audioMixer) {
return;
}
audioMixer->pushSystem(data, byteCount);
})) {
std::cerr << "ERROR: Failed to start WASAPI loopback capture" << std::endl;
microphoneCapture.stop();
audioMixer->stop();
return false;
}
}
return true;
};
if (!startAudioCaptures()) {
return 1;
}
if (config.webcamEnabled) {
if (!webcamCapture.start()) {
microphoneCapture.stop();
loopbackCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
std::cerr << "ERROR: Failed to start native webcam capture" << std::endl;
return 1;
}
webcamActive = true;
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
std::vector<BYTE> candidateWebcamFrame;
int candidateWebcamWidth = 0;
int candidateWebcamHeight = 0;
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
hasVisibleBgraContent(candidateWebcamFrame)) {
latestWebcamFrame = std::move(candidateWebcamFrame);
latestWebcamWidth = candidateWebcamWidth;
latestWebcamHeight = candidateWebcamHeight;
hasVisibleWebcamFrame = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!hasVisibleWebcamFrame) {
std::cerr << "WARNING: Native webcam started but no visible frame was available before screen capture"
<< std::endl;
}
}
if (!session.start()) {
webcamCapture.stop();
microphoneCapture.stop();
loopbackCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
std::cerr << "ERROR: Failed to start WGC session" << std::endl;
return 1;
}
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
{
std::unique_lock lock(mutex);
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
return firstFrameWritten.load() || stopRequested.load();
});
if (!started || !firstFrameWritten) {
stopRequested = true;
cv.notify_all();
if (stdinThread.joinable()) {
stdinThread.detach();
}
microphoneCapture.stop();
loopbackCapture.stop();
webcamCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
session.stop();
std::cerr << "ERROR: Timed out waiting for first WGC frame" << std::endl;
return 1;
}
}
if (audioMixer) {
audioMixer->beginTimeline();
}
startVideoWriter();
std::cout << "{\"event\":\"recording-started\",\"schemaVersion\":2}" << std::endl;
std::cout << "Recording started" << std::endl;
{
std::unique_lock lock(mutex);
cv.wait(lock, [&] {
return stopRequested.load();
});
}
microphoneCapture.stop();
loopbackCapture.stop();
webcamCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
stopVideoWriter();
session.stop();
{
std::scoped_lock lock(mutex);
encoder.finalize();
}
if (stdinThread.joinable()) {
stdinThread.detach();
}
if (encodeFailed) {
std::cerr << "ERROR: Failed to encode WGC frame" << std::endl;
return 1;
}
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
<< jsonEscape(config.outputPath) << "\"}" << std::endl;
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
return 0;
}
@@ -0,0 +1,363 @@
#include "mf_encoder.h"
#include "audio_sample_utils.h"
#include <mfapi.h>
#include <mferror.h>
#include <propvarutil.h>
#include <algorithm>
#include <cstring>
#include <iostream>
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
}
void setFrameRate(IMFMediaType* type, UINT32 fps) {
MFSetAttributeRatio(type, MF_MT_FRAME_RATE, fps, 1);
}
void setPixelAspectRatio(IMFMediaType* type) {
MFSetAttributeRatio(type, MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
}
void setAudioFormat(IMFMediaType* type, UINT32 channels, UINT32 sampleRate, UINT32 bitsPerSample) {
type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, channels);
type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, sampleRate);
type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, bitsPerSample);
}
void compositeWebcam(BYTE* destination, int width, int height, const BgraFrameView& webcamFrame) {
if (!webcamFrame.data || webcamFrame.width <= 0 || webcamFrame.height <= 0 || width <= 0 || height <= 0) {
return;
}
const int margin = std::max(16, std::min(width, height) / 60);
const int maxOverlayWidth = std::max(2, width / 4);
int overlayWidth = maxOverlayWidth;
int overlayHeight = static_cast<int>(
(static_cast<int64_t>(overlayWidth) * webcamFrame.height) / std::max(1, webcamFrame.width));
const int maxOverlayHeight = std::max(2, height / 3);
if (overlayHeight > maxOverlayHeight) {
overlayHeight = maxOverlayHeight;
overlayWidth = static_cast<int>(
(static_cast<int64_t>(overlayHeight) * webcamFrame.width) / std::max(1, webcamFrame.height));
}
overlayWidth = std::max(2, std::min(overlayWidth, width - margin * 2));
overlayHeight = std::max(2, std::min(overlayHeight, height - margin * 2));
const int originX = std::max(0, width - overlayWidth - margin);
const int originY = std::max(0, height - overlayHeight - margin);
for (int y = 0; y < overlayHeight; y += 1) {
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * webcamFrame.height) / overlayHeight);
BYTE* destinationRow = destination + ((originY + y) * width + originX) * 4;
for (int x = 0; x < overlayWidth; x += 1) {
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * webcamFrame.width) / overlayWidth);
const BYTE* source = webcamFrame.data + (sourceY * webcamFrame.width + sourceX) * 4;
BYTE* target = destinationRow + x * 4;
target[0] = source[0];
target[1] = source[1];
target[2] = source[2];
target[3] = 255;
}
}
}
} // namespace
MFEncoder::~MFEncoder() {
finalize();
}
bool MFEncoder::initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat) {
width_ = (std::max(2, width) / 2) * 2;
height_ = (std::max(2, height) / 2) * 2;
fps_ = std::max(1, fps);
device_ = device;
context_ = context;
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
outputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast<UINT32>(std::max(1, bitrate)));
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
setFrameSize(outputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(outputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(outputType.Get());
if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
"MFCreateSinkWriterFromURL")) {
return false;
}
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
return false;
}
if (audioFormat && !configureAudioStream(*audioFormat)) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(width_ * 4));
setFrameSize(inputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(inputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(inputType.Get());
if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType")) {
return false;
}
if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) {
return false;
}
return true;
}
bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) {
if (!sinkWriter_) {
return false;
}
if (audioFormat.sampleRate == 0 || audioFormat.channels == 0 || audioFormat.blockAlign == 0) {
std::cerr << "ERROR: Invalid audio input format" << std::endl;
return false;
}
const AudioInputFormat encoderFormat = makeAacCompatibleAudioFormat(audioFormat);
const UINT32 aacBytesPerSecond = 24'000;
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
setAudioFormat(outputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, 16);
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
inputType->SetGUID(MF_MT_SUBTYPE, encoderFormat.subtype);
setAudioFormat(inputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, encoderFormat.bitsPerSample);
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, encoderFormat.blockAlign);
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, encoderFormat.avgBytesPerSec);
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType(audio)")) {
return false;
}
hasAudioStream_ = true;
return true;
}
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
if (stagingTexture_) {
return true;
}
D3D11_TEXTURE2D_DESC desc{};
texture->GetDesc(&desc);
desc.Width = static_cast<UINT>(width_);
desc.Height = static_cast<UINT>(height_);
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.SampleDesc.Quality = 0;
desc.Usage = D3D11_USAGE_STAGING;
desc.BindFlags = 0;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
desc.MiscFlags = 0;
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
"CreateTexture2D(staging)");
}
bool MFEncoder::copyFrameToBuffer(
ID3D11Texture2D* texture,
BYTE* destination,
DWORD destinationSize,
const BgraFrameView* webcamFrame) {
if (!ensureStagingTexture(texture)) {
return false;
}
context_->CopyResource(stagingTexture_.Get(), texture);
D3D11_MAPPED_SUBRESOURCE mapped{};
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
return false;
}
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
if (destinationSize < requiredBytes) {
context_->Unmap(stagingTexture_.Get(), 0);
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
return false;
}
auto* source = static_cast<const BYTE*>(mapped.pData);
for (int y = 0; y < height_; y += 1) {
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
}
if (webcamFrame) {
compositeWebcam(destination, width_, height_, *webcamFrame);
}
context_->Unmap(stagingTexture_.Get(), 0);
return true;
}
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_) {
return false;
}
if (firstTimestampHns_ < 0) {
firstTimestampHns_ = timestampHns;
}
int64_t sampleTime = timestampHns - firstTimestampHns_;
if (sampleTime <= lastTimestampHns_) {
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
}
const int64_t sampleDuration = 10'000'000LL / fps_;
lastTimestampHns_ = sampleTime;
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
return false;
}
BYTE* data = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&data, &maxLength, &currentLength), "IMFMediaBuffer::Lock")) {
return false;
}
const bool copied = copyFrameToBuffer(texture, data, maxLength, webcamFrame);
buffer->Unlock();
if (!copied) {
return false;
}
buffer->SetCurrentLength(frameBytes);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(sampleTime);
sample->SetSampleDuration(sampleDuration);
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
}
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
return false;
}
if (!data || byteCount == 0 || durationHns <= 0) {
return true;
}
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
return false;
}
BYTE* destination = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&destination, &maxLength, &currentLength),
"IMFMediaBuffer::Lock(audio)")) {
return false;
}
if (maxLength < byteCount) {
buffer->Unlock();
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
return false;
}
std::memcpy(destination, data, byteCount);
buffer->Unlock();
buffer->SetCurrentLength(byteCount);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
sample->SetSampleDuration(durationHns);
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
}
bool MFEncoder::finalize() {
std::scoped_lock writerLock(writerMutex_);
if (finalized_) {
return true;
}
finalized_ = true;
bool ok = true;
if (sinkWriter_) {
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
sinkWriter_.Reset();
}
stagingTexture_.Reset();
context_.Reset();
device_.Reset();
MFShutdown();
return ok;
}
@@ -0,0 +1,73 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <wrl/client.h>
#include <cstdint>
#include <mutex>
#include <string>
struct BgraFrameView {
const BYTE* data = nullptr;
int width = 0;
int height = 0;
};
struct AudioInputFormat {
GUID subtype = MFAudioFormat_PCM;
UINT32 sampleRate = 0;
UINT32 channels = 0;
UINT32 bitsPerSample = 0;
UINT32 blockAlign = 0;
UINT32 avgBytesPerSec = 0;
};
class MFEncoder {
public:
MFEncoder() = default;
~MFEncoder();
MFEncoder(const MFEncoder&) = delete;
MFEncoder& operator=(const MFEncoder&) = delete;
bool initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat = nullptr);
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
bool finalize();
private:
bool ensureStagingTexture(ID3D11Texture2D* texture);
bool copyFrameToBuffer(
ID3D11Texture2D* texture,
BYTE* destination,
DWORD destinationSize,
const BgraFrameView* webcamFrame);
bool configureAudioStream(const AudioInputFormat& audioFormat);
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
Microsoft::WRL::ComPtr<ID3D11Device> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
std::mutex writerMutex_;
DWORD videoStreamIndex_ = 0;
DWORD audioStreamIndex_ = 0;
bool hasAudioStream_ = false;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
int64_t firstTimestampHns_ = -1;
int64_t lastTimestampHns_ = -1;
bool finalized_ = false;
};
@@ -0,0 +1,88 @@
#include "monitor_utils.h"
#include <algorithm>
#include <cmath>
#include <vector>
namespace {
struct MonitorCandidate {
HMONITOR monitor = nullptr;
RECT rect{};
};
std::vector<MonitorCandidate> enumerateMonitors() {
std::vector<MonitorCandidate> monitors;
EnumDisplayMonitors(
nullptr,
nullptr,
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
result->push_back({monitor, *rect});
return TRUE;
},
reinterpret_cast<LPARAM>(&monitors));
return monitors;
}
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
return rect.left == bounds.x &&
rect.top == bounds.y &&
(rect.right - rect.left) == bounds.width &&
(rect.bottom - rect.top) == bounds.height;
}
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
const LONG left = std::max<LONG>(rect.left, bounds.x);
const LONG top = std::max<LONG>(rect.top, bounds.y);
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
if (right <= left || bottom <= top) {
return 0;
}
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
}
} // namespace
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
const auto monitors = enumerateMonitors();
if (monitors.empty()) {
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
// Electron's display_id is not stable across all Windows capture backends.
// Bounds are the most reliable contract because they come from Electron's
// selected display and match the WGC monitor coordinate space.
if (bounds && bounds->width > 0 && bounds->height > 0) {
for (const auto& candidate : monitors) {
if (rectMatchesBounds(candidate.rect, *bounds)) {
return candidate.monitor;
}
}
HMONITOR bestMonitor = nullptr;
int64_t bestArea = 0;
for (const auto& candidate : monitors) {
const int64_t area = overlapArea(candidate.rect, *bounds);
if (area > bestArea) {
bestArea = area;
bestMonitor = candidate.monitor;
}
}
if (bestMonitor) {
return bestMonitor;
}
}
// Best-effort fallback for helpers invoked without bounds. Some callers pass
// zero-based ids while Win32 monitor handles are pointer values, so only use
// this when it exactly matches the HMONITOR value.
for (const auto& candidate : monitors) {
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
return candidate.monitor;
}
}
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
@@ -0,0 +1,14 @@
#pragma once
#include <Windows.h>
#include <cstdint>
struct MonitorBounds {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
@@ -0,0 +1,411 @@
#include "wasapi_loopback_capture.h"
#include <Functiondiscoverykeys_devpkey.h>
#include <ksmedia.h>
#include <propvarutil.h>
#include <algorithm>
#include <chrono>
#include <cwctype>
#include <iostream>
namespace {
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
constexpr int64_t HnsPerSecond = 10'000'000;
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (format->wFormatTag == WAVE_FORMAT_PCM) {
return MFAudioFormat_PCM;
}
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
return MFAudioFormat_PCM;
}
}
return GUID_NULL;
}
std::wstring normalizeDeviceName(const std::wstring& value) {
std::wstring result;
result.reserve(value.size());
bool lastWasSpace = true;
for (const wchar_t c : value) {
if (std::iswalnum(c)) {
result.push_back(static_cast<wchar_t>(std::towlower(c)));
lastWasSpace = false;
} else if (!lastWasSpace) {
result.push_back(L' ');
lastWasSpace = true;
}
}
if (!result.empty() && result.back() == L' ') {
result.pop_back();
}
return result;
}
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
const std::wstring candidate = normalizeDeviceName(candidateName);
const std::wstring id = normalizeDeviceName(candidateId);
const std::wstring requested = normalizeDeviceName(requestedName);
if (requested.empty()) {
return 0;
}
if (candidate == requested) {
return 1000;
}
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
return 900;
}
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
return 800;
}
int score = 0;
size_t pos = 0;
while (pos < requested.size()) {
const size_t end = requested.find(L' ', pos);
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
if (candidate.find(word) != std::wstring::npos) {
score += 100;
} else if (id.find(word) != std::wstring::npos) {
score += 50;
}
}
if (end == std::wstring::npos) {
break;
}
pos = end + 1;
}
return score;
}
std::wstring getDeviceFriendlyName(IMMDevice* device) {
if (!device) {
return {};
}
Microsoft::WRL::ComPtr<IPropertyStore> properties;
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
if (FAILED(hr) || !properties) {
return {};
}
PROPVARIANT value;
PropVariantInit(&value);
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
std::wstring name;
if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) {
name = value.pwszVal;
}
PropVariantClear(&value);
return name;
}
} // namespace
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
stop();
if (mixFormat_) {
CoTaskMemFree(mixFormat_);
mixFormat_ = nullptr;
}
}
bool WasapiLoopbackCapture::initializeSystemLoopback() {
return initialize(WasapiCaptureEndpoint::SystemLoopback, {}, {});
}
bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName) {
return initialize(WasapiCaptureEndpoint::Microphone, deviceId, deviceName);
}
bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) {
HRESULT hr = CoCreateInstance(
__uuidof(MMDeviceEnumerator),
nullptr,
CLSCTX_ALL,
IID_PPV_ARGS(&deviceEnumerator_));
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
return false;
}
if (endpoint == WasapiCaptureEndpoint::Microphone && !deviceId.empty() && deviceId != L"default") {
hr = deviceEnumerator_->GetDevice(deviceId.c_str(), &device_);
if (FAILED(hr)) {
std::wcerr << L"WARNING: Could not resolve microphone device id directly"
<< std::endl;
device_.Reset();
}
}
if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && !deviceName.empty()) {
if (!resolveMicrophoneByName(deviceName)) {
std::wcerr << L"WARNING: Could not resolve microphone by name; using default capture endpoint"
<< std::endl;
}
}
if (!device_) {
const EDataFlow flow =
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
if (!succeeded(hr, "GetDefaultAudioEndpoint")) {
return false;
}
}
selectedDeviceName_ = getDeviceFriendlyName(device_.Get());
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
return false;
}
hr = audioClient_->GetMixFormat(&mixFormat_);
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
return false;
}
if (!resolveInputFormat(mixFormat_)) {
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
return false;
}
const DWORD streamFlags =
endpoint == WasapiCaptureEndpoint::SystemLoopback ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0;
hr = audioClient_->Initialize(
AUDCLNT_SHAREMODE_SHARED,
streamFlags,
BufferDurationHns,
0,
mixFormat_,
nullptr);
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
return false;
}
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
return false;
}
return true;
}
bool WasapiLoopbackCapture::resolveMicrophoneByName(const std::wstring& deviceName) {
if (!deviceEnumerator_ || deviceName.empty()) {
return false;
}
Microsoft::WRL::ComPtr<IMMDeviceCollection> devices;
HRESULT hr = deviceEnumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices);
if (!succeeded(hr, "IMMDeviceEnumerator::EnumAudioEndpoints(eCapture)")) {
return false;
}
UINT count = 0;
hr = devices->GetCount(&count);
if (!succeeded(hr, "IMMDeviceCollection::GetCount")) {
return false;
}
Microsoft::WRL::ComPtr<IMMDevice> bestDevice;
std::wstring bestId;
std::wstring bestName;
int bestScore = 0;
for (UINT i = 0; i < count; ++i) {
Microsoft::WRL::ComPtr<IMMDevice> candidate;
hr = devices->Item(i, &candidate);
if (FAILED(hr) || !candidate) {
continue;
}
LPWSTR rawId = nullptr;
std::wstring candidateId;
if (SUCCEEDED(candidate->GetId(&rawId)) && rawId) {
candidateId = rawId;
CoTaskMemFree(rawId);
}
const std::wstring candidateName = getDeviceFriendlyName(candidate.Get());
const int score = scoreDeviceName(candidateName, candidateId, deviceName);
std::wcerr << L"Native microphone candidate: " << candidateName << L" score=" << score << std::endl;
if (score > bestScore) {
bestScore = score;
bestDevice = candidate;
bestId = candidateId;
bestName = candidateName;
}
}
if (!bestDevice || bestScore <= 0) {
return false;
}
device_ = bestDevice;
std::wcerr << L"Selected native microphone endpoint: " << bestName << L" id=" << bestId << std::endl;
return true;
}
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
const GUID subtype = audioSubtypeFromFormat(mixFormat);
if (subtype == GUID_NULL) {
return false;
}
inputFormat_.subtype = subtype;
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
inputFormat_.channels = mixFormat->nChannels;
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
inputFormat_.blockAlign = mixFormat->nBlockAlign;
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
}
bool WasapiLoopbackCapture::start(AudioCallback callback) {
if (!audioClient_ || !captureClient_ || !callback) {
return false;
}
callback_ = std::move(callback);
stopRequested_ = false;
writtenFrames_ = 0;
lastDevicePositionEnd_ = 0;
hasLastDevicePosition_ = false;
HRESULT hr = audioClient_->Start();
if (!succeeded(hr, "IAudioClient::Start")) {
return false;
}
thread_ = std::thread([this] {
captureLoop();
});
return true;
}
void WasapiLoopbackCapture::stop() {
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (audioClient_) {
audioClient_->Stop();
}
}
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
return inputFormat_;
}
const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
return selectedDeviceName_;
}
void WasapiLoopbackCapture::captureLoop() {
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
constexpr uint64_t MaxSilenceChunkFrames = 4800;
uint64_t remainingFrames = frames;
int64_t currentTimestampHns = timestampHns;
while (remainingFrames > 0 && !stopRequested_) {
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
const int64_t chunkDurationHns =
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
silenceBuffer_.assign(chunkBytes, 0);
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
remainingFrames -= chunkFrames;
currentTimestampHns += chunkDurationHns;
}
};
while (!stopRequested_) {
UINT32 packetFrames = 0;
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
while (packetFrames > 0 && !stopRequested_) {
BYTE* data = nullptr;
UINT32 framesAvailable = 0;
DWORD flags = 0;
UINT64 devicePosition = 0;
UINT64 qpcPosition = 0;
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
(void)qpcPosition;
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
const int64_t gapTimestampHns =
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
emitSilenceFrames(gapFrames, gapTimestampHns);
}
}
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
const int64_t timestampHns =
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
const int64_t durationHns =
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
inputFormat_.sampleRate);
if (byteCount > 0) {
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
silenceBuffer_.assign(byteCount, 0);
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
} else {
callback_(data, byteCount, timestampHns, durationHns);
}
}
writtenFrames_ += framesAvailable;
lastDevicePositionEnd_ = devicePosition + framesAvailable;
hasLastDevicePosition_ = true;
captureClient_->ReleaseBuffer(framesAvailable);
hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
<< std::hex << hr << std::dec << ")" << std::endl;
packetFrames = 0;
break;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
@@ -0,0 +1,60 @@
#pragma once
#include "mf_encoder.h"
#include <Windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <wrl/client.h>
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <thread>
#include <vector>
enum class WasapiCaptureEndpoint {
SystemLoopback,
Microphone,
};
class WasapiLoopbackCapture {
public:
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
WasapiLoopbackCapture() = default;
~WasapiLoopbackCapture();
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
bool initializeSystemLoopback();
bool initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName);
bool start(AudioCallback callback);
void stop();
const AudioInputFormat& inputFormat() const;
const std::wstring& selectedDeviceName() const;
private:
bool initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName);
bool resolveMicrophoneByName(const std::wstring& deviceName);
void captureLoop();
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
Microsoft::WRL::ComPtr<IMMDevice> device_;
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
WAVEFORMATEX* mixFormat_ = nullptr;
AudioInputFormat inputFormat_{};
std::wstring selectedDeviceName_;
AudioCallback callback_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::vector<BYTE> silenceBuffer_;
uint64_t writtenFrames_ = 0;
uint64_t lastDevicePositionEnd_ = 0;
bool hasLastDevicePosition_ = false;
};
@@ -0,0 +1,417 @@
#include "webcam_capture.h"
#include <mfapi.h>
#include <mferror.h>
#include <propvarutil.h>
#include <algorithm>
#include <chrono>
#include <cwctype>
#include <iostream>
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
WCHAR* value = nullptr;
UINT32 length = 0;
if (FAILED(activate->GetAllocatedString(key, &value, &length)) || !value) {
return {};
}
std::wstring result(value, value + length);
CoTaskMemFree(value);
return result;
}
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
if (haystack.empty() || needle.empty()) {
return false;
}
std::wstring lowerHaystack = haystack;
std::wstring lowerNeedle = needle;
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
}
std::wstring normalizeDeviceName(const std::wstring& value) {
std::wstring normalized;
normalized.reserve(value.size());
bool lastWasSpace = true;
for (const wchar_t ch : value) {
if (std::iswalnum(ch)) {
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
lastWasSpace = false;
continue;
}
if (!lastWasSpace) {
normalized.push_back(L' ');
lastWasSpace = true;
}
}
while (!normalized.empty() && normalized.back() == L' ') {
normalized.pop_back();
}
return normalized;
}
std::vector<std::wstring> splitWords(const std::wstring& value) {
std::vector<std::wstring> words;
size_t start = 0;
while (start < value.size()) {
const size_t end = value.find(L' ', start);
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
words.push_back(word);
}
if (end == std::wstring::npos) {
break;
}
start = end + 1;
}
return words;
}
int deviceMatchScore(
const std::wstring& candidateName,
const std::wstring& candidateLink,
const std::wstring& requestedName,
const std::wstring& requestedId) {
int score = 0;
const auto normalizedName = normalizeDeviceName(candidateName);
const auto normalizedLink = normalizeDeviceName(candidateLink);
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
if (!normalizedRequestedName.empty()) {
if (normalizedName == normalizedRequestedName) {
score = std::max(score, 1000);
}
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
score = std::max(score, 900);
}
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
score = std::max(score, 800);
}
int wordScore = 0;
for (const auto& word : splitWords(normalizedRequestedName)) {
if (normalizedName.find(word) != std::wstring::npos) {
wordScore += 100;
} else if (normalizedLink.find(word) != std::wstring::npos) {
wordScore += 50;
}
}
score = std::max(score, wordScore);
}
if (!normalizedRequestedId.empty()) {
if (containsInsensitive(normalizedLink, normalizedRequestedId)) {
score = std::max(score, 700);
}
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
score = std::max(score, 600);
}
}
return score;
}
} // namespace
WebcamCapture::~WebcamCapture() {
stop();
}
bool WebcamCapture::initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps) {
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
usingDirectShow_ = false;
selectedMatchScore_ = 0;
if (!succeeded(MFStartup(MF_VERSION), "MFStartup(webcam)")) {
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
return false;
}
mfStarted_ = true;
if (!selectDevice(deviceId, deviceName)) {
if (mfStarted_) {
MFShutdown();
mfStarted_ = false;
}
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
return false;
}
if ((!deviceId.empty() || !deviceName.empty()) && selectedMatchScore_ <= 0) {
if (mediaSource_) {
mediaSource_->Shutdown();
}
sourceReader_.Reset();
mediaSource_.Reset();
if (mfStarted_) {
MFShutdown();
mfStarted_ = false;
}
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
std::cerr << "ERROR: Requested webcam device was not found by native Windows webcam providers"
<< std::endl;
return false;
}
return configureReader(requestedWidth, requestedHeight, fps_);
}
bool WebcamCapture::selectDevice(const std::wstring& deviceId, const std::wstring& deviceName) {
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
if (!succeeded(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(webcam enumeration)")) {
return false;
}
if (!succeeded(attributes->SetGUID(
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID),
"SetGUID(webcam source type)")) {
return false;
}
IMFActivate** devices = nullptr;
UINT32 deviceCount = 0;
HRESULT hr = MFEnumDeviceSources(attributes.Get(), &devices, &deviceCount);
if (!succeeded(hr, "MFEnumDeviceSources") || deviceCount == 0) {
if (devices) {
CoTaskMemFree(devices);
}
std::cerr << "ERROR: No native Windows webcam devices were found" << std::endl;
return false;
}
UINT32 selectedIndex = 0;
int bestScore = 0;
for (UINT32 index = 0; index < deviceCount; index += 1) {
const std::wstring name = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
const std::wstring symbolicLink = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK);
const int score = deviceMatchScore(name, symbolicLink, deviceName, deviceId);
std::wcerr << L"INFO: Native webcam candidate [" << index << L"] name=\"" << name << L"\" score=" << score << std::endl;
if (score > bestScore) {
selectedIndex = index;
bestScore = score;
}
}
if ((!deviceId.empty() || !deviceName.empty()) && bestScore <= 0) {
std::cerr << "WARNING: Requested webcam device was not found by Media Foundation; trying DirectShow"
<< std::endl;
}
selectedMatchScore_ = bestScore;
selectedDeviceName_ = readAllocatedString(devices[selectedIndex], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
hr = devices[selectedIndex]->ActivateObject(IID_PPV_ARGS(&mediaSource_));
for (UINT32 index = 0; index < deviceCount; index += 1) {
devices[index]->Release();
}
CoTaskMemFree(devices);
return succeeded(hr, "ActivateObject(webcam)");
}
bool WebcamCapture::configureReader(int requestedWidth, int requestedHeight, int requestedFps) {
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
if (!succeeded(MFCreateAttributes(&attributes, 2), "MFCreateAttributes(webcam reader)")) {
return false;
}
attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);
if (!succeeded(MFCreateSourceReaderFromMediaSource(mediaSource_.Get(), attributes.Get(), &sourceReader_),
"MFCreateSourceReaderFromMediaSource(webcam)")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> mediaType;
if (!succeeded(MFCreateMediaType(&mediaType), "MFCreateMediaType(webcam output)")) {
return false;
}
mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
mediaType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
if (requestedWidth > 0 && requestedHeight > 0) {
MFSetAttributeSize(mediaType.Get(), MF_MT_FRAME_SIZE, static_cast<UINT32>(requestedWidth), static_cast<UINT32>(requestedHeight));
}
MFSetAttributeRatio(mediaType.Get(), MF_MT_FRAME_RATE, static_cast<UINT32>(std::max(1, requestedFps)), 1);
if (!succeeded(sourceReader_->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, mediaType.Get()),
"SetCurrentMediaType(webcam RGB32)")) {
return false;
}
sourceReader_->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
sourceReader_->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
Microsoft::WRL::ComPtr<IMFMediaType> currentType;
if (!succeeded(sourceReader_->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &currentType),
"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,
&timestamp,
&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, &currentLength)) || !data) {
continue;
}
const DWORD expectedLength = static_cast<DWORD>(std::max(0, width_) * std::max(0, height_) * 4);
if (currentLength >= expectedLength && expectedLength > 0) {
std::scoped_lock lock(frameMutex_);
latestFrame_.assign(data, data + expectedLength);
}
buffer->Unlock();
}
CoUninitialize();
}
bool WebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
if (usingDirectShow_) {
return directShowCapture_.copyLatestFrame(destination, width, height);
}
std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false;
}
destination = latestFrame_;
width = width_;
height = height_;
return true;
}
int WebcamCapture::width() const {
if (usingDirectShow_) {
return directShowCapture_.width();
}
return width_;
}
int WebcamCapture::height() const {
if (usingDirectShow_) {
return directShowCapture_.height();
}
return height_;
}
int WebcamCapture::fps() const {
if (usingDirectShow_) {
return directShowCapture_.fps();
}
return fps_;
}
const std::wstring& WebcamCapture::selectedDeviceName() const {
if (usingDirectShow_) {
return directShowCapture_.selectedDeviceName();
}
return selectedDeviceName_;
}
@@ -0,0 +1,60 @@
#pragma once
#include "dshow_webcam_capture.h"
#include <Windows.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <wrl/client.h>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class WebcamCapture {
public:
WebcamCapture() = default;
~WebcamCapture();
WebcamCapture(const WebcamCapture&) = delete;
WebcamCapture& operator=(const WebcamCapture&) = delete;
bool initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps);
bool start();
void stop();
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
int width() const;
int height() const;
int fps() const;
const std::wstring& selectedDeviceName() const;
private:
bool selectDevice(const std::wstring& deviceId, const std::wstring& deviceName);
bool configureReader(int requestedWidth, int requestedHeight, int requestedFps);
void captureLoop();
Microsoft::WRL::ComPtr<IMFMediaSource> mediaSource_;
Microsoft::WRL::ComPtr<IMFSourceReader> sourceReader_;
DirectShowWebcamCapture directShowCapture_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_;
std::vector<BYTE> latestFrame_;
int width_ = 0;
int height_ = 0;
int fps_ = 30;
bool mfStarted_ = false;
bool usingDirectShow_ = false;
int selectedMatchScore_ = 0;
std::wstring selectedDeviceName_;
};
@@ -0,0 +1,276 @@
#include "wgc_session.h"
#include <Windows.Graphics.Capture.Interop.h>
#include <dxgi1_2.h>
#include <inspectable.h>
#include <winrt/base.h>
#include <iostream>
namespace wf = winrt::Windows::Foundation;
namespace wgcap = winrt::Windows::Graphics::Capture;
namespace wgdx = winrt::Windows::Graphics::DirectX;
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
::IDXGIDevice* dxgiDevice,
::IInspectable** graphicsDevice);
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
int64_t timeSpanToHns(wf::TimeSpan const& value) {
return value.count();
}
} // namespace
WgcSession::~WgcSession() {
stop();
}
bool WgcSession::createD3DDevice() {
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
#if defined(_DEBUG)
flags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
D3D_FEATURE_LEVEL featureLevels[] = {
D3D_FEATURE_LEVEL_11_1,
D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1,
D3D_FEATURE_LEVEL_10_0,
};
D3D_FEATURE_LEVEL featureLevel{};
HRESULT hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
#if defined(_DEBUG)
if (FAILED(hr)) {
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
}
#endif
if (!succeeded(hr, "D3D11CreateDevice")) {
return false;
}
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
return false;
}
winrt::com_ptr<::IInspectable> inspectableDevice;
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
"CreateDirect3D11DeviceFromDXGIDevice")) {
return false;
}
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
return true;
}
bool WgcSession::createCaptureItem(HMONITOR monitor) {
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
wgcap::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForMonitor(
monitor,
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
reinterpret_cast<void**>(winrt::put_abi(item)));
if (!succeeded(hr, "CreateForMonitor")) {
return false;
}
item_ = item;
const auto size = item_.Size();
width_ = static_cast<int>(size.Width);
height_ = static_cast<int>(size.Height);
return width_ > 0 && height_ > 0;
}
bool WgcSession::createCaptureItem(HWND window) {
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
wgcap::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForWindow(
window,
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
reinterpret_cast<void**>(winrt::put_abi(item)));
if (!succeeded(hr, "CreateForWindow")) {
return false;
}
item_ = item;
const auto size = item_.Size();
width_ = static_cast<int>(size.Width);
height_ = static_cast<int>(size.Height);
return width_ > 0 && height_ > 0;
}
void WgcSession::applySessionOptions(bool captureCursor) {
try {
session_.IsCursorCaptureEnabled(captureCursor);
} catch (...) {
// Older WGC builds can omit this property. They will keep the OS default.
}
try {
session_.IsBorderRequired(false);
} catch (...) {
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
}
}
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
fps_ = fps > 0 ? fps : 60;
if (!createD3DDevice()) {
return false;
}
if (!createCaptureItem(monitor)) {
return false;
}
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
winrtDevice_,
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
2,
item_.Size());
session_ = framePool_.CreateCaptureSession(item_);
applySessionOptions(captureCursor);
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
return true;
}
bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
fps_ = fps > 0 ? fps : 60;
if (!createD3DDevice()) {
return false;
}
if (!createCaptureItem(window)) {
return false;
}
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
winrtDevice_,
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
2,
item_.Size());
session_ = framePool_.CreateCaptureSession(item_);
applySessionOptions(captureCursor);
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
return true;
}
void WgcSession::setFrameCallback(FrameCallback callback) {
std::scoped_lock lock(callbackMutex_);
frameCallback_ = std::move(callback);
}
bool WgcSession::start() {
if (!session_) {
return false;
}
session_.StartCapture();
started_ = true;
return true;
}
void WgcSession::stop() {
if (framePool_) {
framePool_.FrameArrived(frameArrivedToken_);
}
if (session_) {
session_.Close();
session_ = nullptr;
}
if (framePool_) {
framePool_.Close();
framePool_ = nullptr;
}
item_ = nullptr;
winrtDevice_ = nullptr;
d3dContext_.Reset();
d3dDevice_.Reset();
started_ = false;
}
void WgcSession::onFrameArrived(
wgcap::Direct3D11CaptureFramePool const& sender,
wf::IInspectable const&) {
auto frame = sender.TryGetNextFrame();
if (!frame) {
return;
}
auto surface = frame.Surface();
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
if (FAILED(hr) || !texture) {
return;
}
FrameCallback callback;
{
std::scoped_lock lock(callbackMutex_);
callback = frameCallback_;
}
if (callback) {
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
}
frame.Close();
}
int WgcSession::captureWidth() const {
return width_;
}
int WgcSession::captureHeight() const {
return height_;
}
ID3D11Device* WgcSession::device() const {
return d3dDevice_.Get();
}
ID3D11DeviceContext* WgcSession::context() const {
return d3dContext_.Get();
}
@@ -0,0 +1,58 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <windows.graphics.capture.h>
#include <windows.graphics.directx.direct3d11.interop.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <wrl/client.h>
#include <functional>
#include <mutex>
class WgcSession {
public:
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
WgcSession() = default;
~WgcSession();
WgcSession(const WgcSession&) = delete;
WgcSession& operator=(const WgcSession&) = delete;
bool initialize(HMONITOR monitor, int fps, bool captureCursor);
bool initialize(HWND window, int fps, bool captureCursor);
void setFrameCallback(FrameCallback callback);
bool start();
void stop();
int captureWidth() const;
int captureHeight() const;
ID3D11Device* device() const;
ID3D11DeviceContext* context() const;
private:
bool createD3DDevice();
bool createCaptureItem(HMONITOR monitor);
bool createCaptureItem(HWND window);
void applySessionOptions(bool captureCursor);
void onFrameArrived(
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
winrt::Windows::Foundation::IInspectable const&);
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
winrt::event_token frameArrivedToken_{};
FrameCallback frameCallback_;
std::mutex callbackMutex_;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
bool started_ = false;
};
+44 -8
View File
@@ -1,5 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
// Asset base URL is passed from the main process via webPreferences.additionalArguments
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
@@ -10,12 +12,18 @@ 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");
},
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
@@ -40,10 +48,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
requestAccessibilityAccess: () => {
return ipcRenderer.invoke("request-accessibility-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
@@ -54,8 +58,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording: boolean, recordingId?: number) => {
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
setRecordingState: (
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => {
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
},
isNativeWindowsCaptureAvailable: () => {
return ipcRenderer.invoke("is-native-windows-capture-available");
},
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
return ipcRenderer.invoke("start-native-windows-recording", request);
},
stopNativeWindowsRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-windows-recording", discard);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
@@ -71,8 +88,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
pickExportSavePath: (fileName: string, exportFolder?: string) => {
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
},
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
@@ -134,6 +154,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => {
return ipcRenderer.invoke("save-diagnostic", payload);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
@@ -166,4 +194,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
onRequestCloseConfirm: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("request-close-confirm", listener);
return () => ipcRenderer.removeListener("request-close-confirm", listener);
},
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
ipcRenderer.send("close-confirm-response", choice);
},
});
+7
View File
@@ -24,6 +24,12 @@ ipcMain.on("hud-overlay-hide", () => {
}
});
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
}
});
/**
* 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
@@ -63,6 +69,7 @@ export function createHudOverlayWindow(): BrowserWindow {
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true, { forward: true });
// Follow the user across macOS Spaces (virtual desktops).
// Without this the HUD stays pinned to the Space it was first opened on.
+4
View File
@@ -21,5 +21,9 @@
<!-- Camera (webcam capture) -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
<key>com.apple.security.device.screen-capture</key>
<true/>
</dict>
</plist>
+1 -1
View File
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
-25
View File
@@ -49,7 +49,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"
},
@@ -8409,17 +8408,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",
@@ -11011,19 +10999,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",
+14 -5
View File
@@ -21,17 +21,27 @@
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
"test:wgc-helper:win": "node scripts/test-windows-wgc-helper.mjs",
"test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window",
"test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio",
"test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone",
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"postinstall": "npm run rebuild:native"
"test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts",
"prepare": "husky"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -74,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"
},
+112
View File
@@ -0,0 +1,112 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture");
const BUILD_DIR = path.join(SOURCE_DIR, "build");
const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs");
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64");
const CMAKE = process.env.CMAKE_EXE ?? "cmake";
function findVcVarsAll() {
const explicit = process.env.VCVARSALL;
if (explicit && fs.existsSync(explicit)) {
return explicit;
}
const roots = [
process.env.VSINSTALLDIR,
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\Community",
];
for (const root of roots.filter(Boolean)) {
const candidate = path.join(root, "VC", "Auxiliary", "Build", "vcvarsall.bat");
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT,
stdio: "inherit",
windowsHide: true,
...options,
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
}
});
});
}
async function runInVsEnv(command) {
const vcvarsAll = findVcVarsAll();
if (!vcvarsAll) {
throw new Error(
"Could not find Visual Studio vcvarsall.bat. Install Visual Studio Build Tools with C++.",
);
}
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
fs.writeFileSync(
cmdPath,
[
"@echo off",
`call "${vcvarsAll}" x64`,
"if errorlevel 1 exit /b %errorlevel%",
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
"if errorlevel 1 exit /b %errorlevel%",
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
command,
"exit /b %errorlevel%",
"",
].join("\r\n"),
);
try {
await run("cmd.exe", ["/d", "/c", cmdPath]);
} finally {
fs.rmSync(cmdPath, { force: true });
}
}
if (process.platform !== "win32") {
console.log("Skipping WGC helper build: Windows-only.");
process.exit(0);
}
fs.mkdirSync(BUILD_DIR, { recursive: true });
await runInVsEnv(
`"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`,
);
await runInVsEnv(`"${CMAKE}" --build "${BUILD_DIR}" --config Release`);
const outputPath = path.join(BUILD_DIR, "wgc-capture.exe");
if (!fs.existsSync(outputPath)) {
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
}
fs.mkdirSync(BIN_DIR, { recursive: true });
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
fs.copyFileSync(outputPath, distributablePath);
console.log(`Built ${outputPath}`);
console.log(`Copied ${distributablePath}`);
+258
View File
@@ -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;
}
-21
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+344
View File
@@ -0,0 +1,344 @@
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const HELPER_PATH =
process.env.OPENSCREEN_WGC_CAPTURE_EXE ??
path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe");
const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000);
const WITH_SYSTEM_AUDIO =
process.env.OPENSCREEN_WGC_TEST_SYSTEM_AUDIO === "true" ||
process.argv.includes("--system-audio");
const WITH_MICROPHONE =
process.env.OPENSCREEN_WGC_TEST_MICROPHONE === "true" ||
process.argv.includes("--microphone") ||
process.argv.includes("--mic");
const WITH_WINDOW =
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
const WITH_WEBCAM =
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
function runHelper(config) {
return new Promise((resolve, reject) => {
const child = spawn(HELPER_PATH, [JSON.stringify(config)], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let stopTimer = null;
const scheduleStop = () => {
if (stopTimer) {
return;
}
stopTimer = setTimeout(() => {
child.stdin.write("stop\n");
}, DURATION_MS);
};
const fallbackTimer = setTimeout(scheduleStop, 15_000);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
if (stdout.includes('"recording-started"') || stdout.includes("Recording started")) {
scheduleStop();
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("exit", (code) => {
clearTimeout(fallbackTimer);
if (stopTimer) {
clearTimeout(stopTimer);
}
resolve({ code, stdout, stderr });
});
});
}
function startFixtureWindow() {
return new Promise((resolve, reject) => {
const child = spawn("mspaint.exe", [], {
stdio: ["ignore", "ignore", "ignore"],
windowsHide: false,
});
const poll = setInterval(() => {
const lookup = spawnSync(
"powershell",
[
"-NoProfile",
"-Command",
`(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue).MainWindowHandle`,
],
{ encoding: "utf8", windowsHide: true },
);
const handle = lookup.stdout
.trim()
.split(/\r?\n/)
.find((line) => /^\d+$/.test(line.trim()));
if (handle && handle !== "0") {
clearInterval(poll);
clearTimeout(timer);
resolve({ child, sourceId: `window:${handle.trim()}:0` });
}
}, 250);
const timer = setTimeout(() => {
clearInterval(poll);
child.kill();
reject(new Error("Timed out waiting for fixture window handle"));
}, 10_000);
child.once("error", (error) => {
clearInterval(poll);
clearTimeout(timer);
reject(error);
});
});
}
function normalizeDeviceName(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function scoreDeviceName(candidateName, candidateId, requestedName) {
const candidate = normalizeDeviceName(candidateName ?? "");
const id = normalizeDeviceName(candidateId ?? "");
const requested = normalizeDeviceName(requestedName ?? "");
if (!requested) return 0;
if (candidate === requested) return 1000;
if (candidate.includes(requested) || requested.includes(candidate)) return 900;
if (id.includes(requested) || requested.includes(id)) return 800;
return requested
.split(/\s+/)
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
.reduce((score, word) => {
if (candidate.includes(word)) return score + 100;
if (id.includes(word)) return score + 50;
return score;
}, 0);
}
function resolveDirectShowWebcamClsid(requestedName) {
if (!requestedName) return "";
const query = spawnSync(
"reg.exe",
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
{ encoding: "utf8", windowsHide: true },
);
if (query.status !== 0) return "";
const entries = [];
let current = {};
for (const rawLine of query.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (/^HKEY_/i.test(line)) {
if (current.friendlyName || current.clsid) entries.push(current);
current = {};
continue;
}
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
if (!match) continue;
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
if (match[1] === "CLSID") current.clsid = match[2].trim();
}
if (current.friendlyName || current.clsid) entries.push(current);
let best = null;
for (const entry of entries) {
if (!entry.clsid) continue;
const score = scoreDeviceName(entry.friendlyName, entry.clsid, requestedName);
if (!best || score > best.score) {
best = { ...entry, score };
}
}
return best && best.score > 0 ? best.clsid : "";
}
function probeStreams(outputPath) {
const ffprobe = spawnSync(
"ffprobe",
["-v", "error", "-show_streams", "-of", "json", outputPath],
{ encoding: "utf8", windowsHide: true },
);
if (ffprobe.status !== 0) {
throw new Error(`ffprobe failed: ${ffprobe.stderr || ffprobe.stdout}`);
}
return JSON.parse(ffprobe.stdout).streams ?? [];
}
function measureFirstFrameLuma(outputPath) {
const ffmpeg = spawnSync(
"ffmpeg",
[
"-v",
"error",
"-i",
outputPath,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"gray",
"pipe:1",
],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
);
if (ffmpeg.status !== 0) {
throw new Error(`ffmpeg frame extraction failed: ${ffmpeg.stderr?.toString() ?? ""}`);
}
const data = ffmpeg.stdout;
if (!data || data.length === 0) {
throw new Error(`ffmpeg did not return frame data for ${outputPath}`);
}
let sum = 0;
let max = 0;
for (const value of data) {
sum += value;
if (value > max) {
max = value;
}
}
return { average: sum / data.length, max };
}
if (process.platform !== "win32") {
console.log("Skipping WGC helper smoke test: Windows-only.");
process.exit(0);
}
if (!fs.existsSync(HELPER_PATH)) {
throw new Error(`WGC helper not found at ${HELPER_PATH}. Run npm run build:native:win first.`);
}
const outputPath = path.join(
os.tmpdir(),
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
);
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
const config = {
schemaVersion: 2,
recordingId: Date.now(),
outputPath,
sourceType: fixtureWindow ? "window" : "display",
sourceId: fixtureWindow ? fixtureWindow.sourceId : "screen:0:0",
displayId: 0,
fps: 30,
videoWidth: 1280,
videoHeight: 720,
displayX: 0,
displayY: 0,
displayW: 1920,
displayH: 1080,
hasDisplayBounds: true,
captureSystemAudio: WITH_SYSTEM_AUDIO,
captureMic: WITH_MICROPHONE,
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
microphoneGain: 1.4,
webcamEnabled: WITH_WEBCAM,
webcamDeviceId: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_ID ?? "",
webcamDeviceName: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
webcamDirectShowClsid: resolveDirectShowWebcamClsid(
process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
),
webcamWidth: 640,
webcamHeight: 360,
webcamFps: 30,
outputs: { screenPath: outputPath },
};
let result;
try {
result = await runHelper(config);
} finally {
if (fixtureWindow) {
fixtureWindow.child.kill();
}
}
if (result.code !== 0) {
if (
WITH_WEBCAM &&
/No native Windows webcam devices were found|Failed to initialize native webcam/.test(
result.stderr,
)
) {
console.log("Skipping WGC webcam smoke test: no native Windows webcam device is available.");
process.exit(0);
}
throw new Error(`WGC helper exited with ${result.code}\n${result.stdout}\n${result.stderr}`);
}
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
}
const streams = probeStreams(outputPath);
const hasVideo = streams.some((stream) => stream.codec_type === "video");
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
const webcamFormatLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes('"event":"webcam-format"'));
const webcamFormat = webcamFormatLine ? JSON.parse(webcamFormatLine) : null;
const audioFormatLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes('"event":"audio-format"'));
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
const nativeWebcamDiagnostics = result.stderr
.split(/\r?\n/)
.filter((line) => line.includes("Native webcam candidate"));
const nativeMicrophoneDiagnostics = result.stderr
.split(/\r?\n/)
.filter(
(line) =>
line.includes("Native microphone candidate") ||
line.includes("Selected native microphone endpoint"),
);
if (!hasVideo) {
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
}
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) {
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
}
const frameLuma = measureFirstFrameLuma(outputPath);
if (frameLuma.average < 1 && frameLuma.max < 5) {
throw new Error(
`WGC helper output first frame is black: ${outputPath}\n${result.stdout}\n${result.stderr}`,
);
}
console.log(
JSON.stringify(
{
success: true,
outputPath,
bytes: fs.statSync(outputPath).size,
streams: streams.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
duration: stream.duration,
})),
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
selectedWebcamDeviceName: webcamFormat?.deviceName,
nativeMicrophoneDiagnostics,
nativeWebcamDiagnostics,
firstFrameLuma: frameLuma,
},
null,
2,
),
);
+12 -5
View File
@@ -1,14 +1,19 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
const VideoEditor = lazy(() => import("./components/video-editor/VideoEditor"));
const ShortcutsConfigDialog = lazy(() =>
import("./components/video-editor/ShortcutsConfigDialog").then((module) => ({
default: module.ShortcutsConfigDialog,
})),
);
export default function App() {
const [windowType, setWindowType] = useState(
() => new URLSearchParams(window.location.search).get("windowType") || "",
@@ -59,8 +64,10 @@ export default function App() {
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
<Suspense fallback={<div className="h-screen bg-background" />}>
<VideoEditor />
<ShortcutsConfigDialog />
</Suspense>
</ShortcutsProvider>
);
default:
@@ -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

+46
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+8
View File
@@ -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

+18
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+6
View File
@@ -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

+8
View File
@@ -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

+6
View File
@@ -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

+12 -12
View File
@@ -44,13 +44,13 @@
position: fixed;
right: 0;
top: 0;
width: 12rem;
padding: 0.375rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.14);
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
backdrop-filter: blur(14px);
width: 11rem;
padding: 0.25rem;
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(8, 9, 12, 0.96);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045);
backdrop-filter: blur(18px) saturate(140%);
pointer-events: auto;
box-sizing: border-box;
}
@@ -60,10 +60,10 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
padding: 0.425rem 0.5rem;
border-radius: 0.45rem;
font-size: 11px;
color: rgba(255, 255, 255, 0.88);
color: rgba(255, 255, 255, 0.72);
background: transparent;
border: 0;
cursor: pointer;
@@ -72,12 +72,12 @@
.languageMenuItem:hover,
.languageMenuItem:focus-visible {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.075);
color: #ffffff;
outline: none;
}
.languageMenuItemActive {
background: rgba(255, 255, 255, 0.12);
background: rgba(52, 178, 123, 0.14);
color: #ffffff;
}
+121 -21
View File
@@ -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 },
@@ -62,16 +65,16 @@ function getIcon(name: IconName, className?: string) {
}
const hudGroupClasses =
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
"flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]";
const hudIconBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95";
const hudAuxIconBtnClasses =
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
@@ -87,6 +90,7 @@ export function LaunchWindow() {
resolveSystemLocaleSuggestion,
} = useI18n();
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase();
const {
recording,
@@ -100,12 +104,16 @@ export function LaunchWindow() {
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
setMicrophoneDeviceName,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
setWebcamDeviceName,
cursorCaptureMode,
setCursorCaptureMode,
} = useScreenRecorder();
const showMicControls = microphoneEnabled && !recording;
@@ -119,6 +127,7 @@ export function LaunchWindow() {
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const [isWindows, setIsWindows] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
@@ -147,14 +156,16 @@ export function LaunchWindow() {
const selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
t("audio.defaultMicrophone");
const selectedCameraDevice = cameraDevices.find(
(d) => d.deviceId === (webcamDeviceId || selectedCameraId),
);
const selectedCameraLabel = isCameraDevicesLoading
? t("webcam.searching")
: cameraDevicesError
? t("webcam.unavailable")
: cameraDevices.length === 0
? t("webcam.noneFound")
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
t("webcam.defaultCamera");
: selectedCameraDevice?.label || t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
@@ -164,14 +175,36 @@ export function LaunchWindow() {
useEffect(() => {
if (selectedMicId && selectedMicId !== "default") {
setMicrophoneDeviceId(selectedMicId);
setMicrophoneDeviceName(micDevices.find((d) => d.deviceId === selectedMicId)?.label);
}
}, [selectedMicId, setMicrophoneDeviceId]);
}, [selectedMicId, micDevices, setMicrophoneDeviceId, setMicrophoneDeviceName]);
useEffect(() => {
if (selectedCameraId) {
setWebcamDeviceId(selectedCameraId);
setWebcamDeviceName(cameraDevices.find((d) => d.deviceId === selectedCameraId)?.label);
}
}, [selectedCameraId, setWebcamDeviceId]);
}, [selectedCameraId, cameraDevices, setWebcamDeviceId, setWebcamDeviceName]);
useEffect(() => {
let cancelled = false;
nativeBridgeClient.system
.getPlatform()
.then((platform) => {
if (!cancelled) {
setIsWindows(platform === "win32");
}
})
.catch(() => {
if (!cancelled) {
setIsWindows(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!import.meta.env.DEV) {
@@ -248,8 +281,16 @@ export function LaunchWindow() {
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
useEffect(() => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
return () => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
};
}, []);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [, setRecordPointerDownCount] = useState(0);
useEffect(() => {
const checkSelectedSource = async () => {
@@ -285,13 +326,17 @@ export function LaunchWindow() {
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
if (!setVideoPathResult.success) {
console.error("Failed to set current video path:", setVideoPathResult);
return;
}
await window.electronAPI.switchToEditor();
}
};
const openProjectFile = async () => {
const result = await window.electronAPI.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled || !result.success) return;
await window.electronAPI.switchToEditor();
};
@@ -320,6 +365,12 @@ export function LaunchWindow() {
// recording toolbar widened (issue #305).
<div
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);
}}
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
>
{systemLocaleSuggestion && (
<div
@@ -360,12 +411,13 @@ export function LaunchWindow() {
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
data-hud-interactive="true"
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
>
{/* Mic selector */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsMicHovered(true)}
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
@@ -381,8 +433,10 @@ export function LaunchWindow() {
<select
value={microphoneDeviceId || selectedMicId}
onChange={(e) => {
const selectedDevice = micDevices.find((d) => d.deviceId === e.target.value);
setSelectedMicId(e.target.value);
setMicrophoneDeviceId(e.target.value);
setMicrophoneDeviceName(selectedDevice?.label);
}}
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
>
@@ -409,7 +463,7 @@ export function LaunchWindow() {
{/* Webcam selector */}
{showWebcamControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsWebcamHovered(true)}
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
@@ -440,8 +494,12 @@ export function LaunchWindow() {
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
const device = cameraDevices.find(
(item) => item.deviceId === e.target.value,
);
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
setWebcamDeviceName(device?.label);
}}
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
>
@@ -465,8 +523,10 @@ export function LaunchWindow() {
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
const device = cameraDevices.find((item) => item.deviceId === e.target.value);
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
setWebcamDeviceName(device?.label);
}}
className="sr-only"
>
@@ -485,7 +545,8 @@ export function LaunchWindow() {
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
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%]`}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
@@ -494,18 +555,21 @@ export function LaunchWindow() {
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
{selectedSource}
</span>
</button>
{/* 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}
@@ -518,16 +582,21 @@ export function LaunchWindow() {
: getIcon("volumeOff", "text-white/40")}
</button>
<button
data-testid="launch-microphone-button"
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
onPointerDown={() => {
setRecordPointerDownCount((count) => count + 1);
}}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
data-testid="launch-webcam-button"
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
@@ -539,16 +608,44 @@ export function LaunchWindow() {
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
{isWindows && (
<button
data-testid="launch-cursor-mode-button"
className={`${hudIconBtnClasses} ${
cursorCaptureMode === "editable-overlay"
? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]"
: ""
}`}
onClick={() =>
!recording &&
setCursorCaptureMode(
cursorCaptureMode === "editable-overlay" ? "system" : "editable-overlay",
)
}
disabled={recording}
title={
cursorCaptureMode === "editable-overlay"
? t("cursor.useSystemCursor")
: t("cursor.useEditableCursor")
}
>
{getIcon(
"cursor",
cursorCaptureMode === "editable-overlay" ? "text-green-400" : "text-white/40",
)}
</button>
)}
</div>
{/* Record/Stop group */}
<button
data-testid="launch-record-button"
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "bg-red-500/12 hover:bg-red-500/16"
: "bg-white/5 hover:bg-white/[0.08]"
: "bg-white/[0.06] hover:bg-white/[0.10]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
@@ -595,6 +692,7 @@ export function LaunchWindow() {
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
data-testid="launch-open-video-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
>
@@ -605,6 +703,7 @@ export function LaunchWindow() {
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
data-testid="launch-open-project-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
>
@@ -624,11 +723,12 @@ export function LaunchWindow() {
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
>
<div className="flex w-full items-center justify-center">
<Languages size={13} className="text-white/75" />
</div>
<Languages size={13} className="text-white/70" />
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
{activeLanguageLabel}
</span>
</button>
</div>
+24 -22
View File
@@ -1,8 +1,8 @@
.glassContainer {
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 30px;
background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
border-radius: 24px;
corner-shape: squircle;
/*
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
@@ -11,34 +11,36 @@
/* box-shadow:
0 0px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
border: 1.5px solid rgba(60, 60, 80, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sourceCard {
corner-shape: squircle;
border-radius: 20px;
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
border: 1px solid rgba(60, 60, 80, 0.22);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
border-radius: 13px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
cursor: pointer;
}
.sourceCard:hover {
border-color: rgba(120, 120, 160, 0.35);
background: rgba(255, 255, 255, 0.065);
border-color: rgba(255, 255, 255, 0.14);
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.selected {
border: 1.5px solid #34b27b;
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
border-color: rgba(52, 178, 123, 0.68);
background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045));
box-shadow:
0 0 12px rgba(52, 178, 123, 0.15),
0 0 4px rgba(52, 178, 123, 0.1);
0 0 0 1px rgba(52, 178, 123, 0.18) inset,
0 12px 28px rgba(0, 0, 0, 0.22);
}
.selected:hover {
@@ -46,16 +48,16 @@
}
.icon {
width: 13px;
height: 13px;
width: 12px;
height: 12px;
color: #c7d2fe;
}
.name {
font-size: 0.8rem;
font-size: 0.72rem;
color: #e4e4e7;
font-weight: 500;
letter-spacing: 0.01em;
letter-spacing: 0;
}
.cardText {
@@ -65,14 +67,14 @@
/* Checkmark badge */
.checkBadge {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
background: #34b27b;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35);
}
/* scrollbar */
+17 -15
View File
@@ -77,24 +77,24 @@ export function SourceSelector() {
return (
<div
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
onClick={() => handleSourceSelect(source)}
>
<div className="relative mb-1.5">
<div className="relative mb-1.5 overflow-hidden rounded-lg border border-white/[0.06] bg-black/30">
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
className="w-full aspect-video object-cover"
/>
{isSelected && (
<div className="absolute -top-1 -right-1">
<div className="absolute right-1.5 top-1.5">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
<MdCheck size={11} className="text-white" />
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-1 pb-0.5">
{source.appIcon && (
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
)}
@@ -106,21 +106,21 @@ export function SourceSelector() {
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<div className="flex-1 flex flex-col w-full px-3.5 pt-3.5">
<Tabs
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
<TabsList className="mb-3 grid h-8 grid-cols-2 rounded-xl border border-white/[0.06] bg-white/[0.04] p-0.5">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
>
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
>
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
@@ -128,14 +128,14 @@ export function SourceSelector() {
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
@@ -143,18 +143,20 @@ export function SourceSelector() {
</div>
</Tabs>
</div>
<div className="p-3 justify-center flex gap-2">
<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="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
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"
>
{tc("actions.cancel")}
</Button>
<Button
data-testid="source-selector-share-button"
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
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"
>
{tc("actions.share")}
</Button>
@@ -82,7 +82,7 @@ export function AnnotationOverlay({
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
const blurType = "mosaic";
const blurOverlayColor =
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
const mosaicGridOverlayColor =
@@ -106,7 +106,7 @@ export function AnnotationOverlay({
const { x, y, width, height } = liveRect;
useEffect(() => {
if (annotation.type !== "blur" || blurType !== "mosaic") {
if (annotation.type !== "blur") {
return;
}
void previewFrameVersion;
@@ -173,7 +173,6 @@ export function AnnotationOverlay({
);
}, [
annotation,
blurType,
containerHeight,
containerWidth,
height,
@@ -7,7 +7,6 @@ import {
ChevronDown,
Copy,
Image as ImageIcon,
Info,
Italic,
Trash2,
Type,
@@ -148,39 +147,39 @@ export function AnnotationSettingsPanel({
};
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="mb-3">
<div className="mb-4">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("annotation.active")}
</span>
<div className="mt-1 text-xl font-semibold text-slate-100">{t("annotation.title")}</div>
</div>
{/* Type Selector */}
<Tabs
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="mb-6"
className="mb-4"
>
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsList className="mb-4 bg-white/[0.035] border border-white/[0.06] p-0.5 w-full grid grid-cols-3 h-9 rounded-xl">
<TabsTrigger
value="text"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<Type className="w-4 h-4" />
{t("annotation.typeText")}
</TabsTrigger>
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<ImageIcon className="w-4 h-4" />
{t("annotation.typeImage")}
</TabsTrigger>
<TabsTrigger
value="figure"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<svg
className="w-4 h-4"
@@ -623,18 +622,6 @@ export function AnnotationSettingsPanel({
{t("annotation.deleteAnnotation")}
</Button>
</div>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
<li>{t("annotation.tipTabCycle")}</li>
<li>{t("annotation.tipShiftTabCycle")}</li>
</ul>
</div>
</div>
</div>
);
@@ -1,12 +1,5 @@
import { Info, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { getBlurOverlayColor } from "@/lib/blurEffects";
@@ -19,9 +12,7 @@ import {
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
@@ -49,13 +40,15 @@ export function BlurSettingsPanel({
];
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="mb-3">
<div className="mb-4">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("annotation.blurTypeMosaic")}
</span>
<div className="mt-1 text-xl font-semibold text-slate-100">
{t("annotation.typeBlur")}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -69,6 +62,7 @@ export function BlurSettingsPanel({
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: "mosaic",
shape: shape.value,
};
onBlurDataChange(nextBlurData);
@@ -77,7 +71,7 @@ export function BlurSettingsPanel({
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
"h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
@@ -99,7 +93,7 @@ export function BlurSettingsPanel({
)}
/>
)}
<span className="text-[10px] leading-none">
<span className="text-[10px] leading-none font-medium">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
@@ -107,34 +101,6 @@ export function BlurSettingsPanel({
})}
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurType")}
</label>
<Select
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
onValueChange={(value) => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: value === "mosaic" ? "mosaic" : "blur",
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurColor")}
@@ -150,6 +116,7 @@ export function BlurSettingsPanel({
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: "mosaic",
color: option.value,
};
onBlurDataChange(nextBlurData);
@@ -183,40 +150,29 @@ export function BlurSettingsPanel({
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="mt-4 p-3 rounded-lg editor-control-surface">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{blurRegion.blurData?.type === "mosaic"
? t("annotation.mosaicBlockSize")
: t("annotation.blurIntensity")}
{t("annotation.mosaicBlockSize")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
)}
{Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)}
px
</span>
</div>
<Slider
value={[
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
]}
value={[blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
...(blurRegion.blurData?.type === "mosaic"
? { blockSize: values[0] }
: { intensity: values[0] }),
type: "mosaic",
blockSize: values[0],
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
min={MIN_BLUR_BLOCK_SIZE}
max={MAX_BLUR_BLOCK_SIZE}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -231,16 +187,6 @@ export function BlurSettingsPanel({
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
+3 -1
View File
@@ -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>
)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
import { Save, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
interface UnsavedChangesDialogProps {
isOpen: boolean;
onSaveAndClose: () => void;
onDiscardAndClose: () => void;
onCancel: () => void;
}
export function UnsavedChangesDialog({
isOpen,
onSaveAndClose,
onDiscardAndClose,
onCancel,
}: UnsavedChangesDialogProps) {
const td = useScopedT("dialogs");
const tc = useScopedT("common");
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
<DialogHeader className="mb-5">
<div className="flex items-center gap-3">
<img
src="./openscreen.png"
alt=""
aria-hidden="true"
className="w-9 h-9 rounded-xl flex-shrink-0"
/>
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
{td("unsavedChanges.title")}
</DialogTitle>
</div>
</DialogHeader>
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
<DialogDescription className="text-sm text-slate-500 mb-6">
{td("unsavedChanges.detail")}
</DialogDescription>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onSaveAndClose}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Save className="w-4 h-4" />
{td("unsavedChanges.saveAndClose")}
</button>
<button
type="button"
onClick={onDiscardAndClose}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Trash2 className="w-4 h-4" />
{td("unsavedChanges.discardAndClose")}
</button>
<button
type="button"
onClick={onCancel}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
{tc("actions.cancel")}
</button>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+461 -69
View File
@@ -25,8 +25,24 @@ import {
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import {
createNativeCursorMotionBlurState,
createNativeCursorSmoothingState,
getNativeCursorClickBounceProgress,
getNativeCursorClickBounceScale,
getNativeCursorMotionBlurPx,
hasNativeCursorRecordingData,
projectNativeCursorToLocal,
projectNativeCursorToStage,
resetNativeCursorMotionBlurState,
resetNativeCursorSmoothingState,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
smoothNativeCursorSample,
} from "@/lib/cursor/nativeCursor";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import type { CursorRecordingData } from "@/native/contracts";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -36,7 +52,12 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
type CursorTelemetryPoint,
computeRotation3DContainScale,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_ROTATION_3D,
isRotation3DIdentity,
lerpRotation3D,
@@ -56,17 +77,12 @@ import {
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import {
adaptiveSmoothFactor,
interpolateCursorAt,
smoothCursorFocus,
} from "./videoPlayback/cursorFollowUtils";
import {
type CursorHighlightConfig,
clickEmphasisAlpha,
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
DEFAULT_CURSOR_CONFIG,
PixiCursorOverlay,
preloadCursorAssets,
} from "./videoPlayback/cursorRenderer";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -112,6 +128,7 @@ interface VideoPlaybackProps {
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
aspectRatio: AspectRatio;
cursorRecordingData?: CursorRecordingData | null;
annotationRegions?: AnnotationRegion[];
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
@@ -124,9 +141,13 @@ interface VideoPlaybackProps {
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
cursorHighlight?: CursorHighlightConfig;
cursorTelemetry?: CursorTelemetryPoint[];
cursorClickTimestamps?: number[];
showCursor?: boolean;
cursorSize?: number;
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
}
export interface VideoPlaybackRef {
@@ -139,6 +160,41 @@ export interface VideoPlaybackRef {
pause: () => void;
}
function getResolvedVideoDuration(video: HTMLVideoElement): number | null {
if (Number.isFinite(video.duration) && video.duration > 0) {
return video.duration;
}
if (video.seekable.length > 0) {
const lastRangeIndex = video.seekable.length - 1;
const seekableEnd = video.seekable.end(lastRangeIndex);
if (Number.isFinite(seekableEnd) && seekableEnd > 0) {
return seekableEnd;
}
}
return null;
}
function getEndedVideoDuration(video: HTMLVideoElement): number | null {
const currentTime = video.currentTime;
if (!Number.isFinite(currentTime) || currentTime <= 0) {
return null;
}
if (video.ended) {
return currentTime;
}
const resolvedDuration = getResolvedVideoDuration(video);
const durationEpsilonSeconds = 0.05;
if (resolvedDuration && currentTime >= resolvedDuration - durationEpsilonSeconds) {
return resolvedDuration;
}
return null;
}
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
(
{
@@ -172,6 +228,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
trimRegions = [],
speedRegions = [],
aspectRatio,
cursorRecordingData,
annotationRegions = [],
selectedAnnotationId,
onSelectAnnotation,
@@ -185,8 +242,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
cursorClickTimestamps = [],
showCursor = false,
cursorSize = DEFAULT_CURSOR_SIZE,
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
},
ref,
) => {
@@ -211,10 +272,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
const cursorTelemetryRef = useRef<CursorTelemetryPoint[]>([]);
const cursorClickTimestampsRef = useRef<number[]>([]);
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -251,12 +310,139 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const motionBlurAmountRef = useRef(motionBlurAmount);
const cursorOverlayRef = useRef<PixiCursorOverlay | null>(null);
const showCursorRef = useRef(showCursor);
const cursorSizeRef = useRef(cursorSize);
const cursorSmoothingRef = useRef(cursorSmoothing);
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const durationResolutionTimeoutRef = useRef<number | null>(null);
const lastResolvedDurationRef = useRef<number | null>(null);
const isResolvingDurationRef = useRef(false);
const hasNativeCursorRecordingRef = useRef(false);
const cursorRecordingDataRef = useRef(cursorRecordingData);
const cropRegionRef = useRef(cropRegion);
const nativeCursorSpriteRef = useRef<Sprite | null>(null);
const nativeCursorTextureIdRef = useRef<string | null>(null);
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
const nativeCursorImageIdRef = useRef<string | null>(null);
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
const hasNativeCursorRecording = useMemo(
() => hasNativeCursorRecordingData(cursorRecordingData),
[cursorRecordingData],
);
const syncResolvedDuration = useCallback(
(video: HTMLVideoElement) => {
const resolvedDuration = getResolvedVideoDuration(video);
if (!resolvedDuration) {
return false;
}
const normalizedDuration = Math.round(resolvedDuration * 1000) / 1000;
if (lastResolvedDurationRef.current !== normalizedDuration) {
lastResolvedDurationRef.current = normalizedDuration;
onDurationChange(normalizedDuration);
}
return true;
},
[onDurationChange],
);
const forceResolveDuration = useCallback(
(video: HTMLVideoElement) => {
if (isResolvingDurationRef.current) {
return;
}
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
return;
}
isResolvingDurationRef.current = true;
const previousMuted = video.muted;
const finalize = () => {
video.removeEventListener("durationchange", handleProgress);
video.removeEventListener("timeupdate", handleProgress);
video.removeEventListener("loadeddata", handleProgress);
video.removeEventListener("ended", handleProgress);
if (durationResolutionTimeoutRef.current) {
clearTimeout(durationResolutionTimeoutRef.current);
durationResolutionTimeoutRef.current = null;
}
video.muted = previousMuted;
isResolvingDurationRef.current = false;
};
const resolveCurrentDuration = () => {
if (syncResolvedDuration(video)) {
return true;
}
const endedDuration = getEndedVideoDuration(video);
if (endedDuration) {
lastResolvedDurationRef.current = null;
onDurationChange(Math.round(endedDuration * 1000) / 1000);
return true;
}
return false;
};
const handleProgress = () => {
if (!resolveCurrentDuration()) {
return;
}
try {
video.pause();
video.currentTime = 0;
} catch {
// no-op
}
currentTimeRef.current = 0;
finalize();
};
video.addEventListener("durationchange", handleProgress);
video.addEventListener("timeupdate", handleProgress);
video.addEventListener("loadeddata", handleProgress);
video.addEventListener("ended", handleProgress);
durationResolutionTimeoutRef.current = window.setTimeout(() => {
handleProgress();
finalize();
}, 1500);
video.muted = true;
const playAttempt = video.play();
if (playAttempt && typeof playAttempt.catch === "function") {
playAttempt.catch(() => {
try {
video.currentTime = Math.max(video.currentTime, 0.1);
} catch {
finalize();
}
});
}
try {
video.currentTime = Math.max(video.currentTime, 0.1);
} catch {
finalize();
}
},
[onDurationChange, syncResolvedDuration],
);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -542,13 +728,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
cursorHighlightRef.current = cursorHighlight;
if (cursorHighlightGraphicsRef.current) {
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
}
}, [cursorHighlight]);
useEffect(() => {
cursorClickTimestampsRef.current = cursorClickTimestamps;
}, [cursorClickTimestamps]);
@@ -573,6 +752,55 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurAmountRef.current = motionBlurAmount;
}, [motionBlurAmount]);
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
showCursorRef.current = showCursor;
}, [showCursor]);
useEffect(() => {
hasNativeCursorRecordingRef.current = hasNativeCursorRecording;
}, [hasNativeCursorRecording]);
useEffect(() => {
cursorRecordingDataRef.current = cursorRecordingData;
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
}, [cursorRecordingData]);
useEffect(() => {
cropRegionRef.current = cropRegion;
}, [cropRegion]);
useEffect(() => {
cursorSizeRef.current = cursorSize;
}, [cursorSize]);
useEffect(() => {
cursorSmoothingRef.current = cursorSmoothing;
}, [cursorSmoothing]);
useEffect(() => {
cursorMotionBlurRef.current = cursorMotionBlur;
}, [cursorMotionBlur]);
useEffect(() => {
cursorClickBounceRef.current = cursorClickBounce;
}, [cursorClickBounce]);
// Sync cursor overlay config when settings change
useEffect(() => {
const overlay = cursorOverlayRef.current;
if (!overlay) return;
overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize);
overlay.setSmoothingFactor(cursorSmoothing);
overlay.setMotionBlur(cursorMotionBlur);
overlay.setClickBounce(cursorClickBounce);
overlay.reset();
}, [cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce]);
useEffect(() => {
onTimeUpdateRef.current = onTimeUpdate;
}, [onTimeUpdate]);
@@ -692,6 +920,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
let app: Application | null = null;
(async () => {
let cursorOverlayEnabled = true;
try {
await preloadCursorAssets();
} catch {
cursorOverlayEnabled = false;
}
app = new Application();
await app.init({
@@ -727,12 +962,30 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
// Cursor overlay - rendered above the masked video
if (cursorOverlayEnabled) {
const cursorOverlay = new PixiCursorOverlay({
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current,
smoothingFactor: cursorSmoothingRef.current,
motionBlur: cursorMotionBlurRef.current,
clickBounce: cursorClickBounceRef.current,
});
cursorOverlayRef.current = cursorOverlay;
}
setPixiReady(true);
})();
return () => {
mounted = false;
setPixiReady(false);
if (cursorOverlayRef.current) {
cursorOverlayRef.current.destroy();
cursorOverlayRef.current = null;
}
nativeCursorSpriteRef.current = null;
nativeCursorTextureIdRef.current = null;
nativeCursorImageIdRef.current = null;
if (app && app.renderer) {
app.destroy(true, {
children: true,
@@ -749,6 +1002,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
if (!videoPath) {
lastResolvedDurationRef.current = null;
isResolvingDurationRef.current = false;
setVideoReady(false);
return;
}
@@ -759,11 +1014,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
video.currentTime = 0;
allowPlaybackRef.current = false;
lockedVideoDimensionsRef.current = null;
lastResolvedDurationRef.current = null;
isResolvingDurationRef.current = false;
if (durationResolutionTimeoutRef.current) {
clearTimeout(durationResolutionTimeoutRef.current);
durationResolutionTimeoutRef.current = null;
}
setVideoReady(false);
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
video.load();
}, [videoPath]);
useEffect(() => {
@@ -793,12 +1055,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.addChild(maskGraphics);
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
const nativeCursorSprite = new Sprite(Texture.EMPTY);
nativeCursorSprite.visible = false;
nativeCursorSprite.eventMode = "none";
nativeCursorSpriteRef.current = nativeCursorSprite;
if (cursorOverlayRef.current) {
videoContainer.addChild(cursorOverlayRef.current.container);
}
const cursorHighlightGraphics = new Graphics();
cursorHighlightGraphics.visible = false;
videoContainer.addChild(cursorHighlightGraphics);
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
videoContainer.addChild(nativeCursorSprite);
animationStateRef.current = {
scale: 1,
@@ -862,10 +1127,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
if (cursorHighlightGraphicsRef.current) {
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
cursorHighlightGraphicsRef.current.destroy();
cursorHighlightGraphicsRef.current = null;
if (nativeCursorSpriteRef.current) {
videoContainer.removeChild(nativeCursorSpriteRef.current);
nativeCursorSpriteRef.current.destroy();
nativeCursorSpriteRef.current = null;
nativeCursorTextureIdRef.current = null;
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
@@ -1088,39 +1354,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionVector,
);
const cursorGraphics = cursorHighlightGraphicsRef.current;
const cursorConfig = cursorHighlightRef.current;
const lockedDims = lockedVideoDimensionsRef.current;
if (cursorGraphics) {
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
const emphasisAlpha = clickEmphasisAlpha(
currentTimeRef.current,
cursorClickTimestampsRef.current,
cursorConfig,
);
const cursorPoint =
emphasisAlpha > 0
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
: null;
if (cursorPoint) {
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
cursorGraphics.position.set(
baseOffset.x + cx * lockedDims.width * baseScale,
baseOffset.y + cy * lockedDims.height * baseScale,
);
cursorGraphics.alpha = emphasisAlpha;
cursorGraphics.visible = true;
} else {
cursorGraphics.visible = false;
}
} else {
cursorGraphics.visible = false;
}
}
const isMotionBlurActive =
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
@@ -1139,6 +1372,130 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
}
// Update cursor overlay
const cursorOverlay = cursorOverlayRef.current;
if (cursorOverlay) {
const timeMs = currentTimeRef.current; // already in ms
cursorOverlay.update(
cursorTelemetryRef.current,
timeMs,
baseMaskRef.current,
showCursorRef.current && !hasNativeCursorRecordingRef.current,
!isPlayingRef.current || isSeekingRef.current,
);
}
// Keep the native cursor preview in the same transformed coordinate space as PIXI.
const nativeCursorSprite = nativeCursorSpriteRef.current;
const nativeCursorImage = nativeCursorImageRef.current;
const hideNativeCursorPreview = () => {
if (nativeCursorSprite) {
nativeCursorSprite.visible = false;
}
if (nativeCursorImage) {
nativeCursorImage.style.display = "none";
nativeCursorImage.style.filter = "none";
}
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
};
if (nativeCursorImage) {
if (hasNativeCursorRecordingRef.current && showCursorRef.current) {
const timeMs = currentTimeRef.current; // already in ms
const frame = resolveInterpolatedNativeCursorFrame(
cursorRecordingDataRef.current,
timeMs,
);
if (frame) {
const displaySample = smoothNativeCursorSample({
forceSnap: !isPlayingRef.current || isSeekingRef.current,
sample: frame.sample,
smoothing: cursorSmoothingRef.current,
state: nativeCursorSmoothingStateRef.current,
timeMs,
});
const cameraContainer = cameraContainerRef.current;
const videoContainer = videoContainerRef.current;
const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 };
const projectedLocalPoint = projectNativeCursorToLocal({
cropRegion: cropRegionValue,
maskRect: baseMaskRef.current,
sample: displaySample,
});
const projectedStagePoint =
cameraContainer && videoContainer
? projectNativeCursorToStage({
cameraContainer,
cropRegion: cropRegionValue,
maskRect: baseMaskRef.current,
videoContainerPosition: {
x: videoContainer.x,
y: videoContainer.y,
},
sample: displaySample,
})
: null;
if (projectedLocalPoint && projectedStagePoint) {
const renderAsset = resolveNativeCursorRenderAsset(
frame.asset,
window.devicePixelRatio || 1,
displaySample,
);
const bounceProgress = getNativeCursorClickBounceProgress(
cursorRecordingDataRef.current,
timeMs,
);
const scale =
Math.max(0, cursorSizeRef.current) *
getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress);
const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1);
const blurPx =
!isPlayingRef.current || isSeekingRef.current
? 0
: getNativeCursorMotionBlurPx({
motionBlur: cursorMotionBlurRef.current,
point: projectedStagePoint,
state: nativeCursorMotionBlurStateRef.current,
timeMs,
});
if (nativeCursorImageIdRef.current !== renderAsset.id) {
nativeCursorImage.src = renderAsset.imageDataUrl;
nativeCursorImageIdRef.current = renderAsset.id;
}
nativeCursorImage.style.display = "block";
nativeCursorImage.style.width = `${renderAsset.width * transformedScale}px`;
nativeCursorImage.style.height = `${renderAsset.height * transformedScale}px`;
nativeCursorImage.style.filter =
blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none";
nativeCursorImage.style.transform = `translate3d(${
projectedStagePoint.x - renderAsset.hotspotX * transformedScale
}px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`;
if (nativeCursorSprite) {
nativeCursorSprite.visible = false;
if (nativeCursorTextureIdRef.current !== renderAsset.id) {
nativeCursorSprite.texture = Texture.from(renderAsset.imageDataUrl);
nativeCursorTextureIdRef.current = renderAsset.id;
}
nativeCursorSprite.position.set(
projectedLocalPoint.x - renderAsset.hotspotX * scale,
projectedLocalPoint.y - renderAsset.hotspotY * scale,
);
nativeCursorSprite.width = renderAsset.width * scale;
nativeCursorSprite.height = renderAsset.height * scale;
}
} else {
hideNativeCursorPreview();
}
} else {
hideNativeCursorPreview();
}
} else {
hideNativeCursorPreview();
}
} else {
hideNativeCursorPreview();
}
const composite3D = composite3DRef.current;
const outerWrapper = outerWrapperRef.current;
if (composite3D && outerWrapper) {
@@ -1188,8 +1545,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
onDurationChange(video.duration);
video.currentTime = 0;
const hasResolvedDuration = syncResolvedDuration(video);
if (!hasResolvedDuration) {
forceResolveDuration(video);
} else {
video.currentTime = 0;
}
video.pause();
allowPlaybackRef.current = false;
currentTimeRef.current = 0;
@@ -1202,6 +1563,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const waitForRenderableFrame = () => {
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
if (!syncResolvedDuration(video)) {
forceResolveDuration(video);
}
if (hasDimensions && hasData) {
videoReadyRafRef.current = null;
setVideoReady(true);
@@ -1301,6 +1665,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
window.clearTimeout(scrubEndTimerRef.current);
scrubEndTimerRef.current = null;
}
if (durationResolutionTimeoutRef.current) {
clearTimeout(durationResolutionTimeoutRef.current);
durationResolutionTimeoutRef.current = null;
}
};
}, []);
@@ -1359,6 +1727,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
<img
ref={nativeCursorImageRef}
alt=""
aria-hidden="true"
className="absolute left-0 top-0 select-none"
style={{
display: "none",
pointerEvents: "none",
transformOrigin: "0 0",
zIndex: 18,
}}
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
@@ -1544,11 +1924,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref={videoRef}
src={videoPath}
className="hidden"
preload="metadata"
preload="auto"
playsInline
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={(e) => {
onDurationChange(e.currentTarget.duration);
if (!syncResolvedDuration(e.currentTarget)) {
forceResolveDuration(e.currentTarget);
}
}}
onLoadedData={(e) => {
if (!syncResolvedDuration(e.currentTarget)) {
forceResolveDuration(e.currentTarget);
}
}}
onCanPlay={(e) => {
if (!syncResolvedDuration(e.currentTarget)) {
forceResolveDuration(e.currentTarget);
}
}}
onError={() => onError("Failed to load video")}
/>
@@ -132,7 +132,7 @@ describe("projectPersistence media compatibility", () => {
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
expect(editor.annotationRegions[1].blurData?.type).toBe("mosaic");
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
});
@@ -80,7 +80,6 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -100,6 +99,7 @@ function computeNormalizedWebcamLayoutPreset(
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
case "no-webcam":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
@@ -118,16 +118,14 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function isFileUrl(value: string): boolean {
return /^file:\/\//i.test(value);
}
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
return pathname
.split("/")
.map((segment, index) => {
if (!segment) return "";
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
if (!segment) {
return segment;
}
if (keepWindowsDrive && index === 0 && /^[a-zA-Z]:$/.test(segment)) {
return segment;
}
return encodeURIComponent(segment);
@@ -137,31 +135,25 @@ function encodePathSegments(pathname: string, keepWindowsDrive = false): string
export function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
// Windows drive path: C:/Users/...
if (/^[a-zA-Z]:\//.test(normalized)) {
return `file://${encodePathSegments(`/${normalized}`, true)}`;
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${encodePathSegments(normalized, true)}`;
}
// UNC path: //server/share/...
if (normalized.startsWith("//")) {
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
const withoutPrefix = normalized.slice(2);
const [host = "", ...segments] = withoutPrefix.split("/");
return `file://${host}/${encodePathSegments(segments.join("/"))}`;
}
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `file://${encodePathSegments(absolutePath)}`;
}
export function fromFileUrl(fileUrl: string): string {
const value = fileUrl.trim();
if (!isFileUrl(value)) {
if (!fileUrl.startsWith("file://")) {
return fileUrl;
}
try {
const url = new URL(value);
const url = new URL(fileUrl);
const pathname = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
@@ -174,13 +166,7 @@ export function fromFileUrl(fileUrl: string): string {
return pathname;
} catch {
const rawFallbackPath = value.replace(/^file:\/\//i, "");
let fallbackPath = rawFallbackPath;
try {
fallbackPath = decodeURIComponent(rawFallbackPath);
} catch {
// Keep raw best-effort path if percent decoding fails.
}
const fallbackPath = decodeURIComponent(fileUrl.replace(/^file:\/\//, ""));
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
}
}
@@ -504,52 +490,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
};
}
function normalizeCursorHighlight(
value: unknown,
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
if (!value || typeof value !== "object") return fallback;
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
return {
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
sizePx:
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
color:
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
? v.color
: fallback.color,
opacity:
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
? v.opacity
: fallback.opacity,
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
clickEmphasisDurationMs:
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
? v.clickEmphasisDurationMs
: fallback.clickEmphasisDurationMs,
offsetXNorm:
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
? Math.max(-1, Math.min(1, v.offsetXNorm))
: fallback.offsetXNorm,
offsetYNorm:
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
? Math.max(-1, Math.min(1, v.offsetYNorm))
: fallback.offsetYNorm,
};
}
+11 -7
View File
@@ -14,6 +14,7 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -46,6 +47,7 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
zoomCustomScale,
speedValue,
isAutoFocus = false,
variant = "zoom",
@@ -99,7 +101,7 @@ export default function Item({
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected,
)}
style={{ height: 40, color: "#fff", minWidth: 24 }}
style={{ height: 30, color: "#fff", minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
@@ -128,13 +130,15 @@ export default function Item({
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="relative z-10 flex min-w-0 flex-col items-center justify-center text-white/90 opacity-85 group-hover:opacity-100 transition-opacity select-none overflow-hidden px-3">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
<span className="text-[11px] font-semibold whitespace-nowrap">
{zoomCustomScale != null
? `${zoomCustomScale.toFixed(2)}×`
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
@@ -146,21 +150,21 @@ export default function Item({
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold whitespace-nowrap">
{t("labels.trim")}
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold truncate whitespace-nowrap">
{children}
</span>
</>
@@ -1,39 +1,39 @@
.glassGreen {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(52, 178, 123, 0.15);
border: 1px solid rgba(52, 178, 123, 0.3);
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: linear-gradient(180deg, rgba(52, 178, 123, 0.28), rgba(31, 115, 82, 0.2));
border: 1px solid rgba(77, 221, 157, 0.36);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.1) inset,
0 8px 22px rgba(0, 0, 0, 0.22);
margin: 3px 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassGreen:hover {
background: rgba(52, 178, 123, 0.25);
border-color: rgba(52, 178, 123, 0.5);
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
background: linear-gradient(180deg, rgba(52, 178, 123, 0.36), rgba(31, 115, 82, 0.25));
border-color: rgba(77, 221, 157, 0.62);
}
.glassGreen.selected {
background: rgba(52, 178, 123, 0.35);
background: linear-gradient(180deg, rgba(52, 178, 123, 0.48), rgba(31, 115, 82, 0.32));
border-color: #34b27b;
box-shadow:
0 0 0 1px #34b27b,
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
0 0 0 1px rgba(52, 178, 123, 0.95),
0 0 0 4px rgba(52, 178, 123, 0.14),
0 12px 26px rgba(0, 0, 0, 0.28);
z-index: 10;
}
.glassRed {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -56,12 +56,12 @@
.glassYellow {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -84,12 +84,12 @@
.glassAmber {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -137,13 +137,13 @@
.zoomEndCap.left {
left: 0;
cursor: ew-resize;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.zoomEndCap.right {
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}

Some files were not shown because too many files have changed in this diff Show More